Quellcode durchsuchen

Ship pre-built dist/ in oivo branch

Upstream's git repo excludes dist/ (built from source at publish time).
For the Oivo file: dep consumption pattern we need dist/ present in the
checked-out tree so npm install in cli/ can pack and hoist it without
pulling in devDeps (tsc, vitest, etc.).

dist/ copied verbatim from the v2.1.0 npm tarball
(/usr/lib/node_modules/@tobilu/qmd/dist/) — byte-identical to the
published artifact. See v2.1.0-upstream tag for source provenance.
suby vor 1 Monat
Ursprung
Commit
3f9ca814e1

+ 0 - 1
.gitignore

@@ -1,5 +1,4 @@
 node_modules/
-dist/
 package-lock.json
 .npmrc
 *.sqlite

+ 64 - 0
dist/ast.d.ts

@@ -0,0 +1,64 @@
+/**
+ * AST-aware chunking support via web-tree-sitter.
+ *
+ * Provides language detection, AST break point extraction for supported
+ * code file types, and a stub for future symbol extraction.
+ *
+ * All functions degrade gracefully: parse failures or unsupported languages
+ * return empty arrays, falling back to regex-only chunking.
+ *
+ * ## Dependency Note
+ *
+ * Grammar packages (tree-sitter-typescript, etc.) are listed as
+ * optionalDependencies with pinned versions. They ship native prebuilds
+ * and source files (~72 MB total) but QMD only uses the .wasm files
+ * (~5 MB). If install size becomes a concern, the .wasm files can be
+ * bundled directly in the repo (e.g. assets/grammars/) and resolved
+ * via import.meta.url instead of require.resolve(), eliminating the
+ * grammar packages entirely.
+ */
+import type { BreakPoint } from "./store.js";
+export type SupportedLanguage = "typescript" | "tsx" | "javascript" | "python" | "go" | "rust";
+/**
+ * Detect language from file path extension.
+ * Returns null for unsupported or unknown extensions (including .md).
+ */
+export declare function detectLanguage(filepath: string): SupportedLanguage | null;
+/**
+ * Parse a source file and return break points at AST node boundaries.
+ *
+ * Returns an empty array for unsupported languages, parse failures,
+ * or grammar loading failures. Never throws.
+ *
+ * @param content - The file content to parse.
+ * @param filepath - The file path (used for language detection).
+ * @returns Array of BreakPoint objects suitable for merging with regex break points.
+ */
+export declare function getASTBreakPoints(content: string, filepath: string): Promise<BreakPoint[]>;
+/**
+ * Check which tree-sitter grammars are available.
+ * Returns a status object for each supported language.
+ */
+export declare function getASTStatus(): Promise<{
+    available: boolean;
+    languages: {
+        language: SupportedLanguage;
+        available: boolean;
+        error?: string;
+    }[];
+}>;
+/**
+ * Metadata about a code symbol within a chunk.
+ * Stubbed for Phase 2 — always returns empty array in Phase 1.
+ */
+export interface SymbolInfo {
+    name: string;
+    kind: string;
+    signature?: string;
+    line: number;
+}
+/**
+ * Extract symbol metadata for code within a byte range.
+ * Stubbed for Phase 2 — returns empty array.
+ */
+export declare function extractSymbols(_content: string, _language: string, _startPos: number, _endPos: number): SymbolInfo[];

+ 324 - 0
dist/ast.js

@@ -0,0 +1,324 @@
+/**
+ * AST-aware chunking support via web-tree-sitter.
+ *
+ * Provides language detection, AST break point extraction for supported
+ * code file types, and a stub for future symbol extraction.
+ *
+ * All functions degrade gracefully: parse failures or unsupported languages
+ * return empty arrays, falling back to regex-only chunking.
+ *
+ * ## Dependency Note
+ *
+ * Grammar packages (tree-sitter-typescript, etc.) are listed as
+ * optionalDependencies with pinned versions. They ship native prebuilds
+ * and source files (~72 MB total) but QMD only uses the .wasm files
+ * (~5 MB). If install size becomes a concern, the .wasm files can be
+ * bundled directly in the repo (e.g. assets/grammars/) and resolved
+ * via import.meta.url instead of require.resolve(), eliminating the
+ * grammar packages entirely.
+ */
+import { createRequire } from "node:module";
+import { extname } from "node:path";
+const EXTENSION_MAP = {
+    ".ts": "typescript",
+    ".tsx": "tsx",
+    ".js": "javascript",
+    ".jsx": "tsx",
+    ".mts": "typescript",
+    ".cts": "typescript",
+    ".mjs": "javascript",
+    ".cjs": "javascript",
+    ".py": "python",
+    ".go": "go",
+    ".rs": "rust",
+};
+/**
+ * Detect language from file path extension.
+ * Returns null for unsupported or unknown extensions (including .md).
+ */
+export function detectLanguage(filepath) {
+    const ext = extname(filepath).toLowerCase();
+    return EXTENSION_MAP[ext] ?? null;
+}
+// =============================================================================
+// Grammar Resolution
+// =============================================================================
+/**
+ * Maps language to the npm package and wasm filename for the grammar.
+ */
+const GRAMMAR_MAP = {
+    typescript: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm" },
+    tsx: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-tsx.wasm" },
+    javascript: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm" },
+    python: { pkg: "tree-sitter-python", wasm: "tree-sitter-python.wasm" },
+    go: { pkg: "tree-sitter-go", wasm: "tree-sitter-go.wasm" },
+    rust: { pkg: "tree-sitter-rust", wasm: "tree-sitter-rust.wasm" },
+};
+// =============================================================================
+// Per-Language Query Definitions
+// =============================================================================
+/**
+ * Tree-sitter S-expression queries for each language.
+ * Each capture name maps to a break point score via SCORE_MAP.
+ *
+ * For TypeScript/JavaScript, we match export_statement wrappers to get the
+ * correct start position (before `export`), plus bare declarations for
+ * non-exported code.
+ */
+const LANGUAGE_QUERIES = {
+    typescript: `
+    (export_statement) @export
+    (class_declaration) @class
+    (function_declaration) @func
+    (method_definition) @method
+    (interface_declaration) @iface
+    (type_alias_declaration) @type
+    (enum_declaration) @enum
+    (import_statement) @import
+    (lexical_declaration (variable_declarator value: (arrow_function))) @func
+    (lexical_declaration (variable_declarator value: (function_expression))) @func
+  `,
+    tsx: `
+    (export_statement) @export
+    (class_declaration) @class
+    (function_declaration) @func
+    (method_definition) @method
+    (interface_declaration) @iface
+    (type_alias_declaration) @type
+    (enum_declaration) @enum
+    (import_statement) @import
+    (lexical_declaration (variable_declarator value: (arrow_function))) @func
+    (lexical_declaration (variable_declarator value: (function_expression))) @func
+  `,
+    javascript: `
+    (export_statement) @export
+    (class_declaration) @class
+    (function_declaration) @func
+    (method_definition) @method
+    (import_statement) @import
+    (lexical_declaration (variable_declarator value: (arrow_function))) @func
+    (lexical_declaration (variable_declarator value: (function_expression))) @func
+  `,
+    python: `
+    (class_definition) @class
+    (function_definition) @func
+    (decorated_definition) @decorated
+    (import_statement) @import
+    (import_from_statement) @import
+  `,
+    go: `
+    (type_declaration) @type
+    (function_declaration) @func
+    (method_declaration) @method
+    (import_declaration) @import
+  `,
+    rust: `
+    (struct_item) @struct
+    (impl_item) @impl
+    (function_item) @func
+    (trait_item) @trait
+    (enum_item) @enum
+    (use_declaration) @import
+    (type_item) @type
+    (mod_item) @mod
+  `,
+};
+/**
+ * Score mapping from capture names to break point scores.
+ * Aligned with the markdown BREAK_PATTERNS scale (h1=100, h2=90, etc.)
+ * so findBestCutoff() decay works unchanged.
+ */
+const SCORE_MAP = {
+    class: 100,
+    iface: 100,
+    struct: 100,
+    trait: 100,
+    impl: 100,
+    mod: 100,
+    export: 90,
+    func: 90,
+    method: 90,
+    decorated: 90,
+    type: 80,
+    enum: 80,
+    import: 60,
+};
+// =============================================================================
+// Parser Caching & Initialization
+// =============================================================================
+let ParserClass = null;
+let LanguageClass = null;
+let QueryClass = null;
+let initPromise = null;
+/** Languages that have already failed to load — warn only once per process. */
+const failedLanguages = new Set();
+/** Cached grammar load promises. */
+const grammarCache = new Map();
+/** Cached compiled queries per language. */
+const queryCache = new Map();
+/**
+ * Initialize web-tree-sitter. Called once and cached.
+ */
+async function ensureInit() {
+    if (!initPromise) {
+        initPromise = (async () => {
+            const mod = await import("web-tree-sitter");
+            ParserClass = mod.Parser;
+            LanguageClass = mod.Language;
+            QueryClass = mod.Query;
+            await ParserClass.init();
+        })();
+    }
+    return initPromise;
+}
+/**
+ * Resolve the filesystem path to a grammar .wasm file.
+ * Uses createRequire to resolve from installed dependency packages.
+ */
+function resolveGrammarPath(language) {
+    const { pkg, wasm } = GRAMMAR_MAP[language];
+    const require = createRequire(import.meta.url);
+    return require.resolve(`${pkg}/${wasm}`);
+}
+/**
+ * Load and cache a grammar for the given language.
+ * Returns null on failure (logs once per language).
+ */
+async function loadGrammar(language) {
+    if (failedLanguages.has(language))
+        return null;
+    const wasmKey = GRAMMAR_MAP[language].wasm;
+    if (!grammarCache.has(wasmKey)) {
+        grammarCache.set(wasmKey, (async () => {
+            const path = resolveGrammarPath(language);
+            return LanguageClass.load(path);
+        })());
+    }
+    try {
+        return await grammarCache.get(wasmKey);
+    }
+    catch (err) {
+        failedLanguages.add(language);
+        grammarCache.delete(wasmKey);
+        console.warn(`[qmd] Failed to load tree-sitter grammar for ${language}: ${err}`);
+        return null;
+    }
+}
+/**
+ * Get or create a compiled query for the given language.
+ */
+function getQuery(language, grammar) {
+    if (!queryCache.has(language)) {
+        const source = LANGUAGE_QUERIES[language];
+        const query = new QueryClass(grammar, source);
+        queryCache.set(language, query);
+    }
+    return queryCache.get(language);
+}
+// =============================================================================
+// AST Break Point Extraction
+// =============================================================================
+/**
+ * Parse a source file and return break points at AST node boundaries.
+ *
+ * Returns an empty array for unsupported languages, parse failures,
+ * or grammar loading failures. Never throws.
+ *
+ * @param content - The file content to parse.
+ * @param filepath - The file path (used for language detection).
+ * @returns Array of BreakPoint objects suitable for merging with regex break points.
+ */
+export async function getASTBreakPoints(content, filepath) {
+    const language = detectLanguage(filepath);
+    if (!language)
+        return [];
+    try {
+        await ensureInit();
+        const grammar = await loadGrammar(language);
+        if (!grammar)
+            return [];
+        const parser = new ParserClass();
+        parser.setLanguage(grammar);
+        const tree = parser.parse(content);
+        if (!tree) {
+            parser.delete();
+            return [];
+        }
+        const query = getQuery(language, grammar);
+        const captures = query.captures(tree.rootNode);
+        // Deduplicate: at each byte position, keep the highest-scoring capture.
+        // This handles cases like export_statement wrapping a class_declaration
+        // at different offsets — we want the outermost (earliest) position.
+        const seen = new Map();
+        for (const cap of captures) {
+            const pos = cap.node.startIndex;
+            const score = SCORE_MAP[cap.name] ?? 20;
+            const type = `ast:${cap.name}`;
+            const existing = seen.get(pos);
+            if (!existing || score > existing.score) {
+                seen.set(pos, { pos, score, type });
+            }
+        }
+        tree.delete();
+        parser.delete();
+        return Array.from(seen.values()).sort((a, b) => a.pos - b.pos);
+    }
+    catch (err) {
+        console.warn(`[qmd] AST parse failed for ${filepath}, falling back to regex: ${err instanceof Error ? err.message : err}`);
+        return [];
+    }
+}
+// =============================================================================
+// Health / Status
+// =============================================================================
+/**
+ * Check which tree-sitter grammars are available.
+ * Returns a status object for each supported language.
+ */
+export async function getASTStatus() {
+    const languages = [];
+    try {
+        await ensureInit();
+    }
+    catch (err) {
+        return {
+            available: false,
+            languages: Object.keys(GRAMMAR_MAP).map(lang => ({
+                language: lang,
+                available: false,
+                error: `web-tree-sitter init failed: ${err instanceof Error ? err.message : err}`,
+            })),
+        };
+    }
+    for (const lang of Object.keys(GRAMMAR_MAP)) {
+        try {
+            const grammar = await loadGrammar(lang);
+            if (grammar) {
+                // Also verify the query compiles
+                getQuery(lang, grammar);
+                languages.push({ language: lang, available: true });
+            }
+            else {
+                languages.push({ language: lang, available: false, error: "grammar failed to load" });
+            }
+        }
+        catch (err) {
+            languages.push({
+                language: lang,
+                available: false,
+                error: err instanceof Error ? err.message : String(err),
+            });
+        }
+    }
+    return {
+        available: languages.some(l => l.available),
+        languages,
+    };
+}
+/**
+ * Extract symbol metadata for code within a byte range.
+ * Stubbed for Phase 2 — returns empty array.
+ */
+export function extractSymbols(_content, _language, _startPos, _endPos) {
+    return [];
+}

+ 21 - 0
dist/bench/bench.d.ts

@@ -0,0 +1,21 @@
+/**
+ * QMD Benchmark Harness
+ *
+ * Runs queries from a fixture file against multiple search backends
+ * and measures precision@k, recall, MRR, F1, and latency.
+ *
+ * Usage:
+ *   qmd bench <fixture.json> [--json] [--collection <name>]
+ *
+ * Backends tested:
+ *   - bm25: BM25 keyword search (searchLex)
+ *   - vector: Vector similarity search (searchVector)
+ *   - hybrid: BM25 + vector RRF fusion without reranking
+ *   - full: Full hybrid pipeline with LLM reranking
+ */
+import type { BenchmarkResult } from "./types.js";
+export declare function runBenchmark(fixturePath: string, options?: {
+    json?: boolean;
+    collection?: string;
+    backends?: string[];
+}): Promise<BenchmarkResult>;

+ 185 - 0
dist/bench/bench.js

@@ -0,0 +1,185 @@
+/**
+ * QMD Benchmark Harness
+ *
+ * Runs queries from a fixture file against multiple search backends
+ * and measures precision@k, recall, MRR, F1, and latency.
+ *
+ * Usage:
+ *   qmd bench <fixture.json> [--json] [--collection <name>]
+ *
+ * Backends tested:
+ *   - bm25: BM25 keyword search (searchLex)
+ *   - vector: Vector similarity search (searchVector)
+ *   - hybrid: BM25 + vector RRF fusion without reranking
+ *   - full: Full hybrid pipeline with LLM reranking
+ */
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createStore, getDefaultDbPath, } from "../index.js";
+import { scoreResults } from "./score.js";
+const BACKENDS = [
+    {
+        name: "bm25",
+        run: async (store, query, limit, collection) => {
+            const results = await store.searchLex(query, { limit, collection });
+            return results.map((r) => r.filepath);
+        },
+    },
+    {
+        name: "vector",
+        run: async (store, query, limit, collection) => {
+            const results = await store.searchVector(query, { limit, collection });
+            return results.map((r) => r.filepath);
+        },
+    },
+    {
+        name: "hybrid",
+        run: async (store, query, limit, collection) => {
+            const results = await store.search({ query, limit, collection, rerank: false });
+            return results.map((r) => r.file);
+        },
+    },
+    {
+        name: "full",
+        run: async (store, query, limit, collection) => {
+            const results = await store.search({ query, limit, collection, rerank: true });
+            return results.map((r) => r.file);
+        },
+    },
+];
+async function runQuery(store, backend, query, collection) {
+    const limit = Math.max(query.expected_in_top_k, 10);
+    const start = Date.now();
+    let resultFiles;
+    try {
+        resultFiles = await backend.run(store, query.query, limit, collection);
+    }
+    catch (err) {
+        // Backend may not be available (e.g., no embeddings for vector search)
+        return {
+            precision_at_k: 0,
+            recall: 0,
+            mrr: 0,
+            f1: 0,
+            hits_at_k: 0,
+            total_expected: query.expected_files.length,
+            latency_ms: Date.now() - start,
+            top_files: [],
+        };
+    }
+    const latency_ms = Date.now() - start;
+    const scores = scoreResults(resultFiles, query.expected_files, query.expected_in_top_k);
+    return {
+        ...scores,
+        total_expected: query.expected_files.length,
+        latency_ms,
+        top_files: resultFiles.slice(0, 10),
+    };
+}
+function formatTable(results) {
+    const lines = [];
+    const pad = (s, n) => s.slice(0, n).padEnd(n);
+    const num = (n) => n.toFixed(2).padStart(5);
+    lines.push(`${pad("Query", 25)} ${pad("Backend", 8)} ${pad("P@k", 6)} ${pad("Recall", 7)} ${pad("MRR", 6)} ${pad("F1", 6)} ${pad("ms", 8)}`);
+    lines.push("-".repeat(70));
+    for (const r of results) {
+        for (const [backend, br] of Object.entries(r.backends)) {
+            lines.push(`${pad(r.id, 25)} ${pad(backend, 8)} ${num(br.precision_at_k)} ${num(br.recall)}  ${num(br.mrr)} ${num(br.f1)} ${String(Math.round(br.latency_ms)).padStart(7)}ms`);
+        }
+        lines.push("");
+    }
+    return lines.join("\n");
+}
+function computeSummary(results) {
+    const summary = {};
+    // Collect all backend names
+    const backendNames = new Set();
+    for (const r of results) {
+        for (const name of Object.keys(r.backends)) {
+            backendNames.add(name);
+        }
+    }
+    for (const name of backendNames) {
+        let totalP = 0, totalR = 0, totalMrr = 0, totalF1 = 0, totalLat = 0, count = 0;
+        for (const r of results) {
+            const br = r.backends[name];
+            if (!br)
+                continue;
+            totalP += br.precision_at_k;
+            totalR += br.recall;
+            totalMrr += br.mrr;
+            totalF1 += br.f1;
+            totalLat += br.latency_ms;
+            count++;
+        }
+        if (count > 0) {
+            summary[name] = {
+                avg_precision: totalP / count,
+                avg_recall: totalR / count,
+                avg_mrr: totalMrr / count,
+                avg_f1: totalF1 / count,
+                avg_latency_ms: totalLat / count,
+            };
+        }
+    }
+    return summary;
+}
+export async function runBenchmark(fixturePath, options = {}) {
+    // Load fixture
+    const raw = readFileSync(resolve(fixturePath), "utf-8");
+    const fixture = JSON.parse(raw);
+    if (!fixture.queries || !Array.isArray(fixture.queries)) {
+        throw new Error("Invalid fixture: missing 'queries' array");
+    }
+    // Open store
+    const store = await createStore({ dbPath: getDefaultDbPath() });
+    // Filter backends if requested
+    const activeBackends = options.backends
+        ? BACKENDS.filter(b => options.backends.includes(b.name))
+        : BACKENDS;
+    const collection = options.collection ?? fixture.collection;
+    // Run queries
+    const results = [];
+    for (const query of fixture.queries) {
+        const backends = {};
+        for (const backend of activeBackends) {
+            if (!options.json) {
+                process.stderr.write(`  ${query.id} / ${backend.name}...`);
+            }
+            backends[backend.name] = await runQuery(store, backend, query, collection);
+            if (!options.json) {
+                process.stderr.write(` ${Math.round(backends[backend.name].latency_ms)}ms\n`);
+            }
+        }
+        results.push({
+            id: query.id,
+            query: query.query,
+            type: query.type,
+            backends,
+        });
+    }
+    await store.close();
+    const summary = computeSummary(results);
+    const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
+    const benchResult = {
+        timestamp,
+        fixture: fixturePath,
+        results,
+        summary,
+    };
+    // Output
+    if (options.json) {
+        console.log(JSON.stringify(benchResult, null, 2));
+    }
+    else {
+        console.log("\n" + formatTable(results));
+        console.log("Summary:");
+        console.log("-".repeat(70));
+        const pad = (s, n) => s.slice(0, n).padEnd(n);
+        const num = (n) => n.toFixed(3).padStart(6);
+        for (const [name, s] of Object.entries(summary)) {
+            console.log(`  ${pad(name, 8)} P@k=${num(s.avg_precision)} Recall=${num(s.avg_recall)} MRR=${num(s.avg_mrr)} F1=${num(s.avg_f1)} Avg=${Math.round(s.avg_latency_ms)}ms`);
+        }
+    }
+    return benchResult;
+}

+ 26 - 0
dist/bench/score.d.ts

@@ -0,0 +1,26 @@
+/**
+ * Scoring functions for the QMD benchmark harness.
+ *
+ * Computes precision@k, recall, MRR, and F1 for search results
+ * against ground-truth expected files.
+ */
+/**
+ * Normalize a file path for comparison.
+ * Strips qmd:// prefix, lowercases, removes leading/trailing slashes.
+ */
+export declare function normalizePath(p: string): string;
+/**
+ * Check if two paths refer to the same file.
+ * Handles different path formats by comparing normalized suffixes.
+ */
+export declare function pathsMatch(result: string, expected: string): boolean;
+/**
+ * Score a set of search results against expected files.
+ */
+export declare function scoreResults(resultFiles: string[], expectedFiles: string[], topK: number): {
+    precision_at_k: number;
+    recall: number;
+    mrr: number;
+    f1: number;
+    hits_at_k: number;
+};

+ 67 - 0
dist/bench/score.js

@@ -0,0 +1,67 @@
+/**
+ * Scoring functions for the QMD benchmark harness.
+ *
+ * Computes precision@k, recall, MRR, and F1 for search results
+ * against ground-truth expected files.
+ */
+/**
+ * Normalize a file path for comparison.
+ * Strips qmd:// prefix, lowercases, removes leading/trailing slashes.
+ */
+export function normalizePath(p) {
+    if (p.startsWith("qmd://")) {
+        // qmd://collection/path/to/file → path/to/file
+        const withoutScheme = p.slice("qmd://".length);
+        const slashIdx = withoutScheme.indexOf("/");
+        p = slashIdx >= 0 ? withoutScheme.slice(slashIdx + 1) : withoutScheme;
+    }
+    return p.toLowerCase().replace(/^\/+|\/+$/g, "");
+}
+/**
+ * Check if two paths refer to the same file.
+ * Handles different path formats by comparing normalized suffixes.
+ */
+export function pathsMatch(result, expected) {
+    const nr = normalizePath(result);
+    const ne = normalizePath(expected);
+    if (nr === ne)
+        return true;
+    if (nr.endsWith(ne) || ne.endsWith(nr))
+        return true;
+    return false;
+}
+/**
+ * Score a set of search results against expected files.
+ */
+export function scoreResults(resultFiles, expectedFiles, topK) {
+    // Count hits in top-k
+    const topKResults = resultFiles.slice(0, topK);
+    let hitsAtK = 0;
+    for (const expected of expectedFiles) {
+        if (topKResults.some(r => pathsMatch(r, expected))) {
+            hitsAtK++;
+        }
+    }
+    // Count total hits anywhere
+    let totalHits = 0;
+    for (const expected of expectedFiles) {
+        if (resultFiles.some(r => pathsMatch(r, expected))) {
+            totalHits++;
+        }
+    }
+    // MRR: reciprocal rank of first relevant result
+    let mrr = 0;
+    for (let i = 0; i < resultFiles.length; i++) {
+        if (expectedFiles.some(e => pathsMatch(resultFiles[i], e))) {
+            mrr = 1 / (i + 1);
+            break;
+        }
+    }
+    const denominator = Math.min(topK, expectedFiles.length);
+    const precision_at_k = denominator > 0 ? hitsAtK / denominator : 0;
+    const recall = expectedFiles.length > 0 ? totalHits / expectedFiles.length : 0;
+    const f1 = precision_at_k + recall > 0
+        ? 2 * (precision_at_k * recall) / (precision_at_k + recall)
+        : 0;
+    return { precision_at_k, recall, mrr, f1, hits_at_k: hitsAtK };
+}

+ 67 - 0
dist/bench/types.d.ts

@@ -0,0 +1,67 @@
+/**
+ * Types for the QMD benchmark harness.
+ *
+ * A benchmark fixture defines queries with expected results.
+ * The harness runs each query through multiple search backends
+ * and measures precision, recall, MRR, and latency.
+ */
+export interface BenchmarkQuery {
+    /** Unique identifier for the query */
+    id: string;
+    /** The search query text */
+    query: string;
+    /** Query difficulty/type for grouping results */
+    type: "exact" | "semantic" | "topical" | "cross-domain" | "alias";
+    /** Human-readable description of what this tests */
+    description: string;
+    /** File paths (relative to collection) that should appear in results */
+    expected_files: string[];
+    /** How many of expected_files should appear in top-k results */
+    expected_in_top_k: number;
+}
+export interface BenchmarkFixture {
+    /** Description of the benchmark */
+    description: string;
+    /** Fixture format version */
+    version: number;
+    /** Optional collection to search within */
+    collection?: string;
+    /** The test queries */
+    queries: BenchmarkQuery[];
+}
+export interface BackendResult {
+    /** Fraction of top-k results that are relevant */
+    precision_at_k: number;
+    /** Fraction of expected files found anywhere in results */
+    recall: number;
+    /** Reciprocal rank of first relevant result (1/rank, 0 if not found) */
+    mrr: number;
+    /** Harmonic mean of precision_at_k and recall */
+    f1: number;
+    /** Number of expected files found in top-k */
+    hits_at_k: number;
+    /** Total expected files */
+    total_expected: number;
+    /** Wall-clock latency in milliseconds */
+    latency_ms: number;
+    /** Top result file paths (for inspection) */
+    top_files: string[];
+}
+export interface QueryResult {
+    id: string;
+    query: string;
+    type: string;
+    backends: Record<string, BackendResult>;
+}
+export interface BenchmarkResult {
+    timestamp: string;
+    fixture: string;
+    results: QueryResult[];
+    summary: Record<string, {
+        avg_precision: number;
+        avg_recall: number;
+        avg_mrr: number;
+        avg_f1: number;
+        avg_latency_ms: number;
+    }>;
+}

+ 8 - 0
dist/bench/types.js

@@ -0,0 +1,8 @@
+/**
+ * Types for the QMD benchmark harness.
+ *
+ * A benchmark fixture defines queries with expected results.
+ * The harness runs each query through multiple search backends
+ * and measures precision, recall, MRR, and latency.
+ */
+export {};

+ 120 - 0
dist/cli/formatter.d.ts

@@ -0,0 +1,120 @@
+/**
+ * formatter.ts - Output formatting utilities for QMD
+ *
+ * Provides methods to format search results and documents into various output formats:
+ * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
+ */
+import type { SearchResult, MultiGetResult, DocumentResult } from "../store.js";
+export type { SearchResult, MultiGetResult, DocumentResult };
+export type MultiGetFile = {
+    filepath: string;
+    displayPath: string;
+    title: string;
+    body: string;
+    context?: string | null;
+    skipped: false;
+} | {
+    filepath: string;
+    displayPath: string;
+    title: string;
+    body: string;
+    context?: string | null;
+    skipped: true;
+    skipReason: string;
+};
+export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
+export type FormatOptions = {
+    full?: boolean;
+    query?: string;
+    useColor?: boolean;
+    lineNumbers?: boolean;
+    intent?: string;
+};
+/**
+ * Add line numbers to text content.
+ * Each line becomes: "{lineNum}: {content}"
+ * @param text The text to add line numbers to
+ * @param startLine Optional starting line number (default: 1)
+ */
+export declare function addLineNumbers(text: string, startLine?: number): string;
+/**
+ * Extract short docid from a full hash (first 6 characters).
+ */
+export declare function getDocid(hash: string): string;
+export declare function escapeCSV(value: string | null | number): string;
+export declare function escapeXml(str: string): string;
+/**
+ * Format search results as JSON
+ */
+export declare function searchResultsToJson(results: SearchResult[], opts?: FormatOptions): string;
+/**
+ * Format search results as CSV
+ */
+export declare function searchResultsToCsv(results: SearchResult[], opts?: FormatOptions): string;
+/**
+ * Format search results as simple files list (docid,score,filepath,context)
+ */
+export declare function searchResultsToFiles(results: SearchResult[]): string;
+/**
+ * Format search results as Markdown
+ */
+export declare function searchResultsToMarkdown(results: SearchResult[], opts?: FormatOptions): string;
+/**
+ * Format search results as XML
+ */
+export declare function searchResultsToXml(results: SearchResult[], opts?: FormatOptions): string;
+/**
+ * Format search results for MCP (simpler CSV format with pre-extracted snippets)
+ */
+export declare function searchResultsToMcpCsv(results: {
+    docid: string;
+    file: string;
+    title: string;
+    score: number;
+    context: string | null;
+    snippet: string;
+}[]): string;
+/**
+ * Format documents as JSON
+ */
+export declare function documentsToJson(results: MultiGetFile[]): string;
+/**
+ * Format documents as CSV
+ */
+export declare function documentsToCsv(results: MultiGetFile[]): string;
+/**
+ * Format documents as files list
+ */
+export declare function documentsToFiles(results: MultiGetFile[]): string;
+/**
+ * Format documents as Markdown
+ */
+export declare function documentsToMarkdown(results: MultiGetFile[]): string;
+/**
+ * Format documents as XML
+ */
+export declare function documentsToXml(results: MultiGetFile[]): string;
+/**
+ * Format a single DocumentResult as JSON
+ */
+export declare function documentToJson(doc: DocumentResult): string;
+/**
+ * Format a single DocumentResult as Markdown
+ */
+export declare function documentToMarkdown(doc: DocumentResult): string;
+/**
+ * Format a single DocumentResult as XML
+ */
+export declare function documentToXml(doc: DocumentResult): string;
+/**
+ * Format a single document to the specified format
+ */
+export declare function formatDocument(doc: DocumentResult, format: OutputFormat): string;
+/**
+ * Format search results to the specified output format
+ */
+export declare function formatSearchResults(results: SearchResult[], format: OutputFormat, opts?: FormatOptions): string;
+/**
+ * Format documents to the specified output format
+ */
+export declare function formatDocuments(results: MultiGetFile[], format: OutputFormat): string;

+ 354 - 0
dist/cli/formatter.js

@@ -0,0 +1,354 @@
+/**
+ * formatter.ts - Output formatting utilities for QMD
+ *
+ * Provides methods to format search results and documents into various output formats:
+ * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
+ */
+import { extractSnippet } from "../store.js";
+// =============================================================================
+// Helper Functions
+// =============================================================================
+/**
+ * Add line numbers to text content.
+ * Each line becomes: "{lineNum}: {content}"
+ * @param text The text to add line numbers to
+ * @param startLine Optional starting line number (default: 1)
+ */
+export function addLineNumbers(text, startLine = 1) {
+    const lines = text.split('\n');
+    return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
+}
+/**
+ * Extract short docid from a full hash (first 6 characters).
+ */
+export function getDocid(hash) {
+    return hash.slice(0, 6);
+}
+// =============================================================================
+// Escape Helpers
+// =============================================================================
+export function escapeCSV(value) {
+    if (value === null || value === undefined)
+        return "";
+    const str = String(value);
+    if (str.includes(",") || str.includes('"') || str.includes("\n")) {
+        return `"${str.replace(/"/g, '""')}"`;
+    }
+    return str;
+}
+export function escapeXml(str) {
+    return str
+        .replace(/&/g, "&amp;")
+        .replace(/</g, "&lt;")
+        .replace(/>/g, "&gt;")
+        .replace(/"/g, "&quot;")
+        .replace(/'/g, "&apos;");
+}
+// =============================================================================
+// Search Results Formatters
+// =============================================================================
+/**
+ * Format search results as JSON
+ */
+export function searchResultsToJson(results, opts = {}) {
+    const query = opts.query || "";
+    const output = results.map(row => {
+        const bodyStr = row.body || "";
+        const snippetInfo = bodyStr
+            ? extractSnippet(bodyStr, query, 300, row.chunkPos, undefined, opts.intent)
+            : undefined;
+        let body = opts.full ? bodyStr : undefined;
+        let snippet = !opts.full ? snippetInfo?.snippet : undefined;
+        if (opts.lineNumbers) {
+            if (body)
+                body = addLineNumbers(body);
+            if (snippet)
+                snippet = addLineNumbers(snippet);
+        }
+        return {
+            docid: `#${row.docid}`,
+            score: Math.round(row.score * 100) / 100,
+            file: row.displayPath,
+            ...(snippetInfo && { line: snippetInfo.line }),
+            title: row.title,
+            ...(row.context && { context: row.context }),
+            ...(body && { body }),
+            ...(snippet && { snippet }),
+        };
+    });
+    return JSON.stringify(output, null, 2);
+}
+/**
+ * Format search results as CSV
+ */
+export function searchResultsToCsv(results, opts = {}) {
+    const query = opts.query || "";
+    const header = "docid,score,file,title,context,line,snippet";
+    const rows = results.map(row => {
+        const bodyStr = row.body || "";
+        const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent);
+        let content = opts.full ? bodyStr : snippet;
+        if (opts.lineNumbers && content) {
+            content = addLineNumbers(content);
+        }
+        return [
+            `#${row.docid}`,
+            row.score.toFixed(4),
+            escapeCSV(row.displayPath),
+            escapeCSV(row.title),
+            escapeCSV(row.context || ""),
+            line,
+            escapeCSV(content),
+        ].join(",");
+    });
+    return [header, ...rows].join("\n");
+}
+/**
+ * Format search results as simple files list (docid,score,filepath,context)
+ */
+export function searchResultsToFiles(results) {
+    return results.map(row => {
+        const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
+        return `#${row.docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`;
+    }).join("\n");
+}
+/**
+ * Format search results as Markdown
+ */
+export function searchResultsToMarkdown(results, opts = {}) {
+    const query = opts.query || "";
+    return results.map(row => {
+        const heading = row.title || row.displayPath;
+        const bodyStr = row.body || "";
+        let content;
+        if (opts.full) {
+            content = bodyStr;
+        }
+        else {
+            content = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet;
+        }
+        if (opts.lineNumbers) {
+            content = addLineNumbers(content);
+        }
+        const contextLine = row.context ? `**context:** ${row.context}\n` : "";
+        return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n${contextLine}\n${content}\n`;
+    }).join("\n");
+}
+/**
+ * Format search results as XML
+ */
+export function searchResultsToXml(results, opts = {}) {
+    const query = opts.query || "";
+    const items = results.map(row => {
+        const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
+        const bodyStr = row.body || "";
+        let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet;
+        if (opts.lineNumbers) {
+            content = addLineNumbers(content);
+        }
+        const contextAttr = row.context ? ` context="${escapeXml(row.context)}"` : "";
+        return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}${contextAttr}>\n${escapeXml(content)}\n</file>`;
+    });
+    return items.join("\n\n");
+}
+/**
+ * Format search results for MCP (simpler CSV format with pre-extracted snippets)
+ */
+export function searchResultsToMcpCsv(results) {
+    const header = "docid,file,title,score,context,snippet";
+    const rows = results.map(r => [`#${r.docid}`, r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(","));
+    return [header, ...rows].join("\n");
+}
+// =============================================================================
+// Document Formatters (for multi-get using MultiGetFile from store)
+// =============================================================================
+/**
+ * Format documents as JSON
+ */
+export function documentsToJson(results) {
+    const output = results.map(r => ({
+        file: r.displayPath,
+        title: r.title,
+        ...(r.context && { context: r.context }),
+        ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
+    }));
+    return JSON.stringify(output, null, 2);
+}
+/**
+ * Format documents as CSV
+ */
+export function documentsToCsv(results) {
+    const header = "file,title,context,skipped,body";
+    const rows = results.map(r => [
+        r.displayPath,
+        r.title,
+        r.context || "",
+        r.skipped ? "true" : "false",
+        r.skipped ? (r.skipReason || "") : r.body
+    ].map(escapeCSV).join(","));
+    return [header, ...rows].join("\n");
+}
+/**
+ * Format documents as files list
+ */
+export function documentsToFiles(results) {
+    return results.map(r => {
+        const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
+        const status = r.skipped ? ",[SKIPPED]" : "";
+        return `${r.displayPath}${ctx}${status}`;
+    }).join("\n");
+}
+/**
+ * Format documents as Markdown
+ */
+export function documentsToMarkdown(results) {
+    return results.map(r => {
+        let md = `## ${r.displayPath}\n\n`;
+        if (r.title && r.title !== r.displayPath)
+            md += `**Title:** ${r.title}\n\n`;
+        if (r.context)
+            md += `**Context:** ${r.context}\n\n`;
+        if (r.skipped) {
+            md += `> ${r.skipReason}\n`;
+        }
+        else {
+            md += "```\n" + r.body + "\n```\n";
+        }
+        return md;
+    }).join("\n");
+}
+/**
+ * Format documents as XML
+ */
+export function documentsToXml(results) {
+    const items = results.map(r => {
+        let xml = "  <document>\n";
+        xml += `    <file>${escapeXml(r.displayPath)}</file>\n`;
+        xml += `    <title>${escapeXml(r.title)}</title>\n`;
+        if (r.context)
+            xml += `    <context>${escapeXml(r.context)}</context>\n`;
+        if (r.skipped) {
+            xml += `    <skipped>true</skipped>\n`;
+            xml += `    <reason>${escapeXml(r.skipReason || "")}</reason>\n`;
+        }
+        else {
+            xml += `    <body>${escapeXml(r.body)}</body>\n`;
+        }
+        xml += "  </document>";
+        return xml;
+    });
+    return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`;
+}
+// =============================================================================
+// Single Document Formatters
+// =============================================================================
+/**
+ * Format a single DocumentResult as JSON
+ */
+export function documentToJson(doc) {
+    return JSON.stringify({
+        file: doc.displayPath,
+        title: doc.title,
+        ...(doc.context && { context: doc.context }),
+        hash: doc.hash,
+        modifiedAt: doc.modifiedAt,
+        bodyLength: doc.bodyLength,
+        ...(doc.body !== undefined && { body: doc.body }),
+    }, null, 2);
+}
+/**
+ * Format a single DocumentResult as Markdown
+ */
+export function documentToMarkdown(doc) {
+    let md = `# ${doc.title || doc.displayPath}\n\n`;
+    if (doc.context)
+        md += `**Context:** ${doc.context}\n\n`;
+    md += `**File:** ${doc.displayPath}\n`;
+    md += `**Modified:** ${doc.modifiedAt}\n\n`;
+    if (doc.body !== undefined) {
+        md += "---\n\n" + doc.body + "\n";
+    }
+    return md;
+}
+/**
+ * Format a single DocumentResult as XML
+ */
+export function documentToXml(doc) {
+    let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`;
+    xml += `  <file>${escapeXml(doc.displayPath)}</file>\n`;
+    xml += `  <title>${escapeXml(doc.title)}</title>\n`;
+    if (doc.context)
+        xml += `  <context>${escapeXml(doc.context)}</context>\n`;
+    xml += `  <hash>${escapeXml(doc.hash)}</hash>\n`;
+    xml += `  <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`;
+    xml += `  <bodyLength>${doc.bodyLength}</bodyLength>\n`;
+    if (doc.body !== undefined) {
+        xml += `  <body>${escapeXml(doc.body)}</body>\n`;
+    }
+    xml += `</document>`;
+    return xml;
+}
+/**
+ * Format a single document to the specified format
+ */
+export function formatDocument(doc, format) {
+    switch (format) {
+        case "json":
+            return documentToJson(doc);
+        case "md":
+            return documentToMarkdown(doc);
+        case "xml":
+            return documentToXml(doc);
+        default:
+            // Default to markdown for CLI and other formats
+            return documentToMarkdown(doc);
+    }
+}
+// =============================================================================
+// Universal Format Function
+// =============================================================================
+/**
+ * Format search results to the specified output format
+ */
+export function formatSearchResults(results, format, opts = {}) {
+    switch (format) {
+        case "json":
+            return searchResultsToJson(results, opts);
+        case "csv":
+            return searchResultsToCsv(results, opts);
+        case "files":
+            return searchResultsToFiles(results);
+        case "md":
+            return searchResultsToMarkdown(results, opts);
+        case "xml":
+            return searchResultsToXml(results, opts);
+        case "cli":
+            // CLI format should be handled separately with colors
+            // Return a simple text version as fallback
+            return searchResultsToMarkdown(results, opts);
+        default:
+            return searchResultsToJson(results, opts);
+    }
+}
+/**
+ * Format documents to the specified output format
+ */
+export function formatDocuments(results, format) {
+    switch (format) {
+        case "json":
+            return documentsToJson(results);
+        case "csv":
+            return documentsToCsv(results);
+        case "files":
+            return documentsToFiles(results);
+        case "md":
+            return documentsToMarkdown(results);
+        case "xml":
+            return documentsToXml(results);
+        case "cli":
+            // CLI format should be handled separately with colors
+            return documentsToMarkdown(results);
+        default:
+            return documentsToJson(results);
+    }
+}

+ 2 - 0
dist/cli/qmd.d.ts

@@ -0,0 +1,2 @@
+export declare function buildEditorUri(template: string, absolutePath: string, line: number, col: number): string;
+export declare function termLink(text: string, url: string, isTTY?: boolean): string;

+ 2899 - 0
dist/cli/qmd.js

@@ -0,0 +1,2899 @@
+#!/usr/bin/env node
+import { openDatabase } from "../db.js";
+import fastGlob from "fast-glob";
+import { execSync, spawn as nodeSpawn } from "child_process";
+import { fileURLToPath } from "url";
+import { dirname, join as pathJoin, relative as relativePath } from "path";
+import { parseArgs } from "util";
+import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync } from "fs";
+import { createInterface } from "readline/promises";
+import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, structuredSearch, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_EMBED_MAX_BATCH_BYTES, DEFAULT_EMBED_MAX_DOCS_PER_BATCH, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, reindexCollection, generateEmbeddings, syncConfigToDb, } from "../store.js";
+import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, setDefaultLlamaCpp, LlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "../llm.js";
+import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
+import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, removeCollection as yamlRemoveCollectionFn, renameCollection as yamlRenameCollectionFn, setGlobalContext, listAllContexts, setConfigIndexName, loadConfig, } from "../collections.js";
+import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
+// Enable production mode - allows using default database path
+// Tests must set INDEX_PATH or use createStore() with explicit path
+enableProductionMode();
+// =============================================================================
+// Store/DB lifecycle (no legacy singletons in store.ts)
+// =============================================================================
+let store = null;
+let storeDbPathOverride;
+function getStore() {
+    if (!store) {
+        store = createStore(storeDbPathOverride);
+        // Sync YAML config into SQLite store_collections so store.ts reads from DB
+        try {
+            const config = loadConfig();
+            syncConfigToDb(store.db, config);
+            if (config.models) {
+                setDefaultLlamaCpp(new LlamaCpp({
+                    embedModel: config.models.embed,
+                    generateModel: config.models.generate,
+                    rerankModel: config.models.rerank,
+                }));
+            }
+        }
+        catch {
+            // Config may not exist yet — that's fine, DB works without it
+        }
+    }
+    return store;
+}
+function getDb() {
+    return getStore().db;
+}
+/** Re-sync YAML config into SQLite after CLI mutations (add/remove/rename collection, context changes) */
+function resyncConfig() {
+    const s = getStore();
+    try {
+        const config = loadConfig();
+        // Clear config hash to force re-sync
+        s.db.prepare(`DELETE FROM store_config WHERE key = 'config_hash'`).run();
+        syncConfigToDb(s.db, config);
+    }
+    catch {
+        // Config may not exist — that's fine
+    }
+}
+function closeDb() {
+    if (store) {
+        store.close();
+        store = null;
+    }
+}
+function getDbPath() {
+    return store?.dbPath ?? storeDbPathOverride ?? getDefaultDbPath();
+}
+function setIndexName(name) {
+    let normalizedName = name;
+    // Normalize relative paths to prevent malformed database paths
+    if (name && name.includes('/')) {
+        const { resolve } = require('path');
+        const { cwd } = require('process');
+        const absolutePath = resolve(cwd(), name);
+        // Replace path separators with underscores to create a valid filename
+        normalizedName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
+    }
+    storeDbPathOverride = normalizedName ? getDefaultDbPath(normalizedName) : undefined;
+    // Reset open handle so next use opens the new index
+    closeDb();
+}
+function ensureVecTable(_db, dimensions) {
+    // Store owns the DB; ignore `_db` and ensure vec table on the active store
+    getStore().ensureVecTable(dimensions);
+}
+// Terminal colors (respects NO_COLOR env)
+const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
+const c = {
+    reset: useColor ? "\x1b[0m" : "",
+    dim: useColor ? "\x1b[2m" : "",
+    bold: useColor ? "\x1b[1m" : "",
+    cyan: useColor ? "\x1b[36m" : "",
+    yellow: useColor ? "\x1b[33m" : "",
+    green: useColor ? "\x1b[32m" : "",
+    magenta: useColor ? "\x1b[35m" : "",
+    blue: useColor ? "\x1b[34m" : "",
+};
+// Terminal cursor control
+const cursor = {
+    hide() { process.stderr.write('\x1b[?25l'); },
+    show() { process.stderr.write('\x1b[?25h'); },
+};
+// Ensure cursor is restored on exit
+process.on('SIGINT', () => { cursor.show(); process.exit(130); });
+process.on('SIGTERM', () => { cursor.show(); process.exit(143); });
+// Terminal progress bar using OSC 9;4 escape sequence (TTY only)
+const isTTY = process.stderr.isTTY;
+const progress = {
+    set(percent) {
+        if (isTTY)
+            process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
+    },
+    clear() {
+        if (isTTY)
+            process.stderr.write(`\x1b]9;4;0\x07`);
+    },
+    indeterminate() {
+        if (isTTY)
+            process.stderr.write(`\x1b]9;4;3\x07`);
+    },
+    error() {
+        if (isTTY)
+            process.stderr.write(`\x1b]9;4;2\x07`);
+    },
+};
+// Format seconds into human-readable ETA
+function formatETA(seconds) {
+    if (seconds < 60)
+        return `${Math.round(seconds)}s`;
+    if (seconds < 3600)
+        return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
+    return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
+}
+// Check index health and print warnings/tips
+function checkIndexHealth(db) {
+    const { needsEmbedding, totalDocs, daysStale } = getIndexHealth(db);
+    // Warn if many docs need embedding
+    if (needsEmbedding > 0) {
+        const pct = Math.round((needsEmbedding / totalDocs) * 100);
+        if (pct >= 10) {
+            process.stderr.write(`${c.yellow}Warning: ${needsEmbedding} documents (${pct}%) need embeddings. Run 'qmd embed' for better results.${c.reset}\n`);
+        }
+        else {
+            process.stderr.write(`${c.dim}Tip: ${needsEmbedding} documents need embeddings. Run 'qmd embed' to index them.${c.reset}\n`);
+        }
+    }
+    // Check if most recent document update is older than 2 weeks
+    if (daysStale !== null && daysStale >= 14) {
+        process.stderr.write(`${c.dim}Tip: Index last updated ${daysStale} days ago. Run 'qmd update' to refresh.${c.reset}\n`);
+    }
+}
+// Compute unique display path for a document
+// Always include at least parent folder + filename, add more parent dirs until unique
+function computeDisplayPath(filepath, collectionPath, existingPaths) {
+    // Get path relative to collection (include collection dir name)
+    const collectionDir = collectionPath.replace(/\/$/, '');
+    const collectionName = collectionDir.split('/').pop() || '';
+    let relativePath;
+    if (filepath.startsWith(collectionDir + '/')) {
+        // filepath is under collection: use collection name + relative path
+        relativePath = collectionName + filepath.slice(collectionDir.length);
+    }
+    else {
+        // Fallback: just use the filepath
+        relativePath = filepath;
+    }
+    const parts = relativePath.split('/').filter(p => p.length > 0);
+    // Always include at least parent folder + filename (minimum 2 parts if available)
+    // Then add more parent dirs until unique
+    const minParts = Math.min(2, parts.length);
+    for (let i = parts.length - minParts; i >= 0; i--) {
+        const candidate = parts.slice(i).join('/');
+        if (!existingPaths.has(candidate)) {
+            return candidate;
+        }
+    }
+    // Absolute fallback: use full path (should be unique)
+    return filepath;
+}
+function formatTimeAgo(date) {
+    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
+    if (seconds < 60)
+        return `${seconds}s ago`;
+    const minutes = Math.floor(seconds / 60);
+    if (minutes < 60)
+        return `${minutes}m ago`;
+    const hours = Math.floor(minutes / 60);
+    if (hours < 24)
+        return `${hours}h ago`;
+    const days = Math.floor(hours / 24);
+    return `${days}d ago`;
+}
+function formatMs(ms) {
+    if (ms < 1000)
+        return `${ms}ms`;
+    return `${(ms / 1000).toFixed(1)}s`;
+}
+function formatBytes(bytes) {
+    if (bytes < 1024)
+        return `${bytes} B`;
+    if (bytes < 1024 * 1024)
+        return `${(bytes / 1024).toFixed(1)} KB`;
+    if (bytes < 1024 * 1024 * 1024)
+        return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+}
+async function showStatus() {
+    const dbPath = getDbPath();
+    const db = getDb();
+    // Collections are defined in YAML; no duplicate cleanup needed.
+    // Collections are defined in YAML; no duplicate cleanup needed.
+    // Index size
+    let indexSize = 0;
+    try {
+        const stat = statSync(dbPath).size;
+        indexSize = stat;
+    }
+    catch { }
+    // Collections info (from YAML + database stats)
+    const collections = listCollections(db);
+    // Overall stats
+    const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get();
+    const vectorCount = db.prepare(`SELECT COUNT(*) as count FROM content_vectors`).get();
+    const needsEmbedding = getHashesNeedingEmbedding(db);
+    // Most recent update across all collections
+    const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get();
+    console.log(`${c.bold}QMD Status${c.reset}\n`);
+    console.log(`Index: ${dbPath}`);
+    console.log(`Size:  ${formatBytes(indexSize)}`);
+    // MCP daemon status (check PID file liveness)
+    const mcpCacheDir = process.env.XDG_CACHE_HOME
+        ? resolve(process.env.XDG_CACHE_HOME, "qmd")
+        : resolve(homedir(), ".cache", "qmd");
+    const mcpPidPath = resolve(mcpCacheDir, "mcp.pid");
+    if (existsSync(mcpPidPath)) {
+        const mcpPid = parseInt(readFileSync(mcpPidPath, "utf-8").trim());
+        try {
+            process.kill(mcpPid, 0);
+            console.log(`MCP:   ${c.green}running${c.reset} (PID ${mcpPid})`);
+        }
+        catch {
+            unlinkSync(mcpPidPath);
+            // Stale PID file cleaned up silently
+        }
+    }
+    console.log("");
+    console.log(`${c.bold}Documents${c.reset}`);
+    console.log(`  Total:    ${totalDocs.count} files indexed`);
+    console.log(`  Vectors:  ${vectorCount.count} embedded`);
+    if (needsEmbedding > 0) {
+        console.log(`  ${c.yellow}Pending:  ${needsEmbedding} need embedding${c.reset} (run 'qmd embed')`);
+    }
+    if (mostRecent.latest) {
+        const lastUpdate = new Date(mostRecent.latest);
+        console.log(`  Updated:  ${formatTimeAgo(lastUpdate)}`);
+    }
+    // Get all contexts grouped by collection (from YAML)
+    const allContexts = listAllContexts();
+    const contextsByCollection = new Map();
+    for (const ctx of allContexts) {
+        // Group contexts by collection name
+        if (!contextsByCollection.has(ctx.collection)) {
+            contextsByCollection.set(ctx.collection, []);
+        }
+        contextsByCollection.get(ctx.collection).push({
+            path_prefix: ctx.path,
+            context: ctx.context
+        });
+    }
+    // AST chunking status
+    try {
+        const { getASTStatus } = await import("../ast.js");
+        const ast = await getASTStatus();
+        console.log(`\n${c.bold}AST Chunking${c.reset}`);
+        if (ast.available) {
+            const ok = ast.languages.filter(l => l.available).map(l => l.language);
+            const fail = ast.languages.filter(l => !l.available);
+            console.log(`  Status:   ${c.green}active${c.reset}`);
+            console.log(`  Languages: ${ok.join(", ")}`);
+            if (fail.length > 0) {
+                for (const f of fail) {
+                    console.log(`  ${c.yellow}Unavailable: ${f.language} (${f.error})${c.reset}`);
+                }
+            }
+        }
+        else {
+            console.log(`  Status:   ${c.yellow}unavailable${c.reset} (falling back to regex chunking)`);
+            for (const l of ast.languages) {
+                if (l.error)
+                    console.log(`  ${c.dim}${l.language}: ${l.error}${c.reset}`);
+            }
+        }
+    }
+    catch {
+        console.log(`\n${c.bold}AST Chunking${c.reset}`);
+        console.log(`  Status:   ${c.dim}not available${c.reset}`);
+    }
+    if (collections.length > 0) {
+        console.log(`\n${c.bold}Collections${c.reset}`);
+        for (const col of collections) {
+            const lastMod = col.last_modified ? formatTimeAgo(new Date(col.last_modified)) : "never";
+            const contexts = contextsByCollection.get(col.name) || [];
+            console.log(`  ${c.cyan}${col.name}${c.reset} ${c.dim}(qmd://${col.name}/)${c.reset}`);
+            console.log(`    ${c.dim}Pattern:${c.reset}  ${col.glob_pattern}`);
+            console.log(`    ${c.dim}Files:${c.reset}    ${col.active_count} (updated ${lastMod})`);
+            if (contexts.length > 0) {
+                console.log(`    ${c.dim}Contexts:${c.reset} ${contexts.length}`);
+                for (const ctx of contexts) {
+                    // Handle both empty string and '/' as root context
+                    const pathDisplay = (ctx.path_prefix === '' || ctx.path_prefix === '/') ? '/' : `/${ctx.path_prefix}`;
+                    const contextPreview = ctx.context.length > 60
+                        ? ctx.context.substring(0, 57) + '...'
+                        : ctx.context;
+                    console.log(`      ${c.dim}${pathDisplay}:${c.reset} ${contextPreview}`);
+                }
+            }
+        }
+        // Show examples of virtual paths
+        console.log(`\n${c.bold}Examples${c.reset}`);
+        console.log(`  ${c.dim}# List files in a collection${c.reset}`);
+        if (collections.length > 0 && collections[0]) {
+            console.log(`  qmd ls ${collections[0].name}`);
+        }
+        console.log(`  ${c.dim}# Get a document${c.reset}`);
+        if (collections.length > 0 && collections[0]) {
+            console.log(`  qmd get qmd://${collections[0].name}/path/to/file.md`);
+        }
+        console.log(`  ${c.dim}# Search within a collection${c.reset}`);
+        if (collections.length > 0 && collections[0]) {
+            console.log(`  qmd search "query" -c ${collections[0].name}`);
+        }
+    }
+    else {
+        console.log(`\n${c.dim}No collections. Run 'qmd collection add .' to index markdown files.${c.reset}`);
+    }
+    // Models
+    {
+        // hf:org/repo/file.gguf → https://huggingface.co/org/repo
+        const hfLink = (uri) => {
+            const match = uri.match(/^hf:([^/]+\/[^/]+)\//);
+            return match ? `https://huggingface.co/${match[1]}` : uri;
+        };
+        console.log(`\n${c.bold}Models${c.reset}`);
+        console.log(`  Embedding:   ${hfLink(DEFAULT_EMBED_MODEL_URI)}`);
+        console.log(`  Reranking:   ${hfLink(DEFAULT_RERANK_MODEL_URI)}`);
+        console.log(`  Generation:  ${hfLink(DEFAULT_GENERATE_MODEL_URI)}`);
+    }
+    // Device / GPU info
+    try {
+        const llm = getDefaultLlamaCpp();
+        const device = await llm.getDeviceInfo();
+        console.log(`\n${c.bold}Device${c.reset}`);
+        if (device.gpu) {
+            console.log(`  GPU:      ${c.green}${device.gpu}${c.reset} (offloading: ${device.gpuOffloading ? 'yes' : 'no'})`);
+            if (device.gpuDevices.length > 0) {
+                // Deduplicate and count GPUs
+                const counts = new Map();
+                for (const name of device.gpuDevices) {
+                    counts.set(name, (counts.get(name) || 0) + 1);
+                }
+                const deviceStr = Array.from(counts.entries())
+                    .map(([name, count]) => count > 1 ? `${count}× ${name}` : name)
+                    .join(', ');
+                console.log(`  Devices:  ${deviceStr}`);
+            }
+            if (device.vram) {
+                console.log(`  VRAM:     ${formatBytes(device.vram.free)} free / ${formatBytes(device.vram.total)} total`);
+            }
+        }
+        else {
+            console.log(`  GPU:      ${c.yellow}none${c.reset} (running on CPU — models will be slow)`);
+            console.log(`  ${c.dim}Tip: Install CUDA, Vulkan, or Metal support for GPU acceleration.${c.reset}`);
+        }
+        console.log(`  CPU:      ${device.cpuCores} math cores`);
+    }
+    catch {
+        // Don't fail status if LLM init fails
+    }
+    // Tips section
+    const tips = [];
+    // Check for collections without context
+    const collectionsWithoutContext = collections.filter(col => {
+        const contexts = contextsByCollection.get(col.name) || [];
+        return contexts.length === 0;
+    });
+    if (collectionsWithoutContext.length > 0) {
+        const names = collectionsWithoutContext.map(c => c.name).slice(0, 3).join(', ');
+        const more = collectionsWithoutContext.length > 3 ? ` +${collectionsWithoutContext.length - 3} more` : '';
+        tips.push(`Add context to collections for better search results: ${names}${more}`);
+        tips.push(`  ${c.dim}qmd context add qmd://<name>/ "What this collection contains"${c.reset}`);
+        tips.push(`  ${c.dim}qmd context add qmd://<name>/meeting-notes "Weekly team meeting notes"${c.reset}`);
+    }
+    // Check for collections without update commands
+    const collectionsWithoutUpdate = collections.filter(col => {
+        const yamlCol = getCollectionFromYaml(col.name);
+        return !yamlCol?.update;
+    });
+    if (collectionsWithoutUpdate.length > 0 && collections.length > 1) {
+        const names = collectionsWithoutUpdate.map(c => c.name).slice(0, 3).join(', ');
+        const more = collectionsWithoutUpdate.length > 3 ? ` +${collectionsWithoutUpdate.length - 3} more` : '';
+        tips.push(`Add update commands to keep collections fresh: ${names}${more}`);
+        tips.push(`  ${c.dim}qmd collection update-cmd <name> 'git stash && git pull --rebase --ff-only && git stash pop'${c.reset}`);
+    }
+    if (tips.length > 0) {
+        console.log(`\n${c.bold}Tips${c.reset}`);
+        for (const tip of tips) {
+            console.log(`  ${tip}`);
+        }
+    }
+    closeDb();
+}
+async function updateCollections() {
+    const db = getDb();
+    const storeInstance = getStore();
+    // Collections are defined in YAML; no duplicate cleanup needed.
+    // Clear Ollama cache on update
+    clearCache(db);
+    const collections = listCollections(db);
+    if (collections.length === 0) {
+        console.log(`${c.dim}No collections found. Run 'qmd collection add .' to index markdown files.${c.reset}`);
+        closeDb();
+        return;
+    }
+    console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
+    for (let i = 0; i < collections.length; i++) {
+        const col = collections[i];
+        if (!col)
+            continue;
+        console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.name}${c.reset} ${c.dim}(${col.glob_pattern})${c.reset}`);
+        // Execute custom update command if specified in YAML
+        const yamlCol = getCollectionFromYaml(col.name);
+        if (yamlCol?.update) {
+            console.log(`${c.dim}    Running update command: ${yamlCol.update}${c.reset}`);
+            try {
+                const proc = nodeSpawn("bash", ["-c", yamlCol.update], {
+                    cwd: col.pwd,
+                    stdio: ["ignore", "pipe", "pipe"],
+                });
+                const [output, errorOutput, exitCode] = await new Promise((resolve, reject) => {
+                    let out = "";
+                    let err = "";
+                    proc.stdout?.on("data", (d) => { out += d.toString(); });
+                    proc.stderr?.on("data", (d) => { err += d.toString(); });
+                    proc.on("error", reject);
+                    proc.on("close", (code) => resolve([out, err, code ?? 1]));
+                });
+                if (output.trim()) {
+                    console.log(output.trim().split('\n').map(l => `    ${l}`).join('\n'));
+                }
+                if (errorOutput.trim()) {
+                    console.log(errorOutput.trim().split('\n').map(l => `    ${l}`).join('\n'));
+                }
+                if (exitCode !== 0) {
+                    console.log(`${c.yellow}✗ Update command failed with exit code ${exitCode}${c.reset}`);
+                    process.exit(exitCode);
+                }
+            }
+            catch (err) {
+                console.log(`${c.yellow}✗ Update command failed: ${err}${c.reset}`);
+                process.exit(1);
+            }
+        }
+        const startTime = Date.now();
+        console.log(`Collection: ${col.pwd} (${col.glob_pattern})`);
+        progress.indeterminate();
+        const result = await reindexCollection(storeInstance, col.pwd, col.glob_pattern, col.name, {
+            ignorePatterns: yamlCol?.ignore,
+            onProgress: (info) => {
+                progress.set((info.current / info.total) * 100);
+                const elapsed = (Date.now() - startTime) / 1000;
+                const rate = info.current / elapsed;
+                const remaining = (info.total - info.current) / rate;
+                const eta = info.current > 2 ? ` ETA: ${formatETA(remaining)}` : "";
+                if (isTTY)
+                    process.stderr.write(`\rIndexing: ${info.current}/${info.total}${eta}        `);
+            },
+        });
+        progress.clear();
+        console.log(`\nIndexed: ${result.indexed} new, ${result.updated} updated, ${result.unchanged} unchanged, ${result.removed} removed`);
+        if (result.orphanedCleaned > 0) {
+            console.log(`Cleaned up ${result.orphanedCleaned} orphaned content hash(es)`);
+        }
+        console.log("");
+    }
+    // Check if any documents need embedding (show once at end)
+    const needsEmbedding = getHashesNeedingEmbedding(db);
+    closeDb();
+    console.log(`${c.green}✓ All collections updated.${c.reset}`);
+    if (needsEmbedding > 0) {
+        console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
+    }
+}
+/**
+ * Detect which collection (if any) contains the given filesystem path.
+ * Returns { collectionId, collectionName, relativePath } or null if not in any collection.
+ */
+function detectCollectionFromPath(db, fsPath) {
+    const realPath = getRealPath(fsPath);
+    // Find collections that this path is under from YAML
+    const allCollections = yamlListCollections();
+    // Find longest matching path
+    let bestMatch = null;
+    for (const coll of allCollections) {
+        if (realPath.startsWith(coll.path + '/') || realPath === coll.path) {
+            if (!bestMatch || coll.path.length > bestMatch.path.length) {
+                bestMatch = { name: coll.name, path: coll.path };
+            }
+        }
+    }
+    if (!bestMatch)
+        return null;
+    // Calculate relative path
+    let relativePath = realPath;
+    if (relativePath.startsWith(bestMatch.path + '/')) {
+        relativePath = relativePath.slice(bestMatch.path.length + 1);
+    }
+    else if (relativePath === bestMatch.path) {
+        relativePath = '';
+    }
+    return {
+        collectionName: bestMatch.name,
+        relativePath
+    };
+}
+async function contextAdd(pathArg, contextText) {
+    const db = getDb();
+    // Handle "/" as global context (applies to all collections)
+    if (pathArg === '/') {
+        setGlobalContext(contextText);
+        resyncConfig();
+        console.log(`${c.green}✓${c.reset} Set global context`);
+        console.log(`${c.dim}Context: ${contextText}${c.reset}`);
+        closeDb();
+        return;
+    }
+    // Resolve path - defaults to current directory if not provided
+    let fsPath = pathArg || '.';
+    if (fsPath === '.' || fsPath === './') {
+        fsPath = getPwd();
+    }
+    else if (fsPath.startsWith('~/')) {
+        fsPath = homedir() + fsPath.slice(1);
+    }
+    else if (!fsPath.startsWith('/') && !fsPath.startsWith('qmd://')) {
+        fsPath = resolve(getPwd(), fsPath);
+    }
+    // Handle virtual paths (qmd://collection/path)
+    if (isVirtualPath(fsPath)) {
+        const parsed = parseVirtualPath(fsPath);
+        if (!parsed) {
+            console.error(`${c.yellow}Invalid virtual path: ${fsPath}${c.reset}`);
+            process.exit(1);
+        }
+        const coll = getCollectionFromYaml(parsed.collectionName);
+        if (!coll) {
+            console.error(`${c.yellow}Collection not found: ${parsed.collectionName}${c.reset}`);
+            process.exit(1);
+        }
+        yamlAddContext(parsed.collectionName, parsed.path, contextText);
+        resyncConfig();
+        const displayPath = parsed.path
+            ? `qmd://${parsed.collectionName}/${parsed.path}`
+            : `qmd://${parsed.collectionName}/ (collection root)`;
+        console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
+        console.log(`${c.dim}Context: ${contextText}${c.reset}`);
+        closeDb();
+        return;
+    }
+    // Detect collection from filesystem path
+    const detected = detectCollectionFromPath(db, fsPath);
+    if (!detected) {
+        console.error(`${c.yellow}Path is not in any indexed collection: ${fsPath}${c.reset}`);
+        console.error(`${c.dim}Run 'qmd status' to see indexed collections${c.reset}`);
+        process.exit(1);
+    }
+    yamlAddContext(detected.collectionName, detected.relativePath, contextText);
+    resyncConfig();
+    const displayPath = detected.relativePath ? `qmd://${detected.collectionName}/${detected.relativePath}` : `qmd://${detected.collectionName}/`;
+    console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
+    console.log(`${c.dim}Context: ${contextText}${c.reset}`);
+    closeDb();
+}
+function contextList() {
+    const db = getDb();
+    const allContexts = listAllContexts();
+    if (allContexts.length === 0) {
+        console.log(`${c.dim}No contexts configured. Use 'qmd context add' to add one.${c.reset}`);
+        closeDb();
+        return;
+    }
+    console.log(`\n${c.bold}Configured Contexts${c.reset}\n`);
+    let lastCollection = '';
+    for (const ctx of allContexts) {
+        if (ctx.collection !== lastCollection) {
+            console.log(`${c.cyan}${ctx.collection}${c.reset}`);
+            lastCollection = ctx.collection;
+        }
+        const displayPath = ctx.path ? `  ${ctx.path}` : '  / (root)';
+        console.log(`${displayPath}`);
+        console.log(`    ${c.dim}${ctx.context}${c.reset}`);
+    }
+    closeDb();
+}
+function contextRemove(pathArg) {
+    if (pathArg === '/') {
+        // Remove global context
+        setGlobalContext(undefined);
+        // Resync so SQLite store_config is updated
+        const s = getStore();
+        resyncConfig();
+        closeDb();
+        console.log(`${c.green}✓${c.reset} Removed global context`);
+        return;
+    }
+    // Handle virtual paths
+    if (isVirtualPath(pathArg)) {
+        const parsed = parseVirtualPath(pathArg);
+        if (!parsed) {
+            console.error(`${c.yellow}Invalid virtual path: ${pathArg}${c.reset}`);
+            process.exit(1);
+        }
+        const coll = getCollectionFromYaml(parsed.collectionName);
+        if (!coll) {
+            console.error(`${c.yellow}Collection not found: ${parsed.collectionName}${c.reset}`);
+            process.exit(1);
+        }
+        const success = yamlRemoveContext(coll.name, parsed.path);
+        if (!success) {
+            console.error(`${c.yellow}No context found for: ${pathArg}${c.reset}`);
+            process.exit(1);
+        }
+        console.log(`${c.green}✓${c.reset} Removed context for: ${pathArg}`);
+        return;
+    }
+    // Handle filesystem paths
+    let fsPath = pathArg;
+    if (fsPath === '.' || fsPath === './') {
+        fsPath = getPwd();
+    }
+    else if (fsPath.startsWith('~/')) {
+        fsPath = homedir() + fsPath.slice(1);
+    }
+    else if (!fsPath.startsWith('/')) {
+        fsPath = resolve(getPwd(), fsPath);
+    }
+    const db = getDb();
+    const detected = detectCollectionFromPath(db, fsPath);
+    closeDb();
+    if (!detected) {
+        console.error(`${c.yellow}Path is not in any indexed collection: ${fsPath}${c.reset}`);
+        process.exit(1);
+    }
+    const success = yamlRemoveContext(detected.collectionName, detected.relativePath);
+    if (!success) {
+        console.error(`${c.yellow}No context found for: qmd://${detected.collectionName}/${detected.relativePath}${c.reset}`);
+        process.exit(1);
+    }
+    console.log(`${c.green}✓${c.reset} Removed context for: qmd://${detected.collectionName}/${detected.relativePath}`);
+}
+function getDocument(filename, fromLine, maxLines, lineNumbers) {
+    const db = getDb();
+    // Parse :linenum suffix from filename (e.g., "file.md:100")
+    let inputPath = filename;
+    const colonMatch = inputPath.match(/:(\d+)$/);
+    if (colonMatch && !fromLine) {
+        const matched = colonMatch[1];
+        if (matched) {
+            fromLine = parseInt(matched, 10);
+            inputPath = inputPath.slice(0, -colonMatch[0].length);
+        }
+    }
+    // Handle docid lookup (#abc123, abc123, "#abc123", "abc123", etc.)
+    if (isDocid(inputPath)) {
+        const docidMatch = findDocumentByDocid(db, inputPath);
+        if (docidMatch) {
+            inputPath = docidMatch.filepath;
+        }
+        else {
+            console.error(`Document not found: ${filename}`);
+            closeDb();
+            process.exit(1);
+        }
+    }
+    let doc = null;
+    let virtualPath;
+    // Handle virtual paths (qmd://collection/path)
+    if (isVirtualPath(inputPath)) {
+        const parsed = parseVirtualPath(inputPath);
+        if (!parsed) {
+            console.error(`Invalid virtual path: ${inputPath}`);
+            closeDb();
+            process.exit(1);
+        }
+        // Try exact match on collection + path
+        doc = db.prepare(`
+      SELECT d.collection as collectionName, d.path, content.doc as body
+      FROM documents d
+      JOIN content ON content.hash = d.hash
+      WHERE d.collection = ? AND d.path = ? AND d.active = 1
+    `).get(parsed.collectionName, parsed.path);
+        if (!doc) {
+            // Try fuzzy match by path ending
+            doc = db.prepare(`
+        SELECT d.collection as collectionName, d.path, content.doc as body
+        FROM documents d
+        JOIN content ON content.hash = d.hash
+        WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
+        LIMIT 1
+      `).get(parsed.collectionName, `%${parsed.path}`);
+        }
+        virtualPath = inputPath;
+    }
+    else {
+        // Try to interpret as collection/path format first (before filesystem path)
+        // If path is relative (no / or ~ prefix), check if first component is a collection name
+        if (!inputPath.startsWith('/') && !inputPath.startsWith('~')) {
+            const parts = inputPath.split('/');
+            if (parts.length >= 2) {
+                const possibleCollection = parts[0];
+                const possiblePath = parts.slice(1).join('/');
+                // Check if this collection exists
+                const collExists = possibleCollection ? db.prepare(`
+          SELECT 1 FROM documents WHERE collection = ? AND active = 1 LIMIT 1
+        `).get(possibleCollection) : null;
+                if (collExists) {
+                    // Try exact match on collection + path
+                    doc = db.prepare(`
+            SELECT d.collection as collectionName, d.path, content.doc as body
+            FROM documents d
+            JOIN content ON content.hash = d.hash
+            WHERE d.collection = ? AND d.path = ? AND d.active = 1
+          `).get(possibleCollection || "", possiblePath || "");
+                    if (!doc) {
+                        // Try fuzzy match by path ending
+                        doc = db.prepare(`
+              SELECT d.collection as collectionName, d.path, content.doc as body
+              FROM documents d
+              JOIN content ON content.hash = d.hash
+              WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
+              LIMIT 1
+            `).get(possibleCollection || "", `%${possiblePath}`);
+                    }
+                    if (doc) {
+                        virtualPath = buildVirtualPath(doc.collectionName, doc.path);
+                        // Skip the filesystem path handling below
+                    }
+                }
+            }
+        }
+        // If not found as collection/path, handle as filesystem paths
+        if (!doc) {
+            let fsPath = inputPath;
+            // Expand ~ to home directory
+            if (fsPath.startsWith('~/')) {
+                fsPath = homedir() + fsPath.slice(1);
+            }
+            else if (!fsPath.startsWith('/')) {
+                // Relative path - resolve from current directory
+                fsPath = resolve(getPwd(), fsPath);
+            }
+            fsPath = getRealPath(fsPath);
+            // Try to detect which collection contains this path
+            const detected = detectCollectionFromPath(db, fsPath);
+            if (detected) {
+                // Found collection - query by collection name + relative path
+                doc = db.prepare(`
+          SELECT d.collection as collectionName, d.path, content.doc as body
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.collection = ? AND d.path = ? AND d.active = 1
+        `).get(detected.collectionName, detected.relativePath);
+            }
+            // Fuzzy match by filename (last component of path)
+            if (!doc) {
+                const filename = inputPath.split('/').pop() || inputPath;
+                doc = db.prepare(`
+          SELECT d.collection as collectionName, d.path, content.doc as body
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.path LIKE ? AND d.active = 1
+          LIMIT 1
+        `).get(`%${filename}`);
+            }
+            if (doc) {
+                virtualPath = buildVirtualPath(doc.collectionName, doc.path);
+            }
+            else {
+                virtualPath = inputPath;
+            }
+        }
+    }
+    // Ensure doc is not null before proceeding
+    if (!doc) {
+        console.error(`Document not found: ${filename}`);
+        closeDb();
+        process.exit(1);
+    }
+    // Get context for this file
+    const context = getContextForPath(db, doc.collectionName, doc.path);
+    let output = doc.body;
+    const startLine = fromLine || 1;
+    // Apply line filtering if specified
+    if (fromLine !== undefined || maxLines !== undefined) {
+        const lines = output.split('\n');
+        const start = startLine - 1; // Convert to 0-indexed
+        const end = maxLines !== undefined ? start + maxLines : lines.length;
+        output = lines.slice(start, end).join('\n');
+    }
+    // Add line numbers if requested
+    if (lineNumbers) {
+        output = addLineNumbers(output, startLine);
+    }
+    // Output context header if exists
+    if (context) {
+        console.log(`Folder Context: ${context}\n---\n`);
+    }
+    console.log(output);
+    closeDb();
+}
+// Multi-get: fetch multiple documents by glob pattern or comma-separated list
+function multiGet(pattern, maxLines, maxBytes = DEFAULT_MULTI_GET_MAX_BYTES, format = "cli") {
+    const db = getDb();
+    // Check if it's a comma-separated list or a glob pattern
+    const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?') && !pattern.includes('{');
+    let files;
+    if (isCommaSeparated) {
+        // Comma-separated list of files (can be virtual paths or relative paths)
+        const names = pattern.split(',').map(s => s.trim()).filter(Boolean);
+        files = [];
+        for (const name of names) {
+            let doc = null;
+            // Handle virtual paths
+            if (isVirtualPath(name)) {
+                const parsed = parseVirtualPath(name);
+                if (parsed) {
+                    // Try exact match on collection + path
+                    doc = db.prepare(`
+            SELECT
+              'qmd://' || d.collection || '/' || d.path as virtual_path,
+              LENGTH(content.doc) as body_length,
+              d.collection,
+              d.path
+            FROM documents d
+            JOIN content ON content.hash = d.hash
+            WHERE d.collection = ? AND d.path = ? AND d.active = 1
+          `).get(parsed.collectionName, parsed.path);
+                }
+            }
+            else {
+                // Try exact match on path
+                doc = db.prepare(`
+          SELECT
+            'qmd://' || d.collection || '/' || d.path as virtual_path,
+            LENGTH(content.doc) as body_length,
+            d.collection,
+            d.path
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.path = ? AND d.active = 1
+          LIMIT 1
+        `).get(name);
+                // Try suffix match
+                if (!doc) {
+                    doc = db.prepare(`
+            SELECT
+              'qmd://' || d.collection || '/' || d.path as virtual_path,
+              LENGTH(content.doc) as body_length,
+              d.collection,
+              d.path
+            FROM documents d
+            JOIN content ON content.hash = d.hash
+            WHERE d.path LIKE ? AND d.active = 1
+            LIMIT 1
+          `).get(`%${name}`);
+                }
+            }
+            if (doc) {
+                files.push({
+                    filepath: doc.virtual_path,
+                    displayPath: doc.virtual_path,
+                    bodyLength: doc.body_length,
+                    collection: doc.collection,
+                    path: doc.path
+                });
+            }
+            else {
+                console.error(`File not found: ${name}`);
+            }
+        }
+    }
+    else {
+        // Glob pattern - matchFilesByGlob now returns virtual paths
+        files = matchFilesByGlob(db, pattern).map(f => ({
+            ...f,
+            collection: undefined, // Will be fetched later if needed
+            path: undefined
+        }));
+        if (files.length === 0) {
+            console.error(`No files matched pattern: ${pattern}`);
+            closeDb();
+            process.exit(1);
+        }
+    }
+    // Collect results for structured output
+    const results = [];
+    for (const file of files) {
+        // Parse virtual path to get collection info if not already available
+        let collection = file.collection;
+        let path = file.path;
+        if (!collection || !path) {
+            const parsed = parseVirtualPath(file.filepath);
+            if (parsed) {
+                collection = parsed.collectionName;
+                path = parsed.path;
+            }
+        }
+        // Get context using collection-scoped function
+        const context = collection && path ? getContextForPath(db, collection, path) : null;
+        // Check size limit
+        if (file.bodyLength > maxBytes) {
+            results.push({
+                file: file.filepath,
+                displayPath: file.displayPath,
+                title: file.displayPath.split('/').pop() || file.displayPath,
+                body: "",
+                context,
+                skipped: true,
+                skipReason: `File too large (${Math.round(file.bodyLength / 1024)}KB > ${Math.round(maxBytes / 1024)}KB). Use 'qmd get ${file.displayPath}' to retrieve.`,
+            });
+            continue;
+        }
+        // Fetch document content using collection and path
+        if (!collection || !path)
+            continue;
+        const doc = db.prepare(`
+      SELECT content.doc as body, d.title
+      FROM documents d
+      JOIN content ON content.hash = d.hash
+      WHERE d.collection = ? AND d.path = ? AND d.active = 1
+    `).get(collection, path);
+        if (!doc)
+            continue;
+        let body = doc.body;
+        // Apply line limit if specified
+        if (maxLines !== undefined) {
+            const lines = body.split('\n');
+            body = lines.slice(0, maxLines).join('\n');
+            if (lines.length > maxLines) {
+                body += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
+            }
+        }
+        results.push({
+            file: file.filepath,
+            displayPath: file.displayPath,
+            title: doc.title || file.displayPath.split('/').pop() || file.displayPath,
+            body,
+            context,
+            skipped: false,
+        });
+    }
+    closeDb();
+    // Output based on format
+    if (format === "json") {
+        const output = results.map(r => ({
+            file: r.displayPath,
+            title: r.title,
+            ...(r.context && { context: r.context }),
+            ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
+        }));
+        console.log(JSON.stringify(output, null, 2));
+    }
+    else if (format === "csv") {
+        const escapeField = (val) => {
+            if (val === null || val === undefined)
+                return "";
+            const str = String(val);
+            if (str.includes(",") || str.includes('"') || str.includes("\n")) {
+                return `"${str.replace(/"/g, '""')}"`;
+            }
+            return str;
+        };
+        console.log("file,title,context,skipped,body");
+        for (const r of results) {
+            console.log([r.displayPath, r.title, r.context, r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(","));
+        }
+    }
+    else if (format === "files") {
+        for (const r of results) {
+            const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
+            const status = r.skipped ? "[SKIPPED]" : "";
+            console.log(`${r.displayPath}${ctx}${status ? `,${status}` : ""}`);
+        }
+    }
+    else if (format === "md") {
+        for (const r of results) {
+            console.log(`## ${r.displayPath}\n`);
+            if (r.title && r.title !== r.displayPath)
+                console.log(`**Title:** ${r.title}\n`);
+            if (r.context)
+                console.log(`**Context:** ${r.context}\n`);
+            if (r.skipped) {
+                console.log(`> ${r.skipReason}\n`);
+            }
+            else {
+                console.log("```");
+                console.log(r.body);
+                console.log("```\n");
+            }
+        }
+    }
+    else if (format === "xml") {
+        console.log('<?xml version="1.0" encoding="UTF-8"?>');
+        console.log("<documents>");
+        for (const r of results) {
+            console.log("  <document>");
+            console.log(`    <file>${escapeXml(r.displayPath)}</file>`);
+            console.log(`    <title>${escapeXml(r.title)}</title>`);
+            if (r.context)
+                console.log(`    <context>${escapeXml(r.context)}</context>`);
+            if (r.skipped) {
+                console.log(`    <skipped>true</skipped>`);
+                console.log(`    <reason>${escapeXml(r.skipReason || "")}</reason>`);
+            }
+            else {
+                console.log(`    <body>${escapeXml(r.body)}</body>`);
+            }
+            console.log("  </document>");
+        }
+        console.log("</documents>");
+    }
+    else {
+        // CLI format (default)
+        for (const r of results) {
+            console.log(`\n${'='.repeat(60)}`);
+            console.log(`File: ${r.displayPath}`);
+            console.log(`${'='.repeat(60)}\n`);
+            if (r.skipped) {
+                console.log(`[SKIPPED: ${r.skipReason}]`);
+                continue;
+            }
+            if (r.context) {
+                console.log(`Folder Context: ${r.context}\n---\n`);
+            }
+            console.log(r.body);
+        }
+    }
+}
+// List files in virtual file tree
+function listFiles(pathArg) {
+    const db = getDb();
+    if (!pathArg) {
+        // No argument - list all collections
+        const yamlCollections = yamlListCollections();
+        if (yamlCollections.length === 0) {
+            console.log("No collections found. Run 'qmd collection add .' to index files.");
+            closeDb();
+            return;
+        }
+        // Get file counts from database for each collection
+        const collections = yamlCollections.map(coll => {
+            const stats = db.prepare(`
+        SELECT COUNT(*) as file_count
+        FROM documents d
+        WHERE d.collection = ? AND d.active = 1
+      `).get(coll.name);
+            return {
+                name: coll.name,
+                file_count: stats?.file_count || 0
+            };
+        });
+        console.log(`${c.bold}Collections:${c.reset}\n`);
+        for (const coll of collections) {
+            console.log(`  ${c.dim}qmd://${c.reset}${c.cyan}${coll.name}/${c.reset}  ${c.dim}(${coll.file_count} files)${c.reset}`);
+        }
+        closeDb();
+        return;
+    }
+    // Parse the path argument
+    let collectionName;
+    let pathPrefix = null;
+    if (pathArg.startsWith('qmd://')) {
+        // Virtual path format: qmd://collection/path
+        const parsed = parseVirtualPath(pathArg);
+        if (!parsed) {
+            console.error(`Invalid virtual path: ${pathArg}`);
+            closeDb();
+            process.exit(1);
+        }
+        collectionName = parsed.collectionName;
+        pathPrefix = parsed.path;
+    }
+    else {
+        // Just collection name or collection/path
+        const parts = pathArg.split('/');
+        collectionName = parts[0] || '';
+        if (parts.length > 1) {
+            pathPrefix = parts.slice(1).join('/');
+        }
+    }
+    // Get the collection
+    const coll = getCollectionFromYaml(collectionName);
+    if (!coll) {
+        console.error(`Collection not found: ${collectionName}`);
+        console.error(`Run 'qmd ls' to see available collections.`);
+        closeDb();
+        process.exit(1);
+    }
+    // List files in the collection with size and modification time
+    let query;
+    let params;
+    if (pathPrefix) {
+        // List files under a specific path
+        query = `
+      SELECT d.path, d.title, d.modified_at, LENGTH(ct.doc) as size
+      FROM documents d
+      JOIN content ct ON d.hash = ct.hash
+      WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
+      ORDER BY d.path
+    `;
+        params = [coll.name, `${pathPrefix}%`];
+    }
+    else {
+        // List all files in the collection
+        query = `
+      SELECT d.path, d.title, d.modified_at, LENGTH(ct.doc) as size
+      FROM documents d
+      JOIN content ct ON d.hash = ct.hash
+      WHERE d.collection = ? AND d.active = 1
+      ORDER BY d.path
+    `;
+        params = [coll.name];
+    }
+    const files = db.prepare(query).all(...params);
+    if (files.length === 0) {
+        if (pathPrefix) {
+            console.log(`No files found under qmd://${collectionName}/${pathPrefix}`);
+        }
+        else {
+            console.log(`No files found in collection: ${collectionName}`);
+        }
+        closeDb();
+        return;
+    }
+    // Calculate max widths for alignment
+    const maxSize = Math.max(...files.map(f => formatBytes(f.size).length));
+    // Output in ls -l style
+    for (const file of files) {
+        const sizeStr = formatBytes(file.size).padStart(maxSize);
+        const date = new Date(file.modified_at);
+        const timeStr = formatLsTime(date);
+        // Dim the qmd:// prefix, highlight the filename
+        console.log(`${sizeStr}  ${timeStr}  ${c.dim}qmd://${collectionName}/${c.reset}${c.cyan}${file.path}${c.reset}`);
+    }
+    closeDb();
+}
+// Format date/time like ls -l
+function formatLsTime(date) {
+    const now = new Date();
+    const sixMonthsAgo = new Date(now.getTime() - 6 * 30 * 24 * 60 * 60 * 1000);
+    const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+    const month = months[date.getMonth()];
+    const day = date.getDate().toString().padStart(2, ' ');
+    // If file is older than 6 months, show year instead of time
+    if (date < sixMonthsAgo) {
+        const year = date.getFullYear();
+        return `${month} ${day}  ${year}`;
+    }
+    else {
+        const hours = date.getHours().toString().padStart(2, '0');
+        const minutes = date.getMinutes().toString().padStart(2, '0');
+        return `${month} ${day} ${hours}:${minutes}`;
+    }
+}
+// Collection management commands
+function collectionList() {
+    const db = getDb();
+    const collections = listCollections(db);
+    if (collections.length === 0) {
+        console.log("No collections found. Run 'qmd collection add .' to create one.");
+        closeDb();
+        return;
+    }
+    console.log(`${c.bold}Collections (${collections.length}):${c.reset}\n`);
+    for (const coll of collections) {
+        const updatedAt = coll.last_modified ? new Date(coll.last_modified) : new Date();
+        const timeAgo = formatTimeAgo(updatedAt);
+        // Get YAML config to check includeByDefault
+        const yamlColl = getCollectionFromYaml(coll.name);
+        const excluded = yamlColl?.includeByDefault === false;
+        const excludeTag = excluded ? ` ${c.yellow}[excluded]${c.reset}` : '';
+        console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(qmd://${coll.name}/)${c.reset}${excludeTag}`);
+        console.log(`  ${c.dim}Pattern:${c.reset}  ${coll.glob_pattern}`);
+        if (yamlColl?.ignore?.length) {
+            console.log(`  ${c.dim}Ignore:${c.reset}   ${yamlColl.ignore.join(', ')}`);
+        }
+        console.log(`  ${c.dim}Files:${c.reset}    ${coll.active_count}`);
+        console.log(`  ${c.dim}Updated:${c.reset}  ${timeAgo}`);
+        console.log();
+    }
+    closeDb();
+}
+async function collectionAdd(pwd, globPattern, name) {
+    // If name not provided, generate from pwd basename
+    let collName = name;
+    if (!collName) {
+        const parts = pwd.split('/').filter(Boolean);
+        collName = parts[parts.length - 1] || 'root';
+    }
+    // Check if collection with this name already exists in YAML
+    const existing = getCollectionFromYaml(collName);
+    if (existing) {
+        console.error(`${c.yellow}Collection '${collName}' already exists.${c.reset}`);
+        console.error(`Use a different name with --name <name>`);
+        process.exit(1);
+    }
+    // Check if a collection with this pwd+glob already exists in YAML
+    const allCollections = yamlListCollections();
+    const existingPwdGlob = allCollections.find(c => c.path === pwd && c.pattern === globPattern);
+    if (existingPwdGlob) {
+        console.error(`${c.yellow}A collection already exists for this path and pattern:${c.reset}`);
+        console.error(`  Name: ${existingPwdGlob.name} (qmd://${existingPwdGlob.name}/)`);
+        console.error(`  Pattern: ${globPattern}`);
+        console.error(`\nUse 'qmd update' to re-index it, or remove it first with 'qmd collection remove ${existingPwdGlob.name}'`);
+        process.exit(1);
+    }
+    // Add to YAML config + sync to SQLite
+    const { addCollection } = await import("../collections.js");
+    addCollection(collName, pwd, globPattern);
+    resyncConfig();
+    // Create the collection and index files
+    console.log(`Creating collection '${collName}'...`);
+    const newColl = getCollectionFromYaml(collName);
+    await indexFiles(pwd, globPattern, collName, false, newColl?.ignore);
+    console.log(`${c.green}✓${c.reset} Collection '${collName}' created successfully`);
+}
+function collectionRemove(name) {
+    // Check if collection exists in YAML
+    const coll = getCollectionFromYaml(name);
+    if (!coll) {
+        console.error(`${c.yellow}Collection not found: ${name}${c.reset}`);
+        console.error(`Run 'qmd collection list' to see available collections.`);
+        process.exit(1);
+    }
+    const db = getDb();
+    const result = removeCollection(db, name);
+    // Also remove from YAML config
+    yamlRemoveCollectionFn(name);
+    closeDb();
+    console.log(`${c.green}✓${c.reset} Removed collection '${name}'`);
+    console.log(`  Deleted ${result.deletedDocs} documents`);
+    if (result.cleanedHashes > 0) {
+        console.log(`  Cleaned up ${result.cleanedHashes} orphaned content hashes`);
+    }
+}
+function collectionRename(oldName, newName) {
+    // Check if old collection exists in YAML
+    const coll = getCollectionFromYaml(oldName);
+    if (!coll) {
+        console.error(`${c.yellow}Collection not found: ${oldName}${c.reset}`);
+        console.error(`Run 'qmd collection list' to see available collections.`);
+        process.exit(1);
+    }
+    // Check if new name already exists in YAML
+    const existing = getCollectionFromYaml(newName);
+    if (existing) {
+        console.error(`${c.yellow}Collection name already exists: ${newName}${c.reset}`);
+        console.error(`Choose a different name or remove the existing collection first.`);
+        process.exit(1);
+    }
+    const db = getDb();
+    renameCollection(db, oldName, newName);
+    // Also rename in YAML config
+    yamlRenameCollectionFn(oldName, newName);
+    closeDb();
+    console.log(`${c.green}✓${c.reset} Renamed collection '${oldName}' to '${newName}'`);
+    console.log(`  Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`);
+}
+async function indexFiles(pwd, globPattern = DEFAULT_GLOB, collectionName, suppressEmbedNotice = false, ignorePatterns) {
+    const db = getDb();
+    const resolvedPwd = pwd || getPwd();
+    const now = new Date().toISOString();
+    const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
+    // Clear Ollama cache on index
+    clearCache(db);
+    // Collection name must be provided (from YAML)
+    if (!collectionName) {
+        throw new Error("Collection name is required. Collections must be defined in ~/.config/qmd/index.yml");
+    }
+    console.log(`Collection: ${resolvedPwd} (${globPattern})`);
+    progress.indeterminate();
+    const allIgnore = [
+        ...excludeDirs.map(d => `**/${d}/**`),
+        ...(ignorePatterns || []),
+    ];
+    const allFiles = await fastGlob(globPattern, {
+        cwd: resolvedPwd,
+        onlyFiles: true,
+        followSymbolicLinks: false,
+        dot: false,
+        ignore: allIgnore,
+    });
+    // Filter hidden files/folders (dot: false handles top-level but not nested)
+    const files = allFiles.filter(file => {
+        const parts = file.split("/");
+        return !parts.some(part => part.startsWith("."));
+    });
+    const total = files.length;
+    const hasNoFiles = total === 0;
+    if (hasNoFiles) {
+        progress.clear();
+        console.log("No files found matching pattern.");
+        // Continue so the deactivation pass can mark previously indexed docs as inactive.
+    }
+    let indexed = 0, updated = 0, unchanged = 0, processed = 0;
+    const seenPaths = new Set();
+    const startTime = Date.now();
+    for (const relativeFile of files) {
+        const filepath = getRealPath(resolve(resolvedPwd, relativeFile));
+        const path = handelize(relativeFile); // Normalize path for token-friendliness
+        seenPaths.add(path);
+        let content;
+        try {
+            content = readFileSync(filepath, "utf-8");
+        }
+        catch (err) {
+            // Skip files that can't be read (e.g. iCloud evicted files returning EAGAIN)
+            processed++;
+            progress.set((processed / total) * 100);
+            continue;
+        }
+        // Skip empty files - nothing useful to index
+        if (!content.trim()) {
+            processed++;
+            continue;
+        }
+        const hash = await hashContent(content);
+        const title = extractTitle(content, relativeFile);
+        // Check if document exists in this collection with this path
+        const existing = findActiveDocument(db, collectionName, path);
+        if (existing) {
+            if (existing.hash === hash) {
+                // Hash unchanged, but check if title needs updating
+                if (existing.title !== title) {
+                    updateDocumentTitle(db, existing.id, title, now);
+                    updated++;
+                }
+                else {
+                    unchanged++;
+                }
+            }
+            else {
+                // Content changed - insert new content hash and update document
+                insertContent(db, hash, content, now);
+                const stat = statSync(filepath);
+                updateDocument(db, existing.id, title, hash, stat ? new Date(stat.mtime).toISOString() : now);
+                updated++;
+            }
+        }
+        else {
+            // New document - insert content and document
+            indexed++;
+            insertContent(db, hash, content, now);
+            const stat = statSync(filepath);
+            insertDocument(db, collectionName, path, title, hash, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
+        }
+        processed++;
+        progress.set((processed / total) * 100);
+        const elapsed = (Date.now() - startTime) / 1000;
+        const rate = processed / elapsed;
+        const remaining = (total - processed) / rate;
+        const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
+        if (isTTY)
+            process.stderr.write(`\rIndexing: ${processed}/${total}${eta}        `);
+    }
+    // Deactivate documents in this collection that no longer exist
+    const allActive = getActiveDocumentPaths(db, collectionName);
+    let removed = 0;
+    for (const path of allActive) {
+        if (!seenPaths.has(path)) {
+            deactivateDocument(db, collectionName, path);
+            removed++;
+        }
+    }
+    // Clean up orphaned content hashes (content not referenced by any document)
+    const orphanedContent = cleanupOrphanedContent(db);
+    // Check if vector index needs updating
+    const needsEmbedding = getHashesNeedingEmbedding(db);
+    progress.clear();
+    console.log(`\nIndexed: ${indexed} new, ${updated} updated, ${unchanged} unchanged, ${removed} removed`);
+    if (orphanedContent > 0) {
+        console.log(`Cleaned up ${orphanedContent} orphaned content hash(es)`);
+    }
+    if (needsEmbedding > 0 && !suppressEmbedNotice) {
+        console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
+    }
+    closeDb();
+}
+function renderProgressBar(percent, width = 30) {
+    const filled = Math.round((percent / 100) * width);
+    const empty = width - filled;
+    const bar = "█".repeat(filled) + "░".repeat(empty);
+    return bar;
+}
+function parseEmbedBatchOption(name, value) {
+    if (value === undefined)
+        return undefined;
+    const parsed = Number(value);
+    if (!Number.isInteger(parsed) || parsed < 1) {
+        throw new Error(`${name} must be a positive integer`);
+    }
+    return parsed;
+}
+function parseChunkStrategy(value) {
+    if (value === undefined)
+        return undefined;
+    const s = String(value);
+    if (s === "auto" || s === "regex")
+        return s;
+    throw new Error(`--chunk-strategy must be "auto" or "regex" (got "${s}")`);
+}
+async function vectorIndex(model = DEFAULT_EMBED_MODEL_URI, force = false, batchOptions) {
+    const storeInstance = getStore();
+    const db = storeInstance.db;
+    if (force) {
+        console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
+    }
+    // Check if there's work to do before starting
+    const hashesToEmbed = getHashesNeedingEmbedding(db);
+    if (hashesToEmbed === 0 && !force) {
+        console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
+        closeDb();
+        return;
+    }
+    console.log(`${c.dim}Model: ${model}${c.reset}\n`);
+    if (batchOptions?.maxDocsPerBatch !== undefined || batchOptions?.maxBatchBytes !== undefined) {
+        const maxDocsPerBatch = batchOptions.maxDocsPerBatch ?? DEFAULT_EMBED_MAX_DOCS_PER_BATCH;
+        const maxBatchBytes = batchOptions.maxBatchBytes ?? DEFAULT_EMBED_MAX_BATCH_BYTES;
+        console.log(`${c.dim}Batch: ${maxDocsPerBatch} docs / ${formatBytes(maxBatchBytes)}${c.reset}\n`);
+    }
+    cursor.hide();
+    progress.indeterminate();
+    const startTime = Date.now();
+    const result = await generateEmbeddings(storeInstance, {
+        force,
+        model,
+        maxDocsPerBatch: batchOptions?.maxDocsPerBatch,
+        maxBatchBytes: batchOptions?.maxBatchBytes,
+        chunkStrategy: batchOptions?.chunkStrategy,
+        onProgress: (info) => {
+            if (info.totalBytes === 0)
+                return;
+            const percent = (info.bytesProcessed / info.totalBytes) * 100;
+            progress.set(percent);
+            const elapsed = (Date.now() - startTime) / 1000;
+            const bytesPerSec = info.bytesProcessed / elapsed;
+            const remainingBytes = info.totalBytes - info.bytesProcessed;
+            const etaSec = remainingBytes / bytesPerSec;
+            const bar = renderProgressBar(percent);
+            const percentStr = percent.toFixed(0).padStart(3);
+            const throughput = `${formatBytes(bytesPerSec)}/s`;
+            const eta = elapsed > 2 ? formatETA(etaSec) : "...";
+            const errStr = info.errors > 0 ? ` ${c.yellow}${info.errors} err${c.reset}` : "";
+            if (isTTY)
+                process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${info.chunksEmbedded}/${info.totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset}   `);
+        },
+    });
+    progress.clear();
+    cursor.show();
+    const totalTimeSec = result.durationMs / 1000;
+    if (result.chunksEmbedded === 0 && result.docsProcessed === 0) {
+        console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
+    }
+    else {
+        console.log(`\r${c.green}${renderProgressBar(100)}${c.reset} ${c.bold}100%${c.reset}                                    `);
+        console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${result.chunksEmbedded}${c.reset} chunks from ${c.bold}${result.docsProcessed}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset}`);
+        if (result.errors > 0) {
+            console.log(`${c.yellow}⚠ ${result.errors} chunks failed${c.reset}`);
+        }
+    }
+    closeDb();
+}
+// Sanitize a term for FTS5: remove punctuation except apostrophes
+function sanitizeFTS5Term(term) {
+    // Remove all non-alphanumeric except apostrophes (for contractions like "don't")
+    return term.replace(/[^\w']/g, '').trim();
+}
+// Build FTS5 query: phrase-aware with fallback to individual terms
+function buildFTS5Query(query) {
+    // Sanitize the full query for phrase matching
+    const sanitizedQuery = query.replace(/[^\w\s']/g, '').trim();
+    const terms = query
+        .split(/\s+/)
+        .map(sanitizeFTS5Term)
+        .filter(term => term.length >= 2); // Skip single chars and empty
+    if (terms.length === 0)
+        return "";
+    if (terms.length === 1)
+        return `"${terms[0].replace(/"/g, '""')}"`;
+    // Strategy: exact phrase OR proximity match OR individual terms
+    // Exact phrase matches rank highest, then close proximity, then any term
+    const phrase = `"${sanitizedQuery.replace(/"/g, '""')}"`;
+    const quotedTerms = terms.map(t => `"${t.replace(/"/g, '""')}"`);
+    // FTS5 NEAR syntax: NEAR(term1 term2, distance)
+    const nearPhrase = `NEAR(${quotedTerms.join(' ')}, 10)`;
+    const orTerms = quotedTerms.join(' OR ');
+    // Exact phrase > proximity > any term
+    return `(${phrase}) OR (${nearPhrase}) OR (${orTerms})`;
+}
+// Normalize BM25 score to 0-1 range using sigmoid
+function normalizeBM25(score) {
+    // BM25 scores are negative in SQLite (lower = better)
+    // Typical range: -15 (excellent) to -2 (weak match)
+    // Map to 0-1 where higher is better
+    const absScore = Math.abs(score);
+    // Sigmoid-ish normalization: maps ~2-15 range to ~0.1-0.95
+    return 1 / (1 + Math.exp(-(absScore - 5) / 3));
+}
+// Highlight query terms in text (skip short words < 3 chars)
+function highlightTerms(text, query) {
+    if (!useColor)
+        return text;
+    const terms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
+    let result = text;
+    for (const term of terms) {
+        const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
+        result = result.replace(regex, `${c.yellow}${c.bold}$1${c.reset}`);
+    }
+    return result;
+}
+// Format score with color based on value
+function formatScore(score) {
+    const pct = (score * 100).toFixed(0).padStart(3);
+    if (!useColor)
+        return `${pct}%`;
+    if (score >= 0.7)
+        return `${c.green}${pct}%${c.reset}`;
+    if (score >= 0.4)
+        return `${c.yellow}${pct}%${c.reset}`;
+    return `${c.dim}${pct}%${c.reset}`;
+}
+function formatExplainNumber(value) {
+    return value.toFixed(4);
+}
+// Shorten directory path for display - relative to $HOME (used for context paths, not documents)
+function shortPath(dirpath) {
+    const home = homedir();
+    if (dirpath.startsWith(home)) {
+        return '~' + dirpath.slice(home.length);
+    }
+    return dirpath;
+}
+// Emit format-safe empty output for search commands.
+function printEmptySearchResults(format, reason = "no_results") {
+    if (format === "json") {
+        console.log("[]");
+        return;
+    }
+    if (format === "csv") {
+        console.log("docid,score,file,title,context,line,snippet");
+        return;
+    }
+    if (format === "xml") {
+        console.log("<results></results>");
+        return;
+    }
+    if (format === "md" || format === "files") {
+        return;
+    }
+    if (reason === "min_score") {
+        console.log("No results found above minimum score threshold.");
+        return;
+    }
+    console.log("No results found.");
+}
+const DEFAULT_EDITOR_URI_TEMPLATE = "vscode://file/{path}:{line}:{col}";
+function encodePathForEditorUri(absolutePath) {
+    return encodeURI(absolutePath)
+        .replace(/\?/g, "%3F")
+        .replace(/#/g, "%23");
+}
+function getEditorUriTemplate() {
+    const envTemplate = process.env.QMD_EDITOR_URI?.trim();
+    if (envTemplate)
+        return envTemplate;
+    try {
+        const config = loadConfig();
+        const configTemplate = (config.editor_uri
+            || config.editor_uri_template
+            || config.editorUri
+            || (typeof config["editor-uri"] === "string" ? config["editor-uri"] : undefined))?.trim();
+        if (configTemplate)
+            return configTemplate;
+    }
+    catch {
+        // Ignore config parsing issues and use default template.
+    }
+    return DEFAULT_EDITOR_URI_TEMPLATE;
+}
+export function buildEditorUri(template, absolutePath, line, col) {
+    const safeLine = Number.isFinite(line) && line > 0 ? Math.floor(line) : 1;
+    const safeCol = Number.isFinite(col) && col > 0 ? Math.floor(col) : 1;
+    const encodedPath = encodePathForEditorUri(absolutePath);
+    return template
+        .replace(/\{path\}/g, encodedPath)
+        .replace(/\{line\}/g, String(safeLine))
+        .replace(/\{col\}/g, String(safeCol))
+        .replace(/\{column\}/g, String(safeCol));
+}
+export function termLink(text, url, isTTY = !!process.stdout.isTTY) {
+    if (!isTTY)
+        return text;
+    return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
+}
+function outputResults(results, query, opts) {
+    const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
+    if (filtered.length === 0) {
+        printEmptySearchResults(opts.format, "min_score");
+        return;
+    }
+    // Helper to create qmd:// URI from displayPath
+    const toQmdPath = (displayPath) => `qmd://${displayPath}`;
+    if (opts.format === "json") {
+        // JSON output for LLM consumption
+        const output = filtered.map(row => {
+            const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
+            let body = opts.full ? row.body : undefined;
+            let snippet = !opts.full ? extractSnippet(row.body, query, 300, row.chunkPos, undefined, opts.intent).snippet : undefined;
+            if (opts.lineNumbers) {
+                if (body)
+                    body = addLineNumbers(body);
+                if (snippet)
+                    snippet = addLineNumbers(snippet);
+            }
+            return {
+                ...(docid && { docid: `#${docid}` }),
+                score: Math.round(row.score * 100) / 100,
+                file: toQmdPath(row.displayPath),
+                title: row.title,
+                ...(row.context && { context: row.context }),
+                ...(body && { body }),
+                ...(snippet && { snippet }),
+                ...(opts.explain && row.explain && { explain: row.explain }),
+            };
+        });
+        console.log(JSON.stringify(output, null, 2));
+    }
+    else if (opts.format === "files") {
+        // Simple docid,score,filepath,context output
+        for (const row of filtered) {
+            const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
+            const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
+            console.log(`#${docid},${row.score.toFixed(2)},${toQmdPath(row.displayPath)}${ctx}`);
+        }
+    }
+    else if (opts.format === "cli") {
+        const editorUriTemplate = getEditorUriTemplate();
+        const linkDb = getDb();
+        for (let i = 0; i < filtered.length; i++) {
+            const row = filtered[i];
+            if (!row)
+                continue;
+            const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent);
+            const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
+            // Line 1: filepath with docid
+            const virtualPath = row.file.startsWith("qmd://") ? row.file : toQmdPath(row.displayPath);
+            const parsed = parseVirtualPath(virtualPath);
+            const absolutePath = resolveVirtualPath(linkDb, virtualPath);
+            const legacyPath = toQmdPath(row.displayPath);
+            const displayPath = parsed?.path || row.displayPath;
+            // Only show :line if we actually found a term match in the snippet body (exclude header line).
+            const snippetBody = snippet.split("\n").slice(1).join("\n").toLowerCase();
+            const hasMatch = query.toLowerCase().split(/\s+/).some(t => t.length > 0 && snippetBody.includes(t));
+            const lineInfo = hasMatch ? `:${line}` : "";
+            const docidStr = docid ? ` ${c.dim}#${docid}${c.reset}` : "";
+            if (process.stdout.isTTY && absolutePath && parsed?.path) {
+                const linkLine = hasMatch ? line : 1;
+                const linkTarget = buildEditorUri(editorUriTemplate, absolutePath, linkLine, 1);
+                const clickable = termLink(`${displayPath}${lineInfo}`, linkTarget);
+                console.log(`${c.cyan}${clickable}${c.reset}${docidStr}`);
+            }
+            else {
+                console.log(`${c.cyan}${legacyPath}${c.dim}${lineInfo}${c.reset}${docidStr}`);
+            }
+            // Line 2: Title (if available)
+            if (row.title) {
+                console.log(`${c.bold}Title: ${row.title}${c.reset}`);
+            }
+            // Line 3: Context (if available)
+            if (row.context) {
+                console.log(`${c.dim}Context: ${row.context}${c.reset}`);
+            }
+            // Line 4: Score
+            const score = formatScore(row.score);
+            console.log(`Score: ${c.bold}${score}${c.reset}`);
+            if (opts.explain && row.explain) {
+                const explain = row.explain;
+                const ftsScores = explain.ftsScores.length > 0
+                    ? explain.ftsScores.map(formatExplainNumber).join(", ")
+                    : "none";
+                const vecScores = explain.vectorScores.length > 0
+                    ? explain.vectorScores.map(formatExplainNumber).join(", ")
+                    : "none";
+                const contribSummary = explain.rrf.contributions
+                    .slice()
+                    .sort((a, b) => b.rrfContribution - a.rrfContribution)
+                    .slice(0, 3)
+                    .map(c => `${c.source}/${c.queryType}#${c.rank}:${formatExplainNumber(c.rrfContribution)}`)
+                    .join(" | ");
+                console.log(`${c.dim}Explain: fts=[${ftsScores}] vec=[${vecScores}]${c.reset}`);
+                console.log(`${c.dim}  RRF: total=${formatExplainNumber(explain.rrf.totalScore)} base=${formatExplainNumber(explain.rrf.baseScore)} bonus=${formatExplainNumber(explain.rrf.topRankBonus)} rank=${explain.rrf.rank}${c.reset}`);
+                console.log(`${c.dim}  Blend: ${Math.round(explain.rrf.weight * 100)}%*${formatExplainNumber(explain.rrf.positionScore)} + ${Math.round((1 - explain.rrf.weight) * 100)}%*${formatExplainNumber(explain.rerankScore)} = ${formatExplainNumber(explain.blendedScore)}${c.reset}`);
+                if (contribSummary.length > 0) {
+                    console.log(`${c.dim}  Top RRF contributions: ${contribSummary}${c.reset}`);
+                }
+            }
+            console.log();
+            // Snippet with highlighting (diff-style header included)
+            let displaySnippet = opts.lineNumbers ? addLineNumbers(snippet, line) : snippet;
+            const highlighted = highlightTerms(displaySnippet, query);
+            console.log(highlighted);
+            // Double empty line between results
+            if (i < filtered.length - 1)
+                console.log('\n');
+        }
+    }
+    else if (opts.format === "md") {
+        for (let i = 0; i < filtered.length; i++) {
+            const row = filtered[i];
+            if (!row)
+                continue;
+            const heading = row.title || row.displayPath;
+            const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
+            let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent).snippet;
+            if (opts.lineNumbers) {
+                content = addLineNumbers(content);
+            }
+            const docidLine = docid ? `**docid:** \`#${docid}\`\n` : "";
+            const contextLine = row.context ? `**context:** ${row.context}\n` : "";
+            console.log(`---\n# ${heading}\n${docidLine}${contextLine}\n${content}\n`);
+        }
+    }
+    else if (opts.format === "xml") {
+        for (const row of filtered) {
+            const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
+            const contextAttr = row.context ? ` context="${row.context.replace(/"/g, '&quot;')}"` : "";
+            const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
+            let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent).snippet;
+            if (opts.lineNumbers) {
+                content = addLineNumbers(content);
+            }
+            console.log(`<file docid="#${docid}" name="${toQmdPath(row.displayPath)}"${titleAttr}${contextAttr}>\n${content}\n</file>\n`);
+        }
+    }
+    else {
+        // CSV format
+        console.log("docid,score,file,title,context,line,snippet");
+        for (const row of filtered) {
+            const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent);
+            let content = opts.full ? row.body : snippet;
+            if (opts.lineNumbers) {
+                content = addLineNumbers(content, line);
+            }
+            const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
+            const snippetText = content || "";
+            console.log(`#${docid},${row.score.toFixed(4)},${escapeCSV(toQmdPath(row.displayPath))},${escapeCSV(row.title || "")},${escapeCSV(row.context || "")},${line},${escapeCSV(snippetText)}`);
+        }
+    }
+}
+// Resolve -c collection filter: supports single string, array, or undefined.
+// Returns validated collection names (exits on unknown collection).
+function resolveCollectionFilter(raw, useDefaults = false) {
+    // If no filter specified and useDefaults is true, use default collections
+    if (!raw && useDefaults) {
+        return getDefaultCollectionNames();
+    }
+    if (!raw)
+        return [];
+    const names = Array.isArray(raw) ? raw : [raw];
+    const validated = [];
+    for (const name of names) {
+        const coll = getCollectionFromYaml(name);
+        if (!coll) {
+            console.error(`Collection not found: ${name}`);
+            closeDb();
+            process.exit(1);
+        }
+        validated.push(name);
+    }
+    return validated;
+}
+// Post-filter results to only include files from specified collections.
+function filterByCollections(results, collectionNames) {
+    if (collectionNames.length <= 1)
+        return results;
+    const prefixes = collectionNames.map(n => `qmd://${n}/`);
+    return results.filter(r => {
+        const path = r.filepath || r.file || '';
+        return prefixes.some(p => path.startsWith(p));
+    });
+}
+function parseStructuredQuery(query) {
+    const rawLines = query.split('\n').map((line, idx) => ({
+        raw: line,
+        trimmed: line.trim(),
+        number: idx + 1,
+    })).filter(line => line.trimmed.length > 0);
+    if (rawLines.length === 0)
+        return null;
+    const prefixRe = /^(lex|vec|hyde):\s*/i;
+    const expandRe = /^expand:\s*/i;
+    const intentRe = /^intent:\s*/i;
+    const typed = [];
+    let intent;
+    for (const line of rawLines) {
+        if (expandRe.test(line.trimmed)) {
+            if (rawLines.length > 1) {
+                throw new Error(`Line ${line.number} starts with expand:, but query documents cannot mix expand with typed lines. Submit a single expand query instead.`);
+            }
+            const text = line.trimmed.replace(expandRe, '').trim();
+            if (!text) {
+                throw new Error('expand: query must include text.');
+            }
+            return null; // treat as standalone expand query
+        }
+        // Parse intent: lines
+        if (intentRe.test(line.trimmed)) {
+            if (intent !== undefined) {
+                throw new Error(`Line ${line.number}: only one intent: line is allowed per query document.`);
+            }
+            const text = line.trimmed.replace(intentRe, '').trim();
+            if (!text) {
+                throw new Error(`Line ${line.number}: intent: must include text.`);
+            }
+            intent = text;
+            continue;
+        }
+        const match = line.trimmed.match(prefixRe);
+        if (match) {
+            const type = match[1].toLowerCase();
+            const text = line.trimmed.slice(match[0].length).trim();
+            if (!text) {
+                throw new Error(`Line ${line.number} (${type}:) must include text.`);
+            }
+            if (/\r|\n/.test(text)) {
+                throw new Error(`Line ${line.number} (${type}:) contains a newline. Keep each query on a single line.`);
+            }
+            typed.push({ type, query: text, line: line.number });
+            continue;
+        }
+        if (rawLines.length === 1) {
+            // Single plain line -> implicit expand
+            return null;
+        }
+        throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde:/intent: prefix. Each line in a query document must start with one.`);
+    }
+    // intent: alone is not a valid query — must have at least one search
+    if (intent && typed.length === 0) {
+        throw new Error('intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.');
+    }
+    return typed.length > 0 ? { searches: typed, intent } : null;
+}
+function search(query, opts) {
+    const db = getDb();
+    // Validate collection filter (supports multiple -c flags)
+    // Use default collections if none specified
+    const collectionNames = resolveCollectionFilter(opts.collection, true);
+    const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
+    // Use large limit for --all, otherwise fetch more than needed and let outputResults filter
+    const fetchLimit = opts.all ? 100000 : Math.max(50, opts.limit * 2);
+    const results = filterByCollections(searchFTS(db, query, fetchLimit, singleCollection), collectionNames);
+    // Add context to results
+    const resultsWithContext = results.map(r => ({
+        file: r.filepath,
+        displayPath: r.displayPath,
+        title: r.title,
+        body: r.body || "",
+        score: r.score,
+        context: getContextForFile(db, r.filepath),
+        hash: r.hash,
+        docid: r.docid,
+    }));
+    closeDb();
+    if (resultsWithContext.length === 0) {
+        printEmptySearchResults(opts.format);
+        return;
+    }
+    outputResults(resultsWithContext, query, opts);
+}
+// Log query expansion as a tree to stderr (CLI progress feedback)
+function logExpansionTree(originalQuery, expanded) {
+    const lines = [];
+    lines.push(`${c.dim}├─ ${originalQuery}${c.reset}`);
+    for (const q of expanded) {
+        let preview = q.query.replace(/\n/g, ' ');
+        if (preview.length > 72)
+            preview = preview.substring(0, 69) + '...';
+        lines.push(`${c.dim}├─ ${q.type}: ${preview}${c.reset}`);
+    }
+    if (lines.length > 0) {
+        lines[lines.length - 1] = lines[lines.length - 1].replace('├─', '└─');
+    }
+    for (const line of lines)
+        process.stderr.write(line + '\n');
+}
+async function vectorSearch(query, opts, _model = DEFAULT_EMBED_MODEL) {
+    const store = getStore();
+    // Validate collection filter (supports multiple -c flags)
+    // Use default collections if none specified
+    const collectionNames = resolveCollectionFilter(opts.collection, true);
+    const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
+    checkIndexHealth(store.db);
+    await withLLMSession(async () => {
+        let results = await vectorSearchQuery(store, query, {
+            collection: singleCollection,
+            limit: opts.all ? 500 : (opts.limit || 10),
+            minScore: opts.minScore || 0.3,
+            intent: opts.intent,
+            hooks: {
+                onExpand: (original, expanded) => {
+                    logExpansionTree(original, expanded);
+                    process.stderr.write(`${c.dim}Searching ${expanded.length + 1} vector queries...${c.reset}\n`);
+                },
+            },
+        });
+        // Post-filter for multi-collection
+        if (collectionNames.length > 1) {
+            results = results.filter(r => {
+                const prefixes = collectionNames.map(n => `qmd://${n}/`);
+                return prefixes.some(p => r.file.startsWith(p));
+            });
+        }
+        closeDb();
+        if (results.length === 0) {
+            printEmptySearchResults(opts.format);
+            return;
+        }
+        outputResults(results.map(r => ({
+            file: r.file,
+            displayPath: r.displayPath,
+            title: r.title,
+            body: r.body,
+            score: r.score,
+            context: r.context,
+            docid: r.docid,
+        })), query, { ...opts, limit: results.length });
+    }, { maxDuration: 10 * 60 * 1000, name: 'vectorSearch' });
+}
+async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rerankModel = DEFAULT_RERANK_MODEL) {
+    const store = getStore();
+    // Validate collection filter (supports multiple -c flags)
+    // Use default collections if none specified
+    const collectionNames = resolveCollectionFilter(opts.collection, true);
+    const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
+    checkIndexHealth(store.db);
+    // Check for structured query syntax (lex:/vec:/hyde:/intent: prefixes)
+    const parsed = parseStructuredQuery(query);
+    // Intent can come from --intent flag or from intent: line in query document
+    const intent = opts.intent || parsed?.intent;
+    await withLLMSession(async () => {
+        let results;
+        if (parsed) {
+            const structuredQueries = parsed.searches;
+            // Structured search — user provided their own query expansions
+            const typeLabels = structuredQueries.map(s => s.type).join('+');
+            process.stderr.write(`${c.dim}Structured search: ${structuredQueries.length} queries (${typeLabels})${c.reset}\n`);
+            if (intent) {
+                process.stderr.write(`${c.dim}├─ intent: ${intent}${c.reset}\n`);
+            }
+            // Log each sub-query
+            for (const s of structuredQueries) {
+                let preview = s.query.replace(/\n/g, ' ');
+                if (preview.length > 72)
+                    preview = preview.substring(0, 69) + '...';
+                process.stderr.write(`${c.dim}├─ ${s.type}: ${preview}${c.reset}\n`);
+            }
+            process.stderr.write(`${c.dim}└─ Searching...${c.reset}\n`);
+            results = await structuredSearch(store, structuredQueries, {
+                collections: singleCollection ? [singleCollection] : undefined,
+                limit: opts.all ? 500 : (opts.limit || 10),
+                minScore: opts.minScore || 0,
+                candidateLimit: opts.candidateLimit,
+                skipRerank: opts.skipRerank,
+                explain: !!opts.explain,
+                intent,
+                chunkStrategy: opts.chunkStrategy,
+                hooks: {
+                    onEmbedStart: (count) => {
+                        process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
+                    },
+                    onEmbedDone: (ms) => {
+                        process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
+                    },
+                    onRerankStart: (chunkCount) => {
+                        process.stderr.write(`${c.dim}Reranking ${chunkCount} chunks...${c.reset}`);
+                        progress.indeterminate();
+                    },
+                    onRerankDone: (ms) => {
+                        progress.clear();
+                        process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
+                    },
+                },
+            });
+        }
+        else {
+            // Standard hybrid query with automatic expansion
+            results = await hybridQuery(store, query, {
+                collection: singleCollection,
+                limit: opts.all ? 500 : (opts.limit || 10),
+                minScore: opts.minScore || 0,
+                candidateLimit: opts.candidateLimit,
+                skipRerank: opts.skipRerank,
+                explain: !!opts.explain,
+                intent,
+                chunkStrategy: opts.chunkStrategy,
+                hooks: {
+                    onStrongSignal: (score) => {
+                        process.stderr.write(`${c.dim}Strong BM25 signal (${score.toFixed(2)}) — skipping expansion${c.reset}\n`);
+                    },
+                    onExpandStart: () => {
+                        process.stderr.write(`${c.dim}Expanding query...${c.reset}`);
+                    },
+                    onExpand: (original, expanded, ms) => {
+                        process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
+                        logExpansionTree(original, expanded);
+                        process.stderr.write(`${c.dim}Searching ${expanded.length + 1} queries...${c.reset}\n`);
+                    },
+                    onEmbedStart: (count) => {
+                        process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
+                    },
+                    onEmbedDone: (ms) => {
+                        process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
+                    },
+                    onRerankStart: (chunkCount) => {
+                        process.stderr.write(`${c.dim}Reranking ${chunkCount} chunks...${c.reset}`);
+                        progress.indeterminate();
+                    },
+                    onRerankDone: (ms) => {
+                        progress.clear();
+                        process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
+                    },
+                },
+            });
+        }
+        // Post-filter for multi-collection
+        if (collectionNames.length > 1) {
+            results = results.filter(r => {
+                const prefixes = collectionNames.map(n => `qmd://${n}/`);
+                return prefixes.some(p => r.file.startsWith(p));
+            });
+        }
+        closeDb();
+        if (results.length === 0) {
+            printEmptySearchResults(opts.format);
+            return;
+        }
+        // Use first lex/vec query for output context, or original query
+        const structuredQueries = parsed?.searches;
+        const displayQuery = structuredQueries
+            ? (structuredQueries.find(s => s.type === 'lex')?.query || structuredQueries.find(s => s.type === 'vec')?.query || query)
+            : query;
+        // Map to CLI output format — use bestChunk for snippet display
+        outputResults(results.map(r => ({
+            file: r.file,
+            displayPath: r.displayPath,
+            title: r.title,
+            body: r.bestChunk,
+            chunkPos: r.bestChunkPos,
+            score: r.score,
+            context: r.context,
+            docid: r.docid,
+            explain: r.explain,
+        })), displayQuery, { ...opts, limit: results.length });
+    }, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
+}
+// Parse CLI arguments using util.parseArgs
+function parseCLI() {
+    const { values, positionals } = parseArgs({
+        args: process.argv.slice(2), // Skip node and script path
+        options: {
+            // Global options
+            index: {
+                type: "string",
+            },
+            context: {
+                type: "string",
+            },
+            help: { type: "boolean", short: "h" },
+            version: { type: "boolean", short: "v" },
+            skill: { type: "boolean" },
+            global: { type: "boolean" },
+            yes: { type: "boolean" },
+            // Search options
+            n: { type: "string" },
+            "min-score": { type: "string" },
+            all: { type: "boolean" },
+            full: { type: "boolean" },
+            csv: { type: "boolean" },
+            md: { type: "boolean" },
+            xml: { type: "boolean" },
+            files: { type: "boolean" },
+            json: { type: "boolean" },
+            explain: { type: "boolean" },
+            collection: { type: "string", short: "c", multiple: true }, // Filter by collection(s)
+            // Collection options
+            name: { type: "string" }, // collection name
+            mask: { type: "string" }, // glob pattern
+            // Embed options
+            force: { type: "boolean", short: "f" },
+            "max-docs-per-batch": { type: "string" },
+            "max-batch-mb": { type: "string" },
+            // Update options
+            pull: { type: "boolean" }, // git pull before update
+            refresh: { type: "boolean" },
+            // Get options
+            l: { type: "string" }, // max lines
+            from: { type: "string" }, // start line
+            "max-bytes": { type: "string" }, // max bytes for multi-get
+            "line-numbers": { type: "boolean" }, // add line numbers to output
+            // Query options
+            "candidate-limit": { type: "string", short: "C" },
+            "no-rerank": { type: "boolean", default: false },
+            intent: { type: "string" },
+            // Chunking options
+            "chunk-strategy": { type: "string" }, // "regex" (default) or "auto" (AST for code files)
+            // MCP HTTP transport options
+            http: { type: "boolean" },
+            daemon: { type: "boolean" },
+            port: { type: "string" },
+        },
+        allowPositionals: true,
+        strict: false, // Allow unknown options to pass through
+    });
+    // Select index name (default: "index")
+    const indexName = values.index;
+    if (indexName) {
+        setIndexName(indexName);
+        setConfigIndexName(indexName);
+    }
+    // Determine output format
+    let format = "cli";
+    if (values.csv)
+        format = "csv";
+    else if (values.md)
+        format = "md";
+    else if (values.xml)
+        format = "xml";
+    else if (values.files)
+        format = "files";
+    else if (values.json)
+        format = "json";
+    // Default limit: 20 for --files/--json, 5 otherwise
+    // --all means return all results (use very large limit)
+    const defaultLimit = (format === "files" || format === "json") ? 20 : 5;
+    const isAll = !!values.all;
+    const opts = {
+        format,
+        full: !!values.full,
+        limit: isAll ? 100000 : (values.n ? parseInt(String(values.n), 10) || defaultLimit : defaultLimit),
+        minScore: values["min-score"] ? parseFloat(String(values["min-score"])) || 0 : 0,
+        all: isAll,
+        collection: values.collection,
+        lineNumbers: !!values["line-numbers"],
+        candidateLimit: values["candidate-limit"] ? parseInt(String(values["candidate-limit"]), 10) : undefined,
+        skipRerank: !!values["no-rerank"],
+        explain: !!values.explain,
+        intent: values.intent,
+        chunkStrategy: parseChunkStrategy(values["chunk-strategy"]),
+    };
+    return {
+        command: positionals[0] || "",
+        args: positionals.slice(1),
+        query: positionals.slice(1).join(" "),
+        opts,
+        values,
+    };
+}
+function getSkillInstallDir(globalInstall) {
+    return globalInstall
+        ? resolve(homedir(), ".agents", "skills", "qmd")
+        : resolve(getPwd(), ".agents", "skills", "qmd");
+}
+function getClaudeSkillLinkPath(globalInstall) {
+    return globalInstall
+        ? resolve(homedir(), ".claude", "skills", "qmd")
+        : resolve(getPwd(), ".claude", "skills", "qmd");
+}
+function pathExists(path) {
+    try {
+        lstatSync(path);
+        return true;
+    }
+    catch {
+        return false;
+    }
+}
+function removePath(path) {
+    const stat = lstatSync(path);
+    if (stat.isDirectory() && !stat.isSymbolicLink()) {
+        rmSync(path, { recursive: true, force: true });
+    }
+    else {
+        unlinkSync(path);
+    }
+}
+function showSkill() {
+    console.log("QMD Skill (embedded)");
+    console.log("");
+    const content = getEmbeddedQmdSkillContent();
+    process.stdout.write(content.endsWith("\n") ? content : content + "\n");
+}
+function writeEmbeddedSkill(targetDir, force) {
+    if (pathExists(targetDir)) {
+        if (!force) {
+            throw new Error(`Skill already exists: ${targetDir} (use --force to replace it)`);
+        }
+        removePath(targetDir);
+    }
+    mkdirSync(targetDir, { recursive: true });
+    for (const file of getEmbeddedQmdSkillFiles()) {
+        const destination = resolve(targetDir, file.relativePath);
+        mkdirSync(dirname(destination), { recursive: true });
+        writeFileSync(destination, file.content, "utf-8");
+    }
+}
+function ensureClaudeSymlink(linkPath, targetDir, force) {
+    const parentDir = dirname(linkPath);
+    if (pathExists(parentDir)) {
+        const resolvedTargetDir = realpathSync(dirname(targetDir));
+        const resolvedLinkParent = realpathSync(parentDir);
+        // If .claude/skills already resolves to the same directory as .agents/skills,
+        // the skill is already visible to Claude and creating qmd -> qmd would loop.
+        if (resolvedTargetDir === resolvedLinkParent) {
+            return false;
+        }
+    }
+    const linkTarget = relativePath(parentDir, targetDir) || ".";
+    mkdirSync(parentDir, { recursive: true });
+    if (pathExists(linkPath)) {
+        const stat = lstatSync(linkPath);
+        if (stat.isSymbolicLink() && readlinkSync(linkPath) === linkTarget) {
+            return true;
+        }
+        if (!force) {
+            throw new Error(`Claude skill path already exists: ${linkPath} (use --force to replace it)`);
+        }
+        removePath(linkPath);
+    }
+    symlinkSync(linkTarget, linkPath, "dir");
+    return true;
+}
+async function shouldCreateClaudeSymlink(linkPath, autoYes) {
+    if (autoYes) {
+        return true;
+    }
+    if (!process.stdin.isTTY || !process.stdout.isTTY) {
+        console.log(`Tip: create a Claude symlink manually at ${linkPath}`);
+        return false;
+    }
+    const rl = createInterface({
+        input: process.stdin,
+        output: process.stdout,
+    });
+    try {
+        const answer = await rl.question(`Create a symlink in ${linkPath}? [y/N] `);
+        const normalized = answer.trim().toLowerCase();
+        return normalized === "y" || normalized === "yes";
+    }
+    finally {
+        rl.close();
+    }
+}
+async function installSkill(globalInstall, force, autoYes) {
+    const installDir = getSkillInstallDir(globalInstall);
+    writeEmbeddedSkill(installDir, force);
+    console.log(`✓ Installed QMD skill to ${installDir}`);
+    const claudeLinkPath = getClaudeSkillLinkPath(globalInstall);
+    if (!(await shouldCreateClaudeSymlink(claudeLinkPath, autoYes))) {
+        return;
+    }
+    const linked = ensureClaudeSymlink(claudeLinkPath, installDir, force);
+    if (linked) {
+        console.log(`✓ Linked Claude skill at ${claudeLinkPath}`);
+    }
+    else {
+        console.log(`✓ Claude already sees the skill via ${dirname(claudeLinkPath)}`);
+    }
+}
+function showHelp() {
+    console.log("qmd — Quick Markdown Search");
+    console.log("");
+    console.log("Usage:");
+    console.log("  qmd <command> [options]");
+    console.log("");
+    console.log("Primary commands:");
+    console.log("  qmd query <query>             - Hybrid search with auto expansion + reranking (recommended)");
+    console.log("  qmd query 'lex:..\\nvec:...'   - Structured query document (you provide lex/vec/hyde lines)");
+    console.log("  qmd search <query>            - Full-text BM25 keywords (no LLM)");
+    console.log("  qmd vsearch <query>           - Vector similarity only");
+    console.log("  qmd get <file>[:line] [-l N]  - Show a single document, optional line slice");
+    console.log("  qmd multi-get <pattern>       - Batch fetch via glob or comma-separated list");
+    console.log("  qmd skill show/install        - Show or install the packaged QMD skill");
+    console.log("  qmd mcp                       - Start the MCP server (stdio transport for AI agents)");
+    console.log("  qmd bench <fixture.json>      - Run search quality benchmarks against a fixture file");
+    console.log("");
+    console.log("Collections & context:");
+    console.log("  qmd collection add/list/remove/rename/show   - Manage indexed folders");
+    console.log("  qmd context add/list/rm                      - Attach human-written summaries");
+    console.log("  qmd ls [collection[/path]]                   - Inspect indexed files");
+    console.log("");
+    console.log("Maintenance:");
+    console.log("  qmd status                    - View index + collection health");
+    console.log("  qmd update [--pull]           - Re-index collections (optionally git pull first)");
+    console.log("  qmd embed [-f]                - Generate/refresh vector embeddings");
+    console.log("    --max-docs-per-batch <n>    - Cap docs loaded into memory per embedding batch");
+    console.log("    --max-batch-mb <n>          - Cap UTF-8 MB loaded into memory per embedding batch");
+    console.log("  qmd cleanup                   - Clear caches, vacuum DB");
+    console.log("");
+    console.log("Query syntax (qmd query):");
+    console.log("  QMD queries are either a single expand query (no prefix) or a multi-line");
+    console.log("  document where every line is typed with lex:, vec:, or hyde:. This grammar");
+    console.log("  matches the docs in docs/SYNTAX.md and is enforced in the CLI.");
+    console.log("");
+    const grammar = [
+        `query          = expand_query | query_document ;`,
+        `expand_query   = text | explicit_expand ;`,
+        `explicit_expand= "expand:" text ;`,
+        `query_document = [ intent_line ] { typed_line } ;`,
+        `intent_line    = "intent:" text newline ;`,
+        `typed_line     = type ":" text newline ;`,
+        `type           = "lex" | "vec" | "hyde" ;`,
+        `text           = quoted_phrase | plain_text ;`,
+        `quoted_phrase  = '"' { character } '"' ;`,
+        `plain_text     = { character } ;`,
+        `newline        = "\\n" ;`,
+    ];
+    console.log("  Grammar:");
+    for (const line of grammar) {
+        console.log(`    ${line}`);
+    }
+    console.log("");
+    console.log("  Examples:");
+    console.log("    qmd query \"how does auth work\"                # single-line → implicit expand");
+    console.log("    qmd query $'lex: CAP theorem\\nvec: consistency'  # typed query document");
+    console.log("    qmd query $'lex: \"exact matches\" sports -baseball'  # phrase + negation lex search");
+    console.log("    qmd query $'hyde: Hypothetical answer text'       # hyde-only document");
+    console.log("");
+    console.log("  Constraints:");
+    console.log("    - Standalone expand queries cannot mix with typed lines.");
+    console.log("    - Query documents allow only lex:, vec:, or hyde: prefixes.");
+    console.log("    - Each typed line must be single-line text with balanced quotes.");
+    console.log("");
+    console.log("AI agents & integrations:");
+    console.log("  - Run `qmd mcp` to expose the MCP server (stdio) to agents/IDEs.");
+    console.log("  - `qmd skill install` installs the QMD skill into ./.agents/skills/qmd.");
+    console.log("  - Use `qmd skill install --global` for ~/.agents/skills/qmd.");
+    console.log("  - `qmd --skill` is kept as an alias for `qmd skill show`.");
+    console.log("  - Advanced: `qmd mcp --http ...` and `qmd mcp --http --daemon` are optional for custom transports.");
+    console.log("");
+    console.log("Global options:");
+    console.log("  --index <name>             - Use a named index (default: index)");
+    console.log("  QMD_EDITOR_URI             - Editor link template for clickable TTY search output");
+    console.log("");
+    console.log("Search options:");
+    console.log("  -n <num>                   - Max results (default 5, or 20 for --files/--json)");
+    console.log("  --all                      - Return all matches (pair with --min-score)");
+    console.log("  --min-score <num>          - Minimum similarity score");
+    console.log("  --full                     - Output full document instead of snippet");
+    console.log("  -C, --candidate-limit <n>  - Max candidates to rerank (default 40, lower = faster)");
+    console.log("  --no-rerank                - Skip LLM reranking (use RRF scores only, much faster on CPU)");
+    console.log("  --line-numbers             - Include line numbers in output");
+    console.log("  --explain                  - Include retrieval score traces (query --json/CLI)");
+    console.log("  --files | --json | --csv | --md | --xml  - Output format");
+    console.log("  -c, --collection <name>    - Filter by one or more collections");
+    console.log("");
+    console.log("Embed/query options:");
+    console.log("  --chunk-strategy <auto|regex> - Chunking mode (default: regex; auto uses AST for code files)");
+    console.log("");
+    console.log("Multi-get options:");
+    console.log("  -l <num>                   - Maximum lines per file");
+    console.log("  --max-bytes <num>          - Skip files larger than N bytes (default 10240)");
+    console.log("  --json/--csv/--md/--xml/--files - Same formats as search");
+    console.log("");
+    console.log(`Index: ${getDbPath()}`);
+}
+async function showVersion() {
+    const scriptDir = dirname(fileURLToPath(import.meta.url));
+    const pkgPath = resolve(scriptDir, "..", "..", "package.json");
+    const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
+    let commit = "";
+    try {
+        commit = execSync(`git -C ${scriptDir} rev-parse --short HEAD`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
+    }
+    catch {
+        // Not a git repo or git not available
+    }
+    const versionStr = commit ? `${pkg.version} (${commit})` : pkg.version;
+    console.log(`qmd ${versionStr}`);
+}
+// Main CLI - only run if this is the main module
+const __filename = fileURLToPath(import.meta.url);
+const argv1 = process.argv[1];
+const isMain = argv1 === __filename
+    || argv1?.endsWith("/qmd.ts")
+    || argv1?.endsWith("/qmd.js")
+    || (argv1 != null && realpathSync(argv1) === __filename);
+if (isMain) {
+    const cli = parseCLI();
+    if (cli.values.version) {
+        await showVersion();
+        process.exit(0);
+    }
+    if (cli.values.skill) {
+        showSkill();
+        process.exit(0);
+    }
+    if (cli.values.help && cli.command === "skill") {
+        console.log("Usage: qmd skill <show|install> [options]");
+        console.log("");
+        console.log("Commands:");
+        console.log("  show                 Print the packaged QMD skill");
+        console.log("  install              Install into ./.agents/skills/qmd");
+        console.log("");
+        console.log("Options:");
+        console.log("  --global             Install into ~/.agents/skills/qmd");
+        console.log("  --yes                Also create the .claude/skills/qmd symlink");
+        console.log("  -f, --force          Replace existing install or symlink");
+        process.exit(0);
+    }
+    if (!cli.command || cli.values.help) {
+        showHelp();
+        process.exit(cli.values.help ? 0 : 1);
+    }
+    switch (cli.command) {
+        case "context": {
+            const subcommand = cli.args[0];
+            if (!subcommand) {
+                console.error("Usage: qmd context <add|list|rm>");
+                console.error("");
+                console.error("Commands:");
+                console.error("  qmd context add [path] \"text\"  - Add context (defaults to current dir)");
+                console.error("  qmd context add / \"text\"       - Add global context to all collections");
+                console.error("  qmd context list                - List all contexts");
+                console.error("  qmd context rm <path>           - Remove context");
+                process.exit(1);
+            }
+            switch (subcommand) {
+                case "add": {
+                    if (cli.args.length < 2) {
+                        console.error("Usage: qmd context add [path] \"text\"");
+                        console.error("");
+                        console.error("Examples:");
+                        console.error("  qmd context add \"Context for current directory\"");
+                        console.error("  qmd context add . \"Context for current directory\"");
+                        console.error("  qmd context add /subfolder \"Context for subfolder\"");
+                        console.error("  qmd context add / \"Global context for all collections\"");
+                        console.error("");
+                        console.error("  Using virtual paths:");
+                        console.error("  qmd context add qmd://journals/ \"Context for entire journals collection\"");
+                        console.error("  qmd context add qmd://journals/2024 \"Context for 2024 journals\"");
+                        process.exit(1);
+                    }
+                    let pathArg;
+                    let contextText;
+                    // Check if first arg looks like a path or if it's the context text
+                    const firstArg = cli.args[1] || '';
+                    const secondArg = cli.args[2];
+                    if (secondArg) {
+                        // Two args: path + context
+                        pathArg = firstArg;
+                        contextText = cli.args.slice(2).join(" ");
+                    }
+                    else {
+                        // One arg: context only (use current directory)
+                        pathArg = undefined;
+                        contextText = firstArg;
+                    }
+                    await contextAdd(pathArg, contextText);
+                    break;
+                }
+                case "list": {
+                    contextList();
+                    break;
+                }
+                case "rm":
+                case "remove": {
+                    if (cli.args.length < 2 || !cli.args[1]) {
+                        console.error("Usage: qmd context rm <path>");
+                        console.error("Examples:");
+                        console.error("  qmd context rm /");
+                        console.error("  qmd context rm qmd://journals/2024");
+                        process.exit(1);
+                    }
+                    contextRemove(cli.args[1]);
+                    break;
+                }
+                default:
+                    console.error(`Unknown subcommand: ${subcommand}`);
+                    console.error("Available: add, list, rm");
+                    process.exit(1);
+            }
+            break;
+        }
+        case "get": {
+            if (!cli.args[0]) {
+                console.error("Usage: qmd get <filepath>[:line] [--from <line>] [-l <lines>] [--line-numbers]");
+                process.exit(1);
+            }
+            const fromLine = cli.values.from ? parseInt(cli.values.from, 10) : undefined;
+            const maxLines = cli.values.l ? parseInt(cli.values.l, 10) : undefined;
+            getDocument(cli.args[0], fromLine, maxLines, cli.opts.lineNumbers);
+            break;
+        }
+        case "multi-get": {
+            if (!cli.args[0]) {
+                console.error("Usage: qmd multi-get <pattern> [-l <lines>] [--max-bytes <bytes>] [--json|--csv|--md|--xml|--files]");
+                console.error("  pattern: glob (e.g., 'journals/2025-05*.md') or comma-separated list");
+                process.exit(1);
+            }
+            const maxLinesMulti = cli.values.l ? parseInt(cli.values.l, 10) : undefined;
+            const maxBytes = cli.values["max-bytes"] ? parseInt(cli.values["max-bytes"], 10) : DEFAULT_MULTI_GET_MAX_BYTES;
+            multiGet(cli.args[0], maxLinesMulti, maxBytes, cli.opts.format);
+            break;
+        }
+        case "ls": {
+            listFiles(cli.args[0]);
+            break;
+        }
+        case "collection": {
+            const subcommand = cli.args[0];
+            switch (subcommand) {
+                case "list": {
+                    collectionList();
+                    break;
+                }
+                case "add": {
+                    const pwd = cli.args[1] || getPwd();
+                    const resolvedPwd = pwd === '.' ? getPwd() : getRealPath(resolve(pwd));
+                    const globPattern = cli.values.mask || DEFAULT_GLOB;
+                    const name = cli.values.name;
+                    await collectionAdd(resolvedPwd, globPattern, name);
+                    break;
+                }
+                case "remove":
+                case "rm": {
+                    if (!cli.args[1]) {
+                        console.error("Usage: qmd collection remove <name>");
+                        console.error("  Use 'qmd collection list' to see available collections");
+                        process.exit(1);
+                    }
+                    collectionRemove(cli.args[1]);
+                    break;
+                }
+                case "rename":
+                case "mv": {
+                    if (!cli.args[1] || !cli.args[2]) {
+                        console.error("Usage: qmd collection rename <old-name> <new-name>");
+                        console.error("  Use 'qmd collection list' to see available collections");
+                        process.exit(1);
+                    }
+                    collectionRename(cli.args[1], cli.args[2]);
+                    break;
+                }
+                case "set-update":
+                case "update-cmd": {
+                    const name = cli.args[1];
+                    const cmd = cli.args.slice(2).join(' ') || null;
+                    if (!name) {
+                        console.error("Usage: qmd collection update-cmd <name> [command]");
+                        console.error("  Set the command to run before indexing (e.g., 'git pull')");
+                        console.error("  Omit command to clear it");
+                        process.exit(1);
+                    }
+                    const { updateCollectionSettings, getCollection } = await import("../collections.js");
+                    const col = getCollection(name);
+                    if (!col) {
+                        console.error(`Collection not found: ${name}`);
+                        process.exit(1);
+                    }
+                    updateCollectionSettings(name, { update: cmd });
+                    if (cmd) {
+                        console.log(`✓ Set update command for '${name}': ${cmd}`);
+                    }
+                    else {
+                        console.log(`✓ Cleared update command for '${name}'`);
+                    }
+                    break;
+                }
+                case "include":
+                case "exclude": {
+                    const name = cli.args[1];
+                    if (!name) {
+                        console.error(`Usage: qmd collection ${subcommand} <name>`);
+                        console.error(`  ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`);
+                        process.exit(1);
+                    }
+                    const { updateCollectionSettings, getCollection } = await import("../collections.js");
+                    const col = getCollection(name);
+                    if (!col) {
+                        console.error(`Collection not found: ${name}`);
+                        process.exit(1);
+                    }
+                    const include = subcommand === 'include';
+                    updateCollectionSettings(name, { includeByDefault: include });
+                    console.log(`✓ Collection '${name}' ${include ? 'included in' : 'excluded from'} default queries`);
+                    break;
+                }
+                case "show":
+                case "info": {
+                    const name = cli.args[1];
+                    if (!name) {
+                        console.error("Usage: qmd collection show <name>");
+                        process.exit(1);
+                    }
+                    const { getCollection } = await import("../collections.js");
+                    const col = getCollection(name);
+                    if (!col) {
+                        console.error(`Collection not found: ${name}`);
+                        process.exit(1);
+                    }
+                    console.log(`Collection: ${name}`);
+                    console.log(`  Path:     ${col.path}`);
+                    console.log(`  Pattern:  ${col.pattern}`);
+                    console.log(`  Include:  ${col.includeByDefault !== false ? 'yes (default)' : 'no'}`);
+                    if (col.update) {
+                        console.log(`  Update:   ${col.update}`);
+                    }
+                    if (col.context) {
+                        const ctxCount = Object.keys(col.context).length;
+                        console.log(`  Contexts: ${ctxCount}`);
+                    }
+                    break;
+                }
+                case "help":
+                case undefined: {
+                    console.log("Usage: qmd collection <command> [options]");
+                    console.log("");
+                    console.log("Commands:");
+                    console.log("  list                      List all collections");
+                    console.log("  add <path> [--name NAME]  Add a collection");
+                    console.log("  remove <name>             Remove a collection");
+                    console.log("  rename <old> <new>        Rename a collection");
+                    console.log("  show <name>               Show collection details");
+                    console.log("  update-cmd <name> [cmd]   Set pre-update command (e.g., 'git pull')");
+                    console.log("  include <name>            Include in default queries");
+                    console.log("  exclude <name>            Exclude from default queries");
+                    console.log("");
+                    console.log("Examples:");
+                    console.log("  qmd collection add ~/notes --name notes");
+                    console.log("  qmd collection update-cmd brain 'git pull'");
+                    console.log("  qmd collection exclude archive");
+                    process.exit(0);
+                }
+                default:
+                    console.error(`Unknown subcommand: ${subcommand}`);
+                    console.error("Run 'qmd collection help' for usage");
+                    process.exit(1);
+            }
+            break;
+        }
+        case "status":
+            await showStatus();
+            break;
+        case "update":
+            await updateCollections();
+            break;
+        case "embed":
+            try {
+                const maxDocsPerBatch = parseEmbedBatchOption("maxDocsPerBatch", cli.values["max-docs-per-batch"]);
+                const maxBatchMb = parseEmbedBatchOption("maxBatchBytes", cli.values["max-batch-mb"]);
+                const embedChunkStrategy = parseChunkStrategy(cli.values["chunk-strategy"]);
+                await vectorIndex(DEFAULT_EMBED_MODEL_URI, !!cli.values.force, {
+                    maxDocsPerBatch,
+                    maxBatchBytes: maxBatchMb === undefined ? undefined : maxBatchMb * 1024 * 1024,
+                    chunkStrategy: embedChunkStrategy,
+                });
+            }
+            catch (error) {
+                console.error(error instanceof Error ? error.message : String(error));
+                process.exit(1);
+            }
+            break;
+        case "pull": {
+            const refresh = cli.values.refresh === undefined ? false : Boolean(cli.values.refresh);
+            const models = [
+                DEFAULT_EMBED_MODEL_URI,
+                DEFAULT_GENERATE_MODEL_URI,
+                DEFAULT_RERANK_MODEL_URI,
+            ];
+            console.log(`${c.bold}Pulling models${c.reset}`);
+            const results = await pullModels(models, {
+                refresh,
+                cacheDir: DEFAULT_MODEL_CACHE_DIR,
+            });
+            for (const result of results) {
+                const size = formatBytes(result.sizeBytes);
+                const note = result.refreshed ? "refreshed" : "cached/checked";
+                console.log(`- ${result.model} -> ${result.path} (${size}, ${note})`);
+            }
+            break;
+        }
+        case "search":
+            if (!cli.query) {
+                console.error("Usage: qmd search [options] <query>");
+                process.exit(1);
+            }
+            search(cli.query, cli.opts);
+            break;
+        case "vsearch":
+        case "vector-search": // undocumented alias
+            if (!cli.query) {
+                console.error("Usage: qmd vsearch [options] <query>");
+                process.exit(1);
+            }
+            // Default min-score for vector search is 0.3
+            if (!cli.values["min-score"]) {
+                cli.opts.minScore = 0.3;
+            }
+            await vectorSearch(cli.query, cli.opts);
+            break;
+        case "query":
+        case "deep-search": // undocumented alias
+            if (!cli.query) {
+                console.error("Usage: qmd query [options] <query>");
+                process.exit(1);
+            }
+            await querySearch(cli.query, cli.opts);
+            break;
+        case "bench": {
+            const fixturePath = cli.args[0];
+            if (!fixturePath) {
+                console.error("Usage: qmd bench <fixture.json> [--json] [-c collection]");
+                console.error("");
+                console.error("Run search quality benchmarks against a fixture file.");
+                console.error("See src/bench/fixtures/example.json for the fixture format.");
+                process.exit(1);
+            }
+            const { runBenchmark } = await import("../bench/bench.js");
+            const benchCollection = cli.opts.collection;
+            await runBenchmark(fixturePath, {
+                json: !!cli.opts.json,
+                collection: Array.isArray(benchCollection) ? benchCollection[0] : benchCollection,
+            });
+            break;
+        }
+        case "mcp": {
+            const sub = cli.args[0]; // stop | status | undefined
+            // Cache dir for PID/log files — same dir as the index
+            const cacheDir = process.env.XDG_CACHE_HOME
+                ? resolve(process.env.XDG_CACHE_HOME, "qmd")
+                : resolve(homedir(), ".cache", "qmd");
+            const pidPath = resolve(cacheDir, "mcp.pid");
+            // Subcommands take priority over flags
+            if (sub === "stop") {
+                if (!existsSync(pidPath)) {
+                    console.log("Not running (no PID file).");
+                    process.exit(0);
+                }
+                const pid = parseInt(readFileSync(pidPath, "utf-8").trim());
+                try {
+                    process.kill(pid, 0); // alive?
+                    process.kill(pid, "SIGTERM");
+                    unlinkSync(pidPath);
+                    console.log(`Stopped QMD MCP server (PID ${pid}).`);
+                }
+                catch {
+                    unlinkSync(pidPath);
+                    console.log("Cleaned up stale PID file (server was not running).");
+                }
+                process.exit(0);
+            }
+            if (cli.values.http) {
+                const port = Number(cli.values.port) || 8181;
+                if (cli.values.daemon) {
+                    // Guard: check if already running
+                    if (existsSync(pidPath)) {
+                        const existingPid = parseInt(readFileSync(pidPath, "utf-8").trim());
+                        try {
+                            process.kill(existingPid, 0); // alive?
+                            console.error(`Already running (PID ${existingPid}). Run 'qmd mcp stop' first.`);
+                            process.exit(1);
+                        }
+                        catch {
+                            // Stale PID file — continue
+                        }
+                    }
+                    mkdirSync(cacheDir, { recursive: true });
+                    const logPath = resolve(cacheDir, "mcp.log");
+                    const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run
+                    const selfPath = fileURLToPath(import.meta.url);
+                    const spawnArgs = selfPath.endsWith(".ts")
+                        ? ["--import", pathJoin(dirname(selfPath), "..", "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
+                        : [selfPath, "mcp", "--http", "--port", String(port)];
+                    const child = nodeSpawn(process.execPath, spawnArgs, {
+                        stdio: ["ignore", logFd, logFd],
+                        detached: true,
+                    });
+                    child.unref();
+                    closeSync(logFd); // parent's copy; child inherited the fd
+                    writeFileSync(pidPath, String(child.pid));
+                    console.log(`Started on http://localhost:${port}/mcp (PID ${child.pid})`);
+                    console.log(`Logs: ${logPath}`);
+                    process.exit(0);
+                }
+                // Foreground HTTP mode — remove top-level cursor handlers so the
+                // async cleanup handlers in startMcpHttpServer actually run.
+                process.removeAllListeners("SIGTERM");
+                process.removeAllListeners("SIGINT");
+                const { startMcpHttpServer } = await import("../mcp/server.js");
+                try {
+                    await startMcpHttpServer(port);
+                }
+                catch (e) {
+                    if (e?.code === "EADDRINUSE") {
+                        console.error(`Port ${port} already in use. Try a different port with --port.`);
+                        process.exit(1);
+                    }
+                    throw e;
+                }
+            }
+            else {
+                // Default: stdio transport
+                const { startMcpServer } = await import("../mcp/server.js");
+                await startMcpServer();
+            }
+            break;
+        }
+        case "skill": {
+            const subcommand = cli.args[0];
+            switch (subcommand) {
+                case "show": {
+                    showSkill();
+                    break;
+                }
+                case "install": {
+                    try {
+                        await installSkill(Boolean(cli.values.global), Boolean(cli.values.force), Boolean(cli.values.yes));
+                    }
+                    catch (error) {
+                        console.error(error instanceof Error ? error.message : String(error));
+                        process.exit(1);
+                    }
+                    break;
+                }
+                case "help":
+                case undefined: {
+                    console.log("Usage: qmd skill <show|install> [options]");
+                    console.log("");
+                    console.log("Commands:");
+                    console.log("  show                 Print the packaged QMD skill");
+                    console.log("  install              Install into ./.agents/skills/qmd");
+                    console.log("");
+                    console.log("Options:");
+                    console.log("  --global             Install into ~/.agents/skills/qmd");
+                    console.log("  --yes                Also create the .claude/skills/qmd symlink");
+                    console.log("  -f, --force          Replace existing install or symlink");
+                    process.exit(0);
+                }
+                default:
+                    console.error(`Unknown subcommand: ${subcommand}`);
+                    console.error("Run 'qmd skill help' for usage");
+                    process.exit(1);
+            }
+            break;
+        }
+        case "cleanup": {
+            const db = getDb();
+            // 1. Clear llm_cache
+            const cacheCount = deleteLLMCache(db);
+            console.log(`${c.green}✓${c.reset} Cleared ${cacheCount} cached API responses`);
+            // 2. Remove orphaned vectors
+            const orphanedVecs = cleanupOrphanedVectors(db);
+            if (orphanedVecs > 0) {
+                console.log(`${c.green}✓${c.reset} Removed ${orphanedVecs} orphaned embedding chunks`);
+            }
+            else {
+                console.log(`${c.dim}No orphaned embeddings to remove${c.reset}`);
+            }
+            // 3. Remove inactive documents
+            const inactiveDocs = deleteInactiveDocuments(db);
+            if (inactiveDocs > 0) {
+                console.log(`${c.green}✓${c.reset} Removed ${inactiveDocs} inactive document records`);
+            }
+            // 4. Vacuum to reclaim space
+            vacuumDatabase(db);
+            console.log(`${c.green}✓${c.reset} Database vacuumed`);
+            closeDb();
+            break;
+        }
+        default:
+            console.error(`Unknown command: ${cli.command}`);
+            console.error("Run 'qmd --help' for usage.");
+            process.exit(1);
+    }
+    if (cli.command !== "mcp") {
+        await disposeDefaultLlamaCpp();
+        process.exit(0);
+    }
+} // end if (main module)

+ 157 - 0
dist/collections.d.ts

@@ -0,0 +1,157 @@
+/**
+ * Collections configuration management
+ *
+ * This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml.
+ * Collections define which directories to index and their associated contexts.
+ */
+/**
+ * Context definitions for a collection
+ * Key is path prefix (e.g., "/", "/2024", "/Board of Directors")
+ * Value is the context description
+ */
+export type ContextMap = Record<string, string>;
+/**
+ * A single collection configuration
+ */
+export interface Collection {
+    path: string;
+    pattern: string;
+    ignore?: string[];
+    context?: ContextMap;
+    update?: string;
+    includeByDefault?: boolean;
+}
+/**
+ * Model configuration for embedding, reranking, and generation
+ */
+export interface ModelsConfig {
+    embed?: string;
+    rerank?: string;
+    generate?: string;
+}
+/**
+ * The complete configuration file structure
+ */
+export interface CollectionConfig {
+    global_context?: string;
+    editor_uri?: string;
+    editor_uri_template?: string;
+    collections: Record<string, Collection>;
+    models?: ModelsConfig;
+}
+/**
+ * Collection with its name (for return values)
+ */
+export interface NamedCollection extends Collection {
+    name: string;
+}
+/**
+ * Set the config source for SDK mode.
+ * - File path: load/save from a specific YAML file
+ * - Inline config: use an in-memory CollectionConfig (saveConfig updates in place, no file I/O)
+ * - undefined: reset to default file-based config
+ */
+export declare function setConfigSource(source?: {
+    configPath?: string;
+    config?: CollectionConfig;
+}): void;
+/**
+ * Set the current index name for config file lookup
+ * Config file will be ~/.config/qmd/{indexName}.yml
+ */
+export declare function setConfigIndexName(name: string): void;
+/**
+ * Load configuration from the configured source.
+ * - Inline config: returns the in-memory object directly
+ * - File-based: reads from YAML file (default ~/.config/qmd/index.yml)
+ * Returns empty config if file doesn't exist
+ */
+export declare function loadConfig(): CollectionConfig;
+/**
+ * Save configuration to the configured source.
+ * - Inline config: updates the in-memory object (no file I/O)
+ * - File-based: writes to YAML file (default ~/.config/qmd/index.yml)
+ */
+export declare function saveConfig(config: CollectionConfig): void;
+/**
+ * Get a specific collection by name
+ * Returns null if not found
+ */
+export declare function getCollection(name: string): NamedCollection | null;
+/**
+ * List all collections
+ */
+export declare function listCollections(): NamedCollection[];
+/**
+ * Get collections that are included by default in queries
+ */
+export declare function getDefaultCollections(): NamedCollection[];
+/**
+ * Get collection names that are included by default
+ */
+export declare function getDefaultCollectionNames(): string[];
+/**
+ * Update a collection's settings
+ */
+export declare function updateCollectionSettings(name: string, settings: {
+    update?: string | null;
+    includeByDefault?: boolean;
+}): boolean;
+/**
+ * Add or update a collection
+ */
+export declare function addCollection(name: string, path: string, pattern?: string): void;
+/**
+ * Remove a collection
+ */
+export declare function removeCollection(name: string): boolean;
+/**
+ * Rename a collection
+ */
+export declare function renameCollection(oldName: string, newName: string): boolean;
+/**
+ * Get global context
+ */
+export declare function getGlobalContext(): string | undefined;
+/**
+ * Set global context
+ */
+export declare function setGlobalContext(context: string | undefined): void;
+/**
+ * Get all contexts for a collection
+ */
+export declare function getContexts(collectionName: string): ContextMap | undefined;
+/**
+ * Add or update a context for a specific path in a collection
+ */
+export declare function addContext(collectionName: string, pathPrefix: string, contextText: string): boolean;
+/**
+ * Remove a context from a collection
+ */
+export declare function removeContext(collectionName: string, pathPrefix: string): boolean;
+/**
+ * List all contexts across all collections
+ */
+export declare function listAllContexts(): Array<{
+    collection: string;
+    path: string;
+    context: string;
+}>;
+/**
+ * Find best matching context for a given collection and path
+ * Returns the most specific matching context (longest path prefix match)
+ */
+export declare function findContextForPath(collectionName: string, filePath: string): string | undefined;
+/**
+ * Get the config file path (useful for error messages)
+ */
+export declare function getConfigPath(): string;
+/**
+ * Check if config file exists
+ */
+export declare function configExists(): boolean;
+/**
+ * Validate a collection name
+ * Collection names must be valid and not contain special characters
+ */
+export declare function isValidCollectionName(name: string): boolean;

+ 385 - 0
dist/collections.js

@@ -0,0 +1,385 @@
+/**
+ * Collections configuration management
+ *
+ * This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml.
+ * Collections define which directories to index and their associated contexts.
+ */
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
+import { join, dirname } from "path";
+import { homedir } from "os";
+import YAML from "yaml";
+// ============================================================================
+// Configuration paths
+// ============================================================================
+// Current index name (default: "index")
+let currentIndexName = "index";
+// SDK mode: optional in-memory config or custom config path
+let configSource = { type: 'file' };
+/**
+ * Set the config source for SDK mode.
+ * - File path: load/save from a specific YAML file
+ * - Inline config: use an in-memory CollectionConfig (saveConfig updates in place, no file I/O)
+ * - undefined: reset to default file-based config
+ */
+export function setConfigSource(source) {
+    if (!source) {
+        configSource = { type: 'file' };
+        return;
+    }
+    if (source.config) {
+        // Ensure collections object exists
+        if (!source.config.collections) {
+            source.config.collections = {};
+        }
+        configSource = { type: 'inline', config: source.config };
+    }
+    else if (source.configPath) {
+        configSource = { type: 'file', path: source.configPath };
+    }
+    else {
+        configSource = { type: 'file' };
+    }
+}
+/**
+ * Set the current index name for config file lookup
+ * Config file will be ~/.config/qmd/{indexName}.yml
+ */
+export function setConfigIndexName(name) {
+    // Resolve relative paths to absolute paths and sanitize for use as filename
+    if (name.includes('/')) {
+        const { resolve } = require('path');
+        const { cwd } = require('process');
+        const absolutePath = resolve(cwd(), name);
+        // Replace path separators with underscores to create a valid filename
+        currentIndexName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
+    }
+    else {
+        currentIndexName = name;
+    }
+}
+function getConfigDir() {
+    // Allow override via QMD_CONFIG_DIR for testing
+    if (process.env.QMD_CONFIG_DIR) {
+        return process.env.QMD_CONFIG_DIR;
+    }
+    // Respect XDG Base Directory specification (consistent with store.ts)
+    if (process.env.XDG_CONFIG_HOME) {
+        return join(process.env.XDG_CONFIG_HOME, "qmd");
+    }
+    return join(homedir(), ".config", "qmd");
+}
+function getConfigFilePath() {
+    return join(getConfigDir(), `${currentIndexName}.yml`);
+}
+/**
+ * Ensure config directory exists
+ */
+function ensureConfigDir() {
+    const configDir = getConfigDir();
+    if (!existsSync(configDir)) {
+        mkdirSync(configDir, { recursive: true });
+    }
+}
+// ============================================================================
+// Core functions
+// ============================================================================
+/**
+ * Load configuration from the configured source.
+ * - Inline config: returns the in-memory object directly
+ * - File-based: reads from YAML file (default ~/.config/qmd/index.yml)
+ * Returns empty config if file doesn't exist
+ */
+export function loadConfig() {
+    // SDK inline config mode
+    if (configSource.type === 'inline') {
+        return configSource.config;
+    }
+    // File-based config (SDK custom path or default)
+    const configPath = configSource.path || getConfigFilePath();
+    if (!existsSync(configPath)) {
+        return { collections: {} };
+    }
+    try {
+        const content = readFileSync(configPath, "utf-8");
+        const config = YAML.parse(content);
+        // Ensure collections object exists
+        if (!config.collections) {
+            config.collections = {};
+        }
+        return config;
+    }
+    catch (error) {
+        throw new Error(`Failed to parse ${configPath}: ${error}`);
+    }
+}
+/**
+ * Save configuration to the configured source.
+ * - Inline config: updates the in-memory object (no file I/O)
+ * - File-based: writes to YAML file (default ~/.config/qmd/index.yml)
+ */
+export function saveConfig(config) {
+    // SDK inline config mode: update in place, no file I/O
+    if (configSource.type === 'inline') {
+        configSource.config = config;
+        return;
+    }
+    const configPath = configSource.path || getConfigFilePath();
+    const configDir = dirname(configPath);
+    if (!existsSync(configDir)) {
+        mkdirSync(configDir, { recursive: true });
+    }
+    try {
+        const yaml = YAML.stringify(config, {
+            indent: 2,
+            lineWidth: 0, // Don't wrap lines
+        });
+        writeFileSync(configPath, yaml, "utf-8");
+    }
+    catch (error) {
+        throw new Error(`Failed to write ${configPath}: ${error}`);
+    }
+}
+/**
+ * Get a specific collection by name
+ * Returns null if not found
+ */
+export function getCollection(name) {
+    const config = loadConfig();
+    const collection = config.collections[name];
+    if (!collection) {
+        return null;
+    }
+    return { name, ...collection };
+}
+/**
+ * List all collections
+ */
+export function listCollections() {
+    const config = loadConfig();
+    return Object.entries(config.collections).map(([name, collection]) => ({
+        name,
+        ...collection,
+    }));
+}
+/**
+ * Get collections that are included by default in queries
+ */
+export function getDefaultCollections() {
+    return listCollections().filter(c => c.includeByDefault !== false);
+}
+/**
+ * Get collection names that are included by default
+ */
+export function getDefaultCollectionNames() {
+    return getDefaultCollections().map(c => c.name);
+}
+/**
+ * Update a collection's settings
+ */
+export function updateCollectionSettings(name, settings) {
+    const config = loadConfig();
+    const collection = config.collections[name];
+    if (!collection)
+        return false;
+    if (settings.update !== undefined) {
+        if (settings.update === null) {
+            delete collection.update;
+        }
+        else {
+            collection.update = settings.update;
+        }
+    }
+    if (settings.includeByDefault !== undefined) {
+        if (settings.includeByDefault === true) {
+            // true is default, remove the field
+            delete collection.includeByDefault;
+        }
+        else {
+            collection.includeByDefault = settings.includeByDefault;
+        }
+    }
+    saveConfig(config);
+    return true;
+}
+/**
+ * Add or update a collection
+ */
+export function addCollection(name, path, pattern = "**/*.md") {
+    const config = loadConfig();
+    config.collections[name] = {
+        path,
+        pattern,
+        context: config.collections[name]?.context, // Preserve existing context
+    };
+    saveConfig(config);
+}
+/**
+ * Remove a collection
+ */
+export function removeCollection(name) {
+    const config = loadConfig();
+    if (!config.collections[name]) {
+        return false;
+    }
+    delete config.collections[name];
+    saveConfig(config);
+    return true;
+}
+/**
+ * Rename a collection
+ */
+export function renameCollection(oldName, newName) {
+    const config = loadConfig();
+    if (!config.collections[oldName]) {
+        return false;
+    }
+    if (config.collections[newName]) {
+        throw new Error(`Collection '${newName}' already exists`);
+    }
+    config.collections[newName] = config.collections[oldName];
+    delete config.collections[oldName];
+    saveConfig(config);
+    return true;
+}
+// ============================================================================
+// Context management
+// ============================================================================
+/**
+ * Get global context
+ */
+export function getGlobalContext() {
+    const config = loadConfig();
+    return config.global_context;
+}
+/**
+ * Set global context
+ */
+export function setGlobalContext(context) {
+    const config = loadConfig();
+    config.global_context = context;
+    saveConfig(config);
+}
+/**
+ * Get all contexts for a collection
+ */
+export function getContexts(collectionName) {
+    const collection = getCollection(collectionName);
+    return collection?.context;
+}
+/**
+ * Add or update a context for a specific path in a collection
+ */
+export function addContext(collectionName, pathPrefix, contextText) {
+    const config = loadConfig();
+    const collection = config.collections[collectionName];
+    if (!collection) {
+        return false;
+    }
+    if (!collection.context) {
+        collection.context = {};
+    }
+    collection.context[pathPrefix] = contextText;
+    saveConfig(config);
+    return true;
+}
+/**
+ * Remove a context from a collection
+ */
+export function removeContext(collectionName, pathPrefix) {
+    const config = loadConfig();
+    const collection = config.collections[collectionName];
+    if (!collection?.context?.[pathPrefix]) {
+        return false;
+    }
+    delete collection.context[pathPrefix];
+    // Remove empty context object
+    if (Object.keys(collection.context).length === 0) {
+        delete collection.context;
+    }
+    saveConfig(config);
+    return true;
+}
+/**
+ * List all contexts across all collections
+ */
+export function listAllContexts() {
+    const config = loadConfig();
+    const results = [];
+    // Add global context if present
+    if (config.global_context) {
+        results.push({
+            collection: "*",
+            path: "/",
+            context: config.global_context,
+        });
+    }
+    // Add collection contexts
+    for (const [name, collection] of Object.entries(config.collections)) {
+        if (collection.context) {
+            for (const [path, context] of Object.entries(collection.context)) {
+                results.push({
+                    collection: name,
+                    path,
+                    context,
+                });
+            }
+        }
+    }
+    return results;
+}
+/**
+ * Find best matching context for a given collection and path
+ * Returns the most specific matching context (longest path prefix match)
+ */
+export function findContextForPath(collectionName, filePath) {
+    const config = loadConfig();
+    const collection = config.collections[collectionName];
+    if (!collection?.context) {
+        return config.global_context;
+    }
+    // Find all matching prefixes
+    const matches = [];
+    for (const [prefix, context] of Object.entries(collection.context)) {
+        // Normalize paths for comparison
+        const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
+        const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
+        if (normalizedPath.startsWith(normalizedPrefix)) {
+            matches.push({ prefix: normalizedPrefix, context });
+        }
+    }
+    // Return most specific match (longest prefix)
+    if (matches.length > 0) {
+        matches.sort((a, b) => b.prefix.length - a.prefix.length);
+        return matches[0].context;
+    }
+    // Fallback to global context
+    return config.global_context;
+}
+// ============================================================================
+// Utility functions
+// ============================================================================
+/**
+ * Get the config file path (useful for error messages)
+ */
+export function getConfigPath() {
+    if (configSource.type === 'inline')
+        return '<inline>';
+    return configSource.path || getConfigFilePath();
+}
+/**
+ * Check if config file exists
+ */
+export function configExists() {
+    if (configSource.type === 'inline')
+        return true;
+    const path = configSource.path || getConfigFilePath();
+    return existsSync(path);
+}
+/**
+ * Validate a collection name
+ * Collection names must be valid and not contain special characters
+ */
+export function isValidCollectionName(name) {
+    // Allow alphanumeric, hyphens, underscores
+    return /^[a-zA-Z0-9_-]+$/.test(name);
+}

+ 41 - 0
dist/db.d.ts

@@ -0,0 +1,41 @@
+/**
+ * db.ts - Cross-runtime SQLite compatibility layer
+ *
+ * Provides a unified Database export that works under both Bun (bun:sqlite)
+ * and Node.js (better-sqlite3). The APIs are nearly identical — the main
+ * difference is the import path.
+ *
+ * On macOS, Apple's system SQLite is compiled with SQLITE_OMIT_LOAD_EXTENSION,
+ * which prevents loading native extensions like sqlite-vec. When running under
+ * Bun we call Database.setCustomSQLite() to swap in Homebrew's full-featured
+ * SQLite build before creating any database instances.
+ */
+export declare const isBun: boolean;
+/**
+ * Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
+ */
+export declare function openDatabase(path: string): Database;
+/**
+ * Common subset of the Database interface used throughout QMD.
+ */
+export interface Database {
+    exec(sql: string): void;
+    prepare(sql: string): Statement;
+    loadExtension(path: string): void;
+    close(): void;
+}
+export interface Statement {
+    run(...params: any[]): {
+        changes: number;
+        lastInsertRowid: number | bigint;
+    };
+    get(...params: any[]): any;
+    all(...params: any[]): any[];
+}
+/**
+ * Load the sqlite-vec extension into a database.
+ *
+ * Throws with platform-specific fix instructions when the extension is
+ * unavailable.
+ */
+export declare function loadSqliteVec(db: Database): void;

+ 75 - 0
dist/db.js

@@ -0,0 +1,75 @@
+/**
+ * db.ts - Cross-runtime SQLite compatibility layer
+ *
+ * Provides a unified Database export that works under both Bun (bun:sqlite)
+ * and Node.js (better-sqlite3). The APIs are nearly identical — the main
+ * difference is the import path.
+ *
+ * On macOS, Apple's system SQLite is compiled with SQLITE_OMIT_LOAD_EXTENSION,
+ * which prevents loading native extensions like sqlite-vec. When running under
+ * Bun we call Database.setCustomSQLite() to swap in Homebrew's full-featured
+ * SQLite build before creating any database instances.
+ */
+export const isBun = typeof globalThis.Bun !== "undefined";
+let _Database;
+let _sqliteVecLoad;
+if (isBun) {
+    // Dynamic string prevents tsc from resolving bun:sqlite on Node.js builds
+    const bunSqlite = "bun:" + "sqlite";
+    const BunDatabase = (await import(/* @vite-ignore */ bunSqlite)).Database;
+    // See: https://bun.com/docs/runtime/sqlite#setcustomsqlite
+    if (process.platform === "darwin") {
+        const homebrewPaths = [
+            "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", // Apple Silicon
+            "/usr/local/opt/sqlite/lib/libsqlite3.dylib", // Intel
+        ];
+        for (const p of homebrewPaths) {
+            try {
+                BunDatabase.setCustomSQLite(p);
+                break;
+            }
+            catch { }
+        }
+    }
+    _Database = BunDatabase;
+    // setCustomSQLite may have silently failed — test that extensions actually work.
+    try {
+        const { getLoadablePath } = await import("sqlite-vec");
+        const vecPath = getLoadablePath();
+        const testDb = new BunDatabase(":memory:");
+        testDb.loadExtension(vecPath);
+        testDb.close();
+        _sqliteVecLoad = (db) => db.loadExtension(vecPath);
+    }
+    catch {
+        // Vector search won't work, but BM25 and other operations are unaffected.
+        _sqliteVecLoad = null;
+    }
+}
+else {
+    _Database = (await import("better-sqlite3")).default;
+    const sqliteVec = await import("sqlite-vec");
+    _sqliteVecLoad = (db) => sqliteVec.load(db);
+}
+/**
+ * Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
+ */
+export function openDatabase(path) {
+    return new _Database(path);
+}
+/**
+ * Load the sqlite-vec extension into a database.
+ *
+ * Throws with platform-specific fix instructions when the extension is
+ * unavailable.
+ */
+export function loadSqliteVec(db) {
+    if (!_sqliteVecLoad) {
+        const hint = isBun && process.platform === "darwin"
+            ? "On macOS with Bun, install Homebrew SQLite: brew install sqlite\n" +
+                "Or install qmd with npm instead: npm install -g @tobilu/qmd"
+            : "Ensure the sqlite-vec native module is installed correctly.";
+        throw new Error(`sqlite-vec extension is unavailable. ${hint}`);
+    }
+    _sqliteVecLoad(db);
+}

+ 6 - 0
dist/embedded-skills.d.ts

@@ -0,0 +1,6 @@
+export type EmbeddedSkillFile = {
+    relativePath: string;
+    content: string;
+};
+export declare function getEmbeddedQmdSkillFiles(): EmbeddedSkillFile[];
+export declare function getEmbeddedQmdSkillContent(): string;

Datei-Diff unterdrückt, da er zu groß ist
+ 2 - 0
dist/embedded-skills.js


+ 226 - 0
dist/index.d.ts

@@ -0,0 +1,226 @@
+/**
+ * QMD SDK - Library mode for programmatic access to QMD search and indexing.
+ *
+ * Usage:
+ *   import { createStore } from '@tobilu/qmd'
+ *
+ *   const store = await createStore({
+ *     dbPath: './my-index.sqlite',
+ *     config: {
+ *       collections: {
+ *         docs: { path: '/path/to/docs', pattern: '**\/*.md' }
+ *       }
+ *     }
+ *   })
+ *
+ *   const results = await store.search({ query: "how does auth work?" })
+ *   await store.close()
+ */
+import { extractSnippet, addLineNumbers, DEFAULT_MULTI_GET_MAX_BYTES, type Store as InternalStore, type DocumentResult, type DocumentNotFound, type SearchResult, type HybridQueryResult, type HybridQueryOptions, type HybridQueryExplain, type ExpandedQuery, type StructuredSearchOptions, type MultiGetResult, type IndexStatus, type IndexHealthInfo, type SearchHooks, type ReindexProgress, type ReindexResult, type EmbedProgress, type EmbedResult, type ChunkStrategy } from "./store.js";
+import { type Collection, type CollectionConfig, type NamedCollection, type ContextMap } from "./collections.js";
+export type { DocumentResult, DocumentNotFound, SearchResult, HybridQueryResult, HybridQueryOptions, HybridQueryExplain, ExpandedQuery, StructuredSearchOptions, MultiGetResult, IndexStatus, IndexHealthInfo, SearchHooks, ReindexProgress, ReindexResult, EmbedProgress, EmbedResult, Collection, CollectionConfig, NamedCollection, ContextMap, };
+export type { InternalStore };
+export { extractSnippet, addLineNumbers, DEFAULT_MULTI_GET_MAX_BYTES };
+export type { ChunkStrategy } from "./store.js";
+export { getDefaultDbPath } from "./store.js";
+export { Maintenance } from "./maintenance.js";
+/**
+ * Progress info emitted during update() for each file processed.
+ */
+export type UpdateProgress = {
+    collection: string;
+    file: string;
+    current: number;
+    total: number;
+};
+/**
+ * Aggregated result from update() across all collections.
+ */
+export type UpdateResult = {
+    collections: number;
+    indexed: number;
+    updated: number;
+    unchanged: number;
+    removed: number;
+    needsEmbedding: number;
+};
+/**
+ * Options for the unified search() method.
+ */
+export interface SearchOptions {
+    /** Simple query string — will be auto-expanded via LLM */
+    query?: string;
+    /** Pre-expanded queries (from expandQuery) — skips auto-expansion */
+    queries?: ExpandedQuery[];
+    /** Domain intent hint — steers expansion and reranking */
+    intent?: string;
+    /** Rerank results using LLM (default: true) */
+    rerank?: boolean;
+    /** Filter to a specific collection */
+    collection?: string;
+    /** Filter to specific collections */
+    collections?: string[];
+    /** Max results (default: 10) */
+    limit?: number;
+    /** Minimum score threshold */
+    minScore?: number;
+    /** Include explain traces */
+    explain?: boolean;
+    /** Chunk strategy: "auto" (default, uses AST for code files) or "regex" (legacy) */
+    chunkStrategy?: ChunkStrategy;
+}
+/**
+ * Options for searchLex() — BM25 keyword search.
+ */
+export interface LexSearchOptions {
+    limit?: number;
+    collection?: string;
+}
+/**
+ * Options for searchVector() — vector similarity search.
+ */
+export interface VectorSearchOptions {
+    limit?: number;
+    collection?: string;
+}
+/**
+ * Options for expandQuery() — manual query expansion.
+ */
+export interface ExpandQueryOptions {
+    intent?: string;
+}
+/**
+ * Options for creating a QMD store.
+ *
+ * Provide `dbPath` and optionally `configPath` (YAML file) or `config` (inline).
+ * If neither configPath nor config is provided, the store reads from existing
+ * DB state (useful for reopening a previously-configured store).
+ */
+export interface StoreOptions {
+    /** Path to the SQLite database file */
+    dbPath: string;
+    /** Path to a YAML config file (mutually exclusive with `config`) */
+    configPath?: string;
+    /** Inline collection config (mutually exclusive with `configPath`) */
+    config?: CollectionConfig;
+}
+/**
+ * The QMD SDK store — provides search, retrieval, collection management,
+ * context management, and indexing operations.
+ *
+ * All methods are async. The store manages its own LlamaCpp instance
+ * (lazy-loaded, auto-unloaded after inactivity) — no global singletons.
+ */
+export interface QMDStore {
+    /** The underlying internal store (for advanced use) */
+    readonly internal: InternalStore;
+    /** Path to the SQLite database */
+    readonly dbPath: string;
+    /** Full search: query expansion + multi-signal retrieval + LLM reranking */
+    search(options: SearchOptions): Promise<HybridQueryResult[]>;
+    /** BM25 keyword search (fast, no LLM) */
+    searchLex(query: string, options?: LexSearchOptions): Promise<SearchResult[]>;
+    /** Vector similarity search (embedding model, no reranking) */
+    searchVector(query: string, options?: VectorSearchOptions): Promise<SearchResult[]>;
+    /** Expand a query into typed sub-searches (lex/vec/hyde) for manual control */
+    expandQuery(query: string, options?: ExpandQueryOptions): Promise<ExpandedQuery[]>;
+    /** Get a single document by path or docid */
+    get(pathOrDocid: string, options?: {
+        includeBody?: boolean;
+    }): Promise<DocumentResult | DocumentNotFound>;
+    /** Get the body content of a document, optionally sliced by line range */
+    getDocumentBody(pathOrDocid: string, opts?: {
+        fromLine?: number;
+        maxLines?: number;
+    }): Promise<string | null>;
+    /** Get multiple documents by glob pattern or comma-separated list */
+    multiGet(pattern: string, options?: {
+        includeBody?: boolean;
+        maxBytes?: number;
+    }): Promise<{
+        docs: MultiGetResult[];
+        errors: string[];
+    }>;
+    /** Add or update a collection */
+    addCollection(name: string, opts: {
+        path: string;
+        pattern?: string;
+        ignore?: string[];
+    }): Promise<void>;
+    /** Remove a collection */
+    removeCollection(name: string): Promise<boolean>;
+    /** Rename a collection */
+    renameCollection(oldName: string, newName: string): Promise<boolean>;
+    /** List all collections with document stats */
+    listCollections(): Promise<{
+        name: string;
+        pwd: string;
+        glob_pattern: string;
+        doc_count: number;
+        active_count: number;
+        last_modified: string | null;
+        includeByDefault: boolean;
+    }[]>;
+    /** Get names of collections included by default in queries */
+    getDefaultCollectionNames(): Promise<string[]>;
+    /** Add context for a path within a collection */
+    addContext(collectionName: string, pathPrefix: string, contextText: string): Promise<boolean>;
+    /** Remove context from a collection path */
+    removeContext(collectionName: string, pathPrefix: string): Promise<boolean>;
+    /** Set global context (applies to all collections) */
+    setGlobalContext(context: string | undefined): Promise<void>;
+    /** Get global context */
+    getGlobalContext(): Promise<string | undefined>;
+    /** List all contexts across all collections */
+    listContexts(): Promise<Array<{
+        collection: string;
+        path: string;
+        context: string;
+    }>>;
+    /** Re-index collections by scanning the filesystem */
+    update(options?: {
+        collections?: string[];
+        onProgress?: (info: UpdateProgress) => void;
+    }): Promise<UpdateResult>;
+    /** Generate vector embeddings for documents that need them */
+    embed(options?: {
+        force?: boolean;
+        model?: string;
+        maxDocsPerBatch?: number;
+        maxBatchBytes?: number;
+        chunkStrategy?: ChunkStrategy;
+        onProgress?: (info: EmbedProgress) => void;
+    }): Promise<EmbedResult>;
+    /** Get index status (document counts, collections, embedding state) */
+    getStatus(): Promise<IndexStatus>;
+    /** Get index health info (stale embeddings, etc.) */
+    getIndexHealth(): Promise<IndexHealthInfo>;
+    /** Close the store and release all resources (LLM models, DB connection) */
+    close(): Promise<void>;
+}
+/**
+ * Create a QMD store for programmatic access to search and indexing.
+ *
+ * @example
+ * ```typescript
+ * // With a YAML config file
+ * const store = await createStore({
+ *   dbPath: './index.sqlite',
+ *   configPath: './qmd.yml',
+ * })
+ *
+ * // With inline config (no files needed besides the DB)
+ * const store = await createStore({
+ *   dbPath: './index.sqlite',
+ *   config: {
+ *     collections: {
+ *       docs: { path: '/path/to/docs', pattern: '**\/*.md' }
+ *     }
+ *   }
+ * })
+ *
+ * const results = await store.search({ query: "authentication flow" })
+ * await store.close()
+ * ```
+ */
+export declare function createStore(options: StoreOptions): Promise<QMDStore>;

+ 239 - 0
dist/index.js

@@ -0,0 +1,239 @@
+/**
+ * QMD SDK - Library mode for programmatic access to QMD search and indexing.
+ *
+ * Usage:
+ *   import { createStore } from '@tobilu/qmd'
+ *
+ *   const store = await createStore({
+ *     dbPath: './my-index.sqlite',
+ *     config: {
+ *       collections: {
+ *         docs: { path: '/path/to/docs', pattern: '**\/*.md' }
+ *       }
+ *     }
+ *   })
+ *
+ *   const results = await store.search({ query: "how does auth work?" })
+ *   await store.close()
+ */
+import { createStore as createStoreInternal, hybridQuery, structuredSearch, extractSnippet, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_MULTI_GET_MAX_BYTES, reindexCollection, generateEmbeddings, listCollections as storeListCollections, syncConfigToDb, getStoreCollections, getStoreCollection, getStoreGlobalContext, getStoreContexts, upsertStoreCollection, deleteStoreCollection, renameStoreCollection, updateStoreContext, removeStoreContext, setStoreGlobalContext, vacuumDatabase, cleanupOrphanedContent, cleanupOrphanedVectors, deleteLLMCache, deleteInactiveDocuments, clearAllEmbeddings, } from "./store.js";
+import { LlamaCpp, } from "./llm.js";
+import { setConfigSource, loadConfig, addCollection as collectionsAddCollection, removeCollection as collectionsRemoveCollection, renameCollection as collectionsRenameCollection, addContext as collectionsAddContext, removeContext as collectionsRemoveContext, setGlobalContext as collectionsSetGlobalContext, } from "./collections.js";
+// Re-export utility functions and types used by frontends
+export { extractSnippet, addLineNumbers, DEFAULT_MULTI_GET_MAX_BYTES };
+// Re-export getDefaultDbPath for CLI/MCP that need the default database location
+export { getDefaultDbPath } from "./store.js";
+// Re-export Maintenance class for CLI housekeeping operations
+export { Maintenance } from "./maintenance.js";
+/**
+ * Create a QMD store for programmatic access to search and indexing.
+ *
+ * @example
+ * ```typescript
+ * // With a YAML config file
+ * const store = await createStore({
+ *   dbPath: './index.sqlite',
+ *   configPath: './qmd.yml',
+ * })
+ *
+ * // With inline config (no files needed besides the DB)
+ * const store = await createStore({
+ *   dbPath: './index.sqlite',
+ *   config: {
+ *     collections: {
+ *       docs: { path: '/path/to/docs', pattern: '**\/*.md' }
+ *     }
+ *   }
+ * })
+ *
+ * const results = await store.search({ query: "authentication flow" })
+ * await store.close()
+ * ```
+ */
+export async function createStore(options) {
+    if (!options.dbPath) {
+        throw new Error("dbPath is required");
+    }
+    if (options.configPath && options.config) {
+        throw new Error("Provide either configPath or config, not both");
+    }
+    // Create the internal store (opens DB, creates tables)
+    const internal = createStoreInternal(options.dbPath);
+    const db = internal.db;
+    // Track whether we have a YAML config path for write-through
+    const hasYamlConfig = !!options.configPath;
+    // Sync config into SQLite store_collections
+    let config;
+    if (options.configPath) {
+        // YAML mode: inject config source for write-through, sync to DB
+        setConfigSource({ configPath: options.configPath });
+        config = loadConfig();
+        syncConfigToDb(db, config);
+    }
+    else if (options.config) {
+        // Inline config mode: inject config source for mutations, sync to DB
+        setConfigSource({ config: options.config });
+        config = options.config;
+        syncConfigToDb(db, config);
+    }
+    // else: DB-only mode — no external config, use existing store_collections
+    // Create a per-store LlamaCpp instance — lazy-loads models on first use,
+    // auto-unloads after 5 min inactivity to free VRAM.
+    const llm = new LlamaCpp({
+        embedModel: config?.models?.embed,
+        generateModel: config?.models?.generate,
+        rerankModel: config?.models?.rerank,
+        inactivityTimeoutMs: 5 * 60 * 1000,
+        disposeModelsOnInactivity: true,
+    });
+    internal.llm = llm;
+    const store = {
+        internal,
+        dbPath: internal.dbPath,
+        // Search
+        search: async (opts) => {
+            if (!opts.query && !opts.queries) {
+                throw new Error("search() requires either 'query' or 'queries'");
+            }
+            // Normalize collection/collections
+            const collections = [
+                ...(opts.collection ? [opts.collection] : []),
+                ...(opts.collections ?? []),
+            ];
+            const skipRerank = opts.rerank === false;
+            if (opts.queries) {
+                // Pre-expanded queries — use structuredSearch
+                return structuredSearch(internal, opts.queries, {
+                    collections: collections.length > 0 ? collections : undefined,
+                    limit: opts.limit,
+                    minScore: opts.minScore,
+                    explain: opts.explain,
+                    intent: opts.intent,
+                    skipRerank,
+                    chunkStrategy: opts.chunkStrategy,
+                });
+            }
+            // Simple query string — use hybridQuery (expand + search + rerank)
+            return hybridQuery(internal, opts.query, {
+                collection: collections[0],
+                limit: opts.limit,
+                minScore: opts.minScore,
+                explain: opts.explain,
+                intent: opts.intent,
+                skipRerank,
+                chunkStrategy: opts.chunkStrategy,
+            });
+        },
+        searchLex: async (q, opts) => internal.searchFTS(q, opts?.limit, opts?.collection),
+        searchVector: async (q, opts) => internal.searchVec(q, DEFAULT_EMBED_MODEL, opts?.limit, opts?.collection),
+        expandQuery: async (q, opts) => internal.expandQuery(q, undefined, opts?.intent),
+        get: async (pathOrDocid, opts) => internal.findDocument(pathOrDocid, opts),
+        getDocumentBody: async (pathOrDocid, opts) => {
+            const result = internal.findDocument(pathOrDocid, { includeBody: false });
+            if ("error" in result)
+                return null;
+            return internal.getDocumentBody(result, opts?.fromLine, opts?.maxLines);
+        },
+        multiGet: async (pattern, opts) => internal.findDocuments(pattern, opts),
+        // Collection Management — write to SQLite + write-through to YAML/inline if configured
+        addCollection: async (name, opts) => {
+            upsertStoreCollection(db, name, { path: opts.path, pattern: opts.pattern, ignore: opts.ignore });
+            if (hasYamlConfig || options.config) {
+                collectionsAddCollection(name, opts.path, opts.pattern);
+            }
+        },
+        removeCollection: async (name) => {
+            const result = deleteStoreCollection(db, name);
+            if (hasYamlConfig || options.config) {
+                collectionsRemoveCollection(name);
+            }
+            return result;
+        },
+        renameCollection: async (oldName, newName) => {
+            const result = renameStoreCollection(db, oldName, newName);
+            if (hasYamlConfig || options.config) {
+                collectionsRenameCollection(oldName, newName);
+            }
+            return result;
+        },
+        listCollections: async () => storeListCollections(db),
+        getDefaultCollectionNames: async () => {
+            const collections = storeListCollections(db);
+            return collections.filter(c => c.includeByDefault).map(c => c.name);
+        },
+        // Context Management — write to SQLite + write-through to YAML/inline if configured
+        addContext: async (collectionName, pathPrefix, contextText) => {
+            const result = updateStoreContext(db, collectionName, pathPrefix, contextText);
+            if (hasYamlConfig || options.config) {
+                collectionsAddContext(collectionName, pathPrefix, contextText);
+            }
+            return result;
+        },
+        removeContext: async (collectionName, pathPrefix) => {
+            const result = removeStoreContext(db, collectionName, pathPrefix);
+            if (hasYamlConfig || options.config) {
+                collectionsRemoveContext(collectionName, pathPrefix);
+            }
+            return result;
+        },
+        setGlobalContext: async (context) => {
+            setStoreGlobalContext(db, context);
+            if (hasYamlConfig || options.config) {
+                collectionsSetGlobalContext(context);
+            }
+        },
+        getGlobalContext: async () => getStoreGlobalContext(db),
+        listContexts: async () => getStoreContexts(db),
+        // Indexing — reads collections from SQLite
+        update: async (updateOpts) => {
+            const collections = getStoreCollections(db);
+            const filtered = updateOpts?.collections
+                ? collections.filter(c => updateOpts.collections.includes(c.name))
+                : collections;
+            internal.clearCache();
+            let totalIndexed = 0, totalUpdated = 0, totalUnchanged = 0, totalRemoved = 0;
+            for (const col of filtered) {
+                const result = await reindexCollection(internal, col.path, col.pattern || "**/*.md", col.name, {
+                    ignorePatterns: col.ignore,
+                    onProgress: updateOpts?.onProgress
+                        ? (info) => updateOpts.onProgress({ collection: col.name, ...info })
+                        : undefined,
+                });
+                totalIndexed += result.indexed;
+                totalUpdated += result.updated;
+                totalUnchanged += result.unchanged;
+                totalRemoved += result.removed;
+            }
+            return {
+                collections: filtered.length,
+                indexed: totalIndexed,
+                updated: totalUpdated,
+                unchanged: totalUnchanged,
+                removed: totalRemoved,
+                needsEmbedding: internal.getHashesNeedingEmbedding(),
+            };
+        },
+        embed: async (embedOpts) => {
+            return generateEmbeddings(internal, {
+                force: embedOpts?.force,
+                model: embedOpts?.model,
+                maxDocsPerBatch: embedOpts?.maxDocsPerBatch,
+                maxBatchBytes: embedOpts?.maxBatchBytes,
+                chunkStrategy: embedOpts?.chunkStrategy,
+                onProgress: embedOpts?.onProgress,
+            });
+        },
+        // Index Health
+        getStatus: async () => internal.getStatus(),
+        getIndexHealth: async () => internal.getIndexHealth(),
+        // Lifecycle
+        close: async () => {
+            await llm.dispose();
+            internal.close();
+            if (hasYamlConfig || options.config) {
+                setConfigSource(undefined); // Reset config source
+            }
+        },
+    };
+    return store;
+}

+ 408 - 0
dist/llm.d.ts

@@ -0,0 +1,408 @@
+/**
+ * llm.ts - LLM abstraction layer for QMD using node-llama-cpp
+ *
+ * Provides embeddings, text generation, and reranking using local GGUF models.
+ */
+import { type Token as LlamaToken } from "node-llama-cpp";
+/**
+ * Detect if a model URI uses the Qwen3-Embedding format.
+ * Qwen3-Embedding uses a different prompting style than nomic/embeddinggemma.
+ */
+export declare function isQwen3EmbeddingModel(modelUri: string): boolean;
+/**
+ * Format a query for embedding.
+ * Uses nomic-style task prefix format for embeddinggemma (default).
+ * Uses Qwen3-Embedding instruct format when a Qwen embedding model is active.
+ */
+export declare function formatQueryForEmbedding(query: string, modelUri?: string): string;
+/**
+ * Format a document for embedding.
+ * Uses nomic-style format with title and text fields (default).
+ * Qwen3-Embedding encodes documents as raw text without special prefixes.
+ */
+export declare function formatDocForEmbedding(text: string, title?: string, modelUri?: string): string;
+/**
+ * Token with log probability
+ */
+export type TokenLogProb = {
+    token: string;
+    logprob: number;
+};
+/**
+ * Embedding result
+ */
+export type EmbeddingResult = {
+    embedding: number[];
+    model: string;
+};
+/**
+ * Generation result with optional logprobs
+ */
+export type GenerateResult = {
+    text: string;
+    model: string;
+    logprobs?: TokenLogProb[];
+    done: boolean;
+};
+/**
+ * Rerank result for a single document
+ */
+export type RerankDocumentResult = {
+    file: string;
+    score: number;
+    index: number;
+};
+/**
+ * Batch rerank result
+ */
+export type RerankResult = {
+    results: RerankDocumentResult[];
+    model: string;
+};
+/**
+ * Model info
+ */
+export type ModelInfo = {
+    name: string;
+    exists: boolean;
+    path?: string;
+};
+/**
+ * Options for embedding
+ */
+export type EmbedOptions = {
+    model?: string;
+    isQuery?: boolean;
+    title?: string;
+};
+/**
+ * Options for text generation
+ */
+export type GenerateOptions = {
+    model?: string;
+    maxTokens?: number;
+    temperature?: number;
+};
+/**
+ * Options for reranking
+ */
+export type RerankOptions = {
+    model?: string;
+};
+/**
+ * Options for LLM sessions
+ */
+export type LLMSessionOptions = {
+    /** Max session duration in ms (default: 10 minutes) */
+    maxDuration?: number;
+    /** External abort signal */
+    signal?: AbortSignal;
+    /** Debug name for logging */
+    name?: string;
+};
+/**
+ * Session interface for scoped LLM access with lifecycle guarantees
+ */
+export interface ILLMSession {
+    embed(text: string, options?: EmbedOptions): Promise<EmbeddingResult | null>;
+    embedBatch(texts: string[], options?: EmbedOptions): Promise<(EmbeddingResult | null)[]>;
+    expandQuery(query: string, options?: {
+        context?: string;
+        includeLexical?: boolean;
+    }): Promise<Queryable[]>;
+    rerank(query: string, documents: RerankDocument[], options?: RerankOptions): Promise<RerankResult>;
+    /** Whether this session is still valid (not released or aborted) */
+    readonly isValid: boolean;
+    /** Abort signal for this session (aborts on release or maxDuration) */
+    readonly signal: AbortSignal;
+}
+/**
+ * Supported query types for different search backends
+ */
+export type QueryType = 'lex' | 'vec' | 'hyde';
+/**
+ * A single query and its target backend type
+ */
+export type Queryable = {
+    type: QueryType;
+    text: string;
+};
+/**
+ * Document to rerank
+ */
+export type RerankDocument = {
+    file: string;
+    text: string;
+    title?: string;
+};
+export declare const LFM2_GENERATE_MODEL = "hf:LiquidAI/LFM2-1.2B-GGUF/LFM2-1.2B-Q4_K_M.gguf";
+export declare const LFM2_INSTRUCT_MODEL = "hf:LiquidAI/LFM2.5-1.2B-Instruct-GGUF/LFM2.5-1.2B-Instruct-Q4_K_M.gguf";
+export declare const DEFAULT_EMBED_MODEL_URI = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
+export declare const DEFAULT_RERANK_MODEL_URI = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
+export declare const DEFAULT_GENERATE_MODEL_URI = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
+export declare const DEFAULT_MODEL_CACHE_DIR: string;
+export type PullResult = {
+    model: string;
+    path: string;
+    sizeBytes: number;
+    refreshed: boolean;
+};
+export declare function pullModels(models: string[], options?: {
+    refresh?: boolean;
+    cacheDir?: string;
+}): Promise<PullResult[]>;
+/**
+ * Abstract LLM interface - implement this for different backends
+ */
+export interface LLM {
+    /**
+     * Get embeddings for text
+     */
+    embed(text: string, options?: EmbedOptions): Promise<EmbeddingResult | null>;
+    /**
+     * Generate text completion
+     */
+    generate(prompt: string, options?: GenerateOptions): Promise<GenerateResult | null>;
+    /**
+     * Check if a model exists/is available
+     */
+    modelExists(model: string): Promise<ModelInfo>;
+    /**
+     * Expand a search query into multiple variations for different backends.
+     * Returns a list of Queryable objects.
+     */
+    expandQuery(query: string, options?: {
+        context?: string;
+        includeLexical?: boolean;
+    }): Promise<Queryable[]>;
+    /**
+     * Rerank documents by relevance to a query
+     * Returns list of documents with relevance scores (higher = more relevant)
+     */
+    rerank(query: string, documents: RerankDocument[], options?: RerankOptions): Promise<RerankResult>;
+    /**
+     * Dispose of resources
+     */
+    dispose(): Promise<void>;
+}
+export type LlamaCppConfig = {
+    embedModel?: string;
+    generateModel?: string;
+    rerankModel?: string;
+    modelCacheDir?: string;
+    /**
+     * Context size used for query expansion generation contexts.
+     * Default: 2048. Can also be set via QMD_EXPAND_CONTEXT_SIZE.
+     */
+    expandContextSize?: number;
+    /**
+     * Inactivity timeout in ms before unloading contexts (default: 2 minutes, 0 to disable).
+     *
+     * Per node-llama-cpp lifecycle guidance, we prefer keeping models loaded and only disposing
+     * contexts when idle, since contexts (and their sequences) are the heavy per-session objects.
+     * @see https://node-llama-cpp.withcat.ai/guide/objects-lifecycle
+     */
+    inactivityTimeoutMs?: number;
+    /**
+     * Whether to dispose models on inactivity (default: false).
+     *
+     * Keeping models loaded avoids repeated VRAM thrash; set to true only if you need aggressive
+     * memory reclaim.
+     */
+    disposeModelsOnInactivity?: boolean;
+};
+export declare class LlamaCpp implements LLM {
+    private readonly _ciMode;
+    private llama;
+    private embedModel;
+    private embedContexts;
+    private generateModel;
+    private rerankModel;
+    private rerankContexts;
+    private embedModelUri;
+    private generateModelUri;
+    private rerankModelUri;
+    private modelCacheDir;
+    private expandContextSize;
+    private embedModelLoadPromise;
+    private generateModelLoadPromise;
+    private rerankModelLoadPromise;
+    private inactivityTimer;
+    private inactivityTimeoutMs;
+    private disposeModelsOnInactivity;
+    private disposed;
+    constructor(config?: LlamaCppConfig);
+    get embedModelName(): string;
+    /**
+     * Reset the inactivity timer. Called after each model operation.
+     * When timer fires, models are unloaded to free memory (if no active sessions).
+     */
+    private touchActivity;
+    /**
+     * Check if any contexts are currently loaded (and therefore worth unloading on inactivity).
+     */
+    private hasLoadedContexts;
+    /**
+     * Unload idle resources but keep the instance alive for future use.
+     *
+     * By default, this disposes contexts (and their dependent sequences), while keeping models loaded.
+     * This matches the intended lifecycle: model → context → sequence, where contexts are per-session.
+     */
+    unloadIdleResources(): Promise<void>;
+    /**
+     * Ensure model cache directory exists
+     */
+    private ensureModelCacheDir;
+    /**
+     * Initialize the llama instance (lazy)
+     */
+    private ensureLlama;
+    /**
+     * Resolve a model URI to a local path, downloading if needed
+     */
+    private resolveModel;
+    /**
+     * Load embedding model (lazy)
+     */
+    private ensureEmbedModel;
+    /**
+     * Compute how many parallel contexts to create.
+     *
+     * GPU: constrained by VRAM (25% of free, capped at 8).
+     * CPU: constrained by cores. Splitting threads across contexts enables
+     *      true parallelism (each context runs on its own cores). Use at most
+     *      half the math cores, with at least 4 threads per context.
+     */
+    private computeParallelism;
+    /**
+     * Get the number of threads each context should use, given N parallel contexts.
+     * Splits available math cores evenly across contexts.
+     */
+    private threadsPerContext;
+    /**
+     * Load embedding contexts (lazy). Creates multiple for parallel embedding.
+     * Uses promise guard to prevent concurrent context creation race condition.
+     */
+    private embedContextsCreatePromise;
+    private ensureEmbedContexts;
+    /**
+     * Get a single embed context (for single-embed calls). Uses first from pool.
+     */
+    private ensureEmbedContext;
+    /**
+     * Load generation model (lazy) - context is created fresh per call
+     */
+    private ensureGenerateModel;
+    /**
+     * Load rerank model (lazy)
+     */
+    private ensureRerankModel;
+    /**
+     * Load rerank contexts (lazy). Creates multiple contexts for parallel ranking.
+     * Each context has its own sequence, so they can evaluate independently.
+     *
+     * Tuning choices:
+     * - contextSize 1024: reranking chunks are ~800 tokens max, 1024 is plenty
+     * - flashAttention: ~20% less VRAM per context (568 vs 711 MB)
+     * - Combined: drops from 11.6 GB (auto, no flash) to 568 MB per context (20×)
+     */
+    private static readonly RERANK_CONTEXT_SIZE;
+    private static readonly EMBED_CONTEXT_SIZE;
+    private ensureRerankContexts;
+    /**
+     * Tokenize text using the embedding model's tokenizer
+     * Returns tokenizer tokens (opaque type from node-llama-cpp)
+     */
+    tokenize(text: string): Promise<readonly LlamaToken[]>;
+    /**
+     * Count tokens in text using the embedding model's tokenizer
+     */
+    countTokens(text: string): Promise<number>;
+    /**
+     * Detokenize token IDs back to text
+     */
+    detokenize(tokens: readonly LlamaToken[]): Promise<string>;
+    /**
+     * Truncate text to fit within the embedding model's context window.
+     * Uses the model's own tokenizer for accurate token counting, then
+     * detokenizes back to text if truncation is needed.
+     * Returns the (possibly truncated) text and whether truncation occurred.
+     */
+    private truncateToContextSize;
+    embed(text: string, options?: EmbedOptions): Promise<EmbeddingResult | null>;
+    /**
+     * Batch embed multiple texts efficiently
+     * Uses Promise.all for parallel embedding - node-llama-cpp handles batching internally
+     */
+    embedBatch(texts: string[], options?: EmbedOptions): Promise<(EmbeddingResult | null)[]>;
+    generate(prompt: string, options?: GenerateOptions): Promise<GenerateResult | null>;
+    modelExists(modelUri: string): Promise<ModelInfo>;
+    expandQuery(query: string, options?: {
+        context?: string;
+        includeLexical?: boolean;
+        intent?: string;
+    }): Promise<Queryable[]>;
+    private static readonly RERANK_TEMPLATE_OVERHEAD;
+    private static readonly RERANK_TARGET_DOCS_PER_CONTEXT;
+    rerank(query: string, documents: RerankDocument[], options?: RerankOptions): Promise<RerankResult>;
+    /**
+     * Get device/GPU info for status display.
+     * Initializes llama if not already done.
+     */
+    getDeviceInfo(): Promise<{
+        gpu: string | false;
+        gpuOffloading: boolean;
+        gpuDevices: string[];
+        vram?: {
+            total: number;
+            used: number;
+            free: number;
+        };
+        cpuCores: number;
+    }>;
+    dispose(): Promise<void>;
+}
+/**
+ * Error thrown when an operation is attempted on a released or aborted session.
+ */
+export declare class SessionReleasedError extends Error {
+    constructor(message?: string);
+}
+/**
+ * Execute a function with a scoped LLM session.
+ * The session provides lifecycle guarantees - resources won't be disposed mid-operation.
+ *
+ * @example
+ * ```typescript
+ * await withLLMSession(async (session) => {
+ *   const expanded = await session.expandQuery(query);
+ *   const embeddings = await session.embedBatch(texts);
+ *   const reranked = await session.rerank(query, docs);
+ *   return reranked;
+ * }, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
+ * ```
+ */
+export declare function withLLMSession<T>(fn: (session: ILLMSession) => Promise<T>, options?: LLMSessionOptions): Promise<T>;
+/**
+ * Execute a function with a scoped LLM session using a specific LlamaCpp instance.
+ * Unlike withLLMSession, this does not use the global singleton.
+ */
+export declare function withLLMSessionForLlm<T>(llm: LlamaCpp, fn: (session: ILLMSession) => Promise<T>, options?: LLMSessionOptions): Promise<T>;
+/**
+ * Check if idle unload is safe (no active sessions or operations).
+ * Used internally by LlamaCpp idle timer.
+ */
+export declare function canUnloadLLM(): boolean;
+/**
+ * Get the default LlamaCpp instance (creates one if needed)
+ */
+export declare function getDefaultLlamaCpp(): LlamaCpp;
+/**
+ * Set a custom default LlamaCpp instance (useful for testing)
+ */
+export declare function setDefaultLlamaCpp(llm: LlamaCpp | null): void;
+/**
+ * Dispose the default LlamaCpp instance if it exists.
+ * Call this before process exit to prevent NAPI crashes.
+ */
+export declare function disposeDefaultLlamaCpp(): Promise<void>;

+ 1199 - 0
dist/llm.js

@@ -0,0 +1,1199 @@
+/**
+ * llm.ts - LLM abstraction layer for QMD using node-llama-cpp
+ *
+ * Provides embeddings, text generation, and reranking using local GGUF models.
+ */
+import { getLlama, resolveModelFile, LlamaChatSession, LlamaLogLevel, } from "node-llama-cpp";
+import { homedir } from "os";
+import { join } from "path";
+import { existsSync, mkdirSync, statSync, unlinkSync, readdirSync, readFileSync, writeFileSync } from "fs";
+// =============================================================================
+// Embedding Formatting Functions
+// =============================================================================
+/**
+ * Detect if a model URI uses the Qwen3-Embedding format.
+ * Qwen3-Embedding uses a different prompting style than nomic/embeddinggemma.
+ */
+export function isQwen3EmbeddingModel(modelUri) {
+    return /qwen.*embed/i.test(modelUri) || /embed.*qwen/i.test(modelUri);
+}
+/**
+ * Format a query for embedding.
+ * Uses nomic-style task prefix format for embeddinggemma (default).
+ * Uses Qwen3-Embedding instruct format when a Qwen embedding model is active.
+ */
+export function formatQueryForEmbedding(query, modelUri) {
+    const uri = modelUri ?? process.env.QMD_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
+    if (isQwen3EmbeddingModel(uri)) {
+        return `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`;
+    }
+    return `task: search result | query: ${query}`;
+}
+/**
+ * Format a document for embedding.
+ * Uses nomic-style format with title and text fields (default).
+ * Qwen3-Embedding encodes documents as raw text without special prefixes.
+ */
+export function formatDocForEmbedding(text, title, modelUri) {
+    const uri = modelUri ?? process.env.QMD_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
+    if (isQwen3EmbeddingModel(uri)) {
+        // Qwen3-Embedding: documents are raw text, no task prefix
+        return title ? `${title}\n${text}` : text;
+    }
+    return `title: ${title || "none"} | text: ${text}`;
+}
+// =============================================================================
+// Model Configuration
+// =============================================================================
+// HuggingFace model URIs for node-llama-cpp
+// Format: hf:<user>/<repo>/<file>
+// Override via QMD_EMBED_MODEL env var (e.g. hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf)
+const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
+const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
+// const DEFAULT_GENERATE_MODEL = "hf:ggml-org/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf";
+const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
+// Alternative generation models for query expansion:
+// LiquidAI LFM2 - hybrid architecture optimized for edge/on-device inference
+// Use these as base for fine-tuning with configs/sft_lfm2.yaml
+export const LFM2_GENERATE_MODEL = "hf:LiquidAI/LFM2-1.2B-GGUF/LFM2-1.2B-Q4_K_M.gguf";
+export const LFM2_INSTRUCT_MODEL = "hf:LiquidAI/LFM2.5-1.2B-Instruct-GGUF/LFM2.5-1.2B-Instruct-Q4_K_M.gguf";
+export const DEFAULT_EMBED_MODEL_URI = DEFAULT_EMBED_MODEL;
+export const DEFAULT_RERANK_MODEL_URI = DEFAULT_RERANK_MODEL;
+export const DEFAULT_GENERATE_MODEL_URI = DEFAULT_GENERATE_MODEL;
+// Local model cache directory
+const MODEL_CACHE_DIR = process.env.XDG_CACHE_HOME
+    ? join(process.env.XDG_CACHE_HOME, "qmd", "models")
+    : join(homedir(), ".cache", "qmd", "models");
+export const DEFAULT_MODEL_CACHE_DIR = MODEL_CACHE_DIR;
+function parseHfUri(model) {
+    if (!model.startsWith("hf:"))
+        return null;
+    const without = model.slice(3);
+    const parts = without.split("/");
+    if (parts.length < 3)
+        return null;
+    const repo = parts.slice(0, 2).join("/");
+    const file = parts.slice(2).join("/");
+    return { repo, file };
+}
+async function getRemoteEtag(ref) {
+    const url = `https://huggingface.co/${ref.repo}/resolve/main/${ref.file}`;
+    try {
+        const resp = await fetch(url, { method: "HEAD" });
+        if (!resp.ok)
+            return null;
+        const etag = resp.headers.get("etag");
+        return etag || null;
+    }
+    catch {
+        return null;
+    }
+}
+export async function pullModels(models, options = {}) {
+    const cacheDir = options.cacheDir || MODEL_CACHE_DIR;
+    if (!existsSync(cacheDir)) {
+        mkdirSync(cacheDir, { recursive: true });
+    }
+    const results = [];
+    for (const model of models) {
+        let refreshed = false;
+        const hfRef = parseHfUri(model);
+        const filename = model.split("/").pop();
+        const entries = readdirSync(cacheDir, { withFileTypes: true });
+        const cached = filename
+            ? entries
+                .filter((entry) => entry.isFile() && entry.name.includes(filename))
+                .map((entry) => join(cacheDir, entry.name))
+            : [];
+        if (hfRef && filename) {
+            const etagPath = join(cacheDir, `${filename}.etag`);
+            const remoteEtag = await getRemoteEtag(hfRef);
+            const localEtag = existsSync(etagPath)
+                ? readFileSync(etagPath, "utf-8").trim()
+                : null;
+            const shouldRefresh = options.refresh || !remoteEtag || remoteEtag !== localEtag || cached.length === 0;
+            if (shouldRefresh) {
+                for (const candidate of cached) {
+                    if (existsSync(candidate))
+                        unlinkSync(candidate);
+                }
+                if (existsSync(etagPath))
+                    unlinkSync(etagPath);
+                refreshed = cached.length > 0;
+            }
+        }
+        else if (options.refresh && filename) {
+            for (const candidate of cached) {
+                if (existsSync(candidate))
+                    unlinkSync(candidate);
+                refreshed = true;
+            }
+        }
+        const path = await resolveModelFile(model, cacheDir);
+        const sizeBytes = existsSync(path) ? statSync(path).size : 0;
+        if (hfRef && filename) {
+            const remoteEtag = await getRemoteEtag(hfRef);
+            if (remoteEtag) {
+                const etagPath = join(cacheDir, `${filename}.etag`);
+                writeFileSync(etagPath, remoteEtag + "\n", "utf-8");
+            }
+        }
+        results.push({ model, path, sizeBytes, refreshed });
+    }
+    return results;
+}
+/**
+ * LLM implementation using node-llama-cpp
+ */
+// Default inactivity timeout: 5 minutes (keep models warm during typical search sessions)
+const DEFAULT_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
+const DEFAULT_EXPAND_CONTEXT_SIZE = 2048;
+function resolveExpandContextSize(configValue) {
+    if (configValue !== undefined) {
+        if (!Number.isInteger(configValue) || configValue <= 0) {
+            throw new Error(`Invalid expandContextSize: ${configValue}. Must be a positive integer.`);
+        }
+        return configValue;
+    }
+    const envValue = process.env.QMD_EXPAND_CONTEXT_SIZE?.trim();
+    if (!envValue)
+        return DEFAULT_EXPAND_CONTEXT_SIZE;
+    const parsed = Number.parseInt(envValue, 10);
+    if (!Number.isInteger(parsed) || parsed <= 0) {
+        process.stderr.write(`QMD Warning: invalid QMD_EXPAND_CONTEXT_SIZE="${envValue}", using default ${DEFAULT_EXPAND_CONTEXT_SIZE}.\n`);
+        return DEFAULT_EXPAND_CONTEXT_SIZE;
+    }
+    return parsed;
+}
+export class LlamaCpp {
+    _ciMode = !!process.env.CI;
+    llama = null;
+    embedModel = null;
+    embedContexts = [];
+    generateModel = null;
+    rerankModel = null;
+    rerankContexts = [];
+    embedModelUri;
+    generateModelUri;
+    rerankModelUri;
+    modelCacheDir;
+    expandContextSize;
+    // Ensure we don't load the same model/context concurrently (which can allocate duplicate VRAM).
+    embedModelLoadPromise = null;
+    generateModelLoadPromise = null;
+    rerankModelLoadPromise = null;
+    // Inactivity timer for auto-unloading models
+    inactivityTimer = null;
+    inactivityTimeoutMs;
+    disposeModelsOnInactivity;
+    // Track disposal state to prevent double-dispose
+    disposed = false;
+    constructor(config = {}) {
+        this.embedModelUri = config.embedModel || process.env.QMD_EMBED_MODEL || DEFAULT_EMBED_MODEL;
+        this.generateModelUri = config.generateModel || process.env.QMD_GENERATE_MODEL || DEFAULT_GENERATE_MODEL;
+        this.rerankModelUri = config.rerankModel || process.env.QMD_RERANK_MODEL || DEFAULT_RERANK_MODEL;
+        this.modelCacheDir = config.modelCacheDir || MODEL_CACHE_DIR;
+        this.expandContextSize = resolveExpandContextSize(config.expandContextSize);
+        this.inactivityTimeoutMs = config.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
+        this.disposeModelsOnInactivity = config.disposeModelsOnInactivity ?? false;
+    }
+    get embedModelName() {
+        return this.embedModelUri;
+    }
+    /**
+     * Reset the inactivity timer. Called after each model operation.
+     * When timer fires, models are unloaded to free memory (if no active sessions).
+     */
+    touchActivity() {
+        // Clear existing timer
+        if (this.inactivityTimer) {
+            clearTimeout(this.inactivityTimer);
+            this.inactivityTimer = null;
+        }
+        // Only set timer if we have disposable contexts and timeout is enabled
+        if (this.inactivityTimeoutMs > 0 && this.hasLoadedContexts()) {
+            this.inactivityTimer = setTimeout(() => {
+                // Check if session manager allows unloading
+                // canUnloadLLM is defined later in this file - it checks the session manager
+                // We use dynamic import pattern to avoid circular dependency issues
+                if (typeof canUnloadLLM === 'function' && !canUnloadLLM()) {
+                    // Active sessions/operations - reschedule timer
+                    this.touchActivity();
+                    return;
+                }
+                this.unloadIdleResources().catch(err => {
+                    console.error("Error unloading idle resources:", err);
+                });
+            }, this.inactivityTimeoutMs);
+            // Don't keep process alive just for this timer
+            this.inactivityTimer.unref();
+        }
+    }
+    /**
+     * Check if any contexts are currently loaded (and therefore worth unloading on inactivity).
+     */
+    hasLoadedContexts() {
+        return !!(this.embedContexts.length > 0 || this.rerankContexts.length > 0);
+    }
+    /**
+     * Unload idle resources but keep the instance alive for future use.
+     *
+     * By default, this disposes contexts (and their dependent sequences), while keeping models loaded.
+     * This matches the intended lifecycle: model → context → sequence, where contexts are per-session.
+     */
+    async unloadIdleResources() {
+        // Don't unload if already disposed
+        if (this.disposed) {
+            return;
+        }
+        // Clear timer
+        if (this.inactivityTimer) {
+            clearTimeout(this.inactivityTimer);
+            this.inactivityTimer = null;
+        }
+        // Dispose contexts first
+        for (const ctx of this.embedContexts) {
+            await ctx.dispose();
+        }
+        this.embedContexts = [];
+        for (const ctx of this.rerankContexts) {
+            await ctx.dispose();
+        }
+        this.rerankContexts = [];
+        // Optionally dispose models too (opt-in)
+        if (this.disposeModelsOnInactivity) {
+            if (this.embedModel) {
+                await this.embedModel.dispose();
+                this.embedModel = null;
+            }
+            if (this.generateModel) {
+                await this.generateModel.dispose();
+                this.generateModel = null;
+            }
+            if (this.rerankModel) {
+                await this.rerankModel.dispose();
+                this.rerankModel = null;
+            }
+            // Reset load promises so models can be reloaded later
+            this.embedModelLoadPromise = null;
+            this.generateModelLoadPromise = null;
+            this.rerankModelLoadPromise = null;
+        }
+        // Note: We keep llama instance alive - it's lightweight
+    }
+    /**
+     * Ensure model cache directory exists
+     */
+    ensureModelCacheDir() {
+        if (!existsSync(this.modelCacheDir)) {
+            mkdirSync(this.modelCacheDir, { recursive: true });
+        }
+    }
+    /**
+     * Initialize the llama instance (lazy)
+     */
+    async ensureLlama() {
+        if (!this.llama) {
+            // Allow override via QMD_LLAMA_GPU: "false" | "off" | "none" forces CPU
+            const gpuOverride = (process.env.QMD_LLAMA_GPU ?? "").toLowerCase();
+            const forceCpu = ["false", "off", "none", "disable", "disabled", "0"].includes(gpuOverride);
+            const loadLlama = async (gpu) => await getLlama({
+                build: "autoAttempt",
+                logLevel: LlamaLogLevel.error,
+                gpu,
+            });
+            let llama;
+            if (forceCpu) {
+                llama = await loadLlama(false);
+            }
+            else {
+                try {
+                    llama = await loadLlama("auto");
+                }
+                catch (err) {
+                    // GPU backend (e.g. Vulkan on headless/driverless machines) can throw at init.
+                    // Fall back to CPU so qmd still works.
+                    process.stderr.write(`QMD Warning: GPU init failed (${err instanceof Error ? err.message : String(err)}), falling back to CPU.\n`);
+                    llama = await loadLlama(false);
+                }
+            }
+            if (llama.gpu === false) {
+                process.stderr.write("QMD Warning: no GPU acceleration, running on CPU (slow). Run 'qmd status' for details.\n");
+            }
+            this.llama = llama;
+        }
+        return this.llama;
+    }
+    /**
+     * Resolve a model URI to a local path, downloading if needed
+     */
+    async resolveModel(modelUri) {
+        this.ensureModelCacheDir();
+        // resolveModelFile handles HF URIs and downloads to the cache dir
+        return await resolveModelFile(modelUri, this.modelCacheDir);
+    }
+    /**
+     * Load embedding model (lazy)
+     */
+    async ensureEmbedModel() {
+        if (this.embedModel) {
+            return this.embedModel;
+        }
+        if (this.embedModelLoadPromise) {
+            return await this.embedModelLoadPromise;
+        }
+        this.embedModelLoadPromise = (async () => {
+            const llama = await this.ensureLlama();
+            const modelPath = await this.resolveModel(this.embedModelUri);
+            const model = await llama.loadModel({ modelPath });
+            this.embedModel = model;
+            // Model loading counts as activity - ping to keep alive
+            this.touchActivity();
+            return model;
+        })();
+        try {
+            return await this.embedModelLoadPromise;
+        }
+        finally {
+            // Keep the resolved model cached; clear only the in-flight promise.
+            this.embedModelLoadPromise = null;
+        }
+    }
+    /**
+     * Compute how many parallel contexts to create.
+     *
+     * GPU: constrained by VRAM (25% of free, capped at 8).
+     * CPU: constrained by cores. Splitting threads across contexts enables
+     *      true parallelism (each context runs on its own cores). Use at most
+     *      half the math cores, with at least 4 threads per context.
+     */
+    async computeParallelism(perContextMB) {
+        const llama = await this.ensureLlama();
+        if (llama.gpu) {
+            try {
+                const vram = await llama.getVramState();
+                const freeMB = vram.free / (1024 * 1024);
+                const maxByVram = Math.floor((freeMB * 0.25) / perContextMB);
+                return Math.max(1, Math.min(8, maxByVram));
+            }
+            catch {
+                return 2;
+            }
+        }
+        // CPU: split cores across contexts. At least 4 threads per context.
+        const cores = llama.cpuMathCores || 4;
+        const maxContexts = Math.floor(cores / 4);
+        return Math.max(1, Math.min(4, maxContexts));
+    }
+    /**
+     * Get the number of threads each context should use, given N parallel contexts.
+     * Splits available math cores evenly across contexts.
+     */
+    async threadsPerContext(parallelism) {
+        const llama = await this.ensureLlama();
+        if (llama.gpu)
+            return 0; // GPU: let the library decide
+        const cores = llama.cpuMathCores || 4;
+        return Math.max(1, Math.floor(cores / parallelism));
+    }
+    /**
+     * Load embedding contexts (lazy). Creates multiple for parallel embedding.
+     * Uses promise guard to prevent concurrent context creation race condition.
+     */
+    embedContextsCreatePromise = null;
+    async ensureEmbedContexts() {
+        if (this.embedContexts.length > 0) {
+            this.touchActivity();
+            return this.embedContexts;
+        }
+        if (this.embedContextsCreatePromise) {
+            return await this.embedContextsCreatePromise;
+        }
+        this.embedContextsCreatePromise = (async () => {
+            const model = await this.ensureEmbedModel();
+            // Embed contexts are ~143 MB each (nomic-embed 2048 ctx)
+            const n = await this.computeParallelism(150);
+            const threads = await this.threadsPerContext(n);
+            for (let i = 0; i < n; i++) {
+                try {
+                    this.embedContexts.push(await model.createEmbeddingContext({
+                        contextSize: LlamaCpp.EMBED_CONTEXT_SIZE,
+                        ...(threads > 0 ? { threads } : {}),
+                    }));
+                }
+                catch {
+                    if (this.embedContexts.length === 0)
+                        throw new Error("Failed to create any embedding context");
+                    break;
+                }
+            }
+            this.touchActivity();
+            return this.embedContexts;
+        })();
+        try {
+            return await this.embedContextsCreatePromise;
+        }
+        finally {
+            this.embedContextsCreatePromise = null;
+        }
+    }
+    /**
+     * Get a single embed context (for single-embed calls). Uses first from pool.
+     */
+    async ensureEmbedContext() {
+        const contexts = await this.ensureEmbedContexts();
+        return contexts[0];
+    }
+    /**
+     * Load generation model (lazy) - context is created fresh per call
+     */
+    async ensureGenerateModel() {
+        if (!this.generateModel) {
+            if (this.generateModelLoadPromise) {
+                return await this.generateModelLoadPromise;
+            }
+            this.generateModelLoadPromise = (async () => {
+                const llama = await this.ensureLlama();
+                const modelPath = await this.resolveModel(this.generateModelUri);
+                const model = await llama.loadModel({ modelPath });
+                this.generateModel = model;
+                return model;
+            })();
+            try {
+                await this.generateModelLoadPromise;
+            }
+            finally {
+                this.generateModelLoadPromise = null;
+            }
+        }
+        this.touchActivity();
+        if (!this.generateModel) {
+            throw new Error("Generate model not loaded");
+        }
+        return this.generateModel;
+    }
+    /**
+     * Load rerank model (lazy)
+     */
+    async ensureRerankModel() {
+        if (this.rerankModel) {
+            return this.rerankModel;
+        }
+        if (this.rerankModelLoadPromise) {
+            return await this.rerankModelLoadPromise;
+        }
+        this.rerankModelLoadPromise = (async () => {
+            const llama = await this.ensureLlama();
+            const modelPath = await this.resolveModel(this.rerankModelUri);
+            const model = await llama.loadModel({ modelPath });
+            this.rerankModel = model;
+            // Model loading counts as activity - ping to keep alive
+            this.touchActivity();
+            return model;
+        })();
+        try {
+            return await this.rerankModelLoadPromise;
+        }
+        finally {
+            this.rerankModelLoadPromise = null;
+        }
+    }
+    /**
+     * Load rerank contexts (lazy). Creates multiple contexts for parallel ranking.
+     * Each context has its own sequence, so they can evaluate independently.
+     *
+     * Tuning choices:
+     * - contextSize 1024: reranking chunks are ~800 tokens max, 1024 is plenty
+     * - flashAttention: ~20% less VRAM per context (568 vs 711 MB)
+     * - Combined: drops from 11.6 GB (auto, no flash) to 568 MB per context (20×)
+     */
+    // Qwen3 reranker template adds ~200 tokens overhead (system prompt, tags, etc.)
+    // Default 2048 was too small for longer documents (e.g. session transcripts,
+    // CJK text, or large markdown files) — callers hit "input lengths exceed
+    // context size" errors even after truncation because the overhead estimate
+    // was insufficient.  4096 comfortably fits the largest real-world chunks
+    // while staying well below the 40 960-token auto size.
+    // Override with QMD_RERANK_CONTEXT_SIZE env var if you need more headroom.
+    static RERANK_CONTEXT_SIZE = (() => {
+        const v = parseInt(process.env.QMD_RERANK_CONTEXT_SIZE ?? "", 10);
+        return Number.isFinite(v) && v > 0 ? v : 4096;
+    })();
+    static EMBED_CONTEXT_SIZE = (() => {
+        const v = parseInt(process.env.QMD_EMBED_CONTEXT_SIZE ?? "", 10);
+        return Number.isFinite(v) && v > 0 ? v : 2048;
+    })();
+    async ensureRerankContexts() {
+        if (this.rerankContexts.length === 0) {
+            const model = await this.ensureRerankModel();
+            // ~960 MB per context with flash attention at contextSize 2048
+            const n = Math.min(await this.computeParallelism(1000), 4);
+            const threads = await this.threadsPerContext(n);
+            for (let i = 0; i < n; i++) {
+                try {
+                    this.rerankContexts.push(await model.createRankingContext({
+                        contextSize: LlamaCpp.RERANK_CONTEXT_SIZE,
+                        flashAttention: true,
+                        ...(threads > 0 ? { threads } : {}),
+                    }));
+                }
+                catch {
+                    if (this.rerankContexts.length === 0) {
+                        // Flash attention might not be supported — retry without it
+                        try {
+                            this.rerankContexts.push(await model.createRankingContext({
+                                contextSize: LlamaCpp.RERANK_CONTEXT_SIZE,
+                                ...(threads > 0 ? { threads } : {}),
+                            }));
+                        }
+                        catch {
+                            throw new Error("Failed to create any rerank context");
+                        }
+                    }
+                    break;
+                }
+            }
+        }
+        this.touchActivity();
+        return this.rerankContexts;
+    }
+    // ==========================================================================
+    // Tokenization
+    // ==========================================================================
+    /**
+     * Tokenize text using the embedding model's tokenizer
+     * Returns tokenizer tokens (opaque type from node-llama-cpp)
+     */
+    async tokenize(text) {
+        await this.ensureEmbedContext(); // Ensure model is loaded
+        if (!this.embedModel) {
+            throw new Error("Embed model not loaded");
+        }
+        return this.embedModel.tokenize(text);
+    }
+    /**
+     * Count tokens in text using the embedding model's tokenizer
+     */
+    async countTokens(text) {
+        const tokens = await this.tokenize(text);
+        return tokens.length;
+    }
+    /**
+     * Detokenize token IDs back to text
+     */
+    async detokenize(tokens) {
+        await this.ensureEmbedContext();
+        if (!this.embedModel) {
+            throw new Error("Embed model not loaded");
+        }
+        return this.embedModel.detokenize(tokens);
+    }
+    // ==========================================================================
+    // Core API methods
+    // ==========================================================================
+    /**
+     * Truncate text to fit within the embedding model's context window.
+     * Uses the model's own tokenizer for accurate token counting, then
+     * detokenizes back to text if truncation is needed.
+     * Returns the (possibly truncated) text and whether truncation occurred.
+     */
+    async truncateToContextSize(text) {
+        if (!this.embedModel)
+            return { text, truncated: false };
+        const maxTokens = this.embedModel.trainContextSize;
+        if (maxTokens <= 0)
+            return { text, truncated: false };
+        const tokens = this.embedModel.tokenize(text);
+        if (tokens.length <= maxTokens)
+            return { text, truncated: false };
+        // Leave a small margin (4 tokens) for BOS/EOS overhead
+        const safeLimit = Math.max(1, maxTokens - 4);
+        const truncatedTokens = tokens.slice(0, safeLimit);
+        const truncatedText = this.embedModel.detokenize(truncatedTokens);
+        return { text: truncatedText, truncated: true };
+    }
+    async embed(text, options = {}) {
+        // Ping activity at start to keep models alive during this operation
+        this.touchActivity();
+        try {
+            const context = await this.ensureEmbedContext();
+            // Guard: truncate text that exceeds model context window to prevent GGML crash
+            const { text: safeText, truncated } = await this.truncateToContextSize(text);
+            if (truncated) {
+                console.warn(`⚠ Text truncated to fit embedding context (${this.embedModel?.trainContextSize} tokens)`);
+            }
+            const embedding = await context.getEmbeddingFor(safeText);
+            return {
+                embedding: Array.from(embedding.vector),
+                model: options.model ?? this.embedModelUri,
+            };
+        }
+        catch (error) {
+            console.error("Embedding error:", error);
+            return null;
+        }
+    }
+    /**
+     * Batch embed multiple texts efficiently
+     * Uses Promise.all for parallel embedding - node-llama-cpp handles batching internally
+     */
+    async embedBatch(texts, options = {}) {
+        if (this._ciMode)
+            throw new Error("LLM operations are disabled in CI (set CI=true)");
+        // Ping activity at start to keep models alive during this operation
+        this.touchActivity();
+        if (texts.length === 0)
+            return [];
+        try {
+            const contexts = await this.ensureEmbedContexts();
+            const n = contexts.length;
+            if (n === 1) {
+                // Single context: sequential (no point splitting)
+                const context = contexts[0];
+                const embeddings = [];
+                for (const text of texts) {
+                    try {
+                        const { text: safeText, truncated } = await this.truncateToContextSize(text);
+                        if (truncated) {
+                            console.warn(`⚠ Batch text truncated to fit embedding context (${this.embedModel?.trainContextSize} tokens)`);
+                        }
+                        const embedding = await context.getEmbeddingFor(safeText);
+                        this.touchActivity();
+                        embeddings.push({ embedding: Array.from(embedding.vector), model: options.model ?? this.embedModelUri });
+                    }
+                    catch (err) {
+                        console.error("Embedding error for text:", err);
+                        embeddings.push(null);
+                    }
+                }
+                return embeddings;
+            }
+            // Multiple contexts: split texts across contexts for parallel evaluation
+            const chunkSize = Math.ceil(texts.length / n);
+            const chunks = Array.from({ length: n }, (_, i) => texts.slice(i * chunkSize, (i + 1) * chunkSize));
+            const chunkResults = await Promise.all(chunks.map(async (chunk, i) => {
+                const ctx = contexts[i];
+                const results = [];
+                for (const text of chunk) {
+                    try {
+                        const { text: safeText, truncated } = await this.truncateToContextSize(text);
+                        if (truncated) {
+                            console.warn(`⚠ Batch text truncated to fit embedding context (${this.embedModel?.trainContextSize} tokens)`);
+                        }
+                        const embedding = await ctx.getEmbeddingFor(safeText);
+                        this.touchActivity();
+                        results.push({ embedding: Array.from(embedding.vector), model: options.model ?? this.embedModelUri });
+                    }
+                    catch (err) {
+                        console.error("Embedding error for text:", err);
+                        results.push(null);
+                    }
+                }
+                return results;
+            }));
+            return chunkResults.flat();
+        }
+        catch (error) {
+            console.error("Batch embedding error:", error);
+            return texts.map(() => null);
+        }
+    }
+    async generate(prompt, options = {}) {
+        if (this._ciMode)
+            throw new Error("LLM operations are disabled in CI (set CI=true)");
+        // Ping activity at start to keep models alive during this operation
+        this.touchActivity();
+        // Ensure model is loaded
+        await this.ensureGenerateModel();
+        // Create fresh context -> sequence -> session for each call
+        const context = await this.generateModel.createContext();
+        const sequence = context.getSequence();
+        const session = new LlamaChatSession({ contextSequence: sequence });
+        const maxTokens = options.maxTokens ?? 150;
+        // Qwen3 recommends temp=0.7, topP=0.8, topK=20 for non-thinking mode
+        // DO NOT use greedy decoding (temp=0) - causes repetition loops
+        const temperature = options.temperature ?? 0.7;
+        let result = "";
+        try {
+            await session.prompt(prompt, {
+                maxTokens,
+                temperature,
+                topK: 20,
+                topP: 0.8,
+                onTextChunk: (text) => {
+                    result += text;
+                },
+            });
+            return {
+                text: result,
+                model: this.generateModelUri,
+                done: true,
+            };
+        }
+        finally {
+            // Dispose context (which disposes dependent sequences/sessions per lifecycle rules)
+            await context.dispose();
+        }
+    }
+    async modelExists(modelUri) {
+        // For HuggingFace URIs, we assume they exist
+        // For local paths, check if file exists
+        if (modelUri.startsWith("hf:")) {
+            return { name: modelUri, exists: true };
+        }
+        const exists = existsSync(modelUri);
+        return {
+            name: modelUri,
+            exists,
+            path: exists ? modelUri : undefined,
+        };
+    }
+    // ==========================================================================
+    // High-level abstractions
+    // ==========================================================================
+    async expandQuery(query, options = {}) {
+        if (this._ciMode)
+            throw new Error("LLM operations are disabled in CI (set CI=true)");
+        // Ping activity at start to keep models alive during this operation
+        this.touchActivity();
+        const llama = await this.ensureLlama();
+        await this.ensureGenerateModel();
+        const includeLexical = options.includeLexical ?? true;
+        const context = options.context;
+        const grammar = await llama.createGrammar({
+            grammar: `
+        root ::= line+
+        line ::= type ": " content "\\n"
+        type ::= "lex" | "vec" | "hyde"
+        content ::= [^\\n]+
+      `
+        });
+        const intent = options.intent;
+        const prompt = intent
+            ? `/no_think Expand this search query: ${query}\nQuery intent: ${intent}`
+            : `/no_think Expand this search query: ${query}`;
+        // Create a bounded context for expansion to prevent large default VRAM allocations.
+        const genContext = await this.generateModel.createContext({
+            contextSize: this.expandContextSize,
+        });
+        const sequence = genContext.getSequence();
+        const session = new LlamaChatSession({ contextSequence: sequence });
+        try {
+            // Qwen3 recommended settings for non-thinking mode:
+            // temp=0.7, topP=0.8, topK=20, presence_penalty for repetition
+            // DO NOT use greedy decoding (temp=0) - causes infinite loops
+            const result = await session.prompt(prompt, {
+                grammar,
+                maxTokens: 600,
+                temperature: 0.7,
+                topK: 20,
+                topP: 0.8,
+                repeatPenalty: {
+                    lastTokens: 64,
+                    presencePenalty: 0.5,
+                },
+            });
+            const lines = result.trim().split("\n");
+            const queryLower = query.toLowerCase();
+            const queryTerms = queryLower.replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
+            const hasQueryTerm = (text) => {
+                const lower = text.toLowerCase();
+                if (queryTerms.length === 0)
+                    return true;
+                return queryTerms.some(term => lower.includes(term));
+            };
+            const queryables = lines.map(line => {
+                const colonIdx = line.indexOf(":");
+                if (colonIdx === -1)
+                    return null;
+                const type = line.slice(0, colonIdx).trim();
+                if (type !== 'lex' && type !== 'vec' && type !== 'hyde')
+                    return null;
+                const text = line.slice(colonIdx + 1).trim();
+                if (!hasQueryTerm(text))
+                    return null;
+                return { type: type, text };
+            }).filter((q) => q !== null);
+            // Filter out lex entries if not requested
+            const filtered = includeLexical ? queryables : queryables.filter(q => q.type !== 'lex');
+            if (filtered.length > 0)
+                return filtered;
+            const fallback = [
+                { type: 'hyde', text: `Information about ${query}` },
+                { type: 'lex', text: query },
+                { type: 'vec', text: query },
+            ];
+            return includeLexical ? fallback : fallback.filter(q => q.type !== 'lex');
+        }
+        catch (error) {
+            console.error("Structured query expansion failed:", error);
+            // Fallback to original query
+            const fallback = [{ type: 'vec', text: query }];
+            if (includeLexical)
+                fallback.unshift({ type: 'lex', text: query });
+            return fallback;
+        }
+        finally {
+            await genContext.dispose();
+        }
+    }
+    // Qwen3 reranker chat template overhead (system prompt, tags, separators).
+    // Measured at ~350 tokens on real queries; use 512 as a safe upper bound so
+    // the truncation budget never lets a document slip past the context limit.
+    static RERANK_TEMPLATE_OVERHEAD = 512;
+    static RERANK_TARGET_DOCS_PER_CONTEXT = 10;
+    async rerank(query, documents, options = {}) {
+        if (this._ciMode)
+            throw new Error("LLM operations are disabled in CI (set CI=true)");
+        // Ping activity at start to keep models alive during this operation
+        this.touchActivity();
+        const contexts = await this.ensureRerankContexts();
+        const model = await this.ensureRerankModel();
+        // Truncate documents that would exceed the rerank context size.
+        // Budget = contextSize - template overhead - query tokens
+        const queryTokens = model.tokenize(query).length;
+        const maxDocTokens = LlamaCpp.RERANK_CONTEXT_SIZE - LlamaCpp.RERANK_TEMPLATE_OVERHEAD - queryTokens;
+        const truncationCache = new Map();
+        const truncatedDocs = documents.map((doc) => {
+            const cached = truncationCache.get(doc.text);
+            if (cached !== undefined) {
+                return cached === doc.text ? doc : { ...doc, text: cached };
+            }
+            const tokens = model.tokenize(doc.text);
+            const truncatedText = tokens.length <= maxDocTokens
+                ? doc.text
+                : model.detokenize(tokens.slice(0, maxDocTokens));
+            truncationCache.set(doc.text, truncatedText);
+            if (truncatedText === doc.text)
+                return doc;
+            return { ...doc, text: truncatedText };
+        });
+        // Deduplicate identical effective texts before scoring.
+        // This avoids redundant work for repeated chunks and fixes collisions where
+        // multiple docs map to the same chunk text.
+        const textToDocs = new Map();
+        truncatedDocs.forEach((doc, index) => {
+            const existing = textToDocs.get(doc.text);
+            if (existing) {
+                existing.push({ file: doc.file, index });
+            }
+            else {
+                textToDocs.set(doc.text, [{ file: doc.file, index }]);
+            }
+        });
+        // Extract just the text for ranking
+        const texts = Array.from(textToDocs.keys());
+        // Split documents across contexts for parallel evaluation.
+        // Each context has its own sequence with a lock, so parallelism comes
+        // from multiple contexts evaluating different chunks simultaneously.
+        const activeContextCount = Math.max(1, Math.min(contexts.length, Math.ceil(texts.length / LlamaCpp.RERANK_TARGET_DOCS_PER_CONTEXT)));
+        const activeContexts = contexts.slice(0, activeContextCount);
+        const chunkSize = Math.ceil(texts.length / activeContexts.length);
+        const chunks = Array.from({ length: activeContexts.length }, (_, i) => texts.slice(i * chunkSize, (i + 1) * chunkSize)).filter(chunk => chunk.length > 0);
+        const allScores = await Promise.all(chunks.map((chunk, i) => activeContexts[i].rankAll(query, chunk)));
+        // Reassemble scores in original order and sort
+        const flatScores = allScores.flat();
+        const ranked = texts
+            .map((text, i) => ({ document: text, score: flatScores[i] }))
+            .sort((a, b) => b.score - a.score);
+        // Map back to our result format.
+        const results = [];
+        for (const item of ranked) {
+            const docInfos = textToDocs.get(item.document) ?? [];
+            for (const docInfo of docInfos) {
+                results.push({
+                    file: docInfo.file,
+                    score: item.score,
+                    index: docInfo.index,
+                });
+            }
+        }
+        return {
+            results,
+            model: this.rerankModelUri,
+        };
+    }
+    /**
+     * Get device/GPU info for status display.
+     * Initializes llama if not already done.
+     */
+    async getDeviceInfo() {
+        const llama = await this.ensureLlama();
+        const gpuDevices = await llama.getGpuDeviceNames();
+        let vram;
+        if (llama.gpu) {
+            try {
+                const state = await llama.getVramState();
+                vram = { total: state.total, used: state.used, free: state.free };
+            }
+            catch { /* no vram info */ }
+        }
+        return {
+            gpu: llama.gpu,
+            gpuOffloading: llama.supportsGpuOffloading,
+            gpuDevices,
+            vram,
+            cpuCores: llama.cpuMathCores,
+        };
+    }
+    async dispose() {
+        // Prevent double-dispose
+        if (this.disposed) {
+            return;
+        }
+        this.disposed = true;
+        // Clear inactivity timer
+        if (this.inactivityTimer) {
+            clearTimeout(this.inactivityTimer);
+            this.inactivityTimer = null;
+        }
+        // Disposing llama cascades to models and contexts automatically
+        // See: https://node-llama-cpp.withcat.ai/guide/objects-lifecycle
+        // Note: llama.dispose() can hang indefinitely, so we use a timeout
+        if (this.llama) {
+            const disposePromise = this.llama.dispose();
+            const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 1000));
+            await Promise.race([disposePromise, timeoutPromise]);
+        }
+        // Clear references
+        this.embedContexts = [];
+        this.rerankContexts = [];
+        this.embedModel = null;
+        this.generateModel = null;
+        this.rerankModel = null;
+        this.llama = null;
+        // Clear any in-flight load/create promises
+        this.embedModelLoadPromise = null;
+        this.embedContextsCreatePromise = null;
+        this.generateModelLoadPromise = null;
+        this.rerankModelLoadPromise = null;
+    }
+}
+// =============================================================================
+// Session Management Layer
+// =============================================================================
+/**
+ * Manages LLM session lifecycle with reference counting.
+ * Coordinates with LlamaCpp idle timeout to prevent disposal during active sessions.
+ */
+class LLMSessionManager {
+    llm;
+    _activeSessionCount = 0;
+    _inFlightOperations = 0;
+    constructor(llm) {
+        this.llm = llm;
+    }
+    get activeSessionCount() {
+        return this._activeSessionCount;
+    }
+    get inFlightOperations() {
+        return this._inFlightOperations;
+    }
+    /**
+     * Returns true only when both session count and in-flight operations are 0.
+     * Used by LlamaCpp to determine if idle unload is safe.
+     */
+    canUnload() {
+        return this._activeSessionCount === 0 && this._inFlightOperations === 0;
+    }
+    acquire() {
+        this._activeSessionCount++;
+    }
+    release() {
+        this._activeSessionCount = Math.max(0, this._activeSessionCount - 1);
+    }
+    operationStart() {
+        this._inFlightOperations++;
+    }
+    operationEnd() {
+        this._inFlightOperations = Math.max(0, this._inFlightOperations - 1);
+    }
+    getLlamaCpp() {
+        return this.llm;
+    }
+}
+/**
+ * Error thrown when an operation is attempted on a released or aborted session.
+ */
+export class SessionReleasedError extends Error {
+    constructor(message = "LLM session has been released or aborted") {
+        super(message);
+        this.name = "SessionReleasedError";
+    }
+}
+/**
+ * Scoped LLM session with automatic lifecycle management.
+ * Wraps LlamaCpp methods with operation tracking and abort handling.
+ */
+class LLMSession {
+    manager;
+    released = false;
+    abortController;
+    maxDurationTimer = null;
+    name;
+    constructor(manager, options = {}) {
+        this.manager = manager;
+        this.name = options.name || "unnamed";
+        this.abortController = new AbortController();
+        // Link external abort signal if provided
+        if (options.signal) {
+            if (options.signal.aborted) {
+                this.abortController.abort(options.signal.reason);
+            }
+            else {
+                options.signal.addEventListener("abort", () => {
+                    this.abortController.abort(options.signal.reason);
+                }, { once: true });
+            }
+        }
+        // Set up max duration timer
+        const maxDuration = options.maxDuration ?? 10 * 60 * 1000; // Default 10 minutes
+        if (maxDuration > 0) {
+            this.maxDurationTimer = setTimeout(() => {
+                this.abortController.abort(new Error(`Session "${this.name}" exceeded max duration of ${maxDuration}ms`));
+            }, maxDuration);
+            this.maxDurationTimer.unref(); // Don't keep process alive
+        }
+        // Acquire session lease
+        this.manager.acquire();
+    }
+    get isValid() {
+        return !this.released && !this.abortController.signal.aborted;
+    }
+    get signal() {
+        return this.abortController.signal;
+    }
+    /**
+     * Release the session and decrement ref count.
+     * Called automatically by withLLMSession when the callback completes.
+     */
+    release() {
+        if (this.released)
+            return;
+        this.released = true;
+        if (this.maxDurationTimer) {
+            clearTimeout(this.maxDurationTimer);
+            this.maxDurationTimer = null;
+        }
+        this.abortController.abort(new Error("Session released"));
+        this.manager.release();
+    }
+    /**
+     * Wrap an operation with tracking and abort checking.
+     */
+    async withOperation(fn) {
+        if (!this.isValid) {
+            throw new SessionReleasedError();
+        }
+        this.manager.operationStart();
+        try {
+            // Check abort before starting
+            if (this.abortController.signal.aborted) {
+                throw new SessionReleasedError(this.abortController.signal.reason?.message || "Session aborted");
+            }
+            return await fn();
+        }
+        finally {
+            this.manager.operationEnd();
+        }
+    }
+    async embed(text, options) {
+        return this.withOperation(() => this.manager.getLlamaCpp().embed(text, options));
+    }
+    async embedBatch(texts, options) {
+        return this.withOperation(() => this.manager.getLlamaCpp().embedBatch(texts, options));
+    }
+    async expandQuery(query, options) {
+        return this.withOperation(() => this.manager.getLlamaCpp().expandQuery(query, options));
+    }
+    async rerank(query, documents, options) {
+        return this.withOperation(() => this.manager.getLlamaCpp().rerank(query, documents, options));
+    }
+}
+// Session manager for the default LlamaCpp instance
+let defaultSessionManager = null;
+/**
+ * Get the session manager for the default LlamaCpp instance.
+ */
+function getSessionManager() {
+    const llm = getDefaultLlamaCpp();
+    if (!defaultSessionManager || defaultSessionManager.getLlamaCpp() !== llm) {
+        defaultSessionManager = new LLMSessionManager(llm);
+    }
+    return defaultSessionManager;
+}
+/**
+ * Execute a function with a scoped LLM session.
+ * The session provides lifecycle guarantees - resources won't be disposed mid-operation.
+ *
+ * @example
+ * ```typescript
+ * await withLLMSession(async (session) => {
+ *   const expanded = await session.expandQuery(query);
+ *   const embeddings = await session.embedBatch(texts);
+ *   const reranked = await session.rerank(query, docs);
+ *   return reranked;
+ * }, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
+ * ```
+ */
+export async function withLLMSession(fn, options) {
+    const manager = getSessionManager();
+    const session = new LLMSession(manager, options);
+    try {
+        return await fn(session);
+    }
+    finally {
+        session.release();
+    }
+}
+/**
+ * Execute a function with a scoped LLM session using a specific LlamaCpp instance.
+ * Unlike withLLMSession, this does not use the global singleton.
+ */
+export async function withLLMSessionForLlm(llm, fn, options) {
+    const manager = new LLMSessionManager(llm);
+    const session = new LLMSession(manager, options);
+    try {
+        return await fn(session);
+    }
+    finally {
+        session.release();
+    }
+}
+/**
+ * Check if idle unload is safe (no active sessions or operations).
+ * Used internally by LlamaCpp idle timer.
+ */
+export function canUnloadLLM() {
+    if (!defaultSessionManager)
+        return true;
+    return defaultSessionManager.canUnload();
+}
+// =============================================================================
+// Singleton for default LlamaCpp instance
+// =============================================================================
+let defaultLlamaCpp = null;
+/**
+ * Get the default LlamaCpp instance (creates one if needed)
+ */
+export function getDefaultLlamaCpp() {
+    if (!defaultLlamaCpp) {
+        defaultLlamaCpp = new LlamaCpp();
+    }
+    return defaultLlamaCpp;
+}
+/**
+ * Set a custom default LlamaCpp instance (useful for testing)
+ */
+export function setDefaultLlamaCpp(llm) {
+    defaultLlamaCpp = llm;
+}
+/**
+ * Dispose the default LlamaCpp instance if it exists.
+ * Call this before process exit to prevent NAPI crashes.
+ */
+export async function disposeDefaultLlamaCpp() {
+    if (defaultLlamaCpp) {
+        await defaultLlamaCpp.dispose();
+        defaultLlamaCpp = null;
+    }
+}

+ 23 - 0
dist/maintenance.d.ts

@@ -0,0 +1,23 @@
+/**
+ * Maintenance - Database cleanup operations for QMD.
+ *
+ * Wraps low-level store operations that the CLI needs for housekeeping.
+ * Takes an internal Store in the constructor — allowed to access DB directly.
+ */
+import type { Store } from "./store.js";
+export declare class Maintenance {
+    private store;
+    constructor(store: Store);
+    /** Run VACUUM on the SQLite database to reclaim space */
+    vacuum(): void;
+    /** Remove content rows that are no longer referenced by any document */
+    cleanupOrphanedContent(): number;
+    /** Remove vector embeddings for content that no longer exists */
+    cleanupOrphanedVectors(): number;
+    /** Clear the LLM response cache (query expansion, reranking) */
+    clearLLMCache(): number;
+    /** Delete documents marked as inactive (removed from filesystem) */
+    deleteInactiveDocs(): number;
+    /** Clear all vector embeddings (forces re-embedding) */
+    clearEmbeddings(): void;
+}

+ 37 - 0
dist/maintenance.js

@@ -0,0 +1,37 @@
+/**
+ * Maintenance - Database cleanup operations for QMD.
+ *
+ * Wraps low-level store operations that the CLI needs for housekeeping.
+ * Takes an internal Store in the constructor — allowed to access DB directly.
+ */
+import { vacuumDatabase, cleanupOrphanedContent, cleanupOrphanedVectors, deleteLLMCache, deleteInactiveDocuments, clearAllEmbeddings, } from "./store.js";
+export class Maintenance {
+    store;
+    constructor(store) {
+        this.store = store;
+    }
+    /** Run VACUUM on the SQLite database to reclaim space */
+    vacuum() {
+        vacuumDatabase(this.store.db);
+    }
+    /** Remove content rows that are no longer referenced by any document */
+    cleanupOrphanedContent() {
+        return cleanupOrphanedContent(this.store.db);
+    }
+    /** Remove vector embeddings for content that no longer exists */
+    cleanupOrphanedVectors() {
+        return cleanupOrphanedVectors(this.store.db);
+    }
+    /** Clear the LLM response cache (query expansion, reranking) */
+    clearLLMCache() {
+        return deleteLLMCache(this.store.db);
+    }
+    /** Delete documents marked as inactive (removed from filesystem) */
+    deleteInactiveDocs() {
+        return deleteInactiveDocuments(this.store.db);
+    }
+    /** Clear all vector embeddings (forces re-embedding) */
+    clearEmbeddings() {
+        clearAllEmbeddings(this.store.db);
+    }
+}

+ 21 - 0
dist/mcp/server.d.ts

@@ -0,0 +1,21 @@
+/**
+ * QMD MCP Server - Model Context Protocol server for QMD
+ *
+ * Exposes QMD search and document retrieval as MCP tools and resources.
+ * Documents are accessible via qmd:// URIs.
+ *
+ * Follows MCP spec 2025-06-18 for proper response types.
+ */
+export declare function startMcpServer(): Promise<void>;
+export type HttpServerHandle = {
+    httpServer: import("http").Server;
+    port: number;
+    stop: () => Promise<void>;
+};
+/**
+ * Start MCP server over Streamable HTTP (JSON responses, no SSE).
+ * Binds to localhost only. Returns a handle for shutdown and port discovery.
+ */
+export declare function startMcpHttpServer(port: number, options?: {
+    quiet?: boolean;
+}): Promise<HttpServerHandle>;

+ 675 - 0
dist/mcp/server.js

@@ -0,0 +1,675 @@
+/**
+ * QMD MCP Server - Model Context Protocol server for QMD
+ *
+ * Exposes QMD search and document retrieval as MCP tools and resources.
+ * Documents are accessible via qmd:// URIs.
+ *
+ * Follows MCP spec 2025-06-18 for proper response types.
+ */
+import { createServer } from "node:http";
+import { randomUUID } from "node:crypto";
+import { readFileSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "url";
+import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
+import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
+import { z } from "zod";
+import { existsSync } from "fs";
+import { createStore, extractSnippet, addLineNumbers, getDefaultDbPath, DEFAULT_MULTI_GET_MAX_BYTES, } from "../index.js";
+import { getConfigPath } from "../collections.js";
+// =============================================================================
+// Helper functions
+// =============================================================================
+/**
+ * Encode a path for use in qmd:// URIs.
+ * Encodes special characters but preserves forward slashes for readability.
+ */
+function encodeQmdPath(path) {
+    // Encode each path segment separately to preserve slashes
+    return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
+}
+/**
+ * Format search results as human-readable text summary
+ */
+function formatSearchSummary(results, query) {
+    if (results.length === 0) {
+        return `No results found for "${query}"`;
+    }
+    const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
+    for (const r of results) {
+        lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
+    }
+    return lines.join('\n');
+}
+function getPackageVersion() {
+    try {
+        const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../package.json");
+        const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
+        return pkg.version ?? "unknown";
+    }
+    catch {
+        return "unknown";
+    }
+}
+// =============================================================================
+// MCP Server
+// =============================================================================
+/**
+ * Build dynamic server instructions from actual index state.
+ * Injected into the LLM's system prompt via MCP initialize response —
+ * gives the LLM immediate context about what's searchable without a tool call.
+ */
+async function buildInstructions(store) {
+    const status = await store.getStatus();
+    const contexts = await store.listContexts();
+    const globalCtx = await store.getGlobalContext();
+    const lines = [];
+    // --- What is this? ---
+    lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
+    if (globalCtx)
+        lines.push(`Context: ${globalCtx}`);
+    // --- What's searchable? ---
+    if (status.collections.length > 0) {
+        lines.push("");
+        lines.push("Collections (scope with `collection` parameter):");
+        for (const col of status.collections) {
+            // Find root context for this collection
+            const rootCtx = contexts.find(c => c.collection === col.name && (c.path === "" || c.path === "/"));
+            const desc = rootCtx ? ` — ${rootCtx.context}` : "";
+            lines.push(`  - "${col.name}" (${col.documents} docs)${desc}`);
+        }
+    }
+    // --- Capability gaps ---
+    if (!status.hasVectorIndex) {
+        lines.push("");
+        lines.push("Note: No vector embeddings yet. Run `qmd embed` to enable semantic search (vec/hyde).");
+    }
+    else if (status.needsEmbedding > 0) {
+        lines.push("");
+        lines.push(`Note: ${status.needsEmbedding} documents need embedding. Run \`qmd embed\` to update.`);
+    }
+    // --- Search tool ---
+    lines.push("");
+    lines.push("Search: Use `query` with sub-queries (lex/vec/hyde):");
+    lines.push("  - type:'lex' — BM25 keyword search (exact terms, fast)");
+    lines.push("  - type:'vec' — semantic vector search (meaning-based)");
+    lines.push("  - type:'hyde' — hypothetical document (write what the answer looks like)");
+    lines.push("");
+    lines.push("  Always provide `intent` on every search call to disambiguate and improve snippets.");
+    lines.push("");
+    lines.push("Examples:");
+    lines.push("  Quick keyword lookup: [{type:'lex', query:'error handling'}]");
+    lines.push("  Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]");
+    lines.push("  Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]");
+    lines.push("  With intent: searches=[{type:'lex', query:'performance'}], intent='web page load times'");
+    // --- Retrieval workflow ---
+    lines.push("");
+    lines.push("Retrieval:");
+    lines.push("  - `get` — single document by path or docid (#abc123). Supports line offset (`file.md:100`).");
+    lines.push("  - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`) or comma-separated list.");
+    // --- Non-obvious things that prevent mistakes ---
+    lines.push("");
+    lines.push("Tips:");
+    lines.push("  - File paths in results are relative to their collection.");
+    lines.push("  - Use `minScore: 0.5` to filter low-confidence results.");
+    lines.push("  - Results include a `context` field describing the content type.");
+    return lines.join("\n");
+}
+/**
+ * Create an MCP server with all QMD tools, resources, and prompts registered.
+ * Shared by both stdio and HTTP transports.
+ */
+async function createMcpServer(store) {
+    const server = new McpServer({ name: "qmd", version: getPackageVersion() }, { instructions: await buildInstructions(store) });
+    // Pre-fetch default collection names for search tools
+    const defaultCollectionNames = await store.getDefaultCollectionNames();
+    // ---------------------------------------------------------------------------
+    // Resource: qmd://{path} - read-only access to documents by path
+    // Note: No list() - documents are discovered via search tools
+    // ---------------------------------------------------------------------------
+    server.registerResource("document", new ResourceTemplate("qmd://{+path}", { list: undefined }), {
+        title: "QMD Document",
+        description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.",
+        mimeType: "text/markdown",
+    }, async (uri, { path }) => {
+        // Decode URL-encoded path (MCP clients send encoded URIs)
+        const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
+        const decodedPath = decodeURIComponent(pathStr);
+        // Use SDK to find document — findDocument handles collection/path resolution
+        const result = await store.get(decodedPath, { includeBody: true });
+        if ("error" in result) {
+            return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
+        }
+        let text = addLineNumbers(result.body || ""); // Default to line numbers
+        if (result.context) {
+            text = `<!-- Context: ${result.context} -->\n\n` + text;
+        }
+        return {
+            contents: [{
+                    uri: uri.href,
+                    name: result.displayPath,
+                    title: result.title || result.displayPath,
+                    mimeType: "text/markdown",
+                    text,
+                }],
+        };
+    });
+    // ---------------------------------------------------------------------------
+    // Tool: query (Primary search tool)
+    // ---------------------------------------------------------------------------
+    const subSearchSchema = z.object({
+        type: z.enum(['lex', 'vec', 'hyde']).describe("lex = BM25 keywords (supports \"phrase\" and -negation); " +
+            "vec = semantic question; hyde = hypothetical answer passage"),
+        query: z.string().describe("The query text. For lex: use keywords, \"quoted phrases\", and -negation. " +
+            "For vec: natural language question. For hyde: 50-100 word answer passage."),
+    });
+    server.registerTool("query", {
+        title: "Query",
+        description: `Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.
+
+## Query Types
+
+**lex** — BM25 keyword search. Fast, exact, no LLM needed.
+Full lex syntax:
+- \`term\` — prefix match ("perf" matches "performance")
+- \`"exact phrase"\` — phrase must appear verbatim
+- \`-term\` or \`-"phrase"\` — exclude documents containing this
+
+Good lex examples:
+- \`"connection pool" timeout -redis\`
+- \`"machine learning" -sports -athlete\`
+- \`handleError async typescript\`
+
+**vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words.
+- \`how does the rate limiter handle burst traffic?\`
+- \`what is the tradeoff between consistency and availability?\`
+
+**hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics.
+- \`The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.\`
+
+## Strategy
+
+Combine types for best results. First sub-query gets 2× weight — put your strongest signal first.
+
+| Goal | Approach |
+|------|----------|
+| Know exact term/name | \`lex\` only |
+| Concept search | \`vec\` only |
+| Best recall | \`lex\` + \`vec\` |
+| Complex/nuanced | \`lex\` + \`vec\` + \`hyde\` |
+| Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it |
+
+## Examples
+
+Simple lookup:
+\`\`\`json
+[{ "type": "lex", "query": "CAP theorem" }]
+\`\`\`
+
+Best recall on a technical topic:
+\`\`\`json
+[
+  { "type": "lex", "query": "\\"connection pool\\" timeout -redis" },
+  { "type": "vec", "query": "why do database connections time out under load" },
+  { "type": "hyde", "query": "Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected." }
+]
+\`\`\`
+
+Intent-aware lex (C++ performance, not sports):
+\`\`\`json
+[
+  { "type": "lex", "query": "\\"C++ performance\\" optimization -sports -athlete" },
+  { "type": "vec", "query": "how to optimize C++ program performance" }
+]
+\`\`\``,
+        annotations: { readOnlyHint: true, openWorldHint: false },
+        inputSchema: {
+            searches: z.array(subSearchSchema).min(1).max(10).describe("Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight."),
+            limit: z.number().optional().default(10).describe("Max results (default: 10)"),
+            minScore: z.number().optional().default(0).describe("Min relevance 0-1 (default: 0)"),
+            candidateLimit: z.number().optional().describe("Maximum candidates to rerank (default: 40, lower = faster but may miss results)"),
+            collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"),
+            intent: z.string().optional().describe("Background context to disambiguate the query. Example: query='performance', intent='web page load times and Core Web Vitals'. Does not search on its own."),
+            rerank: z.boolean().optional().default(true).describe("Rerank results using LLM (default: true). Set to false for faster results on CPU-only machines."),
+        },
+    }, async ({ searches, limit, minScore, candidateLimit, collections, intent, rerank }) => {
+        // Map to internal format
+        const queries = searches.map(s => ({
+            type: s.type,
+            query: s.query,
+        }));
+        // Use default collections if none specified
+        const effectiveCollections = collections ?? defaultCollectionNames;
+        const results = await store.search({
+            queries,
+            collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
+            limit,
+            minScore,
+            rerank,
+            intent,
+        });
+        // Use first lex or vec query for snippet extraction
+        const primaryQuery = searches.find(s => s.type === 'lex')?.query
+            || searches.find(s => s.type === 'vec')?.query
+            || searches[0]?.query || "";
+        const filtered = results.map(r => {
+            const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300, undefined, undefined, intent);
+            return {
+                docid: `#${r.docid}`,
+                file: r.displayPath,
+                title: r.title,
+                score: Math.round(r.score * 100) / 100,
+                context: r.context,
+                snippet: addLineNumbers(snippet, line),
+            };
+        });
+        return {
+            content: [{ type: "text", text: formatSearchSummary(filtered, primaryQuery) }],
+            structuredContent: { results: filtered },
+        };
+    });
+    // ---------------------------------------------------------------------------
+    // Tool: qmd_get (Retrieve document)
+    // ---------------------------------------------------------------------------
+    server.registerTool("get", {
+        title: "Get Document",
+        description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.",
+        annotations: { readOnlyHint: true, openWorldHint: false },
+        inputSchema: {
+            file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"),
+            fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
+            maxLines: z.number().optional().describe("Maximum number of lines to return"),
+            lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
+        },
+    }, async ({ file, fromLine, maxLines, lineNumbers }) => {
+        // Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided
+        let parsedFromLine = fromLine;
+        let lookup = file;
+        const colonMatch = lookup.match(/:(\d+)$/);
+        if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
+            parsedFromLine = parseInt(colonMatch[1], 10);
+            lookup = lookup.slice(0, -colonMatch[0].length);
+        }
+        const result = await store.get(lookup, { includeBody: false });
+        if ("error" in result) {
+            let msg = `Document not found: ${file}`;
+            if (result.similarFiles.length > 0) {
+                msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => `  - ${s}`).join('\n')}`;
+            }
+            return {
+                content: [{ type: "text", text: msg }],
+                isError: true,
+            };
+        }
+        const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines }) ?? "";
+        let text = body;
+        if (lineNumbers) {
+            const startLine = parsedFromLine || 1;
+            text = addLineNumbers(text, startLine);
+        }
+        if (result.context) {
+            text = `<!-- Context: ${result.context} -->\n\n` + text;
+        }
+        return {
+            content: [{
+                    type: "resource",
+                    resource: {
+                        uri: `qmd://${encodeQmdPath(result.displayPath)}`,
+                        name: result.displayPath,
+                        title: result.title,
+                        mimeType: "text/markdown",
+                        text,
+                    },
+                }],
+        };
+    });
+    // ---------------------------------------------------------------------------
+    // Tool: qmd_multi_get (Retrieve multiple documents)
+    // ---------------------------------------------------------------------------
+    server.registerTool("multi_get", {
+        title: "Multi-Get Documents",
+        description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
+        annotations: { readOnlyHint: true, openWorldHint: false },
+        inputSchema: {
+            pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
+            maxLines: z.number().optional().describe("Maximum lines per file"),
+            maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
+            lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
+        },
+    }, async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
+        const { docs, errors } = await store.multiGet(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
+        if (docs.length === 0 && errors.length === 0) {
+            return {
+                content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
+                isError: true,
+            };
+        }
+        const content = [];
+        if (errors.length > 0) {
+            content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
+        }
+        for (const result of docs) {
+            if (result.skipped) {
+                content.push({
+                    type: "text",
+                    text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`,
+                });
+                continue;
+            }
+            let text = result.doc.body || "";
+            if (maxLines !== undefined) {
+                const lines = text.split("\n");
+                text = lines.slice(0, maxLines).join("\n");
+                if (lines.length > maxLines) {
+                    text += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
+                }
+            }
+            if (lineNumbers) {
+                text = addLineNumbers(text);
+            }
+            if (result.doc.context) {
+                text = `<!-- Context: ${result.doc.context} -->\n\n` + text;
+            }
+            content.push({
+                type: "resource",
+                resource: {
+                    uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`,
+                    name: result.doc.displayPath,
+                    title: result.doc.title,
+                    mimeType: "text/markdown",
+                    text,
+                },
+            });
+        }
+        return { content };
+    });
+    // ---------------------------------------------------------------------------
+    // Tool: qmd_status (Index status)
+    // ---------------------------------------------------------------------------
+    server.registerTool("status", {
+        title: "Index Status",
+        description: "Show the status of the QMD index: collections, document counts, and health information.",
+        annotations: { readOnlyHint: true, openWorldHint: false },
+        inputSchema: {},
+    }, async () => {
+        const status = await store.getStatus();
+        const summary = [
+            `QMD Index Status:`,
+            `  Total documents: ${status.totalDocuments}`,
+            `  Needs embedding: ${status.needsEmbedding}`,
+            `  Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
+            `  Collections: ${status.collections.length}`,
+        ];
+        for (const col of status.collections) {
+            summary.push(`    - ${col.name}: ${col.path} (${col.documents} docs)`);
+        }
+        return {
+            content: [{ type: "text", text: summary.join('\n') }],
+            structuredContent: status,
+        };
+    });
+    return server;
+}
+// =============================================================================
+// Transport: stdio (default)
+// =============================================================================
+export async function startMcpServer() {
+    const configPath = getConfigPath();
+    const store = await createStore({
+        dbPath: getDefaultDbPath(),
+        ...(existsSync(configPath) ? { configPath } : {}),
+    });
+    const server = await createMcpServer(store);
+    const transport = new StdioServerTransport();
+    await server.connect(transport);
+}
+/**
+ * Start MCP server over Streamable HTTP (JSON responses, no SSE).
+ * Binds to localhost only. Returns a handle for shutdown and port discovery.
+ */
+export async function startMcpHttpServer(port, options) {
+    const configPath = getConfigPath();
+    const store = await createStore({
+        dbPath: getDefaultDbPath(),
+        ...(existsSync(configPath) ? { configPath } : {}),
+    });
+    // Pre-fetch default collection names for REST endpoint
+    const defaultCollectionNames = await store.getDefaultCollectionNames();
+    // Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
+    // The store is shared — it's stateless SQLite, safe for concurrent access.
+    const sessions = new Map();
+    async function createSession() {
+        const transport = new WebStandardStreamableHTTPServerTransport({
+            sessionIdGenerator: () => randomUUID(),
+            enableJsonResponse: true,
+            onsessioninitialized: (sessionId) => {
+                sessions.set(sessionId, transport);
+                log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
+            },
+        });
+        const server = await createMcpServer(store);
+        await server.connect(transport);
+        transport.onclose = () => {
+            if (transport.sessionId) {
+                sessions.delete(transport.sessionId);
+            }
+        };
+        return transport;
+    }
+    const startTime = Date.now();
+    const quiet = options?.quiet ?? false;
+    /** Format timestamp for request logging */
+    function ts() {
+        return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS
+    }
+    /** Extract a human-readable label from a JSON-RPC body */
+    function describeRequest(body) {
+        const method = body?.method ?? "unknown";
+        if (method === "tools/call") {
+            const tool = body.params?.name ?? "?";
+            const args = body.params?.arguments;
+            // Show query string if present, truncated
+            if (args?.query) {
+                const q = String(args.query).slice(0, 80);
+                return `tools/call ${tool} "${q}"`;
+            }
+            if (args?.path)
+                return `tools/call ${tool} ${args.path}`;
+            if (args?.pattern)
+                return `tools/call ${tool} ${args.pattern}`;
+            return `tools/call ${tool}`;
+        }
+        return method;
+    }
+    function log(msg) {
+        if (!quiet)
+            console.error(msg);
+    }
+    // Helper to collect request body
+    async function collectBody(req) {
+        const chunks = [];
+        for await (const chunk of req)
+            chunks.push(chunk);
+        return Buffer.concat(chunks).toString();
+    }
+    const httpServer = createServer(async (nodeReq, nodeRes) => {
+        const reqStart = Date.now();
+        const pathname = nodeReq.url || "/";
+        try {
+            if (pathname === "/health" && nodeReq.method === "GET") {
+                const body = JSON.stringify({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000) });
+                nodeRes.writeHead(200, { "Content-Type": "application/json" });
+                nodeRes.end(body);
+                log(`${ts()} GET /health (${Date.now() - reqStart}ms)`);
+                return;
+            }
+            // REST endpoint: POST /search — structured search without MCP protocol
+            // REST endpoint: POST /query (alias: /search) — structured search without MCP protocol
+            if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
+                const rawBody = await collectBody(nodeReq);
+                const params = JSON.parse(rawBody);
+                // Validate required fields
+                if (!params.searches || !Array.isArray(params.searches)) {
+                    nodeRes.writeHead(400, { "Content-Type": "application/json" });
+                    nodeRes.end(JSON.stringify({ error: "Missing required field: searches (array)" }));
+                    return;
+                }
+                // Map to internal format
+                const queries = params.searches.map((s) => ({
+                    type: s.type,
+                    query: String(s.query || ""),
+                }));
+                // Use default collections if none specified
+                const effectiveCollections = params.collections ?? defaultCollectionNames;
+                const results = await store.search({
+                    queries,
+                    collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
+                    limit: params.limit ?? 10,
+                    minScore: params.minScore ?? 0,
+                    intent: params.intent,
+                });
+                // Use first lex or vec query for snippet extraction
+                const primaryQuery = params.searches.find((s) => s.type === 'lex')?.query
+                    || params.searches.find((s) => s.type === 'vec')?.query
+                    || params.searches[0]?.query || "";
+                const formatted = results.map(r => {
+                    const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300);
+                    return {
+                        docid: `#${r.docid}`,
+                        file: r.displayPath,
+                        title: r.title,
+                        score: Math.round(r.score * 100) / 100,
+                        context: r.context,
+                        snippet: addLineNumbers(snippet, line),
+                    };
+                });
+                nodeRes.writeHead(200, { "Content-Type": "application/json" });
+                nodeRes.end(JSON.stringify({ results: formatted }));
+                log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`);
+                return;
+            }
+            if (pathname === "/mcp" && nodeReq.method === "POST") {
+                const rawBody = await collectBody(nodeReq);
+                const body = JSON.parse(rawBody);
+                const label = describeRequest(body);
+                const url = `http://localhost:${port}${pathname}`;
+                const headers = {};
+                for (const [k, v] of Object.entries(nodeReq.headers)) {
+                    if (typeof v === "string")
+                        headers[k] = v;
+                }
+                // Route to existing session or create new one on initialize
+                const sessionId = headers["mcp-session-id"];
+                let transport;
+                if (sessionId) {
+                    const existing = sessions.get(sessionId);
+                    if (!existing) {
+                        nodeRes.writeHead(404, { "Content-Type": "application/json" });
+                        nodeRes.end(JSON.stringify({
+                            jsonrpc: "2.0",
+                            error: { code: -32001, message: "Session not found" },
+                            id: body?.id ?? null,
+                        }));
+                        return;
+                    }
+                    transport = existing;
+                }
+                else if (isInitializeRequest(body)) {
+                    transport = await createSession();
+                }
+                else {
+                    nodeRes.writeHead(400, { "Content-Type": "application/json" });
+                    nodeRes.end(JSON.stringify({
+                        jsonrpc: "2.0",
+                        error: { code: -32000, message: "Bad Request: Missing session ID" },
+                        id: body?.id ?? null,
+                    }));
+                    return;
+                }
+                const request = new Request(url, { method: "POST", headers, body: rawBody });
+                const response = await transport.handleRequest(request, { parsedBody: body });
+                nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
+                nodeRes.end(Buffer.from(await response.arrayBuffer()));
+                log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`);
+                return;
+            }
+            if (pathname === "/mcp") {
+                const headers = {};
+                for (const [k, v] of Object.entries(nodeReq.headers)) {
+                    if (typeof v === "string")
+                        headers[k] = v;
+                }
+                // GET/DELETE must have a valid session
+                const sessionId = headers["mcp-session-id"];
+                if (!sessionId) {
+                    nodeRes.writeHead(400, { "Content-Type": "application/json" });
+                    nodeRes.end(JSON.stringify({
+                        jsonrpc: "2.0",
+                        error: { code: -32000, message: "Bad Request: Missing session ID" },
+                        id: null,
+                    }));
+                    return;
+                }
+                const transport = sessions.get(sessionId);
+                if (!transport) {
+                    nodeRes.writeHead(404, { "Content-Type": "application/json" });
+                    nodeRes.end(JSON.stringify({
+                        jsonrpc: "2.0",
+                        error: { code: -32001, message: "Session not found" },
+                        id: null,
+                    }));
+                    return;
+                }
+                const url = `http://localhost:${port}${pathname}`;
+                const rawBody = nodeReq.method !== "GET" && nodeReq.method !== "HEAD" ? await collectBody(nodeReq) : undefined;
+                const request = new Request(url, { method: nodeReq.method || "GET", headers, ...(rawBody ? { body: rawBody } : {}) });
+                const response = await transport.handleRequest(request);
+                nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
+                nodeRes.end(Buffer.from(await response.arrayBuffer()));
+                return;
+            }
+            nodeRes.writeHead(404);
+            nodeRes.end("Not Found");
+        }
+        catch (err) {
+            console.error("HTTP handler error:", err);
+            nodeRes.writeHead(500);
+            nodeRes.end("Internal Server Error");
+        }
+    });
+    await new Promise((resolve, reject) => {
+        httpServer.on("error", reject);
+        httpServer.listen(port, "localhost", () => resolve());
+    });
+    const actualPort = httpServer.address().port;
+    let stopping = false;
+    const stop = async () => {
+        if (stopping)
+            return;
+        stopping = true;
+        for (const transport of sessions.values()) {
+            await transport.close();
+        }
+        sessions.clear();
+        httpServer.close();
+        await store.close();
+    };
+    process.on("SIGTERM", async () => {
+        console.error("Shutting down (SIGTERM)...");
+        await stop();
+        process.exit(0);
+    });
+    process.on("SIGINT", async () => {
+        console.error("Shutting down (SIGINT)...");
+        await stop();
+        process.exit(0);
+    });
+    log(`QMD MCP server listening on http://localhost:${actualPort}/mcp`);
+    return { httpServer, port: actualPort, stop };
+}
+// Run if this is the main module
+if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("/server.js")) {
+    startMcpServer().catch(console.error);
+}

+ 909 - 0
dist/store.d.ts

@@ -0,0 +1,909 @@
+/**
+ * QMD Store - Core data access and retrieval functions
+ *
+ * This module provides all database operations, search functions, and document
+ * retrieval for QMD. It returns raw data structures that can be formatted by
+ * CLI or MCP consumers.
+ *
+ * Usage:
+ *   const store = createStore("/path/to/db.sqlite");
+ *   // or use default path:
+ *   const store = createStore();
+ */
+import type { Database } from "./db.js";
+import { LlamaCpp, formatQueryForEmbedding, formatDocForEmbedding, type ILLMSession } from "./llm.js";
+import type { NamedCollection, Collection, CollectionConfig } from "./collections.js";
+export declare const DEFAULT_EMBED_MODEL = "embeddinggemma";
+export declare const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0";
+export declare const DEFAULT_QUERY_MODEL = "Qwen/Qwen3-1.7B";
+export declare const DEFAULT_GLOB = "**/*.md";
+export declare const DEFAULT_MULTI_GET_MAX_BYTES: number;
+export declare const DEFAULT_EMBED_MAX_DOCS_PER_BATCH = 64;
+export declare const DEFAULT_EMBED_MAX_BATCH_BYTES: number;
+export declare const CHUNK_SIZE_TOKENS = 900;
+export declare const CHUNK_OVERLAP_TOKENS: number;
+export declare const CHUNK_SIZE_CHARS: number;
+export declare const CHUNK_OVERLAP_CHARS: number;
+export declare const CHUNK_WINDOW_TOKENS = 200;
+export declare const CHUNK_WINDOW_CHARS: number;
+/**
+ * A potential break point in the document with a base score indicating quality.
+ */
+export interface BreakPoint {
+    pos: number;
+    score: number;
+    type: string;
+}
+/**
+ * A region where a code fence exists (between ``` markers).
+ * We should never split inside a code fence.
+ */
+export interface CodeFenceRegion {
+    start: number;
+    end: number;
+}
+/**
+ * Patterns for detecting break points in markdown documents.
+ * Higher scores indicate better places to split.
+ * Scores are spread wide so headings decisively beat lower-quality breaks.
+ * Order matters for scoring - more specific patterns first.
+ */
+export declare const BREAK_PATTERNS: [RegExp, number, string][];
+/**
+ * Scan text for all potential break points.
+ * Returns sorted array of break points with higher-scoring patterns taking precedence
+ * when multiple patterns match the same position.
+ */
+export declare function scanBreakPoints(text: string): BreakPoint[];
+/**
+ * Find all code fence regions in the text.
+ * Code fences are delimited by ``` and we should never split inside them.
+ */
+export declare function findCodeFences(text: string): CodeFenceRegion[];
+/**
+ * Check if a position is inside a code fence region.
+ */
+export declare function isInsideCodeFence(pos: number, fences: CodeFenceRegion[]): boolean;
+/**
+ * Find the best cut position using scored break points with distance decay.
+ *
+ * Uses squared distance for gentler early decay - headings far back still win
+ * over low-quality breaks near the target.
+ *
+ * @param breakPoints - Pre-scanned break points from scanBreakPoints()
+ * @param targetCharPos - The ideal cut position (e.g., maxChars boundary)
+ * @param windowChars - How far back to search for break points (default ~200 tokens)
+ * @param decayFactor - How much to penalize distance (0.7 = 30% score at window edge)
+ * @param codeFences - Code fence regions to avoid splitting inside
+ * @returns The best position to cut at
+ */
+export declare function findBestCutoff(breakPoints: BreakPoint[], targetCharPos: number, windowChars?: number, decayFactor?: number, codeFences?: CodeFenceRegion[]): number;
+export type ChunkStrategy = "auto" | "regex";
+/**
+ * Merge two sets of break points (e.g. regex + AST), keeping the highest
+ * score at each position. Result is sorted by position.
+ */
+export declare function mergeBreakPoints(a: BreakPoint[], b: BreakPoint[]): BreakPoint[];
+/**
+ * Core chunk algorithm that operates on precomputed break points and code fences.
+ * This is the shared implementation used by both regex-only and AST-aware chunking.
+ */
+export declare function chunkDocumentWithBreakPoints(content: string, breakPoints: BreakPoint[], codeFences: CodeFenceRegion[], maxChars?: number, overlapChars?: number, windowChars?: number): {
+    text: string;
+    pos: number;
+}[];
+export declare const STRONG_SIGNAL_MIN_SCORE = 0.85;
+export declare const STRONG_SIGNAL_MIN_GAP = 0.15;
+export declare const RERANK_CANDIDATE_LIMIT = 40;
+/**
+ * A typed query expansion result. Decoupled from llm.ts internal Queryable —
+ * same shape, but store.ts owns its own public API type.
+ *
+ * - lex: keyword variant → routes to FTS only
+ * - vec: semantic variant → routes to vector only
+ * - hyde: hypothetical document → routes to vector only
+ */
+export type ExpandedQuery = {
+    type: 'lex' | 'vec' | 'hyde';
+    query: string;
+    /** Optional line number for error reporting (CLI parser) */
+    line?: number;
+};
+export declare function homedir(): string;
+/**
+ * Check if a path is absolute.
+ * Supports:
+ * - Unix paths: /path/to/file
+ * - Windows native: C:\path or C:/path
+ * - Git Bash: /c/path or /C/path (C-Z drives, excluding A/B floppy drives)
+ *
+ * Note: /c without trailing slash is treated as Unix path (directory named "c"),
+ * while /c/ or /c/path are treated as Git Bash paths (C: drive).
+ */
+export declare function isAbsolutePath(path: string): boolean;
+/**
+ * Normalize path separators to forward slashes.
+ * Converts Windows backslashes to forward slashes.
+ */
+export declare function normalizePathSeparators(path: string): string;
+/**
+ * Get the relative path from a prefix.
+ * Returns null if path is not under prefix.
+ * Returns empty string if path equals prefix.
+ */
+export declare function getRelativePathFromPrefix(path: string, prefix: string): string | null;
+export declare function resolve(...paths: string[]): string;
+export declare function enableProductionMode(): void;
+/** Reset production mode flag — only for testing. */
+export declare function _resetProductionModeForTesting(): void;
+export declare function getDefaultDbPath(indexName?: string): string;
+export declare function getPwd(): string;
+export declare function getRealPath(path: string): string;
+export type VirtualPath = {
+    collectionName: string;
+    path: string;
+};
+/**
+ * Normalize explicit virtual path formats to standard qmd:// format.
+ * Only handles paths that are already explicitly virtual:
+ * - qmd://collection/path.md (already normalized)
+ * - qmd:////collection/path.md (extra slashes - normalize)
+ * - //collection/path.md (missing qmd: prefix - add it)
+ *
+ * Does NOT handle:
+ * - collection/path.md (bare paths - could be filesystem relative)
+ * - :linenum suffix (should be parsed separately before calling this)
+ */
+export declare function normalizeVirtualPath(input: string): string;
+/**
+ * Parse a virtual path like "qmd://collection-name/path/to/file.md"
+ * into its components.
+ * Also supports collection root: "qmd://collection-name/" or "qmd://collection-name"
+ */
+export declare function parseVirtualPath(virtualPath: string): VirtualPath | null;
+/**
+ * Build a virtual path from collection name and relative path.
+ */
+export declare function buildVirtualPath(collectionName: string, path: string): string;
+/**
+ * Check if a path is explicitly a virtual path.
+ * Only recognizes explicit virtual path formats:
+ * - qmd://collection/path.md
+ * - //collection/path.md
+ *
+ * Does NOT consider bare collection/path.md as virtual - that should be
+ * handled separately by checking if the first component is a collection name.
+ */
+export declare function isVirtualPath(path: string): boolean;
+/**
+ * Resolve a virtual path to absolute filesystem path.
+ */
+export declare function resolveVirtualPath(db: Database, virtualPath: string): string | null;
+/**
+ * Convert an absolute filesystem path to a virtual path.
+ * Returns null if the file is not in any indexed collection.
+ */
+export declare function toVirtualPath(db: Database, absolutePath: string): string | null;
+export declare function verifySqliteVecLoaded(db: Database): void;
+export declare function getStoreCollections(db: Database): NamedCollection[];
+export declare function getStoreCollection(db: Database, name: string): NamedCollection | null;
+export declare function getStoreGlobalContext(db: Database): string | undefined;
+export declare function getStoreContexts(db: Database): Array<{
+    collection: string;
+    path: string;
+    context: string;
+}>;
+export declare function upsertStoreCollection(db: Database, name: string, collection: Omit<Collection, 'pattern'> & {
+    pattern?: string;
+}): void;
+export declare function deleteStoreCollection(db: Database, name: string): boolean;
+export declare function renameStoreCollection(db: Database, oldName: string, newName: string): boolean;
+export declare function updateStoreContext(db: Database, collectionName: string, path: string, text: string): boolean;
+export declare function removeStoreContext(db: Database, collectionName: string, path: string): boolean;
+export declare function setStoreGlobalContext(db: Database, value: string | undefined): void;
+/**
+ * Sync external config (YAML/inline) into SQLite store_collections.
+ * External config always wins. Skips sync if config hash hasn't changed.
+ */
+export declare function syncConfigToDb(db: Database, config: CollectionConfig): void;
+export declare function isSqliteVecAvailable(): boolean;
+export type Store = {
+    db: Database;
+    dbPath: string;
+    /** Optional LlamaCpp instance for this store (overrides the global singleton) */
+    llm?: LlamaCpp;
+    close: () => void;
+    ensureVecTable: (dimensions: number) => void;
+    getHashesNeedingEmbedding: () => number;
+    getIndexHealth: () => IndexHealthInfo;
+    getStatus: () => IndexStatus;
+    getCacheKey: typeof getCacheKey;
+    getCachedResult: (cacheKey: string) => string | null;
+    setCachedResult: (cacheKey: string, result: string) => void;
+    clearCache: () => void;
+    deleteLLMCache: () => number;
+    deleteInactiveDocuments: () => number;
+    cleanupOrphanedContent: () => number;
+    cleanupOrphanedVectors: () => number;
+    vacuumDatabase: () => void;
+    getContextForFile: (filepath: string) => string | null;
+    getContextForPath: (collectionName: string, path: string) => string | null;
+    getCollectionByName: (name: string) => {
+        name: string;
+        pwd: string;
+        glob_pattern: string;
+    } | null;
+    getCollectionsWithoutContext: () => {
+        name: string;
+        pwd: string;
+        doc_count: number;
+    }[];
+    getTopLevelPathsWithoutContext: (collectionName: string) => string[];
+    parseVirtualPath: typeof parseVirtualPath;
+    buildVirtualPath: typeof buildVirtualPath;
+    isVirtualPath: typeof isVirtualPath;
+    resolveVirtualPath: (virtualPath: string) => string | null;
+    toVirtualPath: (absolutePath: string) => string | null;
+    searchFTS: (query: string, limit?: number, collectionName?: string) => SearchResult[];
+    searchVec: (query: string, model: string, limit?: number, collectionName?: string, session?: ILLMSession, precomputedEmbedding?: number[]) => Promise<SearchResult[]>;
+    expandQuery: (query: string, model?: string, intent?: string) => Promise<ExpandedQuery[]>;
+    rerank: (query: string, documents: {
+        file: string;
+        text: string;
+    }[], model?: string, intent?: string) => Promise<{
+        file: string;
+        score: number;
+    }[]>;
+    findDocument: (filename: string, options?: {
+        includeBody?: boolean;
+    }) => DocumentResult | DocumentNotFound;
+    getDocumentBody: (doc: DocumentResult | {
+        filepath: string;
+    }, fromLine?: number, maxLines?: number) => string | null;
+    findDocuments: (pattern: string, options?: {
+        includeBody?: boolean;
+        maxBytes?: number;
+    }) => {
+        docs: MultiGetResult[];
+        errors: string[];
+    };
+    findSimilarFiles: (query: string, maxDistance?: number, limit?: number) => string[];
+    matchFilesByGlob: (pattern: string) => {
+        filepath: string;
+        displayPath: string;
+        bodyLength: number;
+    }[];
+    findDocumentByDocid: (docid: string) => {
+        filepath: string;
+        hash: string;
+    } | null;
+    insertContent: (hash: string, content: string, createdAt: string) => void;
+    insertDocument: (collectionName: string, path: string, title: string, hash: string, createdAt: string, modifiedAt: string) => void;
+    findActiveDocument: (collectionName: string, path: string) => {
+        id: number;
+        hash: string;
+        title: string;
+    } | null;
+    updateDocumentTitle: (documentId: number, title: string, modifiedAt: string) => void;
+    updateDocument: (documentId: number, title: string, hash: string, modifiedAt: string) => void;
+    deactivateDocument: (collectionName: string, path: string) => void;
+    getActiveDocumentPaths: (collectionName: string) => string[];
+    getHashesForEmbedding: () => {
+        hash: string;
+        body: string;
+        path: string;
+    }[];
+    clearAllEmbeddings: () => void;
+    insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string) => void;
+};
+export type ReindexProgress = {
+    file: string;
+    current: number;
+    total: number;
+};
+export type ReindexResult = {
+    indexed: number;
+    updated: number;
+    unchanged: number;
+    removed: number;
+    orphanedCleaned: number;
+};
+/**
+ * Re-index a single collection by scanning the filesystem and updating the database.
+ * Pure function — no console output, no db lifecycle management.
+ */
+export declare function reindexCollection(store: Store, collectionPath: string, globPattern: string, collectionName: string, options?: {
+    ignorePatterns?: string[];
+    onProgress?: (info: ReindexProgress) => void;
+}): Promise<ReindexResult>;
+export type EmbedProgress = {
+    chunksEmbedded: number;
+    totalChunks: number;
+    bytesProcessed: number;
+    totalBytes: number;
+    errors: number;
+};
+export type EmbedResult = {
+    docsProcessed: number;
+    chunksEmbedded: number;
+    errors: number;
+    durationMs: number;
+};
+export type EmbedOptions = {
+    force?: boolean;
+    model?: string;
+    maxDocsPerBatch?: number;
+    maxBatchBytes?: number;
+    chunkStrategy?: ChunkStrategy;
+    onProgress?: (info: EmbedProgress) => void;
+};
+/**
+ * Generate vector embeddings for documents that need them.
+ * Pure function — no console output, no db lifecycle management.
+ * Uses the store's LlamaCpp instance if set, otherwise the global singleton.
+ */
+export declare function generateEmbeddings(store: Store, options?: EmbedOptions): Promise<EmbedResult>;
+/**
+ * Create a new store instance with the given database path.
+ * If no path is provided, uses the default path (~/.cache/qmd/index.sqlite).
+ *
+ * @param dbPath - Path to the SQLite database file
+ * @returns Store instance with all methods bound to the database
+ */
+export declare function createStore(dbPath?: string): Store;
+/**
+ * Unified document result type with all metadata.
+ * Body is optional - use getDocumentBody() to load it separately if needed.
+ */
+export type DocumentResult = {
+    filepath: string;
+    displayPath: string;
+    title: string;
+    context: string | null;
+    hash: string;
+    docid: string;
+    collectionName: string;
+    modifiedAt: string;
+    bodyLength: number;
+    body?: string;
+};
+/**
+ * Extract short docid from a full hash (first 6 characters).
+ */
+export declare function getDocid(hash: string): string;
+export declare function handelize(path: string): string;
+/**
+ * Search result extends DocumentResult with score and source info
+ */
+export type SearchResult = DocumentResult & {
+    score: number;
+    source: "fts" | "vec";
+    chunkPos?: number;
+};
+/**
+ * Ranked result for RRF fusion (simplified, used internally)
+ */
+export type RankedResult = {
+    file: string;
+    displayPath: string;
+    title: string;
+    body: string;
+    score: number;
+};
+export type RRFContributionTrace = {
+    listIndex: number;
+    source: "fts" | "vec";
+    queryType: "original" | "lex" | "vec" | "hyde";
+    query: string;
+    rank: number;
+    weight: number;
+    backendScore: number;
+    rrfContribution: number;
+};
+export type RRFScoreTrace = {
+    contributions: RRFContributionTrace[];
+    baseScore: number;
+    topRank: number;
+    topRankBonus: number;
+    totalScore: number;
+};
+export type HybridQueryExplain = {
+    ftsScores: number[];
+    vectorScores: number[];
+    rrf: {
+        rank: number;
+        positionScore: number;
+        weight: number;
+        baseScore: number;
+        topRankBonus: number;
+        totalScore: number;
+        contributions: RRFContributionTrace[];
+    };
+    rerankScore: number;
+    blendedScore: number;
+};
+/**
+ * Error result when document is not found
+ */
+export type DocumentNotFound = {
+    error: "not_found";
+    query: string;
+    similarFiles: string[];
+};
+/**
+ * Result from multi-get operations
+ */
+export type MultiGetResult = {
+    doc: DocumentResult;
+    skipped: false;
+} | {
+    doc: Pick<DocumentResult, "filepath" | "displayPath">;
+    skipped: true;
+    skipReason: string;
+};
+export type CollectionInfo = {
+    name: string;
+    path: string | null;
+    pattern: string | null;
+    documents: number;
+    lastUpdated: string;
+};
+export type IndexStatus = {
+    totalDocuments: number;
+    needsEmbedding: number;
+    hasVectorIndex: boolean;
+    collections: CollectionInfo[];
+};
+export declare function getHashesNeedingEmbedding(db: Database): number;
+export type IndexHealthInfo = {
+    needsEmbedding: number;
+    totalDocs: number;
+    daysStale: number | null;
+};
+export declare function getIndexHealth(db: Database): IndexHealthInfo;
+export declare function getCacheKey(url: string, body: object): string;
+export declare function getCachedResult(db: Database, cacheKey: string): string | null;
+export declare function setCachedResult(db: Database, cacheKey: string, result: string): void;
+export declare function clearCache(db: Database): void;
+/**
+ * Delete cached LLM API responses.
+ * Returns the number of cached responses deleted.
+ */
+export declare function deleteLLMCache(db: Database): number;
+/**
+ * Remove inactive document records (active = 0).
+ * Returns the number of inactive documents deleted.
+ */
+export declare function deleteInactiveDocuments(db: Database): number;
+/**
+ * Remove orphaned content hashes that are not referenced by any active document.
+ * Returns the number of orphaned content hashes deleted.
+ */
+export declare function cleanupOrphanedContent(db: Database): number;
+/**
+ * Remove orphaned vector embeddings that are not referenced by any active document.
+ * Returns the number of orphaned embedding chunks deleted.
+ */
+export declare function cleanupOrphanedVectors(db: Database): number;
+/**
+ * Run VACUUM to reclaim unused space in the database.
+ * This operation rebuilds the database file to eliminate fragmentation.
+ */
+export declare function vacuumDatabase(db: Database): void;
+export declare function hashContent(content: string): Promise<string>;
+export declare function extractTitle(content: string, filename: string): string;
+/**
+ * Insert content into the content table (content-addressable storage).
+ * Uses INSERT OR IGNORE so duplicate hashes are skipped.
+ */
+export declare function insertContent(db: Database, hash: string, content: string, createdAt: string): void;
+/**
+ * Insert a new document into the documents table.
+ */
+export declare function insertDocument(db: Database, collectionName: string, path: string, title: string, hash: string, createdAt: string, modifiedAt: string): void;
+/**
+ * Find an active document by collection name and path.
+ */
+export declare function findActiveDocument(db: Database, collectionName: string, path: string): {
+    id: number;
+    hash: string;
+    title: string;
+} | null;
+/**
+ * Update the title and modified_at timestamp for a document.
+ */
+export declare function updateDocumentTitle(db: Database, documentId: number, title: string, modifiedAt: string): void;
+/**
+ * Update an existing document's hash, title, and modified_at timestamp.
+ * Used when content changes but the file path stays the same.
+ */
+export declare function updateDocument(db: Database, documentId: number, title: string, hash: string, modifiedAt: string): void;
+/**
+ * Deactivate a document (mark as inactive but don't delete).
+ */
+export declare function deactivateDocument(db: Database, collectionName: string, path: string): void;
+/**
+ * Get all active document paths for a collection.
+ */
+export declare function getActiveDocumentPaths(db: Database, collectionName: string): string[];
+export { formatQueryForEmbedding, formatDocForEmbedding };
+/**
+ * Chunk a document using regex-only break point detection.
+ * This is the sync, backward-compatible API used by tests and legacy callers.
+ */
+export declare function chunkDocument(content: string, maxChars?: number, overlapChars?: number, windowChars?: number): {
+    text: string;
+    pos: number;
+}[];
+/**
+ * Async AST-aware chunking. Detects language from filepath, computes AST
+ * break points for supported code files, merges with regex break points,
+ * and delegates to the shared chunk algorithm.
+ *
+ * Falls back to regex-only when strategy is "regex", filepath is absent,
+ * or language is unsupported.
+ */
+export declare function chunkDocumentAsync(content: string, maxChars?: number, overlapChars?: number, windowChars?: number, filepath?: string, chunkStrategy?: ChunkStrategy): Promise<{
+    text: string;
+    pos: number;
+}[]>;
+/**
+ * Chunk a document by actual token count using the LLM tokenizer.
+ * More accurate than character-based chunking but requires async.
+ *
+ * When filepath and chunkStrategy are provided, uses AST-aware break points
+ * for supported code files.
+ */
+export declare function chunkDocumentByTokens(content: string, maxTokens?: number, overlapTokens?: number, windowTokens?: number, filepath?: string, chunkStrategy?: ChunkStrategy, signal?: AbortSignal): Promise<{
+    text: string;
+    pos: number;
+    tokens: number;
+}[]>;
+/**
+ * Normalize a docid input by stripping surrounding quotes and leading #.
+ * Handles: "#abc123", 'abc123', "abc123", #abc123, abc123
+ * Returns the bare hex string.
+ */
+export declare function normalizeDocid(docid: string): string;
+/**
+ * Check if a string looks like a docid reference.
+ * Accepts: #abc123, abc123, "#abc123", "abc123", '#abc123', 'abc123'
+ * Returns true if the normalized form is a valid hex string of 6+ chars.
+ */
+export declare function isDocid(input: string): boolean;
+/**
+ * Find a document by its short docid (first 6 characters of hash).
+ * Returns the document's virtual path if found, null otherwise.
+ * If multiple documents match the same short hash (collision), returns the first one.
+ *
+ * Accepts lenient input: #abc123, abc123, "#abc123", "abc123"
+ */
+export declare function findDocumentByDocid(db: Database, docid: string): {
+    filepath: string;
+    hash: string;
+} | null;
+export declare function findSimilarFiles(db: Database, query: string, maxDistance?: number, limit?: number): string[];
+export declare function matchFilesByGlob(db: Database, pattern: string): {
+    filepath: string;
+    displayPath: string;
+    bodyLength: number;
+}[];
+/**
+ * Get context for a file path using hierarchical inheritance.
+ * Contexts are collection-scoped and inherit from parent directories.
+ * For example, context at "/talks" applies to "/talks/2024/keynote.md".
+ *
+ * @param db Database instance (unused - kept for compatibility)
+ * @param collectionName Collection name
+ * @param path Relative path within the collection
+ * @returns Context string or null if no context is defined
+ */
+export declare function getContextForPath(db: Database, collectionName: string, path: string): string | null;
+/**
+ * Get context for a file path (virtual or filesystem).
+ * Resolves the collection and relative path from the DB store_collections table.
+ */
+export declare function getContextForFile(db: Database, filepath: string): string | null;
+/**
+ * Get collection by name from DB store_collections table.
+ */
+export declare function getCollectionByName(db: Database, name: string): {
+    name: string;
+    pwd: string;
+    glob_pattern: string;
+} | null;
+/**
+ * List all collections with document counts from database.
+ * Merges store_collections config with database statistics.
+ */
+export declare function listCollections(db: Database): {
+    name: string;
+    pwd: string;
+    glob_pattern: string;
+    doc_count: number;
+    active_count: number;
+    last_modified: string | null;
+    includeByDefault: boolean;
+}[];
+/**
+ * Remove a collection and clean up its documents.
+ * Uses collections.ts to remove from YAML config and cleans up database.
+ */
+export declare function removeCollection(db: Database, collectionName: string): {
+    deletedDocs: number;
+    cleanedHashes: number;
+};
+/**
+ * Rename a collection.
+ * Updates both YAML config and database documents table.
+ */
+export declare function renameCollection(db: Database, oldName: string, newName: string): void;
+/**
+ * Insert or update a context for a specific collection and path prefix.
+ */
+export declare function insertContext(db: Database, collectionId: number, pathPrefix: string, context: string): void;
+/**
+ * Delete a context for a specific collection and path prefix.
+ * Returns the number of contexts deleted.
+ */
+export declare function deleteContext(db: Database, collectionName: string, pathPrefix: string): number;
+/**
+ * Delete all global contexts (contexts with empty path_prefix).
+ * Returns the number of contexts deleted.
+ */
+export declare function deleteGlobalContexts(db: Database): number;
+/**
+ * List all contexts, grouped by collection.
+ * Returns contexts ordered by collection name, then by path prefix length (longest first).
+ */
+export declare function listPathContexts(db: Database): {
+    collection_name: string;
+    path_prefix: string;
+    context: string;
+}[];
+/**
+ * Get all collections (name only - from YAML config).
+ */
+export declare function getAllCollections(db: Database): {
+    name: string;
+}[];
+/**
+ * Check which collections don't have any context defined.
+ * Returns collections that have no context entries at all (not even root context).
+ */
+export declare function getCollectionsWithoutContext(db: Database): {
+    name: string;
+    pwd: string;
+    doc_count: number;
+}[];
+/**
+ * Get top-level directories in a collection that don't have context.
+ * Useful for suggesting where context might be needed.
+ */
+export declare function getTopLevelPathsWithoutContext(db: Database, collectionName: string): string[];
+export declare function sanitizeFTS5Term(term: string): string;
+/**
+ * Validate that a vec/hyde query doesn't use lex-only syntax.
+ * Returns error message if invalid, null if valid.
+ */
+export declare function validateSemanticQuery(query: string): string | null;
+export declare function validateLexQuery(query: string): string | null;
+export declare function searchFTS(db: Database, query: string, limit?: number, collectionName?: string): SearchResult[];
+export declare function searchVec(db: Database, query: string, model: string, limit?: number, collectionName?: string, session?: ILLMSession, precomputedEmbedding?: number[]): Promise<SearchResult[]>;
+/**
+ * Get all unique content hashes that need embeddings (from active documents).
+ * Returns hash, document body, and a sample path for display purposes.
+ */
+export declare function getHashesForEmbedding(db: Database): {
+    hash: string;
+    body: string;
+    path: string;
+}[];
+/**
+ * Clear all embeddings from the database (force re-index).
+ * Deletes all rows from content_vectors and drops the vectors_vec table.
+ */
+export declare function clearAllEmbeddings(db: Database): void;
+/**
+ * Insert a single embedding into both content_vectors and vectors_vec tables.
+ * The hash_seq key is formatted as "hash_seq" for the vectors_vec table.
+ *
+ * content_vectors is inserted first so that getHashesForEmbedding (which checks
+ * only content_vectors) won't re-select the hash on a crash between the two inserts.
+ *
+ * vectors_vec uses DELETE + INSERT instead of INSERT OR REPLACE because sqlite-vec's
+ * vec0 virtual tables silently ignore the OR REPLACE conflict clause.
+ */
+export declare function insertEmbedding(db: Database, hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string): void;
+export declare function expandQuery(query: string, model: string | undefined, db: Database, intent?: string, llmOverride?: LlamaCpp): Promise<ExpandedQuery[]>;
+export declare function rerank(query: string, documents: {
+    file: string;
+    text: string;
+}[], model: string | undefined, db: Database, intent?: string, llmOverride?: LlamaCpp): Promise<{
+    file: string;
+    score: number;
+}[]>;
+export declare function reciprocalRankFusion(resultLists: RankedResult[][], weights?: number[], k?: number): RankedResult[];
+/**
+ * Build per-document RRF contribution traces for explain/debug output.
+ */
+export declare function buildRrfTrace(resultLists: RankedResult[][], weights?: number[], listMeta?: RankedListMeta[], k?: number): Map<string, RRFScoreTrace>;
+/**
+ * Find a document by filename/path, docid (#hash), or with fuzzy matching.
+ * Returns document metadata without body by default.
+ *
+ * Supports:
+ * - Virtual paths: qmd://collection/path/to/file.md
+ * - Absolute paths: /path/to/file.md
+ * - Relative paths: path/to/file.md
+ * - Short docid: #abc123 (first 6 chars of hash)
+ */
+export declare function findDocument(db: Database, filename: string, options?: {
+    includeBody?: boolean;
+}): DocumentResult | DocumentNotFound;
+/**
+ * Get the body content for a document
+ * Optionally slice by line range
+ */
+export declare function getDocumentBody(db: Database, doc: DocumentResult | {
+    filepath: string;
+}, fromLine?: number, maxLines?: number): string | null;
+/**
+ * Find multiple documents by glob pattern or comma-separated list
+ * Returns documents without body by default (use getDocumentBody to load)
+ */
+export declare function findDocuments(db: Database, pattern: string, options?: {
+    includeBody?: boolean;
+    maxBytes?: number;
+}): {
+    docs: MultiGetResult[];
+    errors: string[];
+};
+export declare function getStatus(db: Database): IndexStatus;
+export type SnippetResult = {
+    line: number;
+    snippet: string;
+    linesBefore: number;
+    linesAfter: number;
+    snippetLines: number;
+};
+/** Weight for intent terms relative to query terms (1.0) in snippet scoring */
+export declare const INTENT_WEIGHT_SNIPPET = 0.3;
+/** Weight for intent terms relative to query terms (1.0) in chunk selection */
+export declare const INTENT_WEIGHT_CHUNK = 0.5;
+/**
+ * Extract meaningful terms from an intent string, filtering stop words and punctuation.
+ * Uses Unicode-aware punctuation stripping so domain terms like "API" survive.
+ * Returns lowercase terms suitable for text matching.
+ */
+export declare function extractIntentTerms(intent: string): string[];
+export declare function extractSnippet(body: string, query: string, maxLen?: number, chunkPos?: number, chunkLen?: number, intent?: string): SnippetResult;
+/**
+ * Add line numbers to text content.
+ * Each line becomes: "{lineNum}: {content}"
+ */
+export declare function addLineNumbers(text: string, startLine?: number): string;
+/**
+ * Optional progress hooks for search orchestration.
+ * CLI wires these to stderr for user feedback; MCP leaves them unset.
+ */
+export interface SearchHooks {
+    /** BM25 probe found strong signal — expansion will be skipped */
+    onStrongSignal?: (topScore: number) => void;
+    /** Query expansion starting */
+    onExpandStart?: () => void;
+    /** Query expansion complete. Empty array = strong signal skip. elapsedMs = time taken. */
+    onExpand?: (original: string, expanded: ExpandedQuery[], elapsedMs: number) => void;
+    /** Embedding starting (vec/hyde queries) */
+    onEmbedStart?: (count: number) => void;
+    /** Embedding complete */
+    onEmbedDone?: (elapsedMs: number) => void;
+    /** Reranking is about to start */
+    onRerankStart?: (chunkCount: number) => void;
+    /** Reranking finished */
+    onRerankDone?: (elapsedMs: number) => void;
+}
+export interface HybridQueryOptions {
+    collection?: string;
+    limit?: number;
+    minScore?: number;
+    candidateLimit?: number;
+    explain?: boolean;
+    intent?: string;
+    skipRerank?: boolean;
+    chunkStrategy?: ChunkStrategy;
+    hooks?: SearchHooks;
+}
+export interface HybridQueryResult {
+    file: string;
+    displayPath: string;
+    title: string;
+    body: string;
+    bestChunk: string;
+    bestChunkPos: number;
+    score: number;
+    context: string | null;
+    docid: string;
+    explain?: HybridQueryExplain;
+}
+export type RankedListMeta = {
+    source: "fts" | "vec";
+    queryType: "original" | "lex" | "vec" | "hyde";
+    query: string;
+};
+/**
+ * Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
+ *
+ * Pipeline:
+ * 1. BM25 probe → skip expansion if strong signal
+ * 2. expandQuery() → typed query variants (lex/vec/hyde)
+ * 3. Type-routed search: original→vector, lex→FTS, vec/hyde→vector
+ * 4. RRF fusion → slice to candidateLimit
+ * 5. chunkDocument() + keyword-best-chunk selection
+ * 6. rerank on chunks (NOT full bodies — O(tokens) trap)
+ * 7. Position-aware score blending (RRF rank × reranker score)
+ * 8. Dedup by file, filter by minScore, slice to limit
+ */
+export declare function hybridQuery(store: Store, query: string, options?: HybridQueryOptions): Promise<HybridQueryResult[]>;
+export interface VectorSearchOptions {
+    collection?: string;
+    limit?: number;
+    minScore?: number;
+    intent?: string;
+    hooks?: Pick<SearchHooks, 'onExpand'>;
+}
+export interface VectorSearchResult {
+    file: string;
+    displayPath: string;
+    title: string;
+    body: string;
+    score: number;
+    context: string | null;
+    docid: string;
+}
+/**
+ * Vector-only semantic search with query expansion.
+ *
+ * Pipeline:
+ * 1. expandQuery() → typed variants, filter to vec/hyde only (lex irrelevant here)
+ * 2. searchVec() for original + vec/hyde variants (sequential — node-llama-cpp embed limitation)
+ * 3. Dedup by filepath (keep max score)
+ * 4. Sort by score descending, filter by minScore, slice to limit
+ */
+export declare function vectorSearchQuery(store: Store, query: string, options?: VectorSearchOptions): Promise<VectorSearchResult[]>;
+/**
+ * A single sub-search in a structured search request.
+ * Matches the format used in QMD training data.
+ */
+export interface StructuredSearchOptions {
+    collections?: string[];
+    limit?: number;
+    minScore?: number;
+    candidateLimit?: number;
+    explain?: boolean;
+    /** Domain intent hint for disambiguation — steers reranking and chunk selection */
+    intent?: string;
+    /** Skip LLM reranking, use only RRF scores */
+    skipRerank?: boolean;
+    chunkStrategy?: ChunkStrategy;
+    hooks?: SearchHooks;
+}
+/**
+ * Structured search: execute pre-expanded queries without LLM query expansion.
+ *
+ * Designed for LLM callers (MCP/HTTP) that generate their own query expansions.
+ * Skips the internal expandQuery() step — goes directly to:
+ *
+ * Pipeline:
+ * 1. Route searches: lex→FTS, vec/hyde→vector (batch embed)
+ * 2. RRF fusion across all result lists
+ * 3. Chunk documents + keyword-best-chunk selection
+ * 4. Rerank on chunks
+ * 5. Position-aware score blending
+ * 6. Dedup, filter, slice
+ *
+ * This is the recommended endpoint for capable LLMs — they can generate
+ * better query variations than our small local model, especially for
+ * domain-specific or nuanced queries.
+ */
+export declare function structuredSearch(store: Store, searches: ExpandedQuery[], options?: StructuredSearchOptions): Promise<HybridQueryResult[]>;

+ 3584 - 0
dist/store.js

@@ -0,0 +1,3584 @@
+/**
+ * QMD Store - Core data access and retrieval functions
+ *
+ * This module provides all database operations, search functions, and document
+ * retrieval for QMD. It returns raw data structures that can be formatted by
+ * CLI or MCP consumers.
+ *
+ * Usage:
+ *   const store = createStore("/path/to/db.sqlite");
+ *   // or use default path:
+ *   const store = createStore();
+ */
+import { openDatabase, loadSqliteVec } from "./db.js";
+import picomatch from "picomatch";
+import { createHash } from "crypto";
+import { readFileSync, realpathSync, statSync, mkdirSync } from "node:fs";
+// Note: node:path resolve is not imported — we export our own cross-platform resolve()
+import fastGlob from "fast-glob";
+import { LlamaCpp, getDefaultLlamaCpp, formatQueryForEmbedding, formatDocForEmbedding, withLLMSessionForLlm, } from "./llm.js";
+// =============================================================================
+// Configuration
+// =============================================================================
+const HOME = process.env.HOME || "/tmp";
+export const DEFAULT_EMBED_MODEL = "embeddinggemma";
+export const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0";
+export const DEFAULT_QUERY_MODEL = "Qwen/Qwen3-1.7B";
+export const DEFAULT_GLOB = "**/*.md";
+export const DEFAULT_MULTI_GET_MAX_BYTES = 10 * 1024; // 10KB
+export const DEFAULT_EMBED_MAX_DOCS_PER_BATCH = 64;
+export const DEFAULT_EMBED_MAX_BATCH_BYTES = 64 * 1024 * 1024; // 64MB
+// Chunking: 900 tokens per chunk with 15% overlap
+// Increased from 800 to accommodate smart chunking finding natural break points
+export const CHUNK_SIZE_TOKENS = 900;
+export const CHUNK_OVERLAP_TOKENS = Math.floor(CHUNK_SIZE_TOKENS * 0.15); // 135 tokens (15% overlap)
+// Fallback char-based approximation for sync chunking (~4 chars per token)
+export const CHUNK_SIZE_CHARS = CHUNK_SIZE_TOKENS * 4; // 3600 chars
+export const CHUNK_OVERLAP_CHARS = CHUNK_OVERLAP_TOKENS * 4; // 540 chars
+// Search window for finding optimal break points (in tokens, ~200 tokens)
+export const CHUNK_WINDOW_TOKENS = 200;
+export const CHUNK_WINDOW_CHARS = CHUNK_WINDOW_TOKENS * 4; // 800 chars
+/**
+ * Get the LlamaCpp instance for a store — prefers the store's own instance,
+ * falls back to the global singleton.
+ */
+function getLlm(store) {
+    return store.llm ?? getDefaultLlamaCpp();
+}
+/**
+ * Patterns for detecting break points in markdown documents.
+ * Higher scores indicate better places to split.
+ * Scores are spread wide so headings decisively beat lower-quality breaks.
+ * Order matters for scoring - more specific patterns first.
+ */
+export const BREAK_PATTERNS = [
+    [/\n#{1}(?!#)/g, 100, 'h1'], // # but not ##
+    [/\n#{2}(?!#)/g, 90, 'h2'], // ## but not ###
+    [/\n#{3}(?!#)/g, 80, 'h3'], // ### but not ####
+    [/\n#{4}(?!#)/g, 70, 'h4'], // #### but not #####
+    [/\n#{5}(?!#)/g, 60, 'h5'], // ##### but not ######
+    [/\n#{6}(?!#)/g, 50, 'h6'], // ######
+    [/\n```/g, 80, 'codeblock'], // code block boundary (same as h3)
+    [/\n(?:---|\*\*\*|___)\s*\n/g, 60, 'hr'], // horizontal rule
+    [/\n\n+/g, 20, 'blank'], // paragraph boundary
+    [/\n[-*]\s/g, 5, 'list'], // unordered list item
+    [/\n\d+\.\s/g, 5, 'numlist'], // ordered list item
+    [/\n/g, 1, 'newline'], // minimal break
+];
+/**
+ * Scan text for all potential break points.
+ * Returns sorted array of break points with higher-scoring patterns taking precedence
+ * when multiple patterns match the same position.
+ */
+export function scanBreakPoints(text) {
+    const points = [];
+    const seen = new Map(); // pos -> best break point at that pos
+    for (const [pattern, score, type] of BREAK_PATTERNS) {
+        for (const match of text.matchAll(pattern)) {
+            const pos = match.index;
+            const existing = seen.get(pos);
+            // Keep higher score if position already seen
+            if (!existing || score > existing.score) {
+                const bp = { pos, score, type };
+                seen.set(pos, bp);
+            }
+        }
+    }
+    // Convert to array and sort by position
+    for (const bp of seen.values()) {
+        points.push(bp);
+    }
+    return points.sort((a, b) => a.pos - b.pos);
+}
+/**
+ * Find all code fence regions in the text.
+ * Code fences are delimited by ``` and we should never split inside them.
+ */
+export function findCodeFences(text) {
+    const regions = [];
+    const fencePattern = /\n```/g;
+    let inFence = false;
+    let fenceStart = 0;
+    for (const match of text.matchAll(fencePattern)) {
+        if (!inFence) {
+            fenceStart = match.index;
+            inFence = true;
+        }
+        else {
+            regions.push({ start: fenceStart, end: match.index + match[0].length });
+            inFence = false;
+        }
+    }
+    // Handle unclosed fence - extends to end of document
+    if (inFence) {
+        regions.push({ start: fenceStart, end: text.length });
+    }
+    return regions;
+}
+/**
+ * Check if a position is inside a code fence region.
+ */
+export function isInsideCodeFence(pos, fences) {
+    return fences.some(f => pos > f.start && pos < f.end);
+}
+/**
+ * Find the best cut position using scored break points with distance decay.
+ *
+ * Uses squared distance for gentler early decay - headings far back still win
+ * over low-quality breaks near the target.
+ *
+ * @param breakPoints - Pre-scanned break points from scanBreakPoints()
+ * @param targetCharPos - The ideal cut position (e.g., maxChars boundary)
+ * @param windowChars - How far back to search for break points (default ~200 tokens)
+ * @param decayFactor - How much to penalize distance (0.7 = 30% score at window edge)
+ * @param codeFences - Code fence regions to avoid splitting inside
+ * @returns The best position to cut at
+ */
+export function findBestCutoff(breakPoints, targetCharPos, windowChars = CHUNK_WINDOW_CHARS, decayFactor = 0.7, codeFences = []) {
+    const windowStart = targetCharPos - windowChars;
+    let bestScore = -1;
+    let bestPos = targetCharPos;
+    for (const bp of breakPoints) {
+        if (bp.pos < windowStart)
+            continue;
+        if (bp.pos > targetCharPos)
+            break; // sorted, so we can stop
+        // Skip break points inside code fences
+        if (isInsideCodeFence(bp.pos, codeFences))
+            continue;
+        const distance = targetCharPos - bp.pos;
+        // Squared distance decay: gentle early, steep late
+        // At target: multiplier = 1.0
+        // At 25% back: multiplier = 0.956
+        // At 50% back: multiplier = 0.825
+        // At 75% back: multiplier = 0.606
+        // At window edge: multiplier = 0.3
+        const normalizedDist = distance / windowChars;
+        const multiplier = 1.0 - (normalizedDist * normalizedDist) * decayFactor;
+        const finalScore = bp.score * multiplier;
+        if (finalScore > bestScore) {
+            bestScore = finalScore;
+            bestPos = bp.pos;
+        }
+    }
+    return bestPos;
+}
+/**
+ * Merge two sets of break points (e.g. regex + AST), keeping the highest
+ * score at each position. Result is sorted by position.
+ */
+export function mergeBreakPoints(a, b) {
+    const seen = new Map();
+    for (const bp of a) {
+        const existing = seen.get(bp.pos);
+        if (!existing || bp.score > existing.score) {
+            seen.set(bp.pos, bp);
+        }
+    }
+    for (const bp of b) {
+        const existing = seen.get(bp.pos);
+        if (!existing || bp.score > existing.score) {
+            seen.set(bp.pos, bp);
+        }
+    }
+    return Array.from(seen.values()).sort((a, b) => a.pos - b.pos);
+}
+/**
+ * Core chunk algorithm that operates on precomputed break points and code fences.
+ * This is the shared implementation used by both regex-only and AST-aware chunking.
+ */
+export function chunkDocumentWithBreakPoints(content, breakPoints, codeFences, maxChars = CHUNK_SIZE_CHARS, overlapChars = CHUNK_OVERLAP_CHARS, windowChars = CHUNK_WINDOW_CHARS) {
+    if (content.length <= maxChars) {
+        return [{ text: content, pos: 0 }];
+    }
+    const chunks = [];
+    let charPos = 0;
+    while (charPos < content.length) {
+        const targetEndPos = Math.min(charPos + maxChars, content.length);
+        let endPos = targetEndPos;
+        if (endPos < content.length) {
+            const bestCutoff = findBestCutoff(breakPoints, targetEndPos, windowChars, 0.7, codeFences);
+            if (bestCutoff > charPos && bestCutoff <= targetEndPos) {
+                endPos = bestCutoff;
+            }
+        }
+        if (endPos <= charPos) {
+            endPos = Math.min(charPos + maxChars, content.length);
+        }
+        chunks.push({ text: content.slice(charPos, endPos), pos: charPos });
+        if (endPos >= content.length) {
+            break;
+        }
+        charPos = endPos - overlapChars;
+        const lastChunkPos = chunks.at(-1).pos;
+        if (charPos <= lastChunkPos) {
+            charPos = endPos;
+        }
+    }
+    return chunks;
+}
+// Hybrid query: strong BM25 signal detection thresholds
+// Skip expensive LLM expansion when top result is strong AND clearly separated from runner-up
+export const STRONG_SIGNAL_MIN_SCORE = 0.85;
+export const STRONG_SIGNAL_MIN_GAP = 0.15;
+// Max candidates to pass to reranker — balances quality vs latency.
+// 40 keeps rank 31-40 visible to the reranker (matters for recall on broad queries).
+export const RERANK_CANDIDATE_LIMIT = 40;
+// =============================================================================
+// Path utilities
+// =============================================================================
+export function homedir() {
+    return HOME;
+}
+/**
+ * Check if a path is absolute.
+ * Supports:
+ * - Unix paths: /path/to/file
+ * - Windows native: C:\path or C:/path
+ * - Git Bash: /c/path or /C/path (C-Z drives, excluding A/B floppy drives)
+ *
+ * Note: /c without trailing slash is treated as Unix path (directory named "c"),
+ * while /c/ or /c/path are treated as Git Bash paths (C: drive).
+ */
+export function isAbsolutePath(path) {
+    if (!path)
+        return false;
+    // Unix absolute path
+    if (path.startsWith('/')) {
+        // Check if it's a Git Bash style path like /c/ or /c/Users (C-Z only, not A or B)
+        // Requires path[2] === '/' to distinguish from Unix paths like /c or /cache
+        // Skipped on WSL where /c/ is a valid drvfs mount point, not a drive letter
+        if (!isWSL() && path.length >= 3 && path[2] === '/') {
+            const driveLetter = path[1];
+            if (driveLetter && /[c-zC-Z]/.test(driveLetter)) {
+                return true;
+            }
+        }
+        // Any other path starting with / is Unix absolute
+        return true;
+    }
+    // Windows native path: C:\ or C:/ (any letter A-Z)
+    if (path.length >= 2 && /[a-zA-Z]/.test(path[0]) && path[1] === ':') {
+        return true;
+    }
+    return false;
+}
+/**
+ * Normalize path separators to forward slashes.
+ * Converts Windows backslashes to forward slashes.
+ */
+export function normalizePathSeparators(path) {
+    return path.replace(/\\/g, '/');
+}
+/**
+ * Detect if running inside WSL (Windows Subsystem for Linux).
+ * On WSL, paths like /c/work/... are valid drvfs mount points, not Git Bash paths.
+ */
+function isWSL() {
+    return !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
+}
+/**
+ * Get the relative path from a prefix.
+ * Returns null if path is not under prefix.
+ * Returns empty string if path equals prefix.
+ */
+export function getRelativePathFromPrefix(path, prefix) {
+    // Empty prefix is invalid
+    if (!prefix) {
+        return null;
+    }
+    const normalizedPath = normalizePathSeparators(path);
+    const normalizedPrefix = normalizePathSeparators(prefix);
+    // Ensure prefix ends with / for proper matching
+    const prefixWithSlash = !normalizedPrefix.endsWith('/')
+        ? normalizedPrefix + '/'
+        : normalizedPrefix;
+    // Exact match
+    if (normalizedPath === normalizedPrefix) {
+        return '';
+    }
+    // Check if path starts with prefix
+    if (normalizedPath.startsWith(prefixWithSlash)) {
+        return normalizedPath.slice(prefixWithSlash.length);
+    }
+    return null;
+}
+export function resolve(...paths) {
+    if (paths.length === 0) {
+        throw new Error("resolve: at least one path segment is required");
+    }
+    // Normalize all paths to use forward slashes
+    const normalizedPaths = paths.map(normalizePathSeparators);
+    let result = '';
+    let windowsDrive = '';
+    // Check if first path is absolute
+    const firstPath = normalizedPaths[0];
+    if (isAbsolutePath(firstPath)) {
+        result = firstPath;
+        // Extract Windows drive letter if present
+        if (firstPath.length >= 2 && /[a-zA-Z]/.test(firstPath[0]) && firstPath[1] === ':') {
+            windowsDrive = firstPath.slice(0, 2);
+            result = firstPath.slice(2);
+        }
+        else if (!isWSL() && firstPath.startsWith('/') && firstPath.length >= 3 && firstPath[2] === '/') {
+            // Git Bash style: /c/ -> C: (C-Z drives only, not A or B)
+            // Skipped on WSL where /c/ is a valid drvfs mount point, not a drive letter
+            const driveLetter = firstPath[1];
+            if (driveLetter && /[c-zC-Z]/.test(driveLetter)) {
+                windowsDrive = driveLetter.toUpperCase() + ':';
+                result = firstPath.slice(2);
+            }
+        }
+    }
+    else {
+        // Start with PWD or cwd, then append the first relative path
+        const pwd = normalizePathSeparators(process.env.PWD || process.cwd());
+        // Extract Windows drive from PWD if present
+        if (pwd.length >= 2 && /[a-zA-Z]/.test(pwd[0]) && pwd[1] === ':') {
+            windowsDrive = pwd.slice(0, 2);
+            result = pwd.slice(2) + '/' + firstPath;
+        }
+        else {
+            result = pwd + '/' + firstPath;
+        }
+    }
+    // Process remaining paths
+    for (let i = 1; i < normalizedPaths.length; i++) {
+        const p = normalizedPaths[i];
+        if (isAbsolutePath(p)) {
+            // Absolute path replaces everything
+            result = p;
+            // Update Windows drive if present
+            if (p.length >= 2 && /[a-zA-Z]/.test(p[0]) && p[1] === ':') {
+                windowsDrive = p.slice(0, 2);
+                result = p.slice(2);
+            }
+            else if (!isWSL() && p.startsWith('/') && p.length >= 3 && p[2] === '/') {
+                // Git Bash style (C-Z drives only, not A or B)
+                // Skipped on WSL where /c/ is a valid drvfs mount point, not a drive letter
+                const driveLetter = p[1];
+                if (driveLetter && /[c-zC-Z]/.test(driveLetter)) {
+                    windowsDrive = driveLetter.toUpperCase() + ':';
+                    result = p.slice(2);
+                }
+                else {
+                    windowsDrive = '';
+                }
+            }
+            else {
+                windowsDrive = '';
+            }
+        }
+        else {
+            // Relative path - append
+            result = result + '/' + p;
+        }
+    }
+    // Normalize . and .. components
+    const parts = result.split('/').filter(Boolean);
+    const normalized = [];
+    for (const part of parts) {
+        if (part === '..') {
+            normalized.pop();
+        }
+        else if (part !== '.') {
+            normalized.push(part);
+        }
+    }
+    // Build final path
+    const finalPath = '/' + normalized.join('/');
+    // Prepend Windows drive if present
+    if (windowsDrive) {
+        return windowsDrive + finalPath;
+    }
+    return finalPath;
+}
+// Flag to indicate production mode (set by qmd.ts at startup)
+let _productionMode = false;
+export function enableProductionMode() {
+    _productionMode = true;
+}
+/** Reset production mode flag — only for testing. */
+export function _resetProductionModeForTesting() {
+    _productionMode = false;
+}
+export function getDefaultDbPath(indexName = "index") {
+    // Always allow override via INDEX_PATH (for testing)
+    if (process.env.INDEX_PATH) {
+        return process.env.INDEX_PATH;
+    }
+    // In non-production mode (tests), require explicit path
+    if (!_productionMode) {
+        throw new Error("Database path not set. Tests must set INDEX_PATH env var or use createStore() with explicit path. " +
+            "This prevents tests from accidentally writing to the global index.");
+    }
+    const cacheDir = process.env.XDG_CACHE_HOME || resolve(homedir(), ".cache");
+    const qmdCacheDir = resolve(cacheDir, "qmd");
+    try {
+        mkdirSync(qmdCacheDir, { recursive: true });
+    }
+    catch { }
+    return resolve(qmdCacheDir, `${indexName}.sqlite`);
+}
+export function getPwd() {
+    return process.env.PWD || process.cwd();
+}
+export function getRealPath(path) {
+    try {
+        return realpathSync(path);
+    }
+    catch {
+        return resolve(path);
+    }
+}
+/**
+ * Normalize explicit virtual path formats to standard qmd:// format.
+ * Only handles paths that are already explicitly virtual:
+ * - qmd://collection/path.md (already normalized)
+ * - qmd:////collection/path.md (extra slashes - normalize)
+ * - //collection/path.md (missing qmd: prefix - add it)
+ *
+ * Does NOT handle:
+ * - collection/path.md (bare paths - could be filesystem relative)
+ * - :linenum suffix (should be parsed separately before calling this)
+ */
+export function normalizeVirtualPath(input) {
+    let path = input.trim();
+    // Handle qmd:// with extra slashes: qmd:////collection/path -> qmd://collection/path
+    if (path.startsWith('qmd:')) {
+        // Remove qmd: prefix and normalize slashes
+        path = path.slice(4);
+        // Remove leading slashes and re-add exactly two
+        path = path.replace(/^\/+/, '');
+        return `qmd://${path}`;
+    }
+    // Handle //collection/path (missing qmd: prefix)
+    if (path.startsWith('//')) {
+        path = path.replace(/^\/+/, '');
+        return `qmd://${path}`;
+    }
+    // Return as-is for other cases (filesystem paths, docids, bare collection/path, etc.)
+    return path;
+}
+/**
+ * Parse a virtual path like "qmd://collection-name/path/to/file.md"
+ * into its components.
+ * Also supports collection root: "qmd://collection-name/" or "qmd://collection-name"
+ */
+export function parseVirtualPath(virtualPath) {
+    // Normalize the path first
+    const normalized = normalizeVirtualPath(virtualPath);
+    // Match: qmd://collection-name[/optional-path]
+    // Allows: qmd://name, qmd://name/, qmd://name/path
+    const match = normalized.match(/^qmd:\/\/([^\/]+)\/?(.*)$/);
+    if (!match?.[1])
+        return null;
+    return {
+        collectionName: match[1],
+        path: match[2] ?? '', // Empty string for collection root
+    };
+}
+/**
+ * Build a virtual path from collection name and relative path.
+ */
+export function buildVirtualPath(collectionName, path) {
+    return `qmd://${collectionName}/${path}`;
+}
+/**
+ * Check if a path is explicitly a virtual path.
+ * Only recognizes explicit virtual path formats:
+ * - qmd://collection/path.md
+ * - //collection/path.md
+ *
+ * Does NOT consider bare collection/path.md as virtual - that should be
+ * handled separately by checking if the first component is a collection name.
+ */
+export function isVirtualPath(path) {
+    const trimmed = path.trim();
+    // Explicit qmd:// prefix (with any number of slashes)
+    if (trimmed.startsWith('qmd:'))
+        return true;
+    // //collection/path format (missing qmd: prefix)
+    if (trimmed.startsWith('//'))
+        return true;
+    return false;
+}
+/**
+ * Resolve a virtual path to absolute filesystem path.
+ */
+export function resolveVirtualPath(db, virtualPath) {
+    const parsed = parseVirtualPath(virtualPath);
+    if (!parsed)
+        return null;
+    const coll = getCollectionByName(db, parsed.collectionName);
+    if (!coll)
+        return null;
+    return resolve(coll.pwd, parsed.path);
+}
+/**
+ * Convert an absolute filesystem path to a virtual path.
+ * Returns null if the file is not in any indexed collection.
+ */
+export function toVirtualPath(db, absolutePath) {
+    // Get all collections from DB
+    const collections = getStoreCollections(db);
+    // Find which collection this absolute path belongs to
+    for (const coll of collections) {
+        if (absolutePath.startsWith(coll.path + '/') || absolutePath === coll.path) {
+            // Extract relative path
+            const relativePath = absolutePath.startsWith(coll.path + '/')
+                ? absolutePath.slice(coll.path.length + 1)
+                : '';
+            // Verify this document exists in the database
+            const doc = db.prepare(`
+        SELECT d.path
+        FROM documents d
+        WHERE d.collection = ? AND d.path = ? AND d.active = 1
+        LIMIT 1
+      `).get(coll.name, relativePath);
+            if (doc) {
+                return buildVirtualPath(coll.name, relativePath);
+            }
+        }
+    }
+    return null;
+}
+// =============================================================================
+// Database initialization
+// =============================================================================
+function createSqliteVecUnavailableError(reason) {
+    return new Error("sqlite-vec extension is unavailable. " +
+        `${reason}. ` +
+        "Install Homebrew SQLite so the sqlite-vec extension can be loaded, " +
+        "and set BREW_PREFIX if Homebrew is installed in a non-standard location.");
+}
+function getErrorMessage(err) {
+    return err instanceof Error ? err.message : String(err);
+}
+export function verifySqliteVecLoaded(db) {
+    try {
+        const row = db.prepare(`SELECT vec_version() AS version`).get();
+        if (!row?.version || typeof row.version !== "string") {
+            throw new Error("vec_version() returned no version");
+        }
+    }
+    catch (err) {
+        const message = getErrorMessage(err);
+        throw createSqliteVecUnavailableError(`sqlite-vec probe failed (${message})`);
+    }
+}
+let _sqliteVecAvailable = null;
+function initializeDatabase(db) {
+    try {
+        loadSqliteVec(db);
+        verifySqliteVecLoaded(db);
+        _sqliteVecAvailable = true;
+    }
+    catch (err) {
+        // sqlite-vec is optional — vector search won't work but FTS is fine
+        _sqliteVecAvailable = false;
+        console.warn(getErrorMessage(err));
+    }
+    db.exec("PRAGMA journal_mode = WAL");
+    db.exec("PRAGMA foreign_keys = ON");
+    // Drop legacy tables that are now managed in YAML
+    db.exec(`DROP TABLE IF EXISTS path_contexts`);
+    db.exec(`DROP TABLE IF EXISTS collections`);
+    // Content-addressable storage - the source of truth for document content
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS content (
+      hash TEXT PRIMARY KEY,
+      doc TEXT NOT NULL,
+      created_at TEXT NOT NULL
+    )
+  `);
+    // Documents table - file system layer mapping virtual paths to content hashes
+    // Collections are now managed in ~/.config/qmd/index.yml
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS documents (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      collection TEXT NOT NULL,
+      path TEXT NOT NULL,
+      title TEXT NOT NULL,
+      hash TEXT NOT NULL,
+      created_at TEXT NOT NULL,
+      modified_at TEXT NOT NULL,
+      active INTEGER NOT NULL DEFAULT 1,
+      FOREIGN KEY (hash) REFERENCES content(hash) ON DELETE CASCADE,
+      UNIQUE(collection, path)
+    )
+  `);
+    db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection, active)`);
+    db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`);
+    db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(path, active)`);
+    // Cache table for LLM API calls
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS llm_cache (
+      hash TEXT PRIMARY KEY,
+      result TEXT NOT NULL,
+      created_at TEXT NOT NULL
+    )
+  `);
+    // Content vectors
+    const cvInfo = db.prepare(`PRAGMA table_info(content_vectors)`).all();
+    const hasSeqColumn = cvInfo.some(col => col.name === 'seq');
+    if (cvInfo.length > 0 && !hasSeqColumn) {
+        db.exec(`DROP TABLE IF EXISTS content_vectors`);
+        db.exec(`DROP TABLE IF EXISTS vectors_vec`);
+    }
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS content_vectors (
+      hash TEXT NOT NULL,
+      seq INTEGER NOT NULL DEFAULT 0,
+      pos INTEGER NOT NULL DEFAULT 0,
+      model TEXT NOT NULL,
+      embedded_at TEXT NOT NULL,
+      PRIMARY KEY (hash, seq)
+    )
+  `);
+    // Store collections — makes the DB self-contained (no external config needed)
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS store_collections (
+      name TEXT PRIMARY KEY,
+      path TEXT NOT NULL,
+      pattern TEXT NOT NULL DEFAULT '**/*.md',
+      ignore_patterns TEXT,
+      include_by_default INTEGER DEFAULT 1,
+      update_command TEXT,
+      context TEXT
+    )
+  `);
+    // Store config — key-value metadata (e.g. config_hash for sync optimization)
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS store_config (
+      key TEXT PRIMARY KEY,
+      value TEXT
+    )
+  `);
+    // FTS - index filepath (collection/path), title, and content
+    db.exec(`
+    CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
+      filepath, title, body,
+      tokenize='porter unicode61'
+    )
+  `);
+    // Triggers to keep FTS in sync
+    db.exec(`
+    CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents
+    WHEN new.active = 1
+    BEGIN
+      INSERT INTO documents_fts(rowid, filepath, title, body)
+      SELECT
+        new.id,
+        new.collection || '/' || new.path,
+        new.title,
+        (SELECT doc FROM content WHERE hash = new.hash)
+      WHERE new.active = 1;
+    END
+  `);
+    db.exec(`
+    CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
+      DELETE FROM documents_fts WHERE rowid = old.id;
+    END
+  `);
+    db.exec(`
+    CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents
+    BEGIN
+      -- Delete from FTS if no longer active
+      DELETE FROM documents_fts WHERE rowid = old.id AND new.active = 0;
+
+      -- Update FTS if still/newly active
+      INSERT OR REPLACE INTO documents_fts(rowid, filepath, title, body)
+      SELECT
+        new.id,
+        new.collection || '/' || new.path,
+        new.title,
+        (SELECT doc FROM content WHERE hash = new.hash)
+      WHERE new.active = 1;
+    END
+  `);
+}
+function rowToNamedCollection(row) {
+    return {
+        name: row.name,
+        path: row.path,
+        pattern: row.pattern,
+        ...(row.ignore_patterns ? { ignore: JSON.parse(row.ignore_patterns) } : {}),
+        ...(row.include_by_default === 0 ? { includeByDefault: false } : {}),
+        ...(row.update_command ? { update: row.update_command } : {}),
+        ...(row.context ? { context: JSON.parse(row.context) } : {}),
+    };
+}
+export function getStoreCollections(db) {
+    const rows = db.prepare(`SELECT * FROM store_collections`).all();
+    return rows.map(rowToNamedCollection);
+}
+export function getStoreCollection(db, name) {
+    const row = db.prepare(`SELECT * FROM store_collections WHERE name = ?`).get(name);
+    if (row == null)
+        return null;
+    return rowToNamedCollection(row);
+}
+export function getStoreGlobalContext(db) {
+    const row = db.prepare(`SELECT value FROM store_config WHERE key = 'global_context'`).get();
+    if (row == null)
+        return undefined;
+    return row.value || undefined;
+}
+export function getStoreContexts(db) {
+    const results = [];
+    // Global context
+    const globalCtx = getStoreGlobalContext(db);
+    if (globalCtx) {
+        results.push({ collection: "*", path: "/", context: globalCtx });
+    }
+    // Collection contexts
+    const rows = db.prepare(`SELECT name, context FROM store_collections WHERE context IS NOT NULL`).all();
+    for (const row of rows) {
+        const ctxMap = JSON.parse(row.context);
+        for (const [path, context] of Object.entries(ctxMap)) {
+            results.push({ collection: row.name, path, context });
+        }
+    }
+    return results;
+}
+export function upsertStoreCollection(db, name, collection) {
+    db.prepare(`
+    INSERT INTO store_collections (name, path, pattern, ignore_patterns, include_by_default, update_command, context)
+    VALUES (?, ?, ?, ?, ?, ?, ?)
+    ON CONFLICT(name) DO UPDATE SET
+      path = excluded.path,
+      pattern = excluded.pattern,
+      ignore_patterns = excluded.ignore_patterns,
+      include_by_default = excluded.include_by_default,
+      update_command = excluded.update_command,
+      context = excluded.context
+  `).run(name, collection.path, collection.pattern || '**/*.md', collection.ignore ? JSON.stringify(collection.ignore) : null, collection.includeByDefault === false ? 0 : 1, collection.update || null, collection.context ? JSON.stringify(collection.context) : null);
+}
+export function deleteStoreCollection(db, name) {
+    const result = db.prepare(`DELETE FROM store_collections WHERE name = ?`).run(name);
+    return result.changes > 0;
+}
+export function renameStoreCollection(db, oldName, newName) {
+    // Check target doesn't exist
+    const existing = db.prepare(`SELECT name FROM store_collections WHERE name = ?`).get(newName);
+    if (existing != null) {
+        throw new Error(`Collection '${newName}' already exists`);
+    }
+    const result = db.prepare(`UPDATE store_collections SET name = ? WHERE name = ?`).run(newName, oldName);
+    return result.changes > 0;
+}
+export function updateStoreContext(db, collectionName, path, text) {
+    const row = db.prepare(`SELECT context FROM store_collections WHERE name = ?`).get(collectionName);
+    if (row == null)
+        return false;
+    const ctxMap = row.context ? JSON.parse(row.context) : {};
+    ctxMap[path] = text;
+    db.prepare(`UPDATE store_collections SET context = ? WHERE name = ?`).run(JSON.stringify(ctxMap), collectionName);
+    return true;
+}
+export function removeStoreContext(db, collectionName, path) {
+    const row = db.prepare(`SELECT context FROM store_collections WHERE name = ?`).get(collectionName);
+    if (row == null)
+        return false;
+    if (!row.context)
+        return false;
+    const ctxMap = JSON.parse(row.context);
+    if (!(path in ctxMap))
+        return false;
+    delete ctxMap[path];
+    const newCtx = Object.keys(ctxMap).length > 0 ? JSON.stringify(ctxMap) : null;
+    db.prepare(`UPDATE store_collections SET context = ? WHERE name = ?`).run(newCtx, collectionName);
+    return true;
+}
+export function setStoreGlobalContext(db, value) {
+    if (value === undefined) {
+        db.prepare(`DELETE FROM store_config WHERE key = 'global_context'`).run();
+    }
+    else {
+        db.prepare(`INSERT INTO store_config (key, value) VALUES ('global_context', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(value);
+    }
+}
+/**
+ * Sync external config (YAML/inline) into SQLite store_collections.
+ * External config always wins. Skips sync if config hash hasn't changed.
+ */
+export function syncConfigToDb(db, config) {
+    // Check config hash — skip sync if unchanged
+    const configJson = JSON.stringify(config);
+    const hash = createHash('sha256').update(configJson).digest('hex');
+    const existingHash = db.prepare(`SELECT value FROM store_config WHERE key = 'config_hash'`).get();
+    if (existingHash != null && existingHash.value === hash) {
+        return; // Config unchanged, skip sync
+    }
+    // Sync collections
+    const configNames = new Set(Object.keys(config.collections));
+    for (const [name, coll] of Object.entries(config.collections)) {
+        upsertStoreCollection(db, name, coll);
+    }
+    // Delete collections not in config
+    const dbCollections = db.prepare(`SELECT name FROM store_collections`).all();
+    for (const row of dbCollections) {
+        if (!configNames.has(row.name)) {
+            db.prepare(`DELETE FROM store_collections WHERE name = ?`).run(row.name);
+        }
+    }
+    // Sync global context
+    if (config.global_context !== undefined) {
+        setStoreGlobalContext(db, config.global_context);
+    }
+    else {
+        setStoreGlobalContext(db, undefined);
+    }
+    // Save config hash
+    db.prepare(`INSERT INTO store_config (key, value) VALUES ('config_hash', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(hash);
+}
+export function isSqliteVecAvailable() {
+    return _sqliteVecAvailable === true;
+}
+function ensureVecTableInternal(db, dimensions) {
+    if (!_sqliteVecAvailable) {
+        throw new Error("sqlite-vec is not available. Vector operations require a SQLite build with extension loading support.");
+    }
+    const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
+    if (tableInfo) {
+        const match = tableInfo.sql.match(/float\[(\d+)\]/);
+        const hasHashSeq = tableInfo.sql.includes('hash_seq');
+        const hasCosine = tableInfo.sql.includes('distance_metric=cosine');
+        const existingDims = match?.[1] ? parseInt(match[1], 10) : null;
+        if (existingDims === dimensions && hasHashSeq && hasCosine)
+            return;
+        if (existingDims !== null && existingDims !== dimensions) {
+            throw new Error(`Embedding dimension mismatch: existing vectors are ${existingDims}d but the current model produces ${dimensions}d. ` +
+                `Run 'qmd embed -f' to re-embed with the new model.`);
+        }
+        db.exec("DROP TABLE IF EXISTS vectors_vec");
+    }
+    db.exec(`CREATE VIRTUAL TABLE vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}] distance_metric=cosine)`);
+}
+/**
+ * Re-index a single collection by scanning the filesystem and updating the database.
+ * Pure function — no console output, no db lifecycle management.
+ */
+export async function reindexCollection(store, collectionPath, globPattern, collectionName, options) {
+    const db = store.db;
+    const now = new Date().toISOString();
+    const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
+    const allIgnore = [
+        ...excludeDirs.map(d => `**/${d}/**`),
+        ...(options?.ignorePatterns || []),
+    ];
+    const allFiles = await fastGlob(globPattern, {
+        cwd: collectionPath,
+        onlyFiles: true,
+        followSymbolicLinks: false,
+        dot: false,
+        ignore: allIgnore,
+    });
+    // Filter hidden files/folders
+    const files = allFiles.filter(file => {
+        const parts = file.split("/");
+        return !parts.some(part => part.startsWith("."));
+    });
+    const total = files.length;
+    let indexed = 0, updated = 0, unchanged = 0, processed = 0;
+    const seenPaths = new Set();
+    for (const relativeFile of files) {
+        const filepath = getRealPath(resolve(collectionPath, relativeFile));
+        const path = handelize(relativeFile);
+        seenPaths.add(path);
+        let content;
+        try {
+            content = readFileSync(filepath, "utf-8");
+        }
+        catch {
+            processed++;
+            options?.onProgress?.({ file: relativeFile, current: processed, total });
+            continue;
+        }
+        if (!content.trim()) {
+            processed++;
+            continue;
+        }
+        const hash = await hashContent(content);
+        const title = extractTitle(content, relativeFile);
+        const existing = findActiveDocument(db, collectionName, path);
+        if (existing) {
+            if (existing.hash === hash) {
+                if (existing.title !== title) {
+                    updateDocumentTitle(db, existing.id, title, now);
+                    updated++;
+                }
+                else {
+                    unchanged++;
+                }
+            }
+            else {
+                insertContent(db, hash, content, now);
+                const stat = statSync(filepath);
+                updateDocument(db, existing.id, title, hash, stat ? new Date(stat.mtime).toISOString() : now);
+                updated++;
+            }
+        }
+        else {
+            indexed++;
+            insertContent(db, hash, content, now);
+            const stat = statSync(filepath);
+            insertDocument(db, collectionName, path, title, hash, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
+        }
+        processed++;
+        options?.onProgress?.({ file: relativeFile, current: processed, total });
+    }
+    // Deactivate documents that no longer exist
+    const allActive = getActiveDocumentPaths(db, collectionName);
+    let removed = 0;
+    for (const path of allActive) {
+        if (!seenPaths.has(path)) {
+            deactivateDocument(db, collectionName, path);
+            removed++;
+        }
+    }
+    const orphanedCleaned = cleanupOrphanedContent(db);
+    return { indexed, updated, unchanged, removed, orphanedCleaned };
+}
+function validatePositiveIntegerOption(name, value, fallback) {
+    if (value === undefined)
+        return fallback;
+    if (!Number.isInteger(value) || value < 1) {
+        throw new Error(`${name} must be a positive integer`);
+    }
+    return value;
+}
+function resolveEmbedOptions(options) {
+    return {
+        maxDocsPerBatch: validatePositiveIntegerOption("maxDocsPerBatch", options?.maxDocsPerBatch, DEFAULT_EMBED_MAX_DOCS_PER_BATCH),
+        maxBatchBytes: validatePositiveIntegerOption("maxBatchBytes", options?.maxBatchBytes, DEFAULT_EMBED_MAX_BATCH_BYTES),
+    };
+}
+function getPendingEmbeddingDocs(db) {
+    return db.prepare(`
+    SELECT d.hash, MIN(d.path) as path, length(CAST(c.doc AS BLOB)) as bytes
+    FROM documents d
+    JOIN content c ON d.hash = c.hash
+    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
+    WHERE d.active = 1 AND v.hash IS NULL
+    GROUP BY d.hash
+    ORDER BY MIN(d.path)
+  `).all();
+}
+function buildEmbeddingBatches(docs, maxDocsPerBatch, maxBatchBytes) {
+    const batches = [];
+    let currentBatch = [];
+    let currentBytes = 0;
+    for (const doc of docs) {
+        const docBytes = Math.max(0, doc.bytes);
+        const wouldExceedDocs = currentBatch.length >= maxDocsPerBatch;
+        const wouldExceedBytes = currentBatch.length > 0 && (currentBytes + docBytes) > maxBatchBytes;
+        if (wouldExceedDocs || wouldExceedBytes) {
+            batches.push(currentBatch);
+            currentBatch = [];
+            currentBytes = 0;
+        }
+        currentBatch.push(doc);
+        currentBytes += docBytes;
+    }
+    if (currentBatch.length > 0) {
+        batches.push(currentBatch);
+    }
+    return batches;
+}
+function getEmbeddingDocsForBatch(db, batch) {
+    if (batch.length === 0)
+        return [];
+    const placeholders = batch.map(() => "?").join(",");
+    const rows = db.prepare(`
+    SELECT hash, doc as body
+    FROM content
+    WHERE hash IN (${placeholders})
+  `).all(...batch.map(doc => doc.hash));
+    const bodyByHash = new Map(rows.map(row => [row.hash, row.body]));
+    return batch.map((doc) => ({
+        ...doc,
+        body: bodyByHash.get(doc.hash) ?? "",
+    }));
+}
+/**
+ * Generate vector embeddings for documents that need them.
+ * Pure function — no console output, no db lifecycle management.
+ * Uses the store's LlamaCpp instance if set, otherwise the global singleton.
+ */
+export async function generateEmbeddings(store, options) {
+    const db = store.db;
+    const model = options?.model ?? DEFAULT_EMBED_MODEL;
+    const now = new Date().toISOString();
+    const { maxDocsPerBatch, maxBatchBytes } = resolveEmbedOptions(options);
+    const encoder = new TextEncoder();
+    if (options?.force) {
+        clearAllEmbeddings(db);
+    }
+    const docsToEmbed = getPendingEmbeddingDocs(db);
+    if (docsToEmbed.length === 0) {
+        return { docsProcessed: 0, chunksEmbedded: 0, errors: 0, durationMs: 0 };
+    }
+    const totalBytes = docsToEmbed.reduce((sum, doc) => sum + Math.max(0, doc.bytes), 0);
+    const totalDocs = docsToEmbed.length;
+    const startTime = Date.now();
+    // Use store's LlamaCpp or global singleton, wrapped in a session
+    const llm = getLlm(store);
+    const embedModelUri = llm.embedModelName;
+    // Create a session manager for this llm instance
+    const result = await withLLMSessionForLlm(llm, async (session) => {
+        let chunksEmbedded = 0;
+        let errors = 0;
+        let bytesProcessed = 0;
+        let totalChunks = 0;
+        let vectorTableInitialized = false;
+        const BATCH_SIZE = 32;
+        const batches = buildEmbeddingBatches(docsToEmbed, maxDocsPerBatch, maxBatchBytes);
+        for (const batchMeta of batches) {
+            // Abort early if session has been invalidated
+            if (!session.isValid) {
+                console.warn(`⚠ Session expired — skipping remaining document batches`);
+                break;
+            }
+            const batchDocs = getEmbeddingDocsForBatch(db, batchMeta);
+            const batchChunks = [];
+            const batchBytes = batchMeta.reduce((sum, doc) => sum + Math.max(0, doc.bytes), 0);
+            for (const doc of batchDocs) {
+                if (!doc.body.trim())
+                    continue;
+                const title = extractTitle(doc.body, doc.path);
+                const chunks = await chunkDocumentByTokens(doc.body, undefined, undefined, undefined, doc.path, options?.chunkStrategy, session.signal);
+                for (let seq = 0; seq < chunks.length; seq++) {
+                    batchChunks.push({
+                        hash: doc.hash,
+                        title,
+                        text: chunks[seq].text,
+                        seq,
+                        pos: chunks[seq].pos,
+                        tokens: chunks[seq].tokens,
+                        bytes: encoder.encode(chunks[seq].text).length,
+                    });
+                }
+            }
+            totalChunks += batchChunks.length;
+            if (batchChunks.length === 0) {
+                bytesProcessed += batchBytes;
+                options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors });
+                continue;
+            }
+            if (!vectorTableInitialized) {
+                const firstChunk = batchChunks[0];
+                const firstText = formatDocForEmbedding(firstChunk.text, firstChunk.title, embedModelUri);
+                const firstResult = await session.embed(firstText, { model });
+                if (!firstResult) {
+                    throw new Error("Failed to get embedding dimensions from first chunk");
+                }
+                store.ensureVecTable(firstResult.embedding.length);
+                vectorTableInitialized = true;
+            }
+            const totalBatchChunkBytes = batchChunks.reduce((sum, chunk) => sum + chunk.bytes, 0);
+            let batchChunkBytesProcessed = 0;
+            for (let batchStart = 0; batchStart < batchChunks.length; batchStart += BATCH_SIZE) {
+                // Abort early if session has been invalidated (e.g. max duration exceeded)
+                if (!session.isValid) {
+                    const remaining = batchChunks.length - batchStart;
+                    errors += remaining;
+                    console.warn(`⚠ Session expired — skipping ${remaining} remaining chunks`);
+                    break;
+                }
+                // Abort early if error rate is too high (>80% of processed chunks failed)
+                const processed = chunksEmbedded + errors;
+                if (processed >= BATCH_SIZE && errors > processed * 0.8) {
+                    const remaining = batchChunks.length - batchStart;
+                    errors += remaining;
+                    console.warn(`⚠ Error rate too high (${errors}/${processed}) — aborting embedding`);
+                    break;
+                }
+                const batchEnd = Math.min(batchStart + BATCH_SIZE, batchChunks.length);
+                const chunkBatch = batchChunks.slice(batchStart, batchEnd);
+                const texts = chunkBatch.map(chunk => formatDocForEmbedding(chunk.text, chunk.title, embedModelUri));
+                try {
+                    const embeddings = await session.embedBatch(texts, { model });
+                    for (let i = 0; i < chunkBatch.length; i++) {
+                        const chunk = chunkBatch[i];
+                        const embedding = embeddings[i];
+                        if (embedding) {
+                            insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(embedding.embedding), model, now);
+                            chunksEmbedded++;
+                        }
+                        else {
+                            errors++;
+                        }
+                        batchChunkBytesProcessed += chunk.bytes;
+                    }
+                }
+                catch {
+                    // Batch failed — try individual embeddings as fallback
+                    // But skip if session is already invalid (avoids N doomed retries)
+                    if (!session.isValid) {
+                        errors += chunkBatch.length;
+                        batchChunkBytesProcessed += chunkBatch.reduce((sum, c) => sum + c.bytes, 0);
+                    }
+                    else {
+                        for (const chunk of chunkBatch) {
+                            try {
+                                const text = formatDocForEmbedding(chunk.text, chunk.title, embedModelUri);
+                                const result = await session.embed(text, { model });
+                                if (result) {
+                                    insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(result.embedding), model, now);
+                                    chunksEmbedded++;
+                                }
+                                else {
+                                    errors++;
+                                }
+                            }
+                            catch {
+                                errors++;
+                            }
+                            batchChunkBytesProcessed += chunk.bytes;
+                        }
+                    }
+                }
+                const proportionalBytes = totalBatchChunkBytes === 0
+                    ? batchBytes
+                    : Math.min(batchBytes, Math.round((batchChunkBytesProcessed / totalBatchChunkBytes) * batchBytes));
+                options?.onProgress?.({
+                    chunksEmbedded,
+                    totalChunks,
+                    bytesProcessed: bytesProcessed + proportionalBytes,
+                    totalBytes,
+                    errors,
+                });
+            }
+            bytesProcessed += batchBytes;
+            options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors });
+        }
+        return { chunksEmbedded, errors };
+    }, { maxDuration: 30 * 60 * 1000, name: 'generateEmbeddings' });
+    return {
+        docsProcessed: totalDocs,
+        chunksEmbedded: result.chunksEmbedded,
+        errors: result.errors,
+        durationMs: Date.now() - startTime,
+    };
+}
+/**
+ * Create a new store instance with the given database path.
+ * If no path is provided, uses the default path (~/.cache/qmd/index.sqlite).
+ *
+ * @param dbPath - Path to the SQLite database file
+ * @returns Store instance with all methods bound to the database
+ */
+export function createStore(dbPath) {
+    const resolvedPath = dbPath || getDefaultDbPath();
+    const db = openDatabase(resolvedPath);
+    initializeDatabase(db);
+    const store = {
+        db,
+        dbPath: resolvedPath,
+        close: () => db.close(),
+        ensureVecTable: (dimensions) => ensureVecTableInternal(db, dimensions),
+        // Index health
+        getHashesNeedingEmbedding: () => getHashesNeedingEmbedding(db),
+        getIndexHealth: () => getIndexHealth(db),
+        getStatus: () => getStatus(db),
+        // Caching
+        getCacheKey,
+        getCachedResult: (cacheKey) => getCachedResult(db, cacheKey),
+        setCachedResult: (cacheKey, result) => setCachedResult(db, cacheKey, result),
+        clearCache: () => clearCache(db),
+        // Cleanup and maintenance
+        deleteLLMCache: () => deleteLLMCache(db),
+        deleteInactiveDocuments: () => deleteInactiveDocuments(db),
+        cleanupOrphanedContent: () => cleanupOrphanedContent(db),
+        cleanupOrphanedVectors: () => cleanupOrphanedVectors(db),
+        vacuumDatabase: () => vacuumDatabase(db),
+        // Context
+        getContextForFile: (filepath) => getContextForFile(db, filepath),
+        getContextForPath: (collectionName, path) => getContextForPath(db, collectionName, path),
+        getCollectionByName: (name) => getCollectionByName(db, name),
+        getCollectionsWithoutContext: () => getCollectionsWithoutContext(db),
+        getTopLevelPathsWithoutContext: (collectionName) => getTopLevelPathsWithoutContext(db, collectionName),
+        // Virtual paths
+        parseVirtualPath,
+        buildVirtualPath,
+        isVirtualPath,
+        resolveVirtualPath: (virtualPath) => resolveVirtualPath(db, virtualPath),
+        toVirtualPath: (absolutePath) => toVirtualPath(db, absolutePath),
+        // Search
+        searchFTS: (query, limit, collectionName) => searchFTS(db, query, limit, collectionName),
+        searchVec: (query, model, limit, collectionName, session, precomputedEmbedding) => searchVec(db, query, model, limit, collectionName, session, precomputedEmbedding),
+        // Query expansion & reranking
+        expandQuery: (query, model, intent) => expandQuery(query, model, db, intent, store.llm),
+        rerank: (query, documents, model, intent) => rerank(query, documents, model, db, intent, store.llm),
+        // Document retrieval
+        findDocument: (filename, options) => findDocument(db, filename, options),
+        getDocumentBody: (doc, fromLine, maxLines) => getDocumentBody(db, doc, fromLine, maxLines),
+        findDocuments: (pattern, options) => findDocuments(db, pattern, options),
+        // Fuzzy matching and docid lookup
+        findSimilarFiles: (query, maxDistance, limit) => findSimilarFiles(db, query, maxDistance, limit),
+        matchFilesByGlob: (pattern) => matchFilesByGlob(db, pattern),
+        findDocumentByDocid: (docid) => findDocumentByDocid(db, docid),
+        // Document indexing operations
+        insertContent: (hash, content, createdAt) => insertContent(db, hash, content, createdAt),
+        insertDocument: (collectionName, path, title, hash, createdAt, modifiedAt) => insertDocument(db, collectionName, path, title, hash, createdAt, modifiedAt),
+        findActiveDocument: (collectionName, path) => findActiveDocument(db, collectionName, path),
+        updateDocumentTitle: (documentId, title, modifiedAt) => updateDocumentTitle(db, documentId, title, modifiedAt),
+        updateDocument: (documentId, title, hash, modifiedAt) => updateDocument(db, documentId, title, hash, modifiedAt),
+        deactivateDocument: (collectionName, path) => deactivateDocument(db, collectionName, path),
+        getActiveDocumentPaths: (collectionName) => getActiveDocumentPaths(db, collectionName),
+        // Vector/embedding operations
+        getHashesForEmbedding: () => getHashesForEmbedding(db),
+        clearAllEmbeddings: () => clearAllEmbeddings(db),
+        insertEmbedding: (hash, seq, pos, embedding, model, embeddedAt) => insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt),
+    };
+    return store;
+}
+/**
+ * Extract short docid from a full hash (first 6 characters).
+ */
+export function getDocid(hash) {
+    return hash.slice(0, 6);
+}
+/**
+ * Handelize a filename to be more token-friendly.
+ * - Convert triple underscore `___` to `/` (folder separator)
+ * - Convert to lowercase
+ * - Replace sequences of non-word chars (except /) with single dash
+ * - Remove leading/trailing dashes from path segments
+ * - Preserve folder structure (a/b/c/d.md stays structured)
+ * - Preserve file extension
+ */
+/** Replace emoji/symbol codepoints with their hex representation (e.g. 🐘 → 1f418) */
+function emojiToHex(str) {
+    return str.replace(/(?:\p{So}\p{Mn}?|\p{Sk})+/gu, (run) => {
+        // Split the run into individual emoji and convert each to hex, dash-separated
+        return [...run].filter(c => /\p{So}|\p{Sk}/u.test(c))
+            .map(c => c.codePointAt(0).toString(16)).join('-');
+    });
+}
+export function handelize(path) {
+    if (!path || path.trim() === '') {
+        throw new Error('handelize: path cannot be empty');
+    }
+    // Allow route-style "$" filenames while still rejecting paths with no usable content.
+    // Emoji (\p{So}) counts as valid content — they get converted to hex codepoints below.
+    const segments = path.split('/').filter(Boolean);
+    const lastSegment = segments[segments.length - 1] || '';
+    const filenameWithoutExt = lastSegment.replace(/\.[^.]+$/, '');
+    const hasValidContent = /[\p{L}\p{N}\p{So}\p{Sk}$]/u.test(filenameWithoutExt);
+    if (!hasValidContent) {
+        throw new Error(`handelize: path "${path}" has no valid filename content`);
+    }
+    const result = path
+        .replace(/___/g, '/') // Triple underscore becomes folder separator
+        .toLowerCase()
+        .split('/')
+        .map((segment, idx, arr) => {
+        const isLastSegment = idx === arr.length - 1;
+        // Convert emoji to hex codepoints before cleaning
+        segment = emojiToHex(segment);
+        if (isLastSegment) {
+            // For the filename (last segment), preserve the extension
+            const extMatch = segment.match(/(\.[a-z0-9]+)$/i);
+            const ext = extMatch ? extMatch[1] : '';
+            const nameWithoutExt = ext ? segment.slice(0, -ext.length) : segment;
+            const cleanedName = nameWithoutExt
+                .replace(/[^\p{L}\p{N}$]+/gu, '-') // Keep letters, numbers, "$"; dash-separate rest (including dots)
+                .replace(/^-+|-+$/g, ''); // Remove leading/trailing dashes
+            return cleanedName + ext;
+        }
+        else {
+            // For directories, just clean normally
+            return segment
+                .replace(/[^\p{L}\p{N}$]+/gu, '-')
+                .replace(/^-+|-+$/g, '');
+        }
+    })
+        .filter(Boolean)
+        .join('/');
+    if (!result) {
+        throw new Error(`handelize: path "${path}" resulted in empty string after processing`);
+    }
+    return result;
+}
+// =============================================================================
+// Index health
+// =============================================================================
+export function getHashesNeedingEmbedding(db) {
+    const result = db.prepare(`
+    SELECT COUNT(DISTINCT d.hash) as count
+    FROM documents d
+    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
+    WHERE d.active = 1 AND v.hash IS NULL
+  `).get();
+    return result.count;
+}
+export function getIndexHealth(db) {
+    const needsEmbedding = getHashesNeedingEmbedding(db);
+    const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get().count;
+    const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get();
+    let daysStale = null;
+    if (mostRecent?.latest) {
+        const lastUpdate = new Date(mostRecent.latest);
+        daysStale = Math.floor((Date.now() - lastUpdate.getTime()) / (24 * 60 * 60 * 1000));
+    }
+    return { needsEmbedding, totalDocs, daysStale };
+}
+// =============================================================================
+// Caching
+// =============================================================================
+export function getCacheKey(url, body) {
+    const hash = createHash("sha256");
+    hash.update(url);
+    hash.update(JSON.stringify(body));
+    return hash.digest("hex");
+}
+export function getCachedResult(db, cacheKey) {
+    const row = db.prepare(`SELECT result FROM llm_cache WHERE hash = ?`).get(cacheKey);
+    return row?.result || null;
+}
+export function setCachedResult(db, cacheKey, result) {
+    const now = new Date().toISOString();
+    db.prepare(`INSERT OR REPLACE INTO llm_cache (hash, result, created_at) VALUES (?, ?, ?)`).run(cacheKey, result, now);
+    if (Math.random() < 0.01) {
+        db.exec(`DELETE FROM llm_cache WHERE hash NOT IN (SELECT hash FROM llm_cache ORDER BY created_at DESC LIMIT 1000)`);
+    }
+}
+export function clearCache(db) {
+    db.exec(`DELETE FROM llm_cache`);
+}
+// =============================================================================
+// Cleanup and maintenance operations
+// =============================================================================
+/**
+ * Delete cached LLM API responses.
+ * Returns the number of cached responses deleted.
+ */
+export function deleteLLMCache(db) {
+    const result = db.prepare(`DELETE FROM llm_cache`).run();
+    return result.changes;
+}
+/**
+ * Remove inactive document records (active = 0).
+ * Returns the number of inactive documents deleted.
+ */
+export function deleteInactiveDocuments(db) {
+    const result = db.prepare(`DELETE FROM documents WHERE active = 0`).run();
+    return result.changes;
+}
+/**
+ * Remove orphaned content hashes that are not referenced by any active document.
+ * Returns the number of orphaned content hashes deleted.
+ */
+export function cleanupOrphanedContent(db) {
+    const result = db.prepare(`
+    DELETE FROM content
+    WHERE hash NOT IN (SELECT DISTINCT hash FROM documents WHERE active = 1)
+  `).run();
+    return result.changes;
+}
+/**
+ * Remove orphaned vector embeddings that are not referenced by any active document.
+ * Returns the number of orphaned embedding chunks deleted.
+ */
+export function cleanupOrphanedVectors(db) {
+    // sqlite-vec may not be loaded (e.g. Bun's bun:sqlite lacks loadExtension).
+    // The vectors_vec virtual table can appear in sqlite_master from a prior
+    // session, but querying it without the vec0 module loaded will crash (#380).
+    if (!isSqliteVecAvailable()) {
+        return 0;
+    }
+    // The schema entry can exist even when sqlite-vec itself is unavailable
+    // (for example when reopening a DB without vec0 loaded). In that case,
+    // touching the virtual table throws "no such module: vec0" and cleanup
+    // should degrade gracefully like the rest of the vector features.
+    try {
+        db.prepare(`SELECT 1 FROM vectors_vec LIMIT 0`).get();
+    }
+    catch {
+        return 0;
+    }
+    // Count orphaned vectors first
+    const countResult = db.prepare(`
+    SELECT COUNT(*) as c FROM content_vectors cv
+    WHERE NOT EXISTS (
+      SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
+    )
+  `).get();
+    if (countResult.c === 0) {
+        return 0;
+    }
+    // Delete from vectors_vec first
+    db.exec(`
+    DELETE FROM vectors_vec WHERE hash_seq IN (
+      SELECT cv.hash || '_' || cv.seq FROM content_vectors cv
+      WHERE NOT EXISTS (
+        SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
+      )
+    )
+  `);
+    // Delete from content_vectors
+    db.exec(`
+    DELETE FROM content_vectors WHERE hash NOT IN (
+      SELECT hash FROM documents WHERE active = 1
+    )
+  `);
+    return countResult.c;
+}
+/**
+ * Run VACUUM to reclaim unused space in the database.
+ * This operation rebuilds the database file to eliminate fragmentation.
+ */
+export function vacuumDatabase(db) {
+    db.exec(`VACUUM`);
+}
+// =============================================================================
+// Document helpers
+// =============================================================================
+export async function hashContent(content) {
+    const hash = createHash("sha256");
+    hash.update(content);
+    return hash.digest("hex");
+}
+const titleExtractors = {
+    '.md': (content) => {
+        const match = content.match(/^##?\s+(.+)$/m);
+        if (match) {
+            const title = (match[1] ?? "").trim();
+            if (title === "📝 Notes" || title === "Notes") {
+                const nextMatch = content.match(/^##\s+(.+)$/m);
+                if (nextMatch?.[1])
+                    return nextMatch[1].trim();
+            }
+            return title;
+        }
+        return null;
+    },
+    '.org': (content) => {
+        const titleProp = content.match(/^#\+TITLE:\s*(.+)$/im);
+        if (titleProp?.[1])
+            return titleProp[1].trim();
+        const heading = content.match(/^\*+\s+(.+)$/m);
+        if (heading?.[1])
+            return heading[1].trim();
+        return null;
+    },
+};
+export function extractTitle(content, filename) {
+    const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase();
+    const extractor = titleExtractors[ext];
+    if (extractor) {
+        const title = extractor(content);
+        if (title)
+            return title;
+    }
+    return filename.replace(/\.[^.]+$/, "").split("/").pop() || filename;
+}
+// =============================================================================
+// Document indexing operations
+// =============================================================================
+/**
+ * Insert content into the content table (content-addressable storage).
+ * Uses INSERT OR IGNORE so duplicate hashes are skipped.
+ */
+export function insertContent(db, hash, content, createdAt) {
+    db.prepare(`INSERT OR IGNORE INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
+        .run(hash, content, createdAt);
+}
+/**
+ * Insert a new document into the documents table.
+ */
+export function insertDocument(db, collectionName, path, title, hash, createdAt, modifiedAt) {
+    db.prepare(`
+    INSERT INTO documents (collection, path, title, hash, created_at, modified_at, active)
+    VALUES (?, ?, ?, ?, ?, ?, 1)
+    ON CONFLICT(collection, path) DO UPDATE SET
+      title = excluded.title,
+      hash = excluded.hash,
+      modified_at = excluded.modified_at,
+      active = 1
+  `).run(collectionName, path, title, hash, createdAt, modifiedAt);
+}
+/**
+ * Find an active document by collection name and path.
+ */
+export function findActiveDocument(db, collectionName, path) {
+    const row = db.prepare(`
+    SELECT id, hash, title FROM documents
+    WHERE collection = ? AND path = ? AND active = 1
+  `).get(collectionName, path);
+    return row ?? null;
+}
+/**
+ * Update the title and modified_at timestamp for a document.
+ */
+export function updateDocumentTitle(db, documentId, title, modifiedAt) {
+    db.prepare(`UPDATE documents SET title = ?, modified_at = ? WHERE id = ?`)
+        .run(title, modifiedAt, documentId);
+}
+/**
+ * Update an existing document's hash, title, and modified_at timestamp.
+ * Used when content changes but the file path stays the same.
+ */
+export function updateDocument(db, documentId, title, hash, modifiedAt) {
+    db.prepare(`UPDATE documents SET title = ?, hash = ?, modified_at = ? WHERE id = ?`)
+        .run(title, hash, modifiedAt, documentId);
+}
+/**
+ * Deactivate a document (mark as inactive but don't delete).
+ */
+export function deactivateDocument(db, collectionName, path) {
+    db.prepare(`UPDATE documents SET active = 0 WHERE collection = ? AND path = ? AND active = 1`)
+        .run(collectionName, path);
+}
+/**
+ * Get all active document paths for a collection.
+ */
+export function getActiveDocumentPaths(db, collectionName) {
+    const rows = db.prepare(`
+    SELECT path FROM documents WHERE collection = ? AND active = 1
+  `).all(collectionName);
+    return rows.map(r => r.path);
+}
+export { formatQueryForEmbedding, formatDocForEmbedding };
+/**
+ * Chunk a document using regex-only break point detection.
+ * This is the sync, backward-compatible API used by tests and legacy callers.
+ */
+export function chunkDocument(content, maxChars = CHUNK_SIZE_CHARS, overlapChars = CHUNK_OVERLAP_CHARS, windowChars = CHUNK_WINDOW_CHARS) {
+    const breakPoints = scanBreakPoints(content);
+    const codeFences = findCodeFences(content);
+    return chunkDocumentWithBreakPoints(content, breakPoints, codeFences, maxChars, overlapChars, windowChars);
+}
+/**
+ * Async AST-aware chunking. Detects language from filepath, computes AST
+ * break points for supported code files, merges with regex break points,
+ * and delegates to the shared chunk algorithm.
+ *
+ * Falls back to regex-only when strategy is "regex", filepath is absent,
+ * or language is unsupported.
+ */
+export async function chunkDocumentAsync(content, maxChars = CHUNK_SIZE_CHARS, overlapChars = CHUNK_OVERLAP_CHARS, windowChars = CHUNK_WINDOW_CHARS, filepath, chunkStrategy = "regex") {
+    const regexPoints = scanBreakPoints(content);
+    const codeFences = findCodeFences(content);
+    let breakPoints = regexPoints;
+    if (chunkStrategy === "auto" && filepath) {
+        const { getASTBreakPoints } = await import("./ast.js");
+        const astPoints = await getASTBreakPoints(content, filepath);
+        if (astPoints.length > 0) {
+            breakPoints = mergeBreakPoints(regexPoints, astPoints);
+        }
+    }
+    return chunkDocumentWithBreakPoints(content, breakPoints, codeFences, maxChars, overlapChars, windowChars);
+}
+/**
+ * Chunk a document by actual token count using the LLM tokenizer.
+ * More accurate than character-based chunking but requires async.
+ *
+ * When filepath and chunkStrategy are provided, uses AST-aware break points
+ * for supported code files.
+ */
+export async function chunkDocumentByTokens(content, maxTokens = CHUNK_SIZE_TOKENS, overlapTokens = CHUNK_OVERLAP_TOKENS, windowTokens = CHUNK_WINDOW_TOKENS, filepath, chunkStrategy = "regex", signal) {
+    const llm = getDefaultLlamaCpp();
+    // Use moderate chars/token estimate (prose ~4, code ~2, mixed ~3)
+    // If chunks exceed limit, they'll be re-split with actual ratio
+    const avgCharsPerToken = 3;
+    const maxChars = maxTokens * avgCharsPerToken;
+    const overlapChars = overlapTokens * avgCharsPerToken;
+    const windowChars = windowTokens * avgCharsPerToken;
+    // Chunk in character space with conservative estimate
+    // Use AST-aware chunking for the first pass when filepath/strategy provided
+    let charChunks = await chunkDocumentAsync(content, maxChars, overlapChars, windowChars, filepath, chunkStrategy);
+    // Tokenize and split any chunks that still exceed limit
+    const results = [];
+    for (const chunk of charChunks) {
+        // Respect abort signal to avoid runaway tokenization
+        if (signal?.aborted)
+            break;
+        const tokens = await llm.tokenize(chunk.text);
+        if (tokens.length <= maxTokens) {
+            results.push({ text: chunk.text, pos: chunk.pos, tokens: tokens.length });
+        }
+        else {
+            // Chunk is still too large - split it further
+            // Use actual token count to estimate better char limit
+            const actualCharsPerToken = chunk.text.length / tokens.length;
+            const safeMaxChars = Math.floor(maxTokens * actualCharsPerToken * 0.95); // 5% safety margin
+            const subChunks = chunkDocument(chunk.text, safeMaxChars, Math.floor(overlapChars * actualCharsPerToken / 2), Math.floor(windowChars * actualCharsPerToken / 2));
+            for (const subChunk of subChunks) {
+                if (signal?.aborted)
+                    break;
+                const subTokens = await llm.tokenize(subChunk.text);
+                results.push({
+                    text: subChunk.text,
+                    pos: chunk.pos + subChunk.pos,
+                    tokens: subTokens.length,
+                });
+            }
+        }
+    }
+    return results;
+}
+// =============================================================================
+// Fuzzy matching
+// =============================================================================
+function levenshtein(a, b) {
+    const m = a.length, n = b.length;
+    if (m === 0)
+        return n;
+    if (n === 0)
+        return m;
+    const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
+    for (let i = 0; i <= m; i++)
+        dp[i][0] = i;
+    for (let j = 0; j <= n; j++)
+        dp[0][j] = j;
+    for (let i = 1; i <= m; i++) {
+        for (let j = 1; j <= n; j++) {
+            const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+            dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
+        }
+    }
+    return dp[m][n];
+}
+/**
+ * Normalize a docid input by stripping surrounding quotes and leading #.
+ * Handles: "#abc123", 'abc123', "abc123", #abc123, abc123
+ * Returns the bare hex string.
+ */
+export function normalizeDocid(docid) {
+    let normalized = docid.trim();
+    // Strip surrounding quotes (single or double)
+    if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
+        (normalized.startsWith("'") && normalized.endsWith("'"))) {
+        normalized = normalized.slice(1, -1);
+    }
+    // Strip leading # if present
+    if (normalized.startsWith('#')) {
+        normalized = normalized.slice(1);
+    }
+    return normalized;
+}
+/**
+ * Check if a string looks like a docid reference.
+ * Accepts: #abc123, abc123, "#abc123", "abc123", '#abc123', 'abc123'
+ * Returns true if the normalized form is a valid hex string of 6+ chars.
+ */
+export function isDocid(input) {
+    const normalized = normalizeDocid(input);
+    // Must be at least 6 hex characters
+    return normalized.length >= 6 && /^[a-f0-9]+$/i.test(normalized);
+}
+/**
+ * Find a document by its short docid (first 6 characters of hash).
+ * Returns the document's virtual path if found, null otherwise.
+ * If multiple documents match the same short hash (collision), returns the first one.
+ *
+ * Accepts lenient input: #abc123, abc123, "#abc123", "abc123"
+ */
+export function findDocumentByDocid(db, docid) {
+    const shortHash = normalizeDocid(docid);
+    if (shortHash.length < 1)
+        return null;
+    // Look up documents where hash starts with the short hash
+    const doc = db.prepare(`
+    SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.hash
+    FROM documents d
+    WHERE d.hash LIKE ? AND d.active = 1
+    LIMIT 1
+  `).get(`${shortHash}%`);
+    return doc;
+}
+export function findSimilarFiles(db, query, maxDistance = 3, limit = 5) {
+    const allFiles = db.prepare(`
+    SELECT d.path
+    FROM documents d
+    WHERE d.active = 1
+  `).all();
+    const queryLower = query.toLowerCase();
+    const scored = allFiles
+        .map(f => ({ path: f.path, dist: levenshtein(f.path.toLowerCase(), queryLower) }))
+        .filter(f => f.dist <= maxDistance)
+        .sort((a, b) => a.dist - b.dist)
+        .slice(0, limit);
+    return scored.map(f => f.path);
+}
+export function matchFilesByGlob(db, pattern) {
+    const allFiles = db.prepare(`
+    SELECT
+      'qmd://' || d.collection || '/' || d.path as virtual_path,
+      LENGTH(content.doc) as body_length,
+      d.path,
+      d.collection
+    FROM documents d
+    JOIN content ON content.hash = d.hash
+    WHERE d.active = 1
+  `).all();
+    const isMatch = picomatch(pattern);
+    return allFiles
+        .filter(f => isMatch(f.virtual_path) || isMatch(f.path) || isMatch(f.collection + '/' + f.path))
+        .map(f => ({
+        filepath: f.virtual_path, // Virtual path for precise lookup
+        displayPath: f.path, // Relative path for display
+        bodyLength: f.body_length
+    }));
+}
+// =============================================================================
+// Context
+// =============================================================================
+/**
+ * Get context for a file path using hierarchical inheritance.
+ * Contexts are collection-scoped and inherit from parent directories.
+ * For example, context at "/talks" applies to "/talks/2024/keynote.md".
+ *
+ * @param db Database instance (unused - kept for compatibility)
+ * @param collectionName Collection name
+ * @param path Relative path within the collection
+ * @returns Context string or null if no context is defined
+ */
+export function getContextForPath(db, collectionName, path) {
+    const coll = getStoreCollection(db, collectionName);
+    if (!coll)
+        return null;
+    // Collect ALL matching contexts (global + all path prefixes)
+    const contexts = [];
+    // Add global context if present
+    const globalCtx = getStoreGlobalContext(db);
+    if (globalCtx) {
+        contexts.push(globalCtx);
+    }
+    // Add all matching path contexts (from most general to most specific)
+    if (coll.context) {
+        const normalizedPath = path.startsWith("/") ? path : `/${path}`;
+        // Collect all matching prefixes
+        const matchingContexts = [];
+        for (const [prefix, context] of Object.entries(coll.context)) {
+            const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
+            if (normalizedPath.startsWith(normalizedPrefix)) {
+                matchingContexts.push({ prefix: normalizedPrefix, context });
+            }
+        }
+        // Sort by prefix length (shortest/most general first)
+        matchingContexts.sort((a, b) => a.prefix.length - b.prefix.length);
+        // Add all matching contexts
+        for (const match of matchingContexts) {
+            contexts.push(match.context);
+        }
+    }
+    // Join all contexts with double newline
+    return contexts.length > 0 ? contexts.join('\n\n') : null;
+}
+/**
+ * Get context for a file path (virtual or filesystem).
+ * Resolves the collection and relative path from the DB store_collections table.
+ */
+export function getContextForFile(db, filepath) {
+    // Handle undefined or null filepath
+    if (!filepath)
+        return null;
+    // Get all collections from DB
+    const collections = getStoreCollections(db);
+    // Parse virtual path format: qmd://collection/path
+    let collectionName = null;
+    let relativePath = null;
+    const parsedVirtual = filepath.startsWith('qmd://') ? parseVirtualPath(filepath) : null;
+    if (parsedVirtual) {
+        collectionName = parsedVirtual.collectionName;
+        relativePath = parsedVirtual.path;
+    }
+    else {
+        // Filesystem path: find which collection this absolute path belongs to
+        for (const coll of collections) {
+            // Skip collections with missing paths
+            if (!coll || !coll.path)
+                continue;
+            if (filepath.startsWith(coll.path + '/') || filepath === coll.path) {
+                collectionName = coll.name;
+                // Extract relative path
+                relativePath = filepath.startsWith(coll.path + '/')
+                    ? filepath.slice(coll.path.length + 1)
+                    : '';
+                break;
+            }
+        }
+        if (!collectionName || relativePath === null)
+            return null;
+    }
+    // Get the collection from DB
+    const coll = getStoreCollection(db, collectionName);
+    if (!coll)
+        return null;
+    // Verify this document exists in the database
+    const doc = db.prepare(`
+    SELECT d.path
+    FROM documents d
+    WHERE d.collection = ? AND d.path = ? AND d.active = 1
+    LIMIT 1
+  `).get(collectionName, relativePath);
+    if (!doc)
+        return null;
+    // Collect ALL matching contexts (global + all path prefixes)
+    const contexts = [];
+    // Add global context if present
+    const globalCtx = getStoreGlobalContext(db);
+    if (globalCtx) {
+        contexts.push(globalCtx);
+    }
+    // Add all matching path contexts (from most general to most specific)
+    if (coll.context) {
+        const normalizedPath = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
+        // Collect all matching prefixes
+        const matchingContexts = [];
+        for (const [prefix, context] of Object.entries(coll.context)) {
+            const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
+            if (normalizedPath.startsWith(normalizedPrefix)) {
+                matchingContexts.push({ prefix: normalizedPrefix, context });
+            }
+        }
+        // Sort by prefix length (shortest/most general first)
+        matchingContexts.sort((a, b) => a.prefix.length - b.prefix.length);
+        // Add all matching contexts
+        for (const match of matchingContexts) {
+            contexts.push(match.context);
+        }
+    }
+    // Join all contexts with double newline
+    return contexts.length > 0 ? contexts.join('\n\n') : null;
+}
+/**
+ * Get collection by name from DB store_collections table.
+ */
+export function getCollectionByName(db, name) {
+    const collection = getStoreCollection(db, name);
+    if (!collection)
+        return null;
+    return {
+        name: collection.name,
+        pwd: collection.path,
+        glob_pattern: collection.pattern,
+    };
+}
+/**
+ * List all collections with document counts from database.
+ * Merges store_collections config with database statistics.
+ */
+export function listCollections(db) {
+    const collections = getStoreCollections(db);
+    // Get document counts from database for each collection
+    const result = collections.map(coll => {
+        const stats = db.prepare(`
+      SELECT
+        COUNT(d.id) as doc_count,
+        SUM(CASE WHEN d.active = 1 THEN 1 ELSE 0 END) as active_count,
+        MAX(d.modified_at) as last_modified
+      FROM documents d
+      WHERE d.collection = ?
+    `).get(coll.name);
+        return {
+            name: coll.name,
+            pwd: coll.path,
+            glob_pattern: coll.pattern,
+            doc_count: stats?.doc_count || 0,
+            active_count: stats?.active_count || 0,
+            last_modified: stats?.last_modified || null,
+            includeByDefault: coll.includeByDefault !== false,
+        };
+    });
+    return result;
+}
+/**
+ * Remove a collection and clean up its documents.
+ * Uses collections.ts to remove from YAML config and cleans up database.
+ */
+export function removeCollection(db, collectionName) {
+    // Delete documents from database
+    const docResult = db.prepare(`DELETE FROM documents WHERE collection = ?`).run(collectionName);
+    // Clean up orphaned content hashes
+    const cleanupResult = db.prepare(`
+    DELETE FROM content
+    WHERE hash NOT IN (SELECT DISTINCT hash FROM documents WHERE active = 1)
+  `).run();
+    // Remove from store_collections
+    deleteStoreCollection(db, collectionName);
+    return {
+        deletedDocs: docResult.changes,
+        cleanedHashes: cleanupResult.changes
+    };
+}
+/**
+ * Rename a collection.
+ * Updates both YAML config and database documents table.
+ */
+export function renameCollection(db, oldName, newName) {
+    // Update all documents with the new collection name in database
+    db.prepare(`UPDATE documents SET collection = ? WHERE collection = ?`)
+        .run(newName, oldName);
+    // Rename in store_collections
+    renameStoreCollection(db, oldName, newName);
+}
+// =============================================================================
+// Context Management Operations
+// =============================================================================
+/**
+ * Insert or update a context for a specific collection and path prefix.
+ */
+export function insertContext(db, collectionId, pathPrefix, context) {
+    // Get collection name from ID
+    const coll = db.prepare(`SELECT name FROM collections WHERE id = ?`).get(collectionId);
+    if (!coll) {
+        throw new Error(`Collection with id ${collectionId} not found`);
+    }
+    // Add context to store_collections
+    updateStoreContext(db, coll.name, pathPrefix, context);
+}
+/**
+ * Delete a context for a specific collection and path prefix.
+ * Returns the number of contexts deleted.
+ */
+export function deleteContext(db, collectionName, pathPrefix) {
+    // Remove context from store_collections
+    const success = removeStoreContext(db, collectionName, pathPrefix);
+    return success ? 1 : 0;
+}
+/**
+ * Delete all global contexts (contexts with empty path_prefix).
+ * Returns the number of contexts deleted.
+ */
+export function deleteGlobalContexts(db) {
+    let deletedCount = 0;
+    // Remove global context
+    setStoreGlobalContext(db, undefined);
+    deletedCount++;
+    // Remove root context (empty string) from all collections
+    const collections = getStoreCollections(db);
+    for (const coll of collections) {
+        const success = removeStoreContext(db, coll.name, '');
+        if (success) {
+            deletedCount++;
+        }
+    }
+    return deletedCount;
+}
+/**
+ * List all contexts, grouped by collection.
+ * Returns contexts ordered by collection name, then by path prefix length (longest first).
+ */
+export function listPathContexts(db) {
+    const allContexts = getStoreContexts(db);
+    // Convert to expected format and sort
+    return allContexts.map(ctx => ({
+        collection_name: ctx.collection,
+        path_prefix: ctx.path,
+        context: ctx.context,
+    })).sort((a, b) => {
+        // Sort by collection name first
+        if (a.collection_name !== b.collection_name) {
+            return a.collection_name.localeCompare(b.collection_name);
+        }
+        // Then by path prefix length (longest first)
+        if (a.path_prefix.length !== b.path_prefix.length) {
+            return b.path_prefix.length - a.path_prefix.length;
+        }
+        // Then alphabetically
+        return a.path_prefix.localeCompare(b.path_prefix);
+    });
+}
+/**
+ * Get all collections (name only - from YAML config).
+ */
+export function getAllCollections(db) {
+    const collections = getStoreCollections(db);
+    return collections.map(c => ({ name: c.name }));
+}
+/**
+ * Check which collections don't have any context defined.
+ * Returns collections that have no context entries at all (not even root context).
+ */
+export function getCollectionsWithoutContext(db) {
+    // Get all collections from DB
+    const allCollections = getStoreCollections(db);
+    // Filter to those without context
+    const collectionsWithoutContext = [];
+    for (const coll of allCollections) {
+        // Check if collection has any context
+        if (!coll.context || Object.keys(coll.context).length === 0) {
+            // Get doc count from database
+            const stats = db.prepare(`
+        SELECT COUNT(d.id) as doc_count
+        FROM documents d
+        WHERE d.collection = ? AND d.active = 1
+      `).get(coll.name);
+            collectionsWithoutContext.push({
+                name: coll.name,
+                pwd: coll.path,
+                doc_count: stats?.doc_count || 0,
+            });
+        }
+    }
+    return collectionsWithoutContext.sort((a, b) => a.name.localeCompare(b.name));
+}
+/**
+ * Get top-level directories in a collection that don't have context.
+ * Useful for suggesting where context might be needed.
+ */
+export function getTopLevelPathsWithoutContext(db, collectionName) {
+    // Get all paths in the collection from database
+    const paths = db.prepare(`
+    SELECT DISTINCT path FROM documents
+    WHERE collection = ? AND active = 1
+  `).all(collectionName);
+    // Get existing contexts for this collection from DB
+    const dbColl = getStoreCollection(db, collectionName);
+    if (!dbColl)
+        return [];
+    const contextPrefixes = new Set();
+    if (dbColl.context) {
+        for (const prefix of Object.keys(dbColl.context)) {
+            contextPrefixes.add(prefix);
+        }
+    }
+    // Extract top-level directories (first path component)
+    const topLevelDirs = new Set();
+    for (const { path } of paths) {
+        const parts = path.split('/').filter(Boolean);
+        if (parts.length > 1) {
+            const dir = parts[0];
+            if (dir)
+                topLevelDirs.add(dir);
+        }
+    }
+    // Filter out directories that already have context (exact or parent)
+    const missing = [];
+    for (const dir of topLevelDirs) {
+        let hasContext = false;
+        // Check if this dir or any parent has context
+        for (const prefix of contextPrefixes) {
+            if (prefix === '' || prefix === dir || dir.startsWith(prefix + '/')) {
+                hasContext = true;
+                break;
+            }
+        }
+        if (!hasContext) {
+            missing.push(dir);
+        }
+    }
+    return missing.sort();
+}
+// =============================================================================
+// FTS Search
+// =============================================================================
+export function sanitizeFTS5Term(term) {
+    return term.replace(/[^\p{L}\p{N}'_]/gu, '').toLowerCase();
+}
+/**
+ * Check if a token is a hyphenated compound word (e.g., multi-agent, DEC-0054, gpt-4).
+ * Returns true if the token contains internal hyphens between word/digit characters.
+ */
+function isHyphenatedToken(token) {
+    return /^[\p{L}\p{N}][\p{L}\p{N}'-]*-[\p{L}\p{N}][\p{L}\p{N}'-]*$/u.test(token);
+}
+/**
+ * Sanitize a hyphenated term into an FTS5 phrase by splitting on hyphens
+ * and sanitizing each part. Returns the parts joined by spaces for use
+ * inside FTS5 quotes: "multi agent" matches "multi-agent" in porter tokenizer.
+ */
+function sanitizeHyphenatedTerm(term) {
+    return term.split('-').map(t => sanitizeFTS5Term(t)).filter(t => t).join(' ');
+}
+/**
+ * Parse lex query syntax into FTS5 query.
+ *
+ * Supports:
+ * - Quoted phrases: "exact phrase" → "exact phrase" (exact match)
+ * - Negation: -term or -"phrase" → uses FTS5 NOT operator
+ * - Hyphenated tokens: multi-agent, DEC-0054, gpt-4 → treated as phrases
+ * - Plain terms: term → "term"* (prefix match)
+ *
+ * FTS5 NOT is a binary operator: `term1 NOT term2` means "match term1 but not term2".
+ * So `-term` only works when there are also positive terms.
+ *
+ * Hyphen disambiguation: `-sports` at a word boundary is negation, but `multi-agent`
+ * (where `-` is between word characters) is treated as a hyphenated phrase.
+ * When a leading `-` is followed by what looks like a hyphenated compound word
+ * (e.g., `-multi-agent`), the entire token is treated as a negated phrase.
+ *
+ * Examples:
+ *   performance -sports     → "performance"* NOT "sports"*
+ *   "machine learning"      → "machine learning"
+ *   multi-agent memory      → "multi agent" AND "memory"*
+ *   DEC-0054               → "dec 0054"
+ *   -multi-agent            → NOT "multi agent"
+ */
+function buildFTS5Query(query) {
+    const positive = [];
+    const negative = [];
+    let i = 0;
+    const s = query.trim();
+    while (i < s.length) {
+        // Skip whitespace
+        while (i < s.length && /\s/.test(s[i]))
+            i++;
+        if (i >= s.length)
+            break;
+        // Check for negation prefix
+        const negated = s[i] === '-';
+        if (negated)
+            i++;
+        // Check for quoted phrase
+        if (s[i] === '"') {
+            const start = i + 1;
+            i++;
+            while (i < s.length && s[i] !== '"')
+                i++;
+            const phrase = s.slice(start, i).trim();
+            i++; // skip closing quote
+            if (phrase.length > 0) {
+                const sanitized = phrase.split(/\s+/).map(t => sanitizeFTS5Term(t)).filter(t => t).join(' ');
+                if (sanitized) {
+                    const ftsPhrase = `"${sanitized}"`; // Exact phrase, no prefix match
+                    if (negated) {
+                        negative.push(ftsPhrase);
+                    }
+                    else {
+                        positive.push(ftsPhrase);
+                    }
+                }
+            }
+        }
+        else {
+            // Plain term (until whitespace or quote)
+            const start = i;
+            while (i < s.length && !/[\s"]/.test(s[i]))
+                i++;
+            const term = s.slice(start, i);
+            // Handle hyphenated tokens: multi-agent, DEC-0054, gpt-4
+            // These get split into phrase queries so FTS5 porter tokenizer matches them.
+            if (isHyphenatedToken(term)) {
+                const sanitized = sanitizeHyphenatedTerm(term);
+                if (sanitized) {
+                    const ftsPhrase = `"${sanitized}"`; // Phrase match (no prefix)
+                    if (negated) {
+                        negative.push(ftsPhrase);
+                    }
+                    else {
+                        positive.push(ftsPhrase);
+                    }
+                }
+            }
+            else {
+                const sanitized = sanitizeFTS5Term(term);
+                if (sanitized) {
+                    const ftsTerm = `"${sanitized}"*`; // Prefix match
+                    if (negated) {
+                        negative.push(ftsTerm);
+                    }
+                    else {
+                        positive.push(ftsTerm);
+                    }
+                }
+            }
+        }
+    }
+    if (positive.length === 0 && negative.length === 0)
+        return null;
+    // If only negative terms, we can't search (FTS5 NOT is binary)
+    if (positive.length === 0)
+        return null;
+    // Join positive terms with AND
+    let result = positive.join(' AND ');
+    // Add NOT clause for negative terms
+    for (const neg of negative) {
+        result = `${result} NOT ${neg}`;
+    }
+    return result;
+}
+/**
+ * Validate that a vec/hyde query doesn't use lex-only syntax.
+ * Returns error message if invalid, null if valid.
+ */
+export function validateSemanticQuery(query) {
+    // Check for negation syntax
+    if (/-\w/.test(query) || /-"/.test(query)) {
+        return 'Negation (-term) is not supported in vec/hyde queries. Use lex for exclusions.';
+    }
+    return null;
+}
+export function validateLexQuery(query) {
+    if (/[\r\n]/.test(query)) {
+        return 'Lex queries must be a single line. Remove newline characters or split into separate lex: lines.';
+    }
+    const quoteCount = (query.match(/"/g) ?? []).length;
+    if (quoteCount % 2 === 1) {
+        return 'Lex query has an unmatched double quote ("). Add the closing quote or remove it.';
+    }
+    return null;
+}
+export function searchFTS(db, query, limit = 20, collectionName) {
+    const ftsQuery = buildFTS5Query(query);
+    if (!ftsQuery)
+        return [];
+    // Use a CTE to force FTS5 to run first, then filter by collection.
+    // Without the CTE, SQLite's query planner combines FTS5 MATCH with the
+    // collection filter in a single WHERE clause, which can cause it to
+    // abandon the FTS5 index and fall back to a full scan — turning an 8ms
+    // query into a 17-second query on large collections.
+    const params = [ftsQuery];
+    // When filtering by collection, fetch extra candidates from the FTS index
+    // since some will be filtered out. Without a collection filter we can
+    // fetch exactly the requested limit.
+    const ftsLimit = collectionName ? limit * 10 : limit;
+    let sql = `
+    WITH fts_matches AS (
+      SELECT rowid, bm25(documents_fts, 1.5, 4.0, 1.0) as bm25_score
+      FROM documents_fts
+      WHERE documents_fts MATCH ?
+      ORDER BY bm25_score ASC
+      LIMIT ${ftsLimit}
+    )
+    SELECT
+      'qmd://' || d.collection || '/' || d.path as filepath,
+      d.collection || '/' || d.path as display_path,
+      d.title,
+      content.doc as body,
+      d.hash,
+      fm.bm25_score
+    FROM fts_matches fm
+    JOIN documents d ON d.id = fm.rowid
+    JOIN content ON content.hash = d.hash
+    WHERE d.active = 1
+  `;
+    if (collectionName) {
+        sql += ` AND d.collection = ?`;
+        params.push(String(collectionName));
+    }
+    // bm25 lower is better; sort ascending.
+    sql += ` ORDER BY fm.bm25_score ASC LIMIT ?`;
+    params.push(limit);
+    const rows = db.prepare(sql).all(...params);
+    return rows.map(row => {
+        const collectionName = row.filepath.split('//')[1]?.split('/')[0] || "";
+        // Convert bm25 (negative, lower is better) into a stable [0..1) score where higher is better.
+        // FTS5 BM25 scores are negative (e.g., -10 is strong, -2 is weak).
+        // |x| / (1 + |x|) maps: strong(-10)→0.91, medium(-2)→0.67, weak(-0.5)→0.33, none(0)→0.
+        // Monotonic and query-independent — no per-query normalization needed.
+        const score = Math.abs(row.bm25_score) / (1 + Math.abs(row.bm25_score));
+        return {
+            filepath: row.filepath,
+            displayPath: row.display_path,
+            title: row.title,
+            hash: row.hash,
+            docid: getDocid(row.hash),
+            collectionName,
+            modifiedAt: "", // Not available in FTS query
+            bodyLength: row.body.length,
+            body: row.body,
+            context: getContextForFile(db, row.filepath),
+            score,
+            source: "fts",
+        };
+    });
+}
+// =============================================================================
+// Vector Search
+// =============================================================================
+export async function searchVec(db, query, model, limit = 20, collectionName, session, precomputedEmbedding) {
+    const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
+    if (!tableExists)
+        return [];
+    const embedding = precomputedEmbedding ?? await getEmbedding(query, model, true, session);
+    if (!embedding)
+        return [];
+    // IMPORTANT: We use a two-step query approach here because sqlite-vec virtual tables
+    // hang indefinitely when combined with JOINs in the same query. Do NOT try to
+    // "optimize" this by combining into a single query with JOINs - it will break.
+    // See: https://github.com/tobi/qmd/pull/23
+    // Step 1: Get vector matches from sqlite-vec (no JOINs allowed)
+    const vecResults = db.prepare(`
+    SELECT hash_seq, distance
+    FROM vectors_vec
+    WHERE embedding MATCH ? AND k = ?
+  `).all(new Float32Array(embedding), limit * 3);
+    if (vecResults.length === 0)
+        return [];
+    // Step 2: Get chunk info and document data
+    const hashSeqs = vecResults.map(r => r.hash_seq);
+    const distanceMap = new Map(vecResults.map(r => [r.hash_seq, r.distance]));
+    // Build query for document lookup
+    const placeholders = hashSeqs.map(() => '?').join(',');
+    let docSql = `
+    SELECT
+      cv.hash || '_' || cv.seq as hash_seq,
+      cv.hash,
+      cv.pos,
+      'qmd://' || d.collection || '/' || d.path as filepath,
+      d.collection || '/' || d.path as display_path,
+      d.title,
+      content.doc as body
+    FROM content_vectors cv
+    JOIN documents d ON d.hash = cv.hash AND d.active = 1
+    JOIN content ON content.hash = d.hash
+    WHERE cv.hash || '_' || cv.seq IN (${placeholders})
+  `;
+    const params = [...hashSeqs];
+    if (collectionName) {
+        docSql += ` AND d.collection = ?`;
+        params.push(collectionName);
+    }
+    const docRows = db.prepare(docSql).all(...params);
+    // Combine with distances and dedupe by filepath
+    const seen = new Map();
+    for (const row of docRows) {
+        const distance = distanceMap.get(row.hash_seq) ?? 1;
+        const existing = seen.get(row.filepath);
+        if (!existing || distance < existing.bestDist) {
+            seen.set(row.filepath, { row, bestDist: distance });
+        }
+    }
+    return Array.from(seen.values())
+        .sort((a, b) => a.bestDist - b.bestDist)
+        .slice(0, limit)
+        .map(({ row, bestDist }) => {
+        const collectionName = row.filepath.split('//')[1]?.split('/')[0] || "";
+        return {
+            filepath: row.filepath,
+            displayPath: row.display_path,
+            title: row.title,
+            hash: row.hash,
+            docid: getDocid(row.hash),
+            collectionName,
+            modifiedAt: "", // Not available in vec query
+            bodyLength: row.body.length,
+            body: row.body,
+            context: getContextForFile(db, row.filepath),
+            score: 1 - bestDist, // Cosine similarity = 1 - cosine distance
+            source: "vec",
+            chunkPos: row.pos,
+        };
+    });
+}
+// =============================================================================
+// Embeddings
+// =============================================================================
+async function getEmbedding(text, model, isQuery, session, llmOverride) {
+    // Format text using the appropriate prompt template
+    const formattedText = isQuery ? formatQueryForEmbedding(text, model) : formatDocForEmbedding(text, undefined, model);
+    const result = session
+        ? await session.embed(formattedText, { model, isQuery })
+        : await (llmOverride ?? getDefaultLlamaCpp()).embed(formattedText, { model, isQuery });
+    return result?.embedding || null;
+}
+/**
+ * Get all unique content hashes that need embeddings (from active documents).
+ * Returns hash, document body, and a sample path for display purposes.
+ */
+export function getHashesForEmbedding(db) {
+    return db.prepare(`
+    SELECT d.hash, c.doc as body, MIN(d.path) as path
+    FROM documents d
+    JOIN content c ON d.hash = c.hash
+    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
+    WHERE d.active = 1 AND v.hash IS NULL
+    GROUP BY d.hash
+  `).all();
+}
+/**
+ * Clear all embeddings from the database (force re-index).
+ * Deletes all rows from content_vectors and drops the vectors_vec table.
+ */
+export function clearAllEmbeddings(db) {
+    db.exec(`DELETE FROM content_vectors`);
+    db.exec(`DROP TABLE IF EXISTS vectors_vec`);
+}
+/**
+ * Insert a single embedding into both content_vectors and vectors_vec tables.
+ * The hash_seq key is formatted as "hash_seq" for the vectors_vec table.
+ *
+ * content_vectors is inserted first so that getHashesForEmbedding (which checks
+ * only content_vectors) won't re-select the hash on a crash between the two inserts.
+ *
+ * vectors_vec uses DELETE + INSERT instead of INSERT OR REPLACE because sqlite-vec's
+ * vec0 virtual tables silently ignore the OR REPLACE conflict clause.
+ */
+export function insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt) {
+    const hashSeq = `${hash}_${seq}`;
+    // Insert content_vectors first — crash-safe ordering (see getHashesForEmbedding)
+    const insertContentVectorStmt = db.prepare(`INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, ?, ?, ?, ?)`);
+    insertContentVectorStmt.run(hash, seq, pos, model, embeddedAt);
+    // vec0 virtual tables don't support OR REPLACE — use DELETE + INSERT
+    const deleteVecStmt = db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`);
+    const insertVecStmt = db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`);
+    deleteVecStmt.run(hashSeq);
+    insertVecStmt.run(hashSeq, embedding);
+}
+// =============================================================================
+// Query expansion
+// =============================================================================
+export async function expandQuery(query, model = DEFAULT_QUERY_MODEL, db, intent, llmOverride) {
+    // Check cache first — stored as JSON preserving types
+    const cacheKey = getCacheKey("expandQuery", { query, model, ...(intent && { intent }) });
+    const cached = getCachedResult(db, cacheKey);
+    if (cached) {
+        try {
+            const parsed = JSON.parse(cached);
+            // Migrate old cache format: { type, text } → { type, query }
+            if (parsed.length > 0 && parsed[0].query) {
+                return parsed;
+            }
+            else if (parsed.length > 0 && parsed[0].text) {
+                return parsed.map((r) => ({ type: r.type, query: r.text }));
+            }
+        }
+        catch {
+            // Old cache format (pre-typed, newline-separated text) — re-expand
+        }
+    }
+    const llm = llmOverride ?? getDefaultLlamaCpp();
+    // Note: LlamaCpp uses hardcoded model, model parameter is ignored
+    const results = await llm.expandQuery(query, { intent });
+    // Map Queryable[] → ExpandedQuery[] (same shape, decoupled from llm.ts internals).
+    // Filter out entries that duplicate the original query text.
+    const expanded = results
+        .filter(r => r.text !== query)
+        .map(r => ({ type: r.type, query: r.text }));
+    if (expanded.length > 0) {
+        setCachedResult(db, cacheKey, JSON.stringify(expanded));
+    }
+    return expanded;
+}
+// =============================================================================
+// Reranking
+// =============================================================================
+export async function rerank(query, documents, model = DEFAULT_RERANK_MODEL, db, intent, llmOverride) {
+    // Prepend intent to rerank query so the reranker scores with domain context
+    const rerankQuery = intent ? `${intent}\n\n${query}` : query;
+    const cachedResults = new Map();
+    const uncachedDocsByChunk = new Map();
+    // Check cache for each document
+    // Cache key includes chunk text — different queries can select different chunks
+    // from the same file, and the reranker score depends on which chunk was sent.
+    // File path is excluded from the new cache key because the reranker score
+    // depends on the chunk content, not where it came from.
+    for (const doc of documents) {
+        const cacheKey = getCacheKey("rerank", { query: rerankQuery, model, chunk: doc.text });
+        const legacyCacheKey = getCacheKey("rerank", { query, file: doc.file, model, chunk: doc.text });
+        const cached = getCachedResult(db, cacheKey) ?? getCachedResult(db, legacyCacheKey);
+        if (cached !== null) {
+            cachedResults.set(doc.text, parseFloat(cached));
+        }
+        else {
+            uncachedDocsByChunk.set(doc.text, { file: doc.file, text: doc.text });
+        }
+    }
+    // Rerank uncached documents using LlamaCpp
+    if (uncachedDocsByChunk.size > 0) {
+        const llm = llmOverride ?? getDefaultLlamaCpp();
+        const uncachedDocs = [...uncachedDocsByChunk.values()];
+        const rerankResult = await llm.rerank(rerankQuery, uncachedDocs, { model });
+        // Cache results by chunk text so identical chunks across files are scored once.
+        const textByFile = new Map(uncachedDocs.map(d => [d.file, d.text]));
+        for (const result of rerankResult.results) {
+            const chunk = textByFile.get(result.file) || "";
+            const cacheKey = getCacheKey("rerank", { query: rerankQuery, model, chunk });
+            setCachedResult(db, cacheKey, result.score.toString());
+            cachedResults.set(chunk, result.score);
+        }
+    }
+    // Return all results sorted by score
+    return documents
+        .map(doc => ({ file: doc.file, score: cachedResults.get(doc.text) || 0 }))
+        .sort((a, b) => b.score - a.score);
+}
+// =============================================================================
+// Reciprocal Rank Fusion
+// =============================================================================
+export function reciprocalRankFusion(resultLists, weights = [], k = 60) {
+    const scores = new Map();
+    for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
+        const list = resultLists[listIdx];
+        if (!list)
+            continue;
+        const weight = weights[listIdx] ?? 1.0;
+        for (let rank = 0; rank < list.length; rank++) {
+            const result = list[rank];
+            if (!result)
+                continue;
+            const rrfContribution = weight / (k + rank + 1);
+            const existing = scores.get(result.file);
+            if (existing) {
+                existing.rrfScore += rrfContribution;
+                existing.topRank = Math.min(existing.topRank, rank);
+            }
+            else {
+                scores.set(result.file, {
+                    result,
+                    rrfScore: rrfContribution,
+                    topRank: rank,
+                });
+            }
+        }
+    }
+    // Top-rank bonus
+    for (const entry of scores.values()) {
+        if (entry.topRank === 0) {
+            entry.rrfScore += 0.05;
+        }
+        else if (entry.topRank <= 2) {
+            entry.rrfScore += 0.02;
+        }
+    }
+    return Array.from(scores.values())
+        .sort((a, b) => b.rrfScore - a.rrfScore)
+        .map(e => ({ ...e.result, score: e.rrfScore }));
+}
+/**
+ * Build per-document RRF contribution traces for explain/debug output.
+ */
+export function buildRrfTrace(resultLists, weights = [], listMeta = [], k = 60) {
+    const traces = new Map();
+    for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
+        const list = resultLists[listIdx];
+        if (!list)
+            continue;
+        const weight = weights[listIdx] ?? 1.0;
+        const meta = listMeta[listIdx] ?? {
+            source: "fts",
+            queryType: "original",
+            query: "",
+        };
+        for (let rank0 = 0; rank0 < list.length; rank0++) {
+            const result = list[rank0];
+            if (!result)
+                continue;
+            const rank = rank0 + 1; // 1-indexed rank for explain output
+            const contribution = weight / (k + rank);
+            const existing = traces.get(result.file);
+            const detail = {
+                listIndex: listIdx,
+                source: meta.source,
+                queryType: meta.queryType,
+                query: meta.query,
+                rank,
+                weight,
+                backendScore: result.score,
+                rrfContribution: contribution,
+            };
+            if (existing) {
+                existing.baseScore += contribution;
+                existing.topRank = Math.min(existing.topRank, rank);
+                existing.contributions.push(detail);
+            }
+            else {
+                traces.set(result.file, {
+                    contributions: [detail],
+                    baseScore: contribution,
+                    topRank: rank,
+                    topRankBonus: 0,
+                    totalScore: 0,
+                });
+            }
+        }
+    }
+    for (const trace of traces.values()) {
+        let bonus = 0;
+        if (trace.topRank === 1)
+            bonus = 0.05;
+        else if (trace.topRank <= 3)
+            bonus = 0.02;
+        trace.topRankBonus = bonus;
+        trace.totalScore = trace.baseScore + bonus;
+    }
+    return traces;
+}
+/**
+ * Find a document by filename/path, docid (#hash), or with fuzzy matching.
+ * Returns document metadata without body by default.
+ *
+ * Supports:
+ * - Virtual paths: qmd://collection/path/to/file.md
+ * - Absolute paths: /path/to/file.md
+ * - Relative paths: path/to/file.md
+ * - Short docid: #abc123 (first 6 chars of hash)
+ */
+export function findDocument(db, filename, options = {}) {
+    let filepath = filename;
+    const colonMatch = filepath.match(/:(\d+)$/);
+    if (colonMatch) {
+        filepath = filepath.slice(0, -colonMatch[0].length);
+    }
+    // Check if this is a docid lookup (#abc123, abc123, "#abc123", "abc123", etc.)
+    if (isDocid(filepath)) {
+        const docidMatch = findDocumentByDocid(db, filepath);
+        if (docidMatch) {
+            filepath = docidMatch.filepath;
+        }
+        else {
+            return { error: "not_found", query: filename, similarFiles: [] };
+        }
+    }
+    if (filepath.startsWith('~/')) {
+        filepath = homedir() + filepath.slice(1);
+    }
+    const bodyCol = options.includeBody ? `, content.doc as body` : ``;
+    // Build computed columns
+    // Note: absoluteFilepath is computed from YAML collections after query
+    const selectCols = `
+    'qmd://' || d.collection || '/' || d.path as virtual_path,
+    d.collection || '/' || d.path as display_path,
+    d.title,
+    d.hash,
+    d.collection,
+    d.modified_at,
+    LENGTH(content.doc) as body_length
+    ${bodyCol}
+  `;
+    // Try to match by virtual path first
+    let doc = db.prepare(`
+    SELECT ${selectCols}
+    FROM documents d
+    JOIN content ON content.hash = d.hash
+    WHERE 'qmd://' || d.collection || '/' || d.path = ? AND d.active = 1
+  `).get(filepath);
+    // Try fuzzy match by virtual path
+    if (!doc) {
+        doc = db.prepare(`
+      SELECT ${selectCols}
+      FROM documents d
+      JOIN content ON content.hash = d.hash
+      WHERE 'qmd://' || d.collection || '/' || d.path LIKE ? AND d.active = 1
+      LIMIT 1
+    `).get(`%${filepath}`);
+    }
+    // Try to match by absolute path (requires looking up collection paths from DB)
+    if (!doc && !filepath.startsWith('qmd://')) {
+        const collections = getStoreCollections(db);
+        for (const coll of collections) {
+            let relativePath = null;
+            // If filepath is absolute and starts with collection path, extract relative part
+            if (filepath.startsWith(coll.path + '/')) {
+                relativePath = filepath.slice(coll.path.length + 1);
+            }
+            // Otherwise treat filepath as relative to collection
+            else if (!filepath.startsWith('/')) {
+                relativePath = filepath;
+            }
+            if (relativePath) {
+                doc = db.prepare(`
+          SELECT ${selectCols}
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.collection = ? AND d.path = ? AND d.active = 1
+        `).get(coll.name, relativePath);
+                if (doc)
+                    break;
+            }
+        }
+    }
+    if (!doc) {
+        const similar = findSimilarFiles(db, filepath, 5, 5);
+        return { error: "not_found", query: filename, similarFiles: similar };
+    }
+    // Get context using virtual path
+    const virtualPath = doc.virtual_path || `qmd://${doc.collection}/${doc.display_path}`;
+    const context = getContextForFile(db, virtualPath);
+    return {
+        filepath: virtualPath,
+        displayPath: doc.display_path,
+        title: doc.title,
+        context,
+        hash: doc.hash,
+        docid: getDocid(doc.hash),
+        collectionName: doc.collection,
+        modifiedAt: doc.modified_at,
+        bodyLength: doc.body_length,
+        ...(options.includeBody && doc.body !== undefined && { body: doc.body }),
+    };
+}
+/**
+ * Get the body content for a document
+ * Optionally slice by line range
+ */
+export function getDocumentBody(db, doc, fromLine, maxLines) {
+    const filepath = doc.filepath;
+    // Try to resolve document by filepath (absolute or virtual)
+    let row = null;
+    // Try virtual path first
+    if (filepath.startsWith('qmd://')) {
+        row = db.prepare(`
+      SELECT content.doc as body
+      FROM documents d
+      JOIN content ON content.hash = d.hash
+      WHERE 'qmd://' || d.collection || '/' || d.path = ? AND d.active = 1
+    `).get(filepath);
+    }
+    // Try absolute path by looking up in DB store_collections
+    if (!row) {
+        const collections = getStoreCollections(db);
+        for (const coll of collections) {
+            if (filepath.startsWith(coll.path + '/')) {
+                const relativePath = filepath.slice(coll.path.length + 1);
+                row = db.prepare(`
+          SELECT content.doc as body
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.collection = ? AND d.path = ? AND d.active = 1
+        `).get(coll.name, relativePath);
+                if (row)
+                    break;
+            }
+        }
+    }
+    if (!row)
+        return null;
+    let body = row.body;
+    if (fromLine !== undefined || maxLines !== undefined) {
+        const lines = body.split('\n');
+        const start = (fromLine || 1) - 1;
+        const end = maxLines !== undefined ? start + maxLines : lines.length;
+        body = lines.slice(start, end).join('\n');
+    }
+    return body;
+}
+/**
+ * Find multiple documents by glob pattern or comma-separated list
+ * Returns documents without body by default (use getDocumentBody to load)
+ */
+export function findDocuments(db, pattern, options = {}) {
+    const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?') && !pattern.includes('{');
+    const errors = [];
+    const maxBytes = options.maxBytes ?? DEFAULT_MULTI_GET_MAX_BYTES;
+    const bodyCol = options.includeBody ? `, content.doc as body` : ``;
+    const selectCols = `
+    'qmd://' || d.collection || '/' || d.path as virtual_path,
+    d.collection || '/' || d.path as display_path,
+    d.title,
+    d.hash,
+    d.collection,
+    d.modified_at,
+    LENGTH(content.doc) as body_length
+    ${bodyCol}
+  `;
+    let fileRows;
+    if (isCommaSeparated) {
+        const names = pattern.split(',').map(s => s.trim()).filter(Boolean);
+        fileRows = [];
+        for (const name of names) {
+            let doc = db.prepare(`
+        SELECT ${selectCols}
+        FROM documents d
+        JOIN content ON content.hash = d.hash
+        WHERE 'qmd://' || d.collection || '/' || d.path = ? AND d.active = 1
+      `).get(name);
+            if (!doc) {
+                doc = db.prepare(`
+          SELECT ${selectCols}
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE 'qmd://' || d.collection || '/' || d.path LIKE ? AND d.active = 1
+          LIMIT 1
+        `).get(`%${name}`);
+            }
+            if (doc) {
+                fileRows.push(doc);
+            }
+            else {
+                const similar = findSimilarFiles(db, name, 5, 3);
+                let msg = `File not found: ${name}`;
+                if (similar.length > 0) {
+                    msg += ` (did you mean: ${similar.join(', ')}?)`;
+                }
+                errors.push(msg);
+            }
+        }
+    }
+    else {
+        // Glob pattern match
+        const matched = matchFilesByGlob(db, pattern);
+        if (matched.length === 0) {
+            errors.push(`No files matched pattern: ${pattern}`);
+            return { docs: [], errors };
+        }
+        const virtualPaths = matched.map(m => m.filepath);
+        const placeholders = virtualPaths.map(() => '?').join(',');
+        fileRows = db.prepare(`
+      SELECT ${selectCols}
+      FROM documents d
+      JOIN content ON content.hash = d.hash
+      WHERE 'qmd://' || d.collection || '/' || d.path IN (${placeholders}) AND d.active = 1
+    `).all(...virtualPaths);
+    }
+    const results = [];
+    for (const row of fileRows) {
+        // Get context using virtual path
+        const virtualPath = row.virtual_path || `qmd://${row.collection}/${row.display_path}`;
+        const context = getContextForFile(db, virtualPath);
+        if (row.body_length > maxBytes) {
+            results.push({
+                doc: { filepath: virtualPath, displayPath: row.display_path },
+                skipped: true,
+                skipReason: `File too large (${Math.round(row.body_length / 1024)}KB > ${Math.round(maxBytes / 1024)}KB)`,
+            });
+            continue;
+        }
+        results.push({
+            doc: {
+                filepath: virtualPath,
+                displayPath: row.display_path,
+                title: row.title || row.display_path.split('/').pop() || row.display_path,
+                context,
+                hash: row.hash,
+                docid: getDocid(row.hash),
+                collectionName: row.collection,
+                modifiedAt: row.modified_at,
+                bodyLength: row.body_length,
+                ...(options.includeBody && row.body !== undefined && { body: row.body }),
+            },
+            skipped: false,
+        });
+    }
+    return { docs: results, errors };
+}
+// =============================================================================
+// Status
+// =============================================================================
+export function getStatus(db) {
+    // DB is source of truth for collections — config provides supplementary metadata
+    const dbCollections = db.prepare(`
+    SELECT
+      collection as name,
+      COUNT(*) as active_count,
+      MAX(modified_at) as last_doc_update
+    FROM documents
+    WHERE active = 1
+    GROUP BY collection
+  `).all();
+    // Build a lookup from store_collections for path/pattern metadata
+    const storeCollections = getStoreCollections(db);
+    const configLookup = new Map(storeCollections.map(c => [c.name, { path: c.path, pattern: c.pattern }]));
+    const collections = dbCollections.map(row => {
+        const config = configLookup.get(row.name);
+        return {
+            name: row.name,
+            path: config?.path ?? null,
+            pattern: config?.pattern ?? null,
+            documents: row.active_count,
+            lastUpdated: row.last_doc_update || new Date().toISOString(),
+        };
+    });
+    // Sort by last update time (most recent first)
+    collections.sort((a, b) => {
+        if (!a.lastUpdated)
+            return 1;
+        if (!b.lastUpdated)
+            return -1;
+        return new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
+    });
+    const totalDocs = db.prepare(`SELECT COUNT(*) as c FROM documents WHERE active = 1`).get().c;
+    const needsEmbedding = getHashesNeedingEmbedding(db);
+    const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
+    return {
+        totalDocuments: totalDocs,
+        needsEmbedding,
+        hasVectorIndex: hasVectors,
+        collections,
+    };
+}
+/** Weight for intent terms relative to query terms (1.0) in snippet scoring */
+export const INTENT_WEIGHT_SNIPPET = 0.3;
+/** Weight for intent terms relative to query terms (1.0) in chunk selection */
+export const INTENT_WEIGHT_CHUNK = 0.5;
+// Common stop words filtered from intent strings before tokenization.
+// Seeded from finetune/reward.py KEY_TERM_STOPWORDS, extended with common
+// 2-3 char function words so the length threshold can drop to >1 and let
+// short domain terms (API, SQL, LLM, CPU, CDN, …) survive.
+const INTENT_STOP_WORDS = new Set([
+    // 2-char function words
+    "am", "an", "as", "at", "be", "by", "do", "he", "if",
+    "in", "is", "it", "me", "my", "no", "of", "on", "or", "so",
+    "to", "up", "us", "we",
+    // 3-char function words
+    "all", "and", "any", "are", "but", "can", "did", "for", "get",
+    "has", "her", "him", "his", "how", "its", "let", "may", "not",
+    "our", "out", "the", "too", "was", "who", "why", "you",
+    // 4+ char common words
+    "also", "does", "find", "from", "have", "into", "more", "need",
+    "show", "some", "tell", "that", "them", "this", "want", "what",
+    "when", "will", "with", "your",
+    // Search-context noise
+    "about", "looking", "notes", "search", "where", "which",
+]);
+/**
+ * Extract meaningful terms from an intent string, filtering stop words and punctuation.
+ * Uses Unicode-aware punctuation stripping so domain terms like "API" survive.
+ * Returns lowercase terms suitable for text matching.
+ */
+export function extractIntentTerms(intent) {
+    return intent.toLowerCase().split(/\s+/)
+        .map(t => t.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
+        .filter(t => t.length > 1 && !INTENT_STOP_WORDS.has(t));
+}
+export function extractSnippet(body, query, maxLen = 500, chunkPos, chunkLen, intent) {
+    const totalLines = body.split('\n').length;
+    let searchBody = body;
+    let lineOffset = 0;
+    if (chunkPos && chunkPos > 0) {
+        // Search within the chunk region, with some padding for context
+        // Use provided chunkLen or fall back to max chunk size (covers variable-length chunks)
+        const searchLen = chunkLen || CHUNK_SIZE_CHARS;
+        const contextStart = Math.max(0, chunkPos - 100);
+        const contextEnd = Math.min(body.length, chunkPos + searchLen + 100);
+        searchBody = body.slice(contextStart, contextEnd);
+        if (contextStart > 0) {
+            lineOffset = body.slice(0, contextStart).split('\n').length - 1;
+        }
+    }
+    const lines = searchBody.split('\n');
+    const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
+    const intentTerms = intent ? extractIntentTerms(intent) : [];
+    let bestLine = 0, bestScore = -1;
+    for (let i = 0; i < lines.length; i++) {
+        const lineLower = (lines[i] ?? "").toLowerCase();
+        let score = 0;
+        for (const term of queryTerms) {
+            if (lineLower.includes(term))
+                score += 1.0;
+        }
+        for (const term of intentTerms) {
+            if (lineLower.includes(term))
+                score += INTENT_WEIGHT_SNIPPET;
+        }
+        if (score > bestScore) {
+            bestScore = score;
+            bestLine = i;
+        }
+    }
+    const start = Math.max(0, bestLine - 1);
+    const end = Math.min(lines.length, bestLine + 3);
+    const snippetLines = lines.slice(start, end);
+    let snippetText = snippetLines.join('\n');
+    // If we focused on a chunk window and it produced an empty/whitespace-only snippet,
+    // fall back to a full-document snippet so we always show something useful.
+    if (chunkPos && chunkPos > 0 && snippetText.trim().length === 0) {
+        return extractSnippet(body, query, maxLen, undefined, undefined, intent);
+    }
+    if (snippetText.length > maxLen)
+        snippetText = snippetText.substring(0, maxLen - 3) + "...";
+    const absoluteStart = lineOffset + start + 1; // 1-indexed
+    const snippetLineCount = snippetLines.length;
+    const linesBefore = absoluteStart - 1;
+    const linesAfter = totalLines - (absoluteStart + snippetLineCount - 1);
+    // Format with diff-style header: @@ -start,count @@ (linesBefore before, linesAfter after)
+    const header = `@@ -${absoluteStart},${snippetLineCount} @@ (${linesBefore} before, ${linesAfter} after)`;
+    const snippet = `${header}\n${snippetText}`;
+    return {
+        line: lineOffset + bestLine + 1,
+        snippet,
+        linesBefore,
+        linesAfter,
+        snippetLines: snippetLineCount,
+    };
+}
+// =============================================================================
+// Shared helpers (used by both CLI and MCP)
+// =============================================================================
+/**
+ * Add line numbers to text content.
+ * Each line becomes: "{lineNum}: {content}"
+ */
+export function addLineNumbers(text, startLine = 1) {
+    const lines = text.split('\n');
+    return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
+}
+/**
+ * Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
+ *
+ * Pipeline:
+ * 1. BM25 probe → skip expansion if strong signal
+ * 2. expandQuery() → typed query variants (lex/vec/hyde)
+ * 3. Type-routed search: original→vector, lex→FTS, vec/hyde→vector
+ * 4. RRF fusion → slice to candidateLimit
+ * 5. chunkDocument() + keyword-best-chunk selection
+ * 6. rerank on chunks (NOT full bodies — O(tokens) trap)
+ * 7. Position-aware score blending (RRF rank × reranker score)
+ * 8. Dedup by file, filter by minScore, slice to limit
+ */
+export async function hybridQuery(store, query, options) {
+    const limit = options?.limit ?? 10;
+    const minScore = options?.minScore ?? 0;
+    const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
+    const collection = options?.collection;
+    const explain = options?.explain ?? false;
+    const intent = options?.intent;
+    const skipRerank = options?.skipRerank ?? false;
+    const hooks = options?.hooks;
+    const rankedLists = [];
+    const rankedListMeta = [];
+    const docidMap = new Map(); // filepath -> docid
+    const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
+    // Step 1: BM25 probe — strong signal skips expensive LLM expansion
+    // When intent is provided, disable strong-signal bypass — the obvious BM25
+    // match may not be what the caller wants (e.g. "performance" with intent
+    // "web page load times" should NOT shortcut to a sports-performance doc).
+    // Pass collection directly into FTS query (filter at SQL level, not post-hoc)
+    const initialFts = store.searchFTS(query, 20, collection);
+    const topScore = initialFts[0]?.score ?? 0;
+    const secondScore = initialFts[1]?.score ?? 0;
+    const hasStrongSignal = !intent && initialFts.length > 0
+        && topScore >= STRONG_SIGNAL_MIN_SCORE
+        && (topScore - secondScore) >= STRONG_SIGNAL_MIN_GAP;
+    if (hasStrongSignal)
+        hooks?.onStrongSignal?.(topScore);
+    // Step 2: Expand query (or skip if strong signal)
+    hooks?.onExpandStart?.();
+    const expandStart = Date.now();
+    const expanded = hasStrongSignal
+        ? []
+        : await store.expandQuery(query, undefined, intent);
+    hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
+    // Seed with initial FTS results (avoid re-running original query FTS)
+    if (initialFts.length > 0) {
+        for (const r of initialFts)
+            docidMap.set(r.filepath, r.docid);
+        rankedLists.push(initialFts.map(r => ({
+            file: r.filepath, displayPath: r.displayPath,
+            title: r.title, body: r.body || "", score: r.score,
+        })));
+        rankedListMeta.push({ source: "fts", queryType: "original", query });
+    }
+    // Step 3: Route searches by query type
+    //
+    // Strategy: run all FTS queries immediately (they're sync/instant), then
+    // batch-embed all vector queries in one embedBatch() call, then run
+    // sqlite-vec lookups with pre-computed embeddings.
+    // 3a: Run FTS for all lex expansions right away (no LLM needed)
+    for (const q of expanded) {
+        if (q.type === 'lex') {
+            const ftsResults = store.searchFTS(q.query, 20, collection);
+            if (ftsResults.length > 0) {
+                for (const r of ftsResults)
+                    docidMap.set(r.filepath, r.docid);
+                rankedLists.push(ftsResults.map(r => ({
+                    file: r.filepath, displayPath: r.displayPath,
+                    title: r.title, body: r.body || "", score: r.score,
+                })));
+                rankedListMeta.push({ source: "fts", queryType: "lex", query: q.query });
+            }
+        }
+    }
+    // 3b: Collect all texts that need vector search (original query + vec/hyde expansions)
+    if (hasVectors) {
+        const vecQueries = [
+            { text: query, queryType: "original" },
+        ];
+        for (const q of expanded) {
+            if (q.type === 'vec' || q.type === 'hyde') {
+                vecQueries.push({ text: q.query, queryType: q.type });
+            }
+        }
+        // Batch embed all vector queries in a single call
+        const llm = getLlm(store);
+        const textsToEmbed = vecQueries.map(q => formatQueryForEmbedding(q.text, llm.embedModelName));
+        hooks?.onEmbedStart?.(textsToEmbed.length);
+        const embedStart = Date.now();
+        const embeddings = await llm.embedBatch(textsToEmbed);
+        hooks?.onEmbedDone?.(Date.now() - embedStart);
+        // Run sqlite-vec lookups with pre-computed embeddings
+        for (let i = 0; i < vecQueries.length; i++) {
+            const embedding = embeddings[i]?.embedding;
+            if (!embedding)
+                continue;
+            const vecResults = await store.searchVec(vecQueries[i].text, DEFAULT_EMBED_MODEL, 20, collection, undefined, embedding);
+            if (vecResults.length > 0) {
+                for (const r of vecResults)
+                    docidMap.set(r.filepath, r.docid);
+                rankedLists.push(vecResults.map(r => ({
+                    file: r.filepath, displayPath: r.displayPath,
+                    title: r.title, body: r.body || "", score: r.score,
+                })));
+                rankedListMeta.push({
+                    source: "vec",
+                    queryType: vecQueries[i].queryType,
+                    query: vecQueries[i].text,
+                });
+            }
+        }
+    }
+    // Step 4: RRF fusion — first 2 lists (original FTS + first vec) get 2x weight
+    const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
+    const fused = reciprocalRankFusion(rankedLists, weights);
+    const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
+    const candidates = fused.slice(0, candidateLimit);
+    if (candidates.length === 0)
+        return [];
+    // Step 5: Chunk documents, pick best chunk per doc for reranking.
+    // Reranking full bodies is O(tokens) — the critical perf lesson that motivated this refactor.
+    const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
+    const intentTerms = intent ? extractIntentTerms(intent) : [];
+    const docChunkMap = new Map();
+    const chunkStrategy = options?.chunkStrategy;
+    for (const cand of candidates) {
+        const chunks = await chunkDocumentAsync(cand.body, undefined, undefined, undefined, cand.file, chunkStrategy);
+        if (chunks.length === 0)
+            continue;
+        // Pick chunk with most keyword overlap (fallback: first chunk)
+        // Intent terms contribute at INTENT_WEIGHT_CHUNK (0.5) relative to query terms (1.0)
+        let bestIdx = 0;
+        let bestScore = -1;
+        for (let i = 0; i < chunks.length; i++) {
+            const chunkLower = chunks[i].text.toLowerCase();
+            let score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
+            for (const term of intentTerms) {
+                if (chunkLower.includes(term))
+                    score += INTENT_WEIGHT_CHUNK;
+            }
+            if (score > bestScore) {
+                bestScore = score;
+                bestIdx = i;
+            }
+        }
+        docChunkMap.set(cand.file, { chunks, bestIdx });
+    }
+    if (skipRerank) {
+        // Skip LLM reranking — return candidates scored by RRF only
+        const seenFiles = new Set();
+        return candidates
+            .map((cand, i) => {
+            const chunkInfo = docChunkMap.get(cand.file);
+            const bestIdx = chunkInfo?.bestIdx ?? 0;
+            const bestChunk = chunkInfo?.chunks[bestIdx]?.text || cand.body || "";
+            const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
+            const rrfRank = i + 1;
+            const rrfScore = 1 / rrfRank;
+            const trace = rrfTraceByFile?.get(cand.file);
+            const explainData = explain ? {
+                ftsScores: trace?.contributions.filter(c => c.source === "fts").map(c => c.backendScore) ?? [],
+                vectorScores: trace?.contributions.filter(c => c.source === "vec").map(c => c.backendScore) ?? [],
+                rrf: {
+                    rank: rrfRank,
+                    positionScore: rrfScore,
+                    weight: 1.0,
+                    baseScore: trace?.baseScore ?? 0,
+                    topRankBonus: trace?.topRankBonus ?? 0,
+                    totalScore: trace?.totalScore ?? 0,
+                    contributions: trace?.contributions ?? [],
+                },
+                rerankScore: 0,
+                blendedScore: rrfScore,
+            } : undefined;
+            return {
+                file: cand.file,
+                displayPath: cand.displayPath,
+                title: cand.title,
+                body: cand.body,
+                bestChunk,
+                bestChunkPos,
+                score: rrfScore,
+                context: store.getContextForFile(cand.file),
+                docid: docidMap.get(cand.file) || "",
+                ...(explainData ? { explain: explainData } : {}),
+            };
+        })
+            .filter(r => {
+            if (seenFiles.has(r.file))
+                return false;
+            seenFiles.add(r.file);
+            return true;
+        })
+            .filter(r => r.score >= minScore)
+            .slice(0, limit);
+    }
+    // Step 6: Rerank chunks (NOT full bodies)
+    const chunksToRerank = [];
+    for (const cand of candidates) {
+        const chunkInfo = docChunkMap.get(cand.file);
+        if (chunkInfo) {
+            chunksToRerank.push({ file: cand.file, text: chunkInfo.chunks[chunkInfo.bestIdx].text });
+        }
+    }
+    hooks?.onRerankStart?.(chunksToRerank.length);
+    const rerankStart = Date.now();
+    const reranked = await store.rerank(query, chunksToRerank, undefined, intent);
+    hooks?.onRerankDone?.(Date.now() - rerankStart);
+    // Step 7: Blend RRF position score with reranker score
+    // Position-aware weights: top retrieval results get more protection from reranker disagreement
+    const candidateMap = new Map(candidates.map(c => [c.file, {
+            displayPath: c.displayPath, title: c.title, body: c.body,
+        }]));
+    const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
+    const blended = reranked.map(r => {
+        const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
+        let rrfWeight;
+        if (rrfRank <= 3)
+            rrfWeight = 0.75;
+        else if (rrfRank <= 10)
+            rrfWeight = 0.60;
+        else
+            rrfWeight = 0.40;
+        const rrfScore = 1 / rrfRank;
+        const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
+        const candidate = candidateMap.get(r.file);
+        const chunkInfo = docChunkMap.get(r.file);
+        const bestIdx = chunkInfo?.bestIdx ?? 0;
+        const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
+        const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
+        const trace = rrfTraceByFile?.get(r.file);
+        const explainData = explain ? {
+            ftsScores: trace?.contributions.filter(c => c.source === "fts").map(c => c.backendScore) ?? [],
+            vectorScores: trace?.contributions.filter(c => c.source === "vec").map(c => c.backendScore) ?? [],
+            rrf: {
+                rank: rrfRank,
+                positionScore: rrfScore,
+                weight: rrfWeight,
+                baseScore: trace?.baseScore ?? 0,
+                topRankBonus: trace?.topRankBonus ?? 0,
+                totalScore: trace?.totalScore ?? 0,
+                contributions: trace?.contributions ?? [],
+            },
+            rerankScore: r.score,
+            blendedScore,
+        } : undefined;
+        return {
+            file: r.file,
+            displayPath: candidate?.displayPath || "",
+            title: candidate?.title || "",
+            body: candidate?.body || "",
+            bestChunk,
+            bestChunkPos,
+            score: blendedScore,
+            context: store.getContextForFile(r.file),
+            docid: docidMap.get(r.file) || "",
+            ...(explainData ? { explain: explainData } : {}),
+        };
+    }).sort((a, b) => b.score - a.score);
+    // Step 8: Dedup by file (safety net — prevents duplicate output)
+    const seenFiles = new Set();
+    return blended
+        .filter(r => {
+        if (seenFiles.has(r.file))
+            return false;
+        seenFiles.add(r.file);
+        return true;
+    })
+        .filter(r => r.score >= minScore)
+        .slice(0, limit);
+}
+/**
+ * Vector-only semantic search with query expansion.
+ *
+ * Pipeline:
+ * 1. expandQuery() → typed variants, filter to vec/hyde only (lex irrelevant here)
+ * 2. searchVec() for original + vec/hyde variants (sequential — node-llama-cpp embed limitation)
+ * 3. Dedup by filepath (keep max score)
+ * 4. Sort by score descending, filter by minScore, slice to limit
+ */
+export async function vectorSearchQuery(store, query, options) {
+    const limit = options?.limit ?? 10;
+    const minScore = options?.minScore ?? 0.3;
+    const collection = options?.collection;
+    const intent = options?.intent;
+    const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
+    if (!hasVectors)
+        return [];
+    // Expand query — filter to vec/hyde only (lex queries target FTS, not vector)
+    const expandStart = Date.now();
+    const allExpanded = await store.expandQuery(query, undefined, intent);
+    const vecExpanded = allExpanded.filter(q => q.type !== 'lex');
+    options?.hooks?.onExpand?.(query, vecExpanded, Date.now() - expandStart);
+    // Run original + vec/hyde expanded through vector, sequentially — concurrent embed() hangs
+    const queryTexts = [query, ...vecExpanded.map(q => q.query)];
+    const allResults = new Map();
+    for (const q of queryTexts) {
+        const vecResults = await store.searchVec(q, DEFAULT_EMBED_MODEL, limit, collection);
+        for (const r of vecResults) {
+            const existing = allResults.get(r.filepath);
+            if (!existing || r.score > existing.score) {
+                allResults.set(r.filepath, {
+                    file: r.filepath,
+                    displayPath: r.displayPath,
+                    title: r.title,
+                    body: r.body || "",
+                    score: r.score,
+                    context: store.getContextForFile(r.filepath),
+                    docid: r.docid,
+                });
+            }
+        }
+    }
+    return Array.from(allResults.values())
+        .sort((a, b) => b.score - a.score)
+        .filter(r => r.score >= minScore)
+        .slice(0, limit);
+}
+/**
+ * Structured search: execute pre-expanded queries without LLM query expansion.
+ *
+ * Designed for LLM callers (MCP/HTTP) that generate their own query expansions.
+ * Skips the internal expandQuery() step — goes directly to:
+ *
+ * Pipeline:
+ * 1. Route searches: lex→FTS, vec/hyde→vector (batch embed)
+ * 2. RRF fusion across all result lists
+ * 3. Chunk documents + keyword-best-chunk selection
+ * 4. Rerank on chunks
+ * 5. Position-aware score blending
+ * 6. Dedup, filter, slice
+ *
+ * This is the recommended endpoint for capable LLMs — they can generate
+ * better query variations than our small local model, especially for
+ * domain-specific or nuanced queries.
+ */
+export async function structuredSearch(store, searches, options) {
+    const limit = options?.limit ?? 10;
+    const minScore = options?.minScore ?? 0;
+    const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
+    const explain = options?.explain ?? false;
+    const intent = options?.intent;
+    const skipRerank = options?.skipRerank ?? false;
+    const hooks = options?.hooks;
+    const collections = options?.collections;
+    if (searches.length === 0)
+        return [];
+    // Validate queries before executing
+    for (const search of searches) {
+        const location = search.line ? `Line ${search.line}` : 'Structured search';
+        if (/[\r\n]/.test(search.query)) {
+            throw new Error(`${location} (${search.type}): queries must be single-line. Remove newline characters.`);
+        }
+        if (search.type === 'lex') {
+            const error = validateLexQuery(search.query);
+            if (error) {
+                throw new Error(`${location} (lex): ${error}`);
+            }
+        }
+        else if (search.type === 'vec' || search.type === 'hyde') {
+            const error = validateSemanticQuery(search.query);
+            if (error) {
+                throw new Error(`${location} (${search.type}): ${error}`);
+            }
+        }
+    }
+    const rankedLists = [];
+    const rankedListMeta = [];
+    const docidMap = new Map(); // filepath -> docid
+    const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
+    // Helper to run search across collections (or all if undefined)
+    const collectionList = collections ?? [undefined]; // undefined = all collections
+    // Step 1: Run FTS for all lex searches (sync, instant)
+    for (const search of searches) {
+        if (search.type === 'lex') {
+            for (const coll of collectionList) {
+                const ftsResults = store.searchFTS(search.query, 20, coll);
+                if (ftsResults.length > 0) {
+                    for (const r of ftsResults)
+                        docidMap.set(r.filepath, r.docid);
+                    rankedLists.push(ftsResults.map(r => ({
+                        file: r.filepath, displayPath: r.displayPath,
+                        title: r.title, body: r.body || "", score: r.score,
+                    })));
+                    rankedListMeta.push({
+                        source: "fts",
+                        queryType: "lex",
+                        query: search.query,
+                    });
+                }
+            }
+        }
+    }
+    // Step 2: Batch embed and run vector searches for vec/hyde
+    if (hasVectors) {
+        const vecSearches = searches.filter((s) => s.type === 'vec' || s.type === 'hyde');
+        if (vecSearches.length > 0) {
+            const llm = getLlm(store);
+            const textsToEmbed = vecSearches.map(s => formatQueryForEmbedding(s.query, llm.embedModelName));
+            hooks?.onEmbedStart?.(textsToEmbed.length);
+            const embedStart = Date.now();
+            const embeddings = await llm.embedBatch(textsToEmbed);
+            hooks?.onEmbedDone?.(Date.now() - embedStart);
+            for (let i = 0; i < vecSearches.length; i++) {
+                const embedding = embeddings[i]?.embedding;
+                if (!embedding)
+                    continue;
+                for (const coll of collectionList) {
+                    const vecResults = await store.searchVec(vecSearches[i].query, DEFAULT_EMBED_MODEL, 20, coll, undefined, embedding);
+                    if (vecResults.length > 0) {
+                        for (const r of vecResults)
+                            docidMap.set(r.filepath, r.docid);
+                        rankedLists.push(vecResults.map(r => ({
+                            file: r.filepath, displayPath: r.displayPath,
+                            title: r.title, body: r.body || "", score: r.score,
+                        })));
+                        rankedListMeta.push({
+                            source: "vec",
+                            queryType: vecSearches[i].type,
+                            query: vecSearches[i].query,
+                        });
+                    }
+                }
+            }
+        }
+    }
+    if (rankedLists.length === 0)
+        return [];
+    // Step 3: RRF fusion — first list gets 2x weight (assume caller ordered by importance)
+    const weights = rankedLists.map((_, i) => i === 0 ? 2.0 : 1.0);
+    const fused = reciprocalRankFusion(rankedLists, weights);
+    const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
+    const candidates = fused.slice(0, candidateLimit);
+    if (candidates.length === 0)
+        return [];
+    hooks?.onExpand?.("", [], 0); // Signal no expansion (pre-expanded)
+    // Step 4: Chunk documents, pick best chunk per doc for reranking
+    // Use first lex query as the "query" for keyword matching, or first vec if no lex
+    const primaryQuery = searches.find(s => s.type === 'lex')?.query
+        || searches.find(s => s.type === 'vec')?.query
+        || searches[0]?.query || "";
+    const queryTerms = primaryQuery.toLowerCase().split(/\s+/).filter(t => t.length > 2);
+    const intentTerms = intent ? extractIntentTerms(intent) : [];
+    const docChunkMap = new Map();
+    const ssChunkStrategy = options?.chunkStrategy;
+    for (const cand of candidates) {
+        const chunks = await chunkDocumentAsync(cand.body, undefined, undefined, undefined, cand.file, ssChunkStrategy);
+        if (chunks.length === 0)
+            continue;
+        // Pick chunk with most keyword overlap
+        // Intent terms contribute at INTENT_WEIGHT_CHUNK (0.5) relative to query terms (1.0)
+        let bestIdx = 0;
+        let bestScore = -1;
+        for (let i = 0; i < chunks.length; i++) {
+            const chunkLower = chunks[i].text.toLowerCase();
+            let score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
+            for (const term of intentTerms) {
+                if (chunkLower.includes(term))
+                    score += INTENT_WEIGHT_CHUNK;
+            }
+            if (score > bestScore) {
+                bestScore = score;
+                bestIdx = i;
+            }
+        }
+        docChunkMap.set(cand.file, { chunks, bestIdx });
+    }
+    if (skipRerank) {
+        // Skip LLM reranking — return candidates scored by RRF only
+        const seenFiles = new Set();
+        return candidates
+            .map((cand, i) => {
+            const chunkInfo = docChunkMap.get(cand.file);
+            const bestIdx = chunkInfo?.bestIdx ?? 0;
+            const bestChunk = chunkInfo?.chunks[bestIdx]?.text || cand.body || "";
+            const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
+            const rrfRank = i + 1;
+            const rrfScore = 1 / rrfRank;
+            const trace = rrfTraceByFile?.get(cand.file);
+            const explainData = explain ? {
+                ftsScores: trace?.contributions.filter(c => c.source === "fts").map(c => c.backendScore) ?? [],
+                vectorScores: trace?.contributions.filter(c => c.source === "vec").map(c => c.backendScore) ?? [],
+                rrf: {
+                    rank: rrfRank,
+                    positionScore: rrfScore,
+                    weight: 1.0,
+                    baseScore: trace?.baseScore ?? 0,
+                    topRankBonus: trace?.topRankBonus ?? 0,
+                    totalScore: trace?.totalScore ?? 0,
+                    contributions: trace?.contributions ?? [],
+                },
+                rerankScore: 0,
+                blendedScore: rrfScore,
+            } : undefined;
+            return {
+                file: cand.file,
+                displayPath: cand.displayPath,
+                title: cand.title,
+                body: cand.body,
+                bestChunk,
+                bestChunkPos,
+                score: rrfScore,
+                context: store.getContextForFile(cand.file),
+                docid: docidMap.get(cand.file) || "",
+                ...(explainData ? { explain: explainData } : {}),
+            };
+        })
+            .filter(r => {
+            if (seenFiles.has(r.file))
+                return false;
+            seenFiles.add(r.file);
+            return true;
+        })
+            .filter(r => r.score >= minScore)
+            .slice(0, limit);
+    }
+    // Step 5: Rerank chunks
+    const chunksToRerank = [];
+    for (const cand of candidates) {
+        const chunkInfo = docChunkMap.get(cand.file);
+        if (chunkInfo) {
+            chunksToRerank.push({ file: cand.file, text: chunkInfo.chunks[chunkInfo.bestIdx].text });
+        }
+    }
+    hooks?.onRerankStart?.(chunksToRerank.length);
+    const rerankStart2 = Date.now();
+    const reranked = await store.rerank(primaryQuery, chunksToRerank, undefined, intent);
+    hooks?.onRerankDone?.(Date.now() - rerankStart2);
+    // Step 6: Blend RRF position score with reranker score
+    const candidateMap = new Map(candidates.map(c => [c.file, {
+            displayPath: c.displayPath, title: c.title, body: c.body,
+        }]));
+    const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
+    const blended = reranked.map(r => {
+        const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
+        let rrfWeight;
+        if (rrfRank <= 3)
+            rrfWeight = 0.75;
+        else if (rrfRank <= 10)
+            rrfWeight = 0.60;
+        else
+            rrfWeight = 0.40;
+        const rrfScore = 1 / rrfRank;
+        const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
+        const candidate = candidateMap.get(r.file);
+        const chunkInfo = docChunkMap.get(r.file);
+        const bestIdx = chunkInfo?.bestIdx ?? 0;
+        const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
+        const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
+        const trace = rrfTraceByFile?.get(r.file);
+        const explainData = explain ? {
+            ftsScores: trace?.contributions.filter(c => c.source === "fts").map(c => c.backendScore) ?? [],
+            vectorScores: trace?.contributions.filter(c => c.source === "vec").map(c => c.backendScore) ?? [],
+            rrf: {
+                rank: rrfRank,
+                positionScore: rrfScore,
+                weight: rrfWeight,
+                baseScore: trace?.baseScore ?? 0,
+                topRankBonus: trace?.topRankBonus ?? 0,
+                totalScore: trace?.totalScore ?? 0,
+                contributions: trace?.contributions ?? [],
+            },
+            rerankScore: r.score,
+            blendedScore,
+        } : undefined;
+        return {
+            file: r.file,
+            displayPath: candidate?.displayPath || "",
+            title: candidate?.title || "",
+            body: candidate?.body || "",
+            bestChunk,
+            bestChunkPos,
+            score: blendedScore,
+            context: store.getContextForFile(r.file),
+            docid: docidMap.get(r.file) || "",
+            ...(explainData ? { explain: explainData } : {}),
+        };
+    }).sort((a, b) => b.score - a.score);
+    // Step 7: Dedup by file
+    const seenFiles = new Set();
+    return blended
+        .filter(r => {
+        if (seenFiles.has(r.file))
+            return false;
+        seenFiles.add(r.file);
+        return true;
+    })
+        .filter(r => r.score >= minScore)
+        .slice(0, limit);
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.