@tobilu/qmd 2.0.1 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,10 @@
6
6
  *
7
7
  * Follows MCP spec 2025-06-18 for proper response types.
8
8
  */
9
- export declare function startMcpServer(): Promise<void>;
9
+ export type McpStartupOptions = {
10
+ dbPath?: string;
11
+ };
12
+ export declare function startMcpServer(options?: McpStartupOptions): Promise<void>;
10
13
  export type HttpServerHandle = {
11
14
  httpServer: import("http").Server;
12
15
  port: number;
@@ -16,6 +19,6 @@ export type HttpServerHandle = {
16
19
  * Start MCP server over Streamable HTTP (JSON responses, no SSE).
17
20
  * Binds to localhost only. Returns a handle for shutdown and port discovery.
18
21
  */
19
- export declare function startMcpHttpServer(port: number, options?: {
22
+ export declare function startMcpHttpServer(port: number, options?: ({
20
23
  quiet?: boolean;
21
- }): Promise<HttpServerHandle>;
24
+ } & McpStartupOptions)): Promise<HttpServerHandle>;
@@ -8,13 +8,18 @@
8
8
  */
9
9
  import { createServer } from "node:http";
10
10
  import { randomUUID } from "node:crypto";
11
+ import { readFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
11
13
  import { fileURLToPath } from "url";
12
14
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
13
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
16
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
15
17
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
16
18
  import { z } from "zod";
19
+ import { existsSync } from "fs";
17
20
  import { createStore, extractSnippet, addLineNumbers, getDefaultDbPath, DEFAULT_MULTI_GET_MAX_BYTES, } from "../index.js";
21
+ import { getConfigPath } from "../collections.js";
22
+ import { enableProductionMode } from "../store.js";
18
23
  // =============================================================================
19
24
  // Helper functions
20
25
  // =============================================================================
@@ -39,6 +44,16 @@ function formatSearchSummary(results, query) {
39
44
  }
40
45
  return lines.join('\n');
41
46
  }
47
+ function getPackageVersion() {
48
+ try {
49
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../package.json");
50
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
51
+ return pkg.version ?? "unknown";
52
+ }
53
+ catch {
54
+ return "unknown";
55
+ }
56
+ }
42
57
  // =============================================================================
43
58
  // MCP Server
44
59
  // =============================================================================
@@ -49,7 +64,6 @@ function formatSearchSummary(results, query) {
49
64
  */
50
65
  async function buildInstructions(store) {
51
66
  const status = await store.getStatus();
52
- const contexts = await store.listContexts();
53
67
  const globalCtx = await store.getGlobalContext();
54
68
  const lines = [];
55
69
  // --- What is this? ---
@@ -57,15 +71,13 @@ async function buildInstructions(store) {
57
71
  if (globalCtx)
58
72
  lines.push(`Context: ${globalCtx}`);
59
73
  // --- What's searchable? ---
74
+ // Emit names only — the per-collection doc counts and descriptions can run to ~1.5 KB
75
+ // across a dozen collections, and the same info is available on demand via the `status` tool.
60
76
  if (status.collections.length > 0) {
61
77
  lines.push("");
62
- lines.push("Collections (scope with `collection` parameter):");
63
- for (const col of status.collections) {
64
- // Find root context for this collection
65
- const rootCtx = contexts.find(c => c.collection === col.name && (c.path === "" || c.path === "/"));
66
- const desc = rootCtx ? ` — ${rootCtx.context}` : "";
67
- lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
68
- }
78
+ const names = status.collections.map(c => c.name).join(", ");
79
+ lines.push(`Collections (scope with \`collection\` parameter): ${names}`);
80
+ lines.push("Call the `status` tool for collection descriptions, paths, and per-collection doc counts.");
69
81
  }
70
82
  // --- Capability gaps ---
71
83
  if (!status.hasVectorIndex) {
@@ -108,7 +120,7 @@ async function buildInstructions(store) {
108
120
  * Shared by both stdio and HTTP transports.
109
121
  */
110
122
  async function createMcpServer(store) {
111
- const server = new McpServer({ name: "qmd", version: "0.9.9" }, { instructions: await buildInstructions(store) });
123
+ const server = new McpServer({ name: "qmd", version: getPackageVersion() }, { instructions: await buildInstructions(store) });
112
124
  // Pre-fetch default collection names for search tools
113
125
  const defaultCollectionNames = await store.getDefaultCollectionNames();
114
126
  // ---------------------------------------------------------------------------
@@ -155,6 +167,8 @@ async function createMcpServer(store) {
155
167
  title: "Query",
156
168
  description: `Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.
157
169
 
170
+ Each result includes a \`line\` field with the absolute 1-indexed line of the best match in the source markdown. To read more context around a hit, call \`get(file, fromLine = max(1, line - 20), maxLines = 80, lineNumbers = true)\`.
171
+
158
172
  ## Query Types
159
173
 
160
174
  **lex** — BM25 keyword search. Fast, exact, no LLM needed.
@@ -218,8 +232,9 @@ Intent-aware lex (C++ performance, not sports):
218
232
  candidateLimit: z.number().optional().describe("Maximum candidates to rerank (default: 40, lower = faster but may miss results)"),
219
233
  collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"),
220
234
  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."),
235
+ rerank: z.boolean().optional().default(true).describe("Rerank results using LLM (default: true). Set to false for faster results on CPU-only machines."),
221
236
  },
222
- }, async ({ searches, limit, minScore, candidateLimit, collections, intent }) => {
237
+ }, async ({ searches, limit, minScore, candidateLimit, collections, intent, rerank }) => {
223
238
  // Map to internal format
224
239
  const queries = searches.map(s => ({
225
240
  type: s.type,
@@ -232,6 +247,8 @@ Intent-aware lex (C++ performance, not sports):
232
247
  collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
233
248
  limit,
234
249
  minScore,
250
+ candidateLimit,
251
+ rerank,
235
252
  intent,
236
253
  });
237
254
  // Use first lex or vec query for snippet extraction
@@ -239,13 +256,14 @@ Intent-aware lex (C++ performance, not sports):
239
256
  || searches.find(s => s.type === 'vec')?.query
240
257
  || searches[0]?.query || "";
241
258
  const filtered = results.map(r => {
242
- const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300, undefined, undefined, intent);
259
+ const { line, snippet } = extractSnippet(r.body, primaryQuery, 300, r.bestChunkPos, r.bestChunk.length, intent);
243
260
  return {
244
261
  docid: `#${r.docid}`,
245
262
  file: r.displayPath,
246
263
  title: r.title,
247
264
  score: Math.round(r.score * 100) / 100,
248
265
  context: r.context,
266
+ line,
249
267
  snippet: addLineNumbers(snippet, line),
250
268
  };
251
269
  });
@@ -276,6 +294,8 @@ Intent-aware lex (C++ performance, not sports):
276
294
  parsedFromLine = parseInt(colonMatch[1], 10);
277
295
  lookup = lookup.slice(0, -colonMatch[0].length);
278
296
  }
297
+ if (parsedFromLine !== undefined)
298
+ parsedFromLine = Math.max(1, parsedFromLine);
279
299
  const result = await store.get(lookup, { includeBody: false });
280
300
  if ("error" in result) {
281
301
  let msg = `Document not found: ${file}`;
@@ -387,7 +407,7 @@ Intent-aware lex (C++ performance, not sports):
387
407
  ` Collections: ${status.collections.length}`,
388
408
  ];
389
409
  for (const col of status.collections) {
390
- summary.push(` - ${col.path} (${col.documents} docs)`);
410
+ summary.push(` - ${col.name}: ${col.path} (${col.documents} docs)`);
391
411
  }
392
412
  return {
393
413
  content: [{ type: "text", text: summary.join('\n') }],
@@ -396,11 +416,18 @@ Intent-aware lex (C++ performance, not sports):
396
416
  });
397
417
  return server;
398
418
  }
399
- // =============================================================================
400
- // Transport: stdio (default)
401
- // =============================================================================
402
- export async function startMcpServer() {
403
- const store = await createStore({ dbPath: getDefaultDbPath() });
419
+ export async function startMcpServer(options = {}) {
420
+ // Opt into production mode when the MCP server is actually started, not
421
+ // when this module is merely imported for its exports. Importing the module
422
+ // at the top level flipped the global production flag and broke test
423
+ // isolation for downstream suites that expect the default (development)
424
+ // database path behaviour.
425
+ enableProductionMode();
426
+ const configPath = getConfigPath();
427
+ const store = await createStore({
428
+ dbPath: options.dbPath ?? getDefaultDbPath(),
429
+ ...(existsSync(configPath) ? { configPath } : {}),
430
+ });
404
431
  const server = await createMcpServer(store);
405
432
  const transport = new StdioServerTransport();
406
433
  await server.connect(transport);
@@ -409,8 +436,16 @@ export async function startMcpServer() {
409
436
  * Start MCP server over Streamable HTTP (JSON responses, no SSE).
410
437
  * Binds to localhost only. Returns a handle for shutdown and port discovery.
411
438
  */
412
- export async function startMcpHttpServer(port, options) {
413
- const store = await createStore({ dbPath: getDefaultDbPath() });
439
+ export async function startMcpHttpServer(port, options = {}) {
440
+ // See startMcpServer() for the rationale flip production mode here so the
441
+ // HTTP transport resolves the real database path, without leaking state into
442
+ // callers that only import this module for its exports (e.g. tests).
443
+ enableProductionMode();
444
+ const configPath = getConfigPath();
445
+ const store = await createStore({
446
+ dbPath: options.dbPath ?? getDefaultDbPath(),
447
+ ...(existsSync(configPath) ? { configPath } : {}),
448
+ });
414
449
  // Pre-fetch default collection names for REST endpoint
415
450
  const defaultCollectionNames = await store.getDefaultCollectionNames();
416
451
  // Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
@@ -442,7 +477,7 @@ export async function startMcpHttpServer(port, options) {
442
477
  }
443
478
  /** Extract a human-readable label from a JSON-RPC body */
444
479
  function describeRequest(body) {
445
- const method = body?.method ?? "unknown";
480
+ const method = typeof body.method === "string" ? body.method : "unknown";
446
481
  if (method === "tools/call") {
447
482
  const tool = body.params?.name ?? "?";
448
483
  const args = body.params?.arguments;
@@ -493,31 +528,35 @@ export async function startMcpHttpServer(port, options) {
493
528
  return;
494
529
  }
495
530
  // Map to internal format
496
- const queries = params.searches.map((s) => ({
531
+ const searches = params.searches;
532
+ const queries = searches.map((s) => ({
497
533
  type: s.type,
498
534
  query: String(s.query || ""),
499
535
  }));
500
536
  // Use default collections if none specified
501
- const effectiveCollections = params.collections ?? defaultCollectionNames;
537
+ const effectiveCollections = Array.isArray(params.collections) ? params.collections.map(String) : defaultCollectionNames;
502
538
  const results = await store.search({
503
539
  queries,
504
540
  collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
505
- limit: params.limit ?? 10,
506
- minScore: params.minScore ?? 0,
507
- intent: params.intent,
541
+ limit: typeof params.limit === "number" ? params.limit : 10,
542
+ minScore: typeof params.minScore === "number" ? params.minScore : 0,
543
+ candidateLimit: typeof params.candidateLimit === "number" ? params.candidateLimit : undefined,
544
+ intent: typeof params.intent === "string" ? params.intent : undefined,
545
+ rerank: typeof params.rerank === "boolean" ? params.rerank : undefined,
508
546
  });
509
547
  // Use first lex or vec query for snippet extraction
510
- const primaryQuery = params.searches.find((s) => s.type === 'lex')?.query
511
- || params.searches.find((s) => s.type === 'vec')?.query
512
- || params.searches[0]?.query || "";
548
+ const primaryQuery = searches.find((s) => s.type === 'lex')?.query
549
+ || searches.find((s) => s.type === 'vec')?.query
550
+ || searches[0]?.query || "";
513
551
  const formatted = results.map(r => {
514
- const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300);
552
+ const { line, snippet } = extractSnippet(r.body, String(primaryQuery), 300, r.bestChunkPos, r.bestChunk.length, typeof params.intent === "string" ? params.intent : undefined);
515
553
  return {
516
554
  docid: `#${r.docid}`,
517
555
  file: r.displayPath,
518
556
  title: r.title,
519
557
  score: Math.round(r.score * 100) / 100,
520
558
  context: r.context,
559
+ line,
521
560
  snippet: addLineNumbers(snippet, line),
522
561
  };
523
562
  });
@@ -0,0 +1 @@
1
+ export declare function qmdHomedir(): string;
package/dist/paths.js ADDED
@@ -0,0 +1,4 @@
1
+ import { homedir as osHomedir } from "node:os";
2
+ export function qmdHomedir() {
3
+ return process.env.HOME || process.env.USERPROFILE || osHomedir() || "/tmp";
4
+ }
package/dist/store.d.ts CHANGED
@@ -13,17 +13,20 @@
13
13
  import type { Database } from "./db.js";
14
14
  import { LlamaCpp, formatQueryForEmbedding, formatDocForEmbedding, type ILLMSession } from "./llm.js";
15
15
  import type { NamedCollection, Collection, CollectionConfig } from "./collections.js";
16
- export declare const DEFAULT_EMBED_MODEL = "embeddinggemma";
17
- export declare const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0";
18
- export declare const DEFAULT_QUERY_MODEL = "Qwen/Qwen3-1.7B";
16
+ export declare const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
17
+ export declare const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
18
+ export declare const DEFAULT_QUERY_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
19
19
  export declare const DEFAULT_GLOB = "**/*.md";
20
20
  export declare const DEFAULT_MULTI_GET_MAX_BYTES: number;
21
+ export declare const DEFAULT_EMBED_MAX_DOCS_PER_BATCH = 64;
22
+ export declare const DEFAULT_EMBED_MAX_BATCH_BYTES: number;
21
23
  export declare const CHUNK_SIZE_TOKENS = 900;
22
24
  export declare const CHUNK_OVERLAP_TOKENS: number;
23
25
  export declare const CHUNK_SIZE_CHARS: number;
24
26
  export declare const CHUNK_OVERLAP_CHARS: number;
25
27
  export declare const CHUNK_WINDOW_TOKENS = 200;
26
28
  export declare const CHUNK_WINDOW_CHARS: number;
29
+ export declare function getEmbeddingFingerprint(model?: string): string;
27
30
  /**
28
31
  * A potential break point in the document with a base score indicating quality.
29
32
  */
@@ -76,6 +79,20 @@ export declare function isInsideCodeFence(pos: number, fences: CodeFenceRegion[]
76
79
  * @returns The best position to cut at
77
80
  */
78
81
  export declare function findBestCutoff(breakPoints: BreakPoint[], targetCharPos: number, windowChars?: number, decayFactor?: number, codeFences?: CodeFenceRegion[]): number;
82
+ export type ChunkStrategy = "auto" | "regex";
83
+ /**
84
+ * Merge two sets of break points (e.g. regex + AST), keeping the highest
85
+ * score at each position. Result is sorted by position.
86
+ */
87
+ export declare function mergeBreakPoints(a: BreakPoint[], b: BreakPoint[]): BreakPoint[];
88
+ /**
89
+ * Core chunk algorithm that operates on precomputed break points and code fences.
90
+ * This is the shared implementation used by both regex-only and AST-aware chunking.
91
+ */
92
+ export declare function chunkDocumentWithBreakPoints(content: string, breakPoints: BreakPoint[], codeFences: CodeFenceRegion[], maxChars?: number, overlapChars?: number, windowChars?: number): {
93
+ text: string;
94
+ pos: number;
95
+ }[];
79
96
  export declare const STRONG_SIGNAL_MIN_SCORE = 0.85;
80
97
  export declare const STRONG_SIGNAL_MIN_GAP = 0.15;
81
98
  export declare const RERANK_CANDIDATE_LIMIT = 40;
@@ -118,12 +135,15 @@ export declare function normalizePathSeparators(path: string): string;
118
135
  export declare function getRelativePathFromPrefix(path: string, prefix: string): string | null;
119
136
  export declare function resolve(...paths: string[]): string;
120
137
  export declare function enableProductionMode(): void;
138
+ /** Reset production mode flag — only for testing. */
139
+ export declare function _resetProductionModeForTesting(): void;
121
140
  export declare function getDefaultDbPath(indexName?: string): string;
122
141
  export declare function getPwd(): string;
123
142
  export declare function getRealPath(path: string): string;
124
143
  export type VirtualPath = {
125
144
  collectionName: string;
126
145
  path: string;
146
+ indexName?: string;
127
147
  };
128
148
  /**
129
149
  * Normalize explicit virtual path formats to standard qmd:// format.
@@ -146,7 +166,7 @@ export declare function parseVirtualPath(virtualPath: string): VirtualPath | nul
146
166
  /**
147
167
  * Build a virtual path from collection name and relative path.
148
168
  */
149
- export declare function buildVirtualPath(collectionName: string, path: string): string;
169
+ export declare function buildVirtualPath(collectionName: string, path: string, indexName?: string): string;
150
170
  /**
151
171
  * Check if a path is explicitly a virtual path.
152
172
  * Only recognizes explicit virtual path formats:
@@ -167,6 +187,12 @@ export declare function resolveVirtualPath(db: Database, virtualPath: string): s
167
187
  */
168
188
  export declare function toVirtualPath(db: Database, absolutePath: string): string | null;
169
189
  export declare function verifySqliteVecLoaded(db: Database): void;
190
+ /**
191
+ * FTS5's unicode61 tokenizer does not segment CJK text into searchable words.
192
+ * Normalize CJK runs by spacing every character so exact CJK queries can be
193
+ * translated into phrase queries while Latin text keeps the default tokenizer.
194
+ */
195
+ export declare function normalizeCjkForFTS(text: string): string;
170
196
  export declare function getStoreCollections(db: Database): NamedCollection[];
171
197
  export declare function getStoreCollection(db: Database, name: string): NamedCollection | null;
172
198
  export declare function getStoreGlobalContext(db: Database): string | undefined;
@@ -196,9 +222,9 @@ export type Store = {
196
222
  llm?: LlamaCpp;
197
223
  close: () => void;
198
224
  ensureVecTable: (dimensions: number) => void;
199
- getHashesNeedingEmbedding: () => number;
200
- getIndexHealth: () => IndexHealthInfo;
201
- getStatus: () => IndexStatus;
225
+ getHashesNeedingEmbedding: (model?: string) => number;
226
+ getIndexHealth: (model?: string) => IndexHealthInfo;
227
+ getStatus: (model?: string) => IndexStatus;
202
228
  getCacheKey: typeof getCacheKey;
203
229
  getCachedResult: (cacheKey: string) => string | null;
204
230
  setCachedResult: (cacheKey: string, result: string) => void;
@@ -266,6 +292,11 @@ export type Store = {
266
292
  hash: string;
267
293
  title: string;
268
294
  } | null;
295
+ findOrMigrateLegacyDocument: (collectionName: string, path: string) => {
296
+ id: number;
297
+ hash: string;
298
+ title: string;
299
+ } | null;
269
300
  updateDocumentTitle: (documentId: number, title: string, modifiedAt: string) => void;
270
301
  updateDocument: (documentId: number, title: string, hash: string, modifiedAt: string) => void;
271
302
  deactivateDocument: (collectionName: string, path: string) => void;
@@ -276,7 +307,7 @@ export type Store = {
276
307
  path: string;
277
308
  }[];
278
309
  clearAllEmbeddings: () => void;
279
- insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string) => void;
310
+ insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, totalChunks?: number, fingerprint?: string) => void;
280
311
  };
281
312
  export type ReindexProgress = {
282
313
  file: string;
@@ -298,29 +329,49 @@ export declare function reindexCollection(store: Store, collectionPath: string,
298
329
  ignorePatterns?: string[];
299
330
  onProgress?: (info: ReindexProgress) => void;
300
331
  }): Promise<ReindexResult>;
332
+ export type EmbedFailure = {
333
+ path: string;
334
+ hash: string;
335
+ seq: number;
336
+ attempts: number;
337
+ reason: string;
338
+ };
301
339
  export type EmbedProgress = {
302
340
  chunksEmbedded: number;
303
341
  totalChunks: number;
304
342
  bytesProcessed: number;
305
343
  totalBytes: number;
344
+ /** Active failed chunks still awaiting a successful retry. */
306
345
  errors: number;
346
+ failures?: EmbedFailure[];
307
347
  };
308
348
  export type EmbedResult = {
309
349
  docsProcessed: number;
310
350
  chunksEmbedded: number;
351
+ /** Active failed chunks that did not recover after retries. */
311
352
  errors: number;
353
+ failures?: EmbedFailure[];
312
354
  durationMs: number;
313
355
  };
356
+ export type EmbedOptions = {
357
+ force?: boolean;
358
+ model?: string;
359
+ /**
360
+ * Restrict embedding to documents in a single collection.
361
+ * When omitted, all pending documents across every collection are embedded.
362
+ */
363
+ collection?: string;
364
+ maxDocsPerBatch?: number;
365
+ maxBatchBytes?: number;
366
+ chunkStrategy?: ChunkStrategy;
367
+ onProgress?: (info: EmbedProgress) => void;
368
+ };
314
369
  /**
315
370
  * Generate vector embeddings for documents that need them.
316
371
  * Pure function — no console output, no db lifecycle management.
317
372
  * Uses the store's LlamaCpp instance if set, otherwise the global singleton.
318
373
  */
319
- export declare function generateEmbeddings(store: Store, options?: {
320
- force?: boolean;
321
- model?: string;
322
- onProgress?: (info: EmbedProgress) => void;
323
- }): Promise<EmbedResult>;
374
+ export declare function generateEmbeddings(store: Store, options?: EmbedOptions): Promise<EmbedResult>;
324
375
  /**
325
376
  * Create a new store instance with the given database path.
326
377
  * If no path is provided, uses the default path (~/.cache/qmd/index.sqlite).
@@ -432,13 +483,19 @@ export type IndexStatus = {
432
483
  hasVectorIndex: boolean;
433
484
  collections: CollectionInfo[];
434
485
  };
435
- export declare function getHashesNeedingEmbedding(db: Database): number;
486
+ export declare function getHashesNeedingEmbedding(db: Database, collection?: string, model?: string): number;
436
487
  export type IndexHealthInfo = {
437
488
  needsEmbedding: number;
438
489
  totalDocs: number;
439
490
  daysStale: number | null;
440
491
  };
441
- export declare function getIndexHealth(db: Database): IndexHealthInfo;
492
+ export type LegacyFingerprintAdoptionResult = {
493
+ checked: boolean;
494
+ adopted: number;
495
+ reason: string;
496
+ };
497
+ export declare function maybeAdoptLegacyEmbeddingFingerprint(store: Store, model?: string): Promise<LegacyFingerprintAdoptionResult>;
498
+ export declare function getIndexHealth(db: Database, model?: string): IndexHealthInfo;
442
499
  export declare function getCacheKey(url: string, body: object): string;
443
500
  export declare function getCachedResult(db: Database, cacheKey: string): string | null;
444
501
  export declare function setCachedResult(db: Database, cacheKey: string, result: string): void;
@@ -454,7 +511,9 @@ export declare function deleteLLMCache(db: Database): number;
454
511
  */
455
512
  export declare function deleteInactiveDocuments(db: Database): number;
456
513
  /**
457
- * Remove orphaned content hashes that are not referenced by any active document.
514
+ * Remove orphaned content hashes that are not referenced by any document.
515
+ * Inactive documents are soft-deleted tombstones, so their content rows must
516
+ * remain referenced until deleteInactiveDocuments() hard-deletes them.
458
517
  * Returns the number of orphaned content hashes deleted.
459
518
  */
460
519
  export declare function cleanupOrphanedContent(db: Database): number;
@@ -487,6 +546,20 @@ export declare function findActiveDocument(db: Database, collectionName: string,
487
546
  hash: string;
488
547
  title: string;
489
548
  } | null;
549
+ /**
550
+ * Find an active document, falling back to a case-insensitive path match.
551
+ * If found under a different casing, renames it in-place and rebuilds the
552
+ * FTS entry. Embeddings are keyed by content hash, so the rename is
553
+ * safe — no re-embedding required.
554
+ *
555
+ * @internal Used by reindexCollection and indexFiles during qmd update.
556
+ * Returns null if the document does not exist under either path.
557
+ */
558
+ export declare function findOrMigrateLegacyDocument(db: Database, collectionName: string, path: string): {
559
+ id: number;
560
+ hash: string;
561
+ title: string;
562
+ } | null;
490
563
  /**
491
564
  * Update the title and modified_at timestamp for a document.
492
565
  */
@@ -505,15 +578,34 @@ export declare function deactivateDocument(db: Database, collectionName: string,
505
578
  */
506
579
  export declare function getActiveDocumentPaths(db: Database, collectionName: string): string[];
507
580
  export { formatQueryForEmbedding, formatDocForEmbedding };
581
+ /**
582
+ * Chunk a document using regex-only break point detection.
583
+ * This is the sync, backward-compatible API used by tests and legacy callers.
584
+ */
508
585
  export declare function chunkDocument(content: string, maxChars?: number, overlapChars?: number, windowChars?: number): {
509
586
  text: string;
510
587
  pos: number;
511
588
  }[];
589
+ /**
590
+ * Async AST-aware chunking. Detects language from filepath, computes AST
591
+ * break points for supported code files, merges with regex break points,
592
+ * and delegates to the shared chunk algorithm.
593
+ *
594
+ * Falls back to regex-only when strategy is "regex", filepath is absent,
595
+ * or language is unsupported.
596
+ */
597
+ export declare function chunkDocumentAsync(content: string, maxChars?: number, overlapChars?: number, windowChars?: number, filepath?: string, chunkStrategy?: ChunkStrategy): Promise<{
598
+ text: string;
599
+ pos: number;
600
+ }[]>;
512
601
  /**
513
602
  * Chunk a document by actual token count using the LLM tokenizer.
514
603
  * More accurate than character-based chunking but requires async.
604
+ *
605
+ * When filepath and chunkStrategy are provided, uses AST-aware break points
606
+ * for supported code files.
515
607
  */
516
- export declare function chunkDocumentByTokens(content: string, maxTokens?: number, overlapTokens?: number, windowTokens?: number): Promise<{
608
+ export declare function chunkDocumentByTokens(content: string, maxTokens?: number, overlapTokens?: number, windowTokens?: number, filepath?: string, chunkStrategy?: ChunkStrategy, signal?: AbortSignal): Promise<{
517
609
  text: string;
518
610
  pos: number;
519
611
  tokens: number;
@@ -640,6 +732,7 @@ export declare function getCollectionsWithoutContext(db: Database): {
640
732
  * Useful for suggesting where context might be needed.
641
733
  */
642
734
  export declare function getTopLevelPathsWithoutContext(db: Database, collectionName: string): string[];
735
+ export declare function sanitizeFTS5Term(term: string): string;
643
736
  /**
644
737
  * Validate that a vec/hyde query doesn't use lex-only syntax.
645
738
  * Returns error message if invalid, null if valid.
@@ -652,21 +745,39 @@ export declare function searchVec(db: Database, query: string, model: string, li
652
745
  * Get all unique content hashes that need embeddings (from active documents).
653
746
  * Returns hash, document body, and a sample path for display purposes.
654
747
  */
655
- export declare function getHashesForEmbedding(db: Database): {
748
+ export declare function getHashesForEmbedding(db: Database, model?: string): {
656
749
  hash: string;
657
750
  body: string;
658
751
  path: string;
659
752
  }[];
660
753
  /**
661
- * Clear all embeddings from the database (force re-index).
662
- * Deletes all rows from content_vectors and drops the vectors_vec table.
754
+ * Clear embeddings for the whole index, or just for one collection.
755
+ *
756
+ * When `collection` is omitted the entire content_vectors table is emptied and
757
+ * the vectors_vec virtual table is dropped (it is recreated with the right
758
+ * dimensions on the next embed run).
759
+ *
760
+ * When `collection` is provided, only vectors whose hash is referenced
761
+ * exclusively by active documents in that collection are removed. Hashes
762
+ * shared with active documents in other collections are left in place so
763
+ * vector search keeps working there (content_vectors is keyed globally by
764
+ * content hash; identical document bodies across collections share a row).
765
+ * vectors_vec is preserved so other collections keep working unless the scoped
766
+ * clear empties content_vectors entirely, in which case it is dropped so the
767
+ * next embed can recreate the table with the current dimensions.
663
768
  */
664
- export declare function clearAllEmbeddings(db: Database): void;
769
+ export declare function clearAllEmbeddings(db: Database, collection?: string): void;
665
770
  /**
666
771
  * Insert a single embedding into both content_vectors and vectors_vec tables.
667
772
  * The hash_seq key is formatted as "hash_seq" for the vectors_vec table.
773
+ *
774
+ * content_vectors is inserted first so that getHashesForEmbedding (which checks
775
+ * only content_vectors) won't re-select the hash on a crash between the two inserts.
776
+ *
777
+ * vectors_vec uses DELETE + INSERT instead of INSERT OR REPLACE because sqlite-vec's
778
+ * vec0 virtual tables silently ignore the OR REPLACE conflict clause.
668
779
  */
669
- export declare function insertEmbedding(db: Database, hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string): void;
780
+ export declare function insertEmbedding(db: Database, hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, totalChunks?: number, fingerprint?: string): void;
670
781
  export declare function expandQuery(query: string, model: string | undefined, db: Database, intent?: string, llmOverride?: LlamaCpp): Promise<ExpandedQuery[]>;
671
782
  export declare function rerank(query: string, documents: {
672
783
  file: string;
@@ -711,7 +822,7 @@ export declare function findDocuments(db: Database, pattern: string, options?: {
711
822
  docs: MultiGetResult[];
712
823
  errors: string[];
713
824
  };
714
- export declare function getStatus(db: Database): IndexStatus;
825
+ export declare function getStatus(db: Database, model?: string): IndexStatus;
715
826
  export type SnippetResult = {
716
827
  line: number;
717
828
  snippet: string;
@@ -763,6 +874,7 @@ export interface HybridQueryOptions {
763
874
  explain?: boolean;
764
875
  intent?: string;
765
876
  skipRerank?: boolean;
877
+ chunkStrategy?: ChunkStrategy;
766
878
  hooks?: SearchHooks;
767
879
  }
768
880
  export interface HybridQueryResult {
@@ -782,6 +894,18 @@ export type RankedListMeta = {
782
894
  queryType: "original" | "lex" | "vec" | "hyde";
783
895
  query: string;
784
896
  };
897
+ /**
898
+ * RRF list weights for hybridQuery.
899
+ *
900
+ * Original-query retrieval paths are the primary evidence and get 2x weight:
901
+ * - original FTS
902
+ * - original vector search
903
+ *
904
+ * Expansion-derived lists (lex/vec/hyde) stay at 1x regardless of list order,
905
+ * so a lex expansion inserted before original vector search cannot steal the
906
+ * original vector boost.
907
+ */
908
+ export declare function getHybridRrfWeights(rankedListMeta: RankedListMeta[]): number[];
785
909
  /**
786
910
  * Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
787
911
  *
@@ -836,6 +960,7 @@ export interface StructuredSearchOptions {
836
960
  intent?: string;
837
961
  /** Skip LLM reranking, use only RRF scores */
838
962
  skipRerank?: boolean;
963
+ chunkStrategy?: ChunkStrategy;
839
964
  hooks?: SearchHooks;
840
965
  }
841
966
  /**