qmdr 1.0.0

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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.env.example +85 -0
  3. package/.gitattributes +3 -0
  4. package/.github/workflows/release.yml +77 -0
  5. package/AI-SETUP.md +466 -0
  6. package/LICENSE +22 -0
  7. package/README.md +78 -0
  8. package/bun.lock +637 -0
  9. package/docs/README-zh.md +78 -0
  10. package/docs/refactor-checklist.md +54 -0
  11. package/docs/setup-openclaw.md +139 -0
  12. package/example-index.yml +33 -0
  13. package/finetune/BALANCED_DISTRIBUTION.md +157 -0
  14. package/finetune/DATA_IMPROVEMENTS.md +218 -0
  15. package/finetune/Justfile +43 -0
  16. package/finetune/Modelfile +16 -0
  17. package/finetune/README.md +299 -0
  18. package/finetune/SCORING.md +286 -0
  19. package/finetune/configs/accelerate_multi_gpu.yaml +17 -0
  20. package/finetune/configs/grpo.yaml +49 -0
  21. package/finetune/configs/sft.yaml +42 -0
  22. package/finetune/configs/sft_local.yaml +40 -0
  23. package/finetune/convert_gguf.py +221 -0
  24. package/finetune/data/best_glm_prompt.txt +17 -0
  25. package/finetune/data/gepa_generated.prompts.json +32 -0
  26. package/finetune/data/qmd_expansion_balanced_deduped.jsonl +413 -0
  27. package/finetune/data/qmd_expansion_diverse_addon.jsonl +386 -0
  28. package/finetune/data/qmd_expansion_handcrafted.jsonl +65 -0
  29. package/finetune/data/qmd_expansion_handcrafted_only.jsonl +336 -0
  30. package/finetune/data/qmd_expansion_locations.jsonl +64 -0
  31. package/finetune/data/qmd_expansion_people.jsonl +46 -0
  32. package/finetune/data/qmd_expansion_short_nontech.jsonl +200 -0
  33. package/finetune/data/qmd_expansion_v2.jsonl +1498 -0
  34. package/finetune/data/qmd_only_sampled.jsonl +399 -0
  35. package/finetune/dataset/analyze_data.py +369 -0
  36. package/finetune/dataset/clean_data.py +906 -0
  37. package/finetune/dataset/generate_balanced.py +823 -0
  38. package/finetune/dataset/generate_data.py +714 -0
  39. package/finetune/dataset/generate_data_offline.py +206 -0
  40. package/finetune/dataset/generate_diverse.py +441 -0
  41. package/finetune/dataset/generate_ollama.py +326 -0
  42. package/finetune/dataset/prepare_data.py +197 -0
  43. package/finetune/dataset/schema.py +73 -0
  44. package/finetune/dataset/score_data.py +115 -0
  45. package/finetune/dataset/validate_schema.py +104 -0
  46. package/finetune/eval.py +196 -0
  47. package/finetune/evals/queries.txt +56 -0
  48. package/finetune/gepa/__init__.py +1 -0
  49. package/finetune/gepa/best_prompt.txt +31 -0
  50. package/finetune/gepa/best_prompt_glm.txt +1 -0
  51. package/finetune/gepa/dspy_gepa.py +204 -0
  52. package/finetune/gepa/example.py +117 -0
  53. package/finetune/gepa/generate.py +129 -0
  54. package/finetune/gepa/gepa_outputs.jsonl +10 -0
  55. package/finetune/gepa/gepa_outputs_glm.jsonl +20 -0
  56. package/finetune/gepa/model.json +19 -0
  57. package/finetune/gepa/optimizer.py +70 -0
  58. package/finetune/gepa/score.py +84 -0
  59. package/finetune/jobs/eval.py +490 -0
  60. package/finetune/jobs/eval_common.py +354 -0
  61. package/finetune/jobs/eval_verbose.py +113 -0
  62. package/finetune/jobs/grpo.py +141 -0
  63. package/finetune/jobs/quantize.py +244 -0
  64. package/finetune/jobs/sft.py +121 -0
  65. package/finetune/pyproject.toml +23 -0
  66. package/finetune/reward.py +610 -0
  67. package/finetune/train.py +611 -0
  68. package/finetune/uv.lock +4070 -0
  69. package/flake.lock +61 -0
  70. package/flake.nix +83 -0
  71. package/migrate-schema.ts +162 -0
  72. package/package.json +56 -0
  73. package/skills/qmdr/SKILL.md +172 -0
  74. package/skills/qmdr/references/mcp-setup.md +88 -0
  75. package/src/app/commands/collection.ts +55 -0
  76. package/src/app/commands/context.ts +82 -0
  77. package/src/app/commands/document.ts +46 -0
  78. package/src/app/commands/maintenance.ts +60 -0
  79. package/src/app/commands/search.ts +45 -0
  80. package/src/app/ports/llm.ts +13 -0
  81. package/src/app/services/llm-service.ts +145 -0
  82. package/src/cli.test.ts +963 -0
  83. package/src/collections.ts +390 -0
  84. package/src/eval.test.ts +412 -0
  85. package/src/formatter.ts +427 -0
  86. package/src/llm.test.ts +559 -0
  87. package/src/llm.ts +1990 -0
  88. package/src/mcp.test.ts +889 -0
  89. package/src/mcp.ts +626 -0
  90. package/src/qmd.ts +3330 -0
  91. package/src/store/collections.ts +7 -0
  92. package/src/store/context.ts +10 -0
  93. package/src/store/db.ts +5 -0
  94. package/src/store/documents.ts +26 -0
  95. package/src/store/maintenance.ts +15 -0
  96. package/src/store/path.ts +13 -0
  97. package/src/store/search.ts +10 -0
  98. package/src/store-paths.test.ts +395 -0
  99. package/src/store.test.ts +2483 -0
  100. package/src/store.ts +2813 -0
  101. package/test/eval-harness.ts +223 -0
  102. package/tsconfig.json +29 -0
package/src/qmd.ts ADDED
@@ -0,0 +1,3330 @@
1
+ #!/usr/bin/env bun
2
+ import { Database } from "bun:sqlite";
3
+ import { Glob, $ } from "bun";
4
+ import { parseArgs } from "util";
5
+ import { readFileSync, statSync } from "fs";
6
+ import * as sqliteVec from "sqlite-vec";
7
+ import {
8
+ getPwd,
9
+ getRealPath,
10
+ homedir,
11
+ resolve,
12
+ enableProductionMode,
13
+ searchFTS,
14
+ searchVec,
15
+ extractSnippet,
16
+ getContextForFile,
17
+ getContextForPath,
18
+ listCollections,
19
+ removeCollection,
20
+ renameCollection,
21
+ findSimilarFiles,
22
+ findDocumentByDocid,
23
+ isDocid,
24
+ matchFilesByGlob,
25
+ getHashesNeedingEmbedding,
26
+ getHashesForEmbedding,
27
+ clearAllEmbeddings,
28
+ insertEmbedding,
29
+ getStatus,
30
+ hashContent,
31
+ extractTitle,
32
+ formatDocForEmbedding,
33
+ formatQueryForEmbedding,
34
+ chunkDocument,
35
+ chunkDocumentByTokens,
36
+ clearCache,
37
+ getCacheKey,
38
+ getCachedResult,
39
+ setCachedResult,
40
+ getIndexHealth,
41
+ parseVirtualPath,
42
+ buildVirtualPath,
43
+ isVirtualPath,
44
+ resolveVirtualPath,
45
+ toVirtualPath,
46
+ insertContent,
47
+ insertDocument,
48
+ findActiveDocument,
49
+ updateDocumentTitle,
50
+ updateDocument,
51
+ deactivateDocument,
52
+ getActiveDocumentPaths,
53
+ cleanupOrphanedContent,
54
+ deleteLLMCache,
55
+ deleteInactiveDocuments,
56
+ cleanupOrphanedVectors,
57
+ vacuumDatabase,
58
+ getCollectionsWithoutContext,
59
+ getTopLevelPathsWithoutContext,
60
+ handelize,
61
+ DEFAULT_EMBED_MODEL,
62
+ DEFAULT_QUERY_MODEL,
63
+ DEFAULT_RERANK_MODEL,
64
+ DEFAULT_GLOB,
65
+ DEFAULT_MULTI_GET_MAX_BYTES,
66
+ createStore,
67
+ getDefaultDbPath,
68
+ } from "./store.js";
69
+ import { getDefaultLlamaCpp, disposeDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR, type ILLMSession, type RerankDocument, type Queryable, type QueryType, RemoteLLM, type RemoteLLMConfig } from "./llm.js";
70
+ import type { SearchResult, RankedResult } from "./store.js";
71
+ import {
72
+ formatSearchResults,
73
+ formatDocuments,
74
+ escapeXml,
75
+ escapeCSV,
76
+ type OutputFormat,
77
+ } from "./formatter.js";
78
+ import {
79
+ getCollection as getCollectionFromYaml,
80
+ listCollections as yamlListCollections,
81
+ addContext as yamlAddContext,
82
+ removeContext as yamlRemoveContext,
83
+ setGlobalContext,
84
+ listAllContexts,
85
+ setConfigIndexName,
86
+ } from "./collections.js";
87
+ import { handleContextCommand } from "./app/commands/context.js";
88
+ import { handleGetCommand, handleMultiGetCommand, handleLsCommand } from "./app/commands/document.js";
89
+ import { handleCollectionCommand } from "./app/commands/collection.js";
90
+ import { handleSearchCommand, handleVSearchCommand, handleQueryCommand } from "./app/commands/search.js";
91
+ import { handleCleanupCommand, handlePullCommand, handleStatusCommand, handleUpdateCommand, handleEmbedCommand, handleMcpCommand, handleDoctorCommand } from "./app/commands/maintenance.js";
92
+ import { createLLMService } from "./app/services/llm-service.js";
93
+
94
+ // Enable production mode - allows using default database path
95
+ // Tests must set INDEX_PATH or use createStore() with explicit path
96
+ enableProductionMode();
97
+
98
+ // =============================================================================
99
+ // Store/DB lifecycle (no legacy singletons in store.ts)
100
+ // =============================================================================
101
+
102
+ let store: ReturnType<typeof createStore> | null = null;
103
+ let storeDbPathOverride: string | undefined;
104
+
105
+ function getStore(): ReturnType<typeof createStore> {
106
+ if (!store) {
107
+ store = createStore(storeDbPathOverride);
108
+ }
109
+ return store;
110
+ }
111
+
112
+ function getDb(): Database {
113
+ return getStore().db;
114
+ }
115
+
116
+ function closeDb(): void {
117
+ if (store) {
118
+ store.close();
119
+ store = null;
120
+ }
121
+ }
122
+
123
+ function getDbPath(): string {
124
+ return store?.dbPath ?? storeDbPathOverride ?? getDefaultDbPath();
125
+ }
126
+
127
+ function setIndexName(name: string | null): void {
128
+ storeDbPathOverride = name ? getDefaultDbPath(name) : undefined;
129
+ // Reset open handle so next use opens the new index
130
+ closeDb();
131
+ }
132
+
133
+ function ensureVecTable(_db: Database, dimensions: number): void {
134
+ // Store owns the DB; ignore `_db` and ensure vec table on the active store
135
+ getStore().ensureVecTable(dimensions);
136
+ }
137
+
138
+ // Terminal colors (respects NO_COLOR env)
139
+ const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
140
+ const c = {
141
+ reset: useColor ? "\x1b[0m" : "",
142
+ dim: useColor ? "\x1b[2m" : "",
143
+ bold: useColor ? "\x1b[1m" : "",
144
+ cyan: useColor ? "\x1b[36m" : "",
145
+ yellow: useColor ? "\x1b[33m" : "",
146
+ green: useColor ? "\x1b[32m" : "",
147
+ magenta: useColor ? "\x1b[35m" : "",
148
+ blue: useColor ? "\x1b[34m" : "",
149
+ };
150
+
151
+ // Terminal cursor control
152
+ const cursor = {
153
+ hide() { process.stderr.write('\x1b[?25l'); },
154
+ show() { process.stderr.write('\x1b[?25h'); },
155
+ };
156
+
157
+ // Ensure cursor is restored on exit
158
+ process.on('SIGINT', () => { cursor.show(); process.exit(130); });
159
+ process.on('SIGTERM', () => { cursor.show(); process.exit(143); });
160
+
161
+ // Terminal progress bar using OSC 9;4 escape sequence
162
+ const progress = {
163
+ set(percent: number) {
164
+ process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
165
+ },
166
+ clear() {
167
+ process.stderr.write(`\x1b]9;4;0\x07`);
168
+ },
169
+ indeterminate() {
170
+ process.stderr.write(`\x1b]9;4;3\x07`);
171
+ },
172
+ error() {
173
+ process.stderr.write(`\x1b]9;4;2\x07`);
174
+ },
175
+ };
176
+
177
+ // Format seconds into human-readable ETA
178
+ function formatETA(seconds: number): string {
179
+ if (seconds < 60) return `${Math.round(seconds)}s`;
180
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
181
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
182
+ }
183
+
184
+
185
+ // Check index health and print warnings/tips
186
+ function checkIndexHealth(db: Database): void {
187
+ const { needsEmbedding, totalDocs, daysStale } = getIndexHealth(db);
188
+
189
+ // Warn if many docs need embedding
190
+ if (needsEmbedding > 0) {
191
+ const pct = Math.round((needsEmbedding / totalDocs) * 100);
192
+ if (pct >= 10) {
193
+ process.stderr.write(`${c.yellow}Warning: ${needsEmbedding} documents (${pct}%) need embeddings. Run 'qmd embed' for better results.${c.reset}\n`);
194
+ } else {
195
+ process.stderr.write(`${c.dim}Tip: ${needsEmbedding} documents need embeddings. Run 'qmd embed' to index them.${c.reset}\n`);
196
+ }
197
+ }
198
+
199
+ // Check if most recent document update is older than 2 weeks
200
+ if (daysStale !== null && daysStale >= 14) {
201
+ process.stderr.write(`${c.dim}Tip: Index last updated ${daysStale} days ago. Run 'qmd update' to refresh.${c.reset}\n`);
202
+ }
203
+ }
204
+
205
+ // Compute unique display path for a document
206
+ // Always include at least parent folder + filename, add more parent dirs until unique
207
+ function computeDisplayPath(
208
+ filepath: string,
209
+ collectionPath: string,
210
+ existingPaths: Set<string>
211
+ ): string {
212
+ // Get path relative to collection (include collection dir name)
213
+ const collectionDir = collectionPath.replace(/\/$/, '');
214
+ const collectionName = collectionDir.split('/').pop() || '';
215
+
216
+ let relativePath: string;
217
+ if (filepath.startsWith(collectionDir + '/')) {
218
+ // filepath is under collection: use collection name + relative path
219
+ relativePath = collectionName + filepath.slice(collectionDir.length);
220
+ } else {
221
+ // Fallback: just use the filepath
222
+ relativePath = filepath;
223
+ }
224
+
225
+ const parts = relativePath.split('/').filter(p => p.length > 0);
226
+
227
+ // Always include at least parent folder + filename (minimum 2 parts if available)
228
+ // Then add more parent dirs until unique
229
+ const minParts = Math.min(2, parts.length);
230
+ for (let i = parts.length - minParts; i >= 0; i--) {
231
+ const candidate = parts.slice(i).join('/');
232
+ if (!existingPaths.has(candidate)) {
233
+ return candidate;
234
+ }
235
+ }
236
+
237
+ // Absolute fallback: use full path (should be unique)
238
+ return filepath;
239
+ }
240
+
241
+ // Remote LLM instance (initialized from env vars; handles rerank + embed + query expansion)
242
+ let remoteLLM: RemoteLLM | null = null;
243
+ const llmService = createLLMService();
244
+
245
+ function getRemoteLLM(): RemoteLLM | null {
246
+ if (remoteLLM) return remoteLLM;
247
+
248
+ // Check env vars for remote config
249
+ const rerankProvider = process.env.QMD_RERANK_PROVIDER as 'siliconflow' | 'gemini' | 'openai' | undefined;
250
+ const embedProvider = process.env.QMD_EMBED_PROVIDER as 'siliconflow' | 'openai' | undefined;
251
+ const queryExpansionProvider = process.env.QMD_QUERY_EXPANSION_PROVIDER as 'siliconflow' | 'gemini' | 'openai' | undefined;
252
+ const rerankMode = (process.env.QMD_RERANK_MODE as 'llm' | 'rerank' | undefined) || 'llm';
253
+ const sfApiKey = process.env.QMD_SILICONFLOW_API_KEY;
254
+ const gmApiKey = process.env.QMD_GEMINI_API_KEY;
255
+ const oaApiKey = process.env.QMD_OPENAI_API_KEY;
256
+ const sfLlmRerankModel = process.env.QMD_SILICONFLOW_LLM_RERANK_MODEL || process.env.QMD_LLM_RERANK_MODEL || 'zai-org/GLM-4.5-Air';
257
+
258
+ let effectiveRerankProvider: 'siliconflow' | 'gemini' | 'openai' | undefined;
259
+ if (rerankMode === 'rerank') {
260
+ if (sfApiKey) {
261
+ effectiveRerankProvider = 'siliconflow';
262
+ } else if (rerankProvider === 'gemini' && gmApiKey) {
263
+ effectiveRerankProvider = 'gemini';
264
+ } else if (rerankProvider === 'openai' && oaApiKey) {
265
+ effectiveRerankProvider = 'openai';
266
+ } else {
267
+ effectiveRerankProvider = gmApiKey ? 'gemini' : (oaApiKey ? 'openai' : undefined);
268
+ }
269
+ } else {
270
+ if (rerankProvider === 'gemini' || rerankProvider === 'openai') {
271
+ effectiveRerankProvider = rerankProvider;
272
+ } else if (rerankProvider === 'siliconflow') {
273
+ effectiveRerankProvider = sfApiKey ? 'openai' : undefined;
274
+ } else {
275
+ effectiveRerankProvider = sfApiKey ? 'openai' : (gmApiKey ? 'gemini' : (oaApiKey ? 'openai' : undefined));
276
+ }
277
+ }
278
+ const effectiveEmbedProvider = embedProvider || (sfApiKey ? 'siliconflow' : (oaApiKey ? 'openai' : undefined));
279
+ const effectiveQueryExpansionProvider = queryExpansionProvider || (sfApiKey ? 'siliconflow' : (oaApiKey ? 'openai' : (gmApiKey ? 'gemini' : undefined)));
280
+
281
+ // Need at least one remote provider configured
282
+ if (!effectiveRerankProvider && !effectiveEmbedProvider && !effectiveQueryExpansionProvider) return null;
283
+
284
+ const config: RemoteLLMConfig = {
285
+ rerankProvider: effectiveRerankProvider || 'siliconflow',
286
+ embedProvider: effectiveEmbedProvider,
287
+ queryExpansionProvider: effectiveQueryExpansionProvider,
288
+ };
289
+
290
+ // SiliconFlow config (shared by rerank, embed, query expansion)
291
+ if (sfApiKey) {
292
+ config.siliconflow = {
293
+ apiKey: sfApiKey,
294
+ baseUrl: process.env.QMD_SILICONFLOW_BASE_URL,
295
+ model: process.env.QMD_SILICONFLOW_RERANK_MODEL || process.env.QMD_SILICONFLOW_MODEL,
296
+ embedModel: process.env.QMD_SILICONFLOW_EMBED_MODEL,
297
+ queryExpansionModel: process.env.QMD_SILICONFLOW_QUERY_EXPANSION_MODEL,
298
+ };
299
+ }
300
+
301
+ // Gemini config
302
+ if (effectiveRerankProvider === 'gemini' || effectiveQueryExpansionProvider === 'gemini') {
303
+ if (gmApiKey) {
304
+ config.gemini = {
305
+ apiKey: gmApiKey,
306
+ baseUrl: process.env.QMD_GEMINI_BASE_URL,
307
+ model: process.env.QMD_GEMINI_RERANK_MODEL || process.env.QMD_GEMINI_MODEL,
308
+ };
309
+ }
310
+ }
311
+
312
+ if (oaApiKey || (effectiveRerankProvider === 'openai' && sfApiKey)) {
313
+ config.openai = {
314
+ apiKey: oaApiKey || sfApiKey || '',
315
+ baseUrl: process.env.QMD_OPENAI_BASE_URL || process.env.QMD_SILICONFLOW_BASE_URL,
316
+ model: process.env.QMD_OPENAI_MODEL || (sfApiKey ? sfLlmRerankModel : undefined),
317
+ embedModel: process.env.QMD_OPENAI_EMBED_MODEL,
318
+ };
319
+ }
320
+
321
+ remoteLLM = new RemoteLLM(config);
322
+ return remoteLLM;
323
+ }
324
+
325
+ // Backward-compat alias
326
+ function getRemoteReranker(): RemoteLLM | null {
327
+ return getRemoteLLM();
328
+ }
329
+
330
+ // Rerank documents using cross-encoder model (local or remote)
331
+ async function rerank(query: string, documents: { file: string; text: string }[], _model: string = DEFAULT_RERANK_MODEL, _db?: Database, session?: ILLMSession): Promise<{ file: string; score: number; extract?: string }[]> {
332
+ if (documents.length === 0) return [];
333
+
334
+ const total = documents.length;
335
+
336
+ // Try remote reranker first
337
+ const remote = getRemoteReranker();
338
+ if (remote) {
339
+ process.stderr.write(`Reranking ${total} documents (remote: ${process.env.QMD_RERANK_PROVIDER})...\n`);
340
+ progress.indeterminate();
341
+
342
+ const rerankDocs: RerankDocument[] = documents.map((doc) => ({
343
+ file: doc.file,
344
+ text: doc.text.slice(0, 4000),
345
+ }));
346
+
347
+ const result = await remote.rerank(query, rerankDocs);
348
+
349
+ progress.clear();
350
+ process.stderr.write("\n");
351
+
352
+ return result.results.map((r) => ({ file: r.file, score: r.score, extract: r.extract }));
353
+ }
354
+
355
+ // Fall back to local LlamaCpp reranker
356
+ process.stderr.write(`Reranking ${total} documents...\n`);
357
+ progress.indeterminate();
358
+
359
+ const rerankDocs: RerankDocument[] = documents.map((doc) => ({
360
+ file: doc.file,
361
+ text: doc.text.slice(0, 4000), // Truncate to context limit
362
+ }));
363
+
364
+ const result = session
365
+ ? await session.rerank(query, rerankDocs)
366
+ : await getDefaultLlamaCpp().rerank(query, rerankDocs);
367
+
368
+ progress.clear();
369
+ process.stderr.write("\n");
370
+
371
+ return result.results.map((r) => ({ file: r.file, score: r.score }));
372
+ }
373
+
374
+ function formatTimeAgo(date: Date): string {
375
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
376
+ if (seconds < 60) return `${seconds}s ago`;
377
+ const minutes = Math.floor(seconds / 60);
378
+ if (minutes < 60) return `${minutes}m ago`;
379
+ const hours = Math.floor(minutes / 60);
380
+ if (hours < 24) return `${hours}h ago`;
381
+ const days = Math.floor(hours / 24);
382
+ return `${days}d ago`;
383
+ }
384
+
385
+ function formatBytes(bytes: number): string {
386
+ if (bytes < 1024) return `${bytes} B`;
387
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
388
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
389
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
390
+ }
391
+
392
+ function showStatus(): void {
393
+ const dbPath = getDbPath();
394
+ const db = getDb();
395
+
396
+ // Collections are defined in YAML; no duplicate cleanup needed.
397
+ // Collections are defined in YAML; no duplicate cleanup needed.
398
+
399
+ // Index size
400
+ let indexSize = 0;
401
+ try {
402
+ const stat = statSync(dbPath).size;
403
+ indexSize = stat;
404
+ } catch { }
405
+
406
+ // Collections info (from YAML + database stats)
407
+ const collections = listCollections(db);
408
+
409
+ // Overall stats
410
+ const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get() as { count: number };
411
+ const vectorCount = db.prepare(`SELECT COUNT(*) as count FROM content_vectors`).get() as { count: number };
412
+ const needsEmbedding = getHashesNeedingEmbedding(db);
413
+
414
+ // Most recent update across all collections
415
+ const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get() as { latest: string | null };
416
+
417
+ console.log(`${c.bold}QMD Status${c.reset}\n`);
418
+ console.log(`Index: ${dbPath}`);
419
+ console.log(`Size: ${formatBytes(indexSize)}\n`);
420
+
421
+ console.log(`${c.bold}Documents${c.reset}`);
422
+ console.log(` Total: ${totalDocs.count} files indexed`);
423
+ console.log(` Vectors: ${vectorCount.count} embedded`);
424
+ if (needsEmbedding > 0) {
425
+ console.log(` ${c.yellow}Pending: ${needsEmbedding} need embedding${c.reset} (run 'qmd embed')`);
426
+ }
427
+ if (mostRecent.latest) {
428
+ const lastUpdate = new Date(mostRecent.latest);
429
+ console.log(` Updated: ${formatTimeAgo(lastUpdate)}`);
430
+ }
431
+
432
+ // Get all contexts grouped by collection (from YAML)
433
+ const allContexts = listAllContexts();
434
+ const contextsByCollection = new Map<string, { path_prefix: string; context: string }[]>();
435
+
436
+ for (const ctx of allContexts) {
437
+ // Group contexts by collection name
438
+ if (!contextsByCollection.has(ctx.collection)) {
439
+ contextsByCollection.set(ctx.collection, []);
440
+ }
441
+ contextsByCollection.get(ctx.collection)!.push({
442
+ path_prefix: ctx.path,
443
+ context: ctx.context
444
+ });
445
+ }
446
+
447
+ if (collections.length > 0) {
448
+ console.log(`\n${c.bold}Collections${c.reset}`);
449
+ for (const col of collections) {
450
+ const lastMod = col.last_modified ? formatTimeAgo(new Date(col.last_modified)) : "never";
451
+ const contexts = contextsByCollection.get(col.name) || [];
452
+
453
+ console.log(` ${c.cyan}${col.name}${c.reset} ${c.dim}(qmd://${col.name}/)${c.reset}`);
454
+ console.log(` ${c.dim}Pattern:${c.reset} ${col.glob_pattern}`);
455
+ console.log(` ${c.dim}Files:${c.reset} ${col.active_count} (updated ${lastMod})`);
456
+
457
+ if (contexts.length > 0) {
458
+ console.log(` ${c.dim}Contexts:${c.reset} ${contexts.length}`);
459
+ for (const ctx of contexts) {
460
+ // Handle both empty string and '/' as root context
461
+ const pathDisplay = (ctx.path_prefix === '' || ctx.path_prefix === '/') ? '/' : `/${ctx.path_prefix}`;
462
+ const contextPreview = ctx.context.length > 60
463
+ ? ctx.context.substring(0, 57) + '...'
464
+ : ctx.context;
465
+ console.log(` ${c.dim}${pathDisplay}:${c.reset} ${contextPreview}`);
466
+ }
467
+ }
468
+ }
469
+
470
+ // Show examples of virtual paths
471
+ console.log(`\n${c.bold}Examples${c.reset}`);
472
+ console.log(` ${c.dim}# List files in a collection${c.reset}`);
473
+ if (collections.length > 0 && collections[0]) {
474
+ console.log(` qmd ls ${collections[0].name}`);
475
+ }
476
+ console.log(` ${c.dim}# Get a document${c.reset}`);
477
+ if (collections.length > 0 && collections[0]) {
478
+ console.log(` qmd get qmd://${collections[0].name}/path/to/file.md`);
479
+ }
480
+ console.log(` ${c.dim}# Search within a collection${c.reset}`);
481
+ if (collections.length > 0 && collections[0]) {
482
+ console.log(` qmd search "query" -c ${collections[0].name}`);
483
+ }
484
+ } else {
485
+ console.log(`\n${c.dim}No collections. Run 'qmd collection add .' to index markdown files.${c.reset}`);
486
+ }
487
+
488
+ closeDb();
489
+ }
490
+
491
+ async function updateCollections(): Promise<void> {
492
+ const db = getDb();
493
+ // Collections are defined in YAML; no duplicate cleanup needed.
494
+
495
+ // Clear Ollama cache on update
496
+ clearCache(db);
497
+
498
+ const collections = listCollections(db);
499
+
500
+ if (collections.length === 0) {
501
+ console.log(`${c.dim}No collections found. Run 'qmd collection add .' to index markdown files.${c.reset}`);
502
+ closeDb();
503
+ return;
504
+ }
505
+
506
+ // Don't close db here - indexFiles will reuse it and close at the end
507
+ console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
508
+
509
+ for (let i = 0; i < collections.length; i++) {
510
+ const col = collections[i];
511
+ if (!col) continue;
512
+ console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.name}${c.reset} ${c.dim}(${col.glob_pattern})${c.reset}`);
513
+
514
+ // Execute custom update command if specified in YAML
515
+ const yamlCol = getCollectionFromYaml(col.name);
516
+ if (yamlCol?.update) {
517
+ console.log(`${c.dim} Running update command: ${yamlCol.update}${c.reset}`);
518
+ try {
519
+ const proc = Bun.spawn(["/usr/bin/env", "bash", "-c", yamlCol.update], {
520
+ cwd: col.pwd,
521
+ stdout: "pipe",
522
+ stderr: "pipe",
523
+ });
524
+
525
+ const output = await new Response(proc.stdout).text();
526
+ const errorOutput = await new Response(proc.stderr).text();
527
+ const exitCode = await proc.exited;
528
+
529
+ if (output.trim()) {
530
+ console.log(output.trim().split('\n').map(l => ` ${l}`).join('\n'));
531
+ }
532
+ if (errorOutput.trim()) {
533
+ console.log(errorOutput.trim().split('\n').map(l => ` ${l}`).join('\n'));
534
+ }
535
+
536
+ if (exitCode !== 0) {
537
+ console.log(`${c.yellow}✗ Update command failed with exit code ${exitCode}${c.reset}`);
538
+ process.exit(exitCode);
539
+ }
540
+ } catch (err) {
541
+ console.log(`${c.yellow}✗ Update command failed: ${err}${c.reset}`);
542
+ process.exit(1);
543
+ }
544
+ }
545
+
546
+ await indexFiles(col.pwd, col.glob_pattern, col.name, true);
547
+ console.log("");
548
+ }
549
+
550
+ // Check if any documents need embedding (show once at end)
551
+ const finalDb = getDb();
552
+ const needsEmbedding = getHashesNeedingEmbedding(finalDb);
553
+ closeDb();
554
+
555
+ console.log(`${c.green}✓ All collections updated.${c.reset}`);
556
+ if (needsEmbedding > 0) {
557
+ console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Detect which collection (if any) contains the given filesystem path.
563
+ * Returns { collectionId, collectionName, relativePath } or null if not in any collection.
564
+ */
565
+ function detectCollectionFromPath(db: Database, fsPath: string): { collectionName: string; relativePath: string } | null {
566
+ const realPath = getRealPath(fsPath);
567
+
568
+ // Find collections that this path is under from YAML
569
+ const allCollections = yamlListCollections();
570
+
571
+ // Find longest matching path
572
+ let bestMatch: { name: string; path: string } | null = null;
573
+ for (const coll of allCollections) {
574
+ if (realPath.startsWith(coll.path + '/') || realPath === coll.path) {
575
+ if (!bestMatch || coll.path.length > bestMatch.path.length) {
576
+ bestMatch = { name: coll.name, path: coll.path };
577
+ }
578
+ }
579
+ }
580
+
581
+ if (!bestMatch) return null;
582
+
583
+ // Calculate relative path
584
+ let relativePath = realPath;
585
+ if (relativePath.startsWith(bestMatch.path + '/')) {
586
+ relativePath = relativePath.slice(bestMatch.path.length + 1);
587
+ } else if (relativePath === bestMatch.path) {
588
+ relativePath = '';
589
+ }
590
+
591
+ return {
592
+ collectionName: bestMatch.name,
593
+ relativePath
594
+ };
595
+ }
596
+
597
+ async function contextAdd(pathArg: string | undefined, contextText: string): Promise<void> {
598
+ const db = getDb();
599
+
600
+ // Handle "/" as global context (applies to all collections)
601
+ if (pathArg === '/') {
602
+ setGlobalContext(contextText);
603
+ console.log(`${c.green}✓${c.reset} Set global context`);
604
+ console.log(`${c.dim}Context: ${contextText}${c.reset}`);
605
+ closeDb();
606
+ return;
607
+ }
608
+
609
+ // Resolve path - defaults to current directory if not provided
610
+ let fsPath = pathArg || '.';
611
+ if (fsPath === '.' || fsPath === './') {
612
+ fsPath = getPwd();
613
+ } else if (fsPath.startsWith('~/')) {
614
+ fsPath = homedir() + fsPath.slice(1);
615
+ } else if (!fsPath.startsWith('/') && !fsPath.startsWith('qmd://')) {
616
+ fsPath = resolve(getPwd(), fsPath);
617
+ }
618
+
619
+ // Handle virtual paths (qmd://collection/path)
620
+ if (isVirtualPath(fsPath)) {
621
+ const parsed = parseVirtualPath(fsPath);
622
+ if (!parsed) {
623
+ console.error(`${c.yellow}Invalid virtual path: ${fsPath}${c.reset}`);
624
+ process.exit(1);
625
+ }
626
+
627
+ const coll = getCollectionFromYaml(parsed.collectionName);
628
+ if (!coll) {
629
+ console.error(`${c.yellow}Collection not found: ${parsed.collectionName}${c.reset}`);
630
+ process.exit(1);
631
+ }
632
+
633
+ yamlAddContext(parsed.collectionName, parsed.path, contextText);
634
+
635
+ const displayPath = parsed.path
636
+ ? `qmd://${parsed.collectionName}/${parsed.path}`
637
+ : `qmd://${parsed.collectionName}/ (collection root)`;
638
+ console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
639
+ console.log(`${c.dim}Context: ${contextText}${c.reset}`);
640
+ closeDb();
641
+ return;
642
+ }
643
+
644
+ // Detect collection from filesystem path
645
+ const detected = detectCollectionFromPath(db, fsPath);
646
+ if (!detected) {
647
+ console.error(`${c.yellow}Path is not in any indexed collection: ${fsPath}${c.reset}`);
648
+ console.error(`${c.dim}Run 'qmd status' to see indexed collections${c.reset}`);
649
+ process.exit(1);
650
+ }
651
+
652
+ yamlAddContext(detected.collectionName, detected.relativePath, contextText);
653
+
654
+ const displayPath = detected.relativePath ? `qmd://${detected.collectionName}/${detected.relativePath}` : `qmd://${detected.collectionName}/`;
655
+ console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
656
+ console.log(`${c.dim}Context: ${contextText}${c.reset}`);
657
+ closeDb();
658
+ }
659
+
660
+ function contextList(): void {
661
+ const db = getDb();
662
+
663
+ const allContexts = listAllContexts();
664
+
665
+ if (allContexts.length === 0) {
666
+ console.log(`${c.dim}No contexts configured. Use 'qmd context add' to add one.${c.reset}`);
667
+ closeDb();
668
+ return;
669
+ }
670
+
671
+ console.log(`\n${c.bold}Configured Contexts${c.reset}\n`);
672
+
673
+ let lastCollection = '';
674
+ for (const ctx of allContexts) {
675
+ if (ctx.collection !== lastCollection) {
676
+ console.log(`${c.cyan}${ctx.collection}${c.reset}`);
677
+ lastCollection = ctx.collection;
678
+ }
679
+
680
+ const displayPath = ctx.path ? ` ${ctx.path}` : ' / (root)';
681
+ console.log(`${displayPath}`);
682
+ console.log(` ${c.dim}${ctx.context}${c.reset}`);
683
+ }
684
+
685
+ closeDb();
686
+ }
687
+
688
+ function contextRemove(pathArg: string): void {
689
+ if (pathArg === '/') {
690
+ // Remove global context
691
+ setGlobalContext(undefined);
692
+ console.log(`${c.green}✓${c.reset} Removed global context`);
693
+ return;
694
+ }
695
+
696
+ // Handle virtual paths
697
+ if (isVirtualPath(pathArg)) {
698
+ const parsed = parseVirtualPath(pathArg);
699
+ if (!parsed) {
700
+ console.error(`${c.yellow}Invalid virtual path: ${pathArg}${c.reset}`);
701
+ process.exit(1);
702
+ }
703
+
704
+ const coll = getCollectionFromYaml(parsed.collectionName);
705
+ if (!coll) {
706
+ console.error(`${c.yellow}Collection not found: ${parsed.collectionName}${c.reset}`);
707
+ process.exit(1);
708
+ }
709
+
710
+ const success = yamlRemoveContext(coll.name, parsed.path);
711
+
712
+ if (!success) {
713
+ console.error(`${c.yellow}No context found for: ${pathArg}${c.reset}`);
714
+ process.exit(1);
715
+ }
716
+
717
+ console.log(`${c.green}✓${c.reset} Removed context for: ${pathArg}`);
718
+ return;
719
+ }
720
+
721
+ // Handle filesystem paths
722
+ let fsPath = pathArg;
723
+ if (fsPath === '.' || fsPath === './') {
724
+ fsPath = getPwd();
725
+ } else if (fsPath.startsWith('~/')) {
726
+ fsPath = homedir() + fsPath.slice(1);
727
+ } else if (!fsPath.startsWith('/')) {
728
+ fsPath = resolve(getPwd(), fsPath);
729
+ }
730
+
731
+ const db = getDb();
732
+ const detected = detectCollectionFromPath(db, fsPath);
733
+ closeDb();
734
+
735
+ if (!detected) {
736
+ console.error(`${c.yellow}Path is not in any indexed collection: ${fsPath}${c.reset}`);
737
+ process.exit(1);
738
+ }
739
+
740
+ const success = yamlRemoveContext(detected.collectionName, detected.relativePath);
741
+
742
+ if (!success) {
743
+ console.error(`${c.yellow}No context found for: qmd://${detected.collectionName}/${detected.relativePath}${c.reset}`);
744
+ process.exit(1);
745
+ }
746
+
747
+ console.log(`${c.green}✓${c.reset} Removed context for: qmd://${detected.collectionName}/${detected.relativePath}`);
748
+ }
749
+
750
+ function contextCheck(): void {
751
+ const db = getDb();
752
+
753
+ // Get collections without any context
754
+ const collectionsWithoutContext = getCollectionsWithoutContext(db);
755
+
756
+ // Get all collections to check for missing path contexts
757
+ const allCollections = listCollections(db);
758
+
759
+ if (collectionsWithoutContext.length === 0 && allCollections.length > 0) {
760
+ // Check if all collections have contexts
761
+ console.log(`\n${c.green}✓${c.reset} ${c.bold}All collections have context configured${c.reset}\n`);
762
+ }
763
+
764
+ if (collectionsWithoutContext.length > 0) {
765
+ console.log(`\n${c.yellow}Collections without any context:${c.reset}\n`);
766
+
767
+ for (const coll of collectionsWithoutContext) {
768
+ console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(${coll.doc_count} documents)${c.reset}`);
769
+ console.log(` ${c.dim}Suggestion: qmd context add qmd://${coll.name}/ "Description of ${coll.name}"${c.reset}\n`);
770
+ }
771
+ }
772
+
773
+ // Check for top-level paths without context within collections that DO have context
774
+ const collectionsWithContext = allCollections.filter(c =>
775
+ c && !collectionsWithoutContext.some(cwc => cwc.name === c.name)
776
+ );
777
+
778
+ let hasPathSuggestions = false;
779
+
780
+ for (const coll of collectionsWithContext) {
781
+ if (!coll) continue;
782
+ const missingPaths = getTopLevelPathsWithoutContext(db, coll.name);
783
+
784
+ if (missingPaths.length > 0) {
785
+ if (!hasPathSuggestions) {
786
+ console.log(`${c.yellow}Top-level directories without context:${c.reset}\n`);
787
+ hasPathSuggestions = true;
788
+ }
789
+
790
+ console.log(`${c.cyan}${coll.name}${c.reset}`);
791
+ for (const path of missingPaths) {
792
+ console.log(` ${path}`);
793
+ console.log(` ${c.dim}Suggestion: qmd context add qmd://${coll.name}/${path} "Description of ${path}"${c.reset}`);
794
+ }
795
+ console.log('');
796
+ }
797
+ }
798
+
799
+ if (collectionsWithoutContext.length === 0 && !hasPathSuggestions) {
800
+ console.log(`${c.dim}All collections and major paths have context configured.${c.reset}`);
801
+ console.log(`${c.dim}Use 'qmd context list' to see all configured contexts.${c.reset}\n`);
802
+ }
803
+
804
+ closeDb();
805
+ }
806
+
807
+ function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean): void {
808
+ const db = getDb();
809
+
810
+ // Parse :linenum suffix from filename (e.g., "file.md:100")
811
+ let inputPath = filename;
812
+ const colonMatch = inputPath.match(/:(\d+)$/);
813
+ if (colonMatch && !fromLine) {
814
+ const matched = colonMatch[1];
815
+ if (matched) {
816
+ fromLine = parseInt(matched, 10);
817
+ inputPath = inputPath.slice(0, -colonMatch[0].length);
818
+ }
819
+ }
820
+
821
+ // Handle docid lookup (#abc123, abc123, "#abc123", "abc123", etc.)
822
+ if (isDocid(inputPath)) {
823
+ const docidMatch = findDocumentByDocid(db, inputPath);
824
+ if (docidMatch) {
825
+ inputPath = docidMatch.filepath;
826
+ } else {
827
+ console.error(`Document not found: ${filename}`);
828
+ closeDb();
829
+ process.exit(1);
830
+ }
831
+ }
832
+
833
+ let doc: { collectionName: string; path: string; body: string } | null = null;
834
+ let virtualPath: string;
835
+
836
+ // Handle virtual paths (qmd://collection/path)
837
+ if (isVirtualPath(inputPath)) {
838
+ const parsed = parseVirtualPath(inputPath);
839
+ if (!parsed) {
840
+ console.error(`Invalid virtual path: ${inputPath}`);
841
+ closeDb();
842
+ process.exit(1);
843
+ }
844
+
845
+ // Try exact match on collection + path
846
+ doc = db.prepare(`
847
+ SELECT d.collection as collectionName, d.path, content.doc as body
848
+ FROM documents d
849
+ JOIN content ON content.hash = d.hash
850
+ WHERE d.collection = ? AND d.path = ? AND d.active = 1
851
+ `).get(parsed.collectionName, parsed.path) as typeof doc;
852
+
853
+ if (!doc) {
854
+ // Try fuzzy match by path ending
855
+ doc = db.prepare(`
856
+ SELECT d.collection as collectionName, d.path, content.doc as body
857
+ FROM documents d
858
+ JOIN content ON content.hash = d.hash
859
+ WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
860
+ LIMIT 1
861
+ `).get(parsed.collectionName, `%${parsed.path}`) as typeof doc;
862
+ }
863
+
864
+ virtualPath = inputPath;
865
+ } else {
866
+ // Try to interpret as collection/path format first (before filesystem path)
867
+ // If path is relative (no / or ~ prefix), check if first component is a collection name
868
+ if (!inputPath.startsWith('/') && !inputPath.startsWith('~')) {
869
+ const parts = inputPath.split('/');
870
+ if (parts.length >= 2) {
871
+ const possibleCollection = parts[0];
872
+ const possiblePath = parts.slice(1).join('/');
873
+
874
+ // Check if this collection exists
875
+ const collExists = possibleCollection ? db.prepare(`
876
+ SELECT 1 FROM documents WHERE collection = ? AND active = 1 LIMIT 1
877
+ `).get(possibleCollection) : null;
878
+
879
+ if (collExists) {
880
+ // Try exact match on collection + path
881
+ doc = db.prepare(`
882
+ SELECT d.collection as collectionName, d.path, content.doc as body
883
+ FROM documents d
884
+ JOIN content ON content.hash = d.hash
885
+ WHERE d.collection = ? AND d.path = ? AND d.active = 1
886
+ `).get(possibleCollection || "", possiblePath || "") as { collectionName: string; path: string; body: string } | null;
887
+
888
+ if (!doc) {
889
+ // Try fuzzy match by path ending
890
+ doc = db.prepare(`
891
+ SELECT d.collection as collectionName, d.path, content.doc as body
892
+ FROM documents d
893
+ JOIN content ON content.hash = d.hash
894
+ WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
895
+ LIMIT 1
896
+ `).get(possibleCollection || "", `%${possiblePath}`) as { collectionName: string; path: string; body: string } | null;
897
+ }
898
+
899
+ if (doc) {
900
+ virtualPath = buildVirtualPath(doc.collectionName, doc.path);
901
+ // Skip the filesystem path handling below
902
+ }
903
+ }
904
+ }
905
+ }
906
+
907
+ // If not found as collection/path, handle as filesystem paths
908
+ if (!doc) {
909
+ let fsPath = inputPath;
910
+
911
+ // Expand ~ to home directory
912
+ if (fsPath.startsWith('~/')) {
913
+ fsPath = homedir() + fsPath.slice(1);
914
+ } else if (!fsPath.startsWith('/')) {
915
+ // Relative path - resolve from current directory
916
+ fsPath = resolve(getPwd(), fsPath);
917
+ }
918
+ fsPath = getRealPath(fsPath);
919
+
920
+ // Try to detect which collection contains this path
921
+ const detected = detectCollectionFromPath(db, fsPath);
922
+
923
+ if (detected) {
924
+ // Found collection - query by collection name + relative path
925
+ doc = db.prepare(`
926
+ SELECT d.collection as collectionName, d.path, content.doc as body
927
+ FROM documents d
928
+ JOIN content ON content.hash = d.hash
929
+ WHERE d.collection = ? AND d.path = ? AND d.active = 1
930
+ `).get(detected.collectionName, detected.relativePath) as { collectionName: string; path: string; body: string } | null;
931
+ }
932
+
933
+ // Fuzzy match by filename (last component of path)
934
+ if (!doc) {
935
+ const filename = inputPath.split('/').pop() || inputPath;
936
+ doc = db.prepare(`
937
+ SELECT d.collection as collectionName, d.path, content.doc as body
938
+ FROM documents d
939
+ JOIN content ON content.hash = d.hash
940
+ WHERE d.path LIKE ? AND d.active = 1
941
+ LIMIT 1
942
+ `).get(`%${filename}`) as { collectionName: string; path: string; body: string } | null;
943
+ }
944
+
945
+ if (doc) {
946
+ virtualPath = buildVirtualPath(doc.collectionName, doc.path);
947
+ } else {
948
+ virtualPath = inputPath;
949
+ }
950
+ }
951
+ }
952
+
953
+ // Ensure doc is not null before proceeding
954
+ if (!doc) {
955
+ console.error(`Document not found: ${filename}`);
956
+ closeDb();
957
+ process.exit(1);
958
+ }
959
+
960
+ // Get context for this file
961
+ const context = getContextForPath(db, doc.collectionName, doc.path);
962
+
963
+ let output = doc.body;
964
+ const startLine = fromLine || 1;
965
+
966
+ // Apply line filtering if specified
967
+ if (fromLine !== undefined || maxLines !== undefined) {
968
+ const lines = output.split('\n');
969
+ const start = startLine - 1; // Convert to 0-indexed
970
+ const end = maxLines !== undefined ? start + maxLines : lines.length;
971
+ output = lines.slice(start, end).join('\n');
972
+ }
973
+
974
+ // Add line numbers if requested
975
+ if (lineNumbers) {
976
+ output = addLineNumbers(output, startLine);
977
+ }
978
+
979
+ // Output context header if exists
980
+ if (context) {
981
+ console.log(`Folder Context: ${context}\n---\n`);
982
+ }
983
+ console.log(output);
984
+ closeDb();
985
+ }
986
+
987
+ // Multi-get: fetch multiple documents by glob pattern or comma-separated list
988
+ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT_MULTI_GET_MAX_BYTES, format: OutputFormat = "cli"): void {
989
+ const db = getDb();
990
+
991
+ // Check if it's a comma-separated list or a glob pattern
992
+ const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?');
993
+
994
+ let files: { filepath: string; displayPath: string; bodyLength: number; collection?: string; path?: string }[];
995
+
996
+ if (isCommaSeparated) {
997
+ // Comma-separated list of files (can be virtual paths or relative paths)
998
+ const names = pattern.split(',').map(s => s.trim()).filter(Boolean);
999
+ files = [];
1000
+ for (const name of names) {
1001
+ let doc: { virtual_path: string; body_length: number; collection: string; path: string } | null = null;
1002
+
1003
+ // Handle virtual paths
1004
+ if (isVirtualPath(name)) {
1005
+ const parsed = parseVirtualPath(name);
1006
+ if (parsed) {
1007
+ // Try exact match on collection + path
1008
+ doc = db.prepare(`
1009
+ SELECT
1010
+ 'qmd://' || d.collection || '/' || d.path as virtual_path,
1011
+ LENGTH(content.doc) as body_length,
1012
+ d.collection,
1013
+ d.path
1014
+ FROM documents d
1015
+ JOIN content ON content.hash = d.hash
1016
+ WHERE d.collection = ? AND d.path = ? AND d.active = 1
1017
+ `).get(parsed.collectionName, parsed.path) as typeof doc;
1018
+ }
1019
+ } else {
1020
+ // Try exact match on path
1021
+ doc = db.prepare(`
1022
+ SELECT
1023
+ 'qmd://' || d.collection || '/' || d.path as virtual_path,
1024
+ LENGTH(content.doc) as body_length,
1025
+ d.collection,
1026
+ d.path
1027
+ FROM documents d
1028
+ JOIN content ON content.hash = d.hash
1029
+ WHERE d.path = ? AND d.active = 1
1030
+ LIMIT 1
1031
+ `).get(name) as { virtual_path: string; body_length: number; collection: string; path: string } | null;
1032
+
1033
+ // Try suffix match
1034
+ if (!doc) {
1035
+ doc = db.prepare(`
1036
+ SELECT
1037
+ 'qmd://' || d.collection || '/' || d.path as virtual_path,
1038
+ LENGTH(content.doc) as body_length,
1039
+ d.collection,
1040
+ d.path
1041
+ FROM documents d
1042
+ JOIN content ON content.hash = d.hash
1043
+ WHERE d.path LIKE ? AND d.active = 1
1044
+ LIMIT 1
1045
+ `).get(`%${name}`) as { virtual_path: string; body_length: number; collection: string; path: string } | null;
1046
+ }
1047
+ }
1048
+
1049
+ if (doc) {
1050
+ files.push({
1051
+ filepath: doc.virtual_path,
1052
+ displayPath: doc.virtual_path,
1053
+ bodyLength: doc.body_length,
1054
+ collection: doc.collection,
1055
+ path: doc.path
1056
+ });
1057
+ } else {
1058
+ console.error(`File not found: ${name}`);
1059
+ }
1060
+ }
1061
+ } else {
1062
+ // Glob pattern - matchFilesByGlob now returns virtual paths
1063
+ files = matchFilesByGlob(db, pattern).map(f => ({
1064
+ ...f,
1065
+ collection: undefined, // Will be fetched later if needed
1066
+ path: undefined
1067
+ }));
1068
+ if (files.length === 0) {
1069
+ console.error(`No files matched pattern: ${pattern}`);
1070
+ closeDb();
1071
+ process.exit(1);
1072
+ }
1073
+ }
1074
+
1075
+ // Collect results for structured output
1076
+ const results: { file: string; displayPath: string; title: string; body: string; context: string | null; skipped: boolean; skipReason?: string }[] = [];
1077
+
1078
+ for (const file of files) {
1079
+ // Parse virtual path to get collection info if not already available
1080
+ let collection = file.collection;
1081
+ let path = file.path;
1082
+
1083
+ if (!collection || !path) {
1084
+ const parsed = parseVirtualPath(file.filepath);
1085
+ if (parsed) {
1086
+ collection = parsed.collectionName;
1087
+ path = parsed.path;
1088
+ }
1089
+ }
1090
+
1091
+ // Get context using collection-scoped function
1092
+ const context = collection && path ? getContextForPath(db, collection, path) : null;
1093
+
1094
+ // Check size limit
1095
+ if (file.bodyLength > maxBytes) {
1096
+ results.push({
1097
+ file: file.filepath,
1098
+ displayPath: file.displayPath,
1099
+ title: file.displayPath.split('/').pop() || file.displayPath,
1100
+ body: "",
1101
+ context,
1102
+ skipped: true,
1103
+ skipReason: `File too large (${Math.round(file.bodyLength / 1024)}KB > ${Math.round(maxBytes / 1024)}KB). Use 'qmd get ${file.displayPath}' to retrieve.`,
1104
+ });
1105
+ continue;
1106
+ }
1107
+
1108
+ // Fetch document content using collection and path
1109
+ if (!collection || !path) continue;
1110
+
1111
+ const doc = db.prepare(`
1112
+ SELECT content.doc as body, d.title
1113
+ FROM documents d
1114
+ JOIN content ON content.hash = d.hash
1115
+ WHERE d.collection = ? AND d.path = ? AND d.active = 1
1116
+ `).get(collection, path) as { body: string; title: string } | null;
1117
+
1118
+ if (!doc) continue;
1119
+
1120
+ let body = doc.body;
1121
+
1122
+ // Apply line limit if specified
1123
+ if (maxLines !== undefined) {
1124
+ const lines = body.split('\n');
1125
+ body = lines.slice(0, maxLines).join('\n');
1126
+ if (lines.length > maxLines) {
1127
+ body += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
1128
+ }
1129
+ }
1130
+
1131
+ results.push({
1132
+ file: file.filepath,
1133
+ displayPath: file.displayPath,
1134
+ title: doc.title || file.displayPath.split('/').pop() || file.displayPath,
1135
+ body,
1136
+ context,
1137
+ skipped: false,
1138
+ });
1139
+ }
1140
+
1141
+ closeDb();
1142
+
1143
+ // Output based on format
1144
+ if (format === "json") {
1145
+ const output = results.map(r => ({
1146
+ file: r.displayPath,
1147
+ title: r.title,
1148
+ ...(r.context && { context: r.context }),
1149
+ ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
1150
+ }));
1151
+ console.log(JSON.stringify(output, null, 2));
1152
+ } else if (format === "csv") {
1153
+ const escapeField = (val: string | null | undefined): string => {
1154
+ if (val === null || val === undefined) return "";
1155
+ const str = String(val);
1156
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
1157
+ return `"${str.replace(/"/g, '""')}"`;
1158
+ }
1159
+ return str;
1160
+ };
1161
+ console.log("file,title,context,skipped,body");
1162
+ for (const r of results) {
1163
+ console.log([r.displayPath, r.title, r.context, r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(","));
1164
+ }
1165
+ } else if (format === "files") {
1166
+ for (const r of results) {
1167
+ const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
1168
+ const status = r.skipped ? "[SKIPPED]" : "";
1169
+ console.log(`${r.displayPath}${ctx}${status ? `,${status}` : ""}`);
1170
+ }
1171
+ } else if (format === "md") {
1172
+ for (const r of results) {
1173
+ console.log(`## ${r.displayPath}\n`);
1174
+ if (r.title && r.title !== r.displayPath) console.log(`**Title:** ${r.title}\n`);
1175
+ if (r.context) console.log(`**Context:** ${r.context}\n`);
1176
+ if (r.skipped) {
1177
+ console.log(`> ${r.skipReason}\n`);
1178
+ } else {
1179
+ console.log("```");
1180
+ console.log(r.body);
1181
+ console.log("```\n");
1182
+ }
1183
+ }
1184
+ } else if (format === "xml") {
1185
+ console.log('<?xml version="1.0" encoding="UTF-8"?>');
1186
+ console.log("<documents>");
1187
+ for (const r of results) {
1188
+ console.log(" <document>");
1189
+ console.log(` <file>${escapeXml(r.displayPath)}</file>`);
1190
+ console.log(` <title>${escapeXml(r.title)}</title>`);
1191
+ if (r.context) console.log(` <context>${escapeXml(r.context)}</context>`);
1192
+ if (r.skipped) {
1193
+ console.log(` <skipped>true</skipped>`);
1194
+ console.log(` <reason>${escapeXml(r.skipReason || "")}</reason>`);
1195
+ } else {
1196
+ console.log(` <body>${escapeXml(r.body)}</body>`);
1197
+ }
1198
+ console.log(" </document>");
1199
+ }
1200
+ console.log("</documents>");
1201
+ } else {
1202
+ // CLI format (default)
1203
+ for (const r of results) {
1204
+ console.log(`\n${'='.repeat(60)}`);
1205
+ console.log(`File: ${r.displayPath}`);
1206
+ console.log(`${'='.repeat(60)}\n`);
1207
+
1208
+ if (r.skipped) {
1209
+ console.log(`[SKIPPED: ${r.skipReason}]`);
1210
+ continue;
1211
+ }
1212
+
1213
+ if (r.context) {
1214
+ console.log(`Folder Context: ${r.context}\n---\n`);
1215
+ }
1216
+ console.log(r.body);
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ // List files in virtual file tree
1222
+ function listFiles(pathArg?: string): void {
1223
+ const db = getDb();
1224
+
1225
+ if (!pathArg) {
1226
+ // No argument - list all collections
1227
+ const yamlCollections = yamlListCollections();
1228
+
1229
+ if (yamlCollections.length === 0) {
1230
+ console.log("No collections found. Run 'qmd add .' to index files.");
1231
+ closeDb();
1232
+ return;
1233
+ }
1234
+
1235
+ // Get file counts from database for each collection
1236
+ const collections = yamlCollections.map(coll => {
1237
+ const stats = db.prepare(`
1238
+ SELECT COUNT(*) as file_count
1239
+ FROM documents d
1240
+ WHERE d.collection = ? AND d.active = 1
1241
+ `).get(coll.name) as { file_count: number } | null;
1242
+
1243
+ return {
1244
+ name: coll.name,
1245
+ file_count: stats?.file_count || 0
1246
+ };
1247
+ });
1248
+
1249
+ console.log(`${c.bold}Collections:${c.reset}\n`);
1250
+ for (const coll of collections) {
1251
+ console.log(` ${c.dim}qmd://${c.reset}${c.cyan}${coll.name}/${c.reset} ${c.dim}(${coll.file_count} files)${c.reset}`);
1252
+ }
1253
+ closeDb();
1254
+ return;
1255
+ }
1256
+
1257
+ // Parse the path argument
1258
+ let collectionName: string;
1259
+ let pathPrefix: string | null = null;
1260
+
1261
+ if (pathArg.startsWith('qmd://')) {
1262
+ // Virtual path format: qmd://collection/path
1263
+ const parsed = parseVirtualPath(pathArg);
1264
+ if (!parsed) {
1265
+ console.error(`Invalid virtual path: ${pathArg}`);
1266
+ closeDb();
1267
+ process.exit(1);
1268
+ }
1269
+ collectionName = parsed.collectionName;
1270
+ pathPrefix = parsed.path;
1271
+ } else {
1272
+ // Just collection name or collection/path
1273
+ const parts = pathArg.split('/');
1274
+ collectionName = parts[0] || '';
1275
+ if (parts.length > 1) {
1276
+ pathPrefix = parts.slice(1).join('/');
1277
+ }
1278
+ }
1279
+
1280
+ // Get the collection
1281
+ const coll = getCollectionFromYaml(collectionName);
1282
+ if (!coll) {
1283
+ console.error(`Collection not found: ${collectionName}`);
1284
+ console.error(`Run 'qmd ls' to see available collections.`);
1285
+ closeDb();
1286
+ process.exit(1);
1287
+ }
1288
+
1289
+ // List files in the collection with size and modification time
1290
+ let query: string;
1291
+ let params: any[];
1292
+
1293
+ if (pathPrefix) {
1294
+ // List files under a specific path
1295
+ query = `
1296
+ SELECT d.path, d.title, d.modified_at, LENGTH(ct.doc) as size
1297
+ FROM documents d
1298
+ JOIN content ct ON d.hash = ct.hash
1299
+ WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
1300
+ ORDER BY d.path
1301
+ `;
1302
+ params = [coll.name, `${pathPrefix}%`];
1303
+ } else {
1304
+ // List all files in the collection
1305
+ query = `
1306
+ SELECT d.path, d.title, d.modified_at, LENGTH(ct.doc) as size
1307
+ FROM documents d
1308
+ JOIN content ct ON d.hash = ct.hash
1309
+ WHERE d.collection = ? AND d.active = 1
1310
+ ORDER BY d.path
1311
+ `;
1312
+ params = [coll.name];
1313
+ }
1314
+
1315
+ const files = db.prepare(query).all(...params) as { path: string; title: string; modified_at: string; size: number }[];
1316
+
1317
+ if (files.length === 0) {
1318
+ if (pathPrefix) {
1319
+ console.log(`No files found under qmd://${collectionName}/${pathPrefix}`);
1320
+ } else {
1321
+ console.log(`No files found in collection: ${collectionName}`);
1322
+ }
1323
+ closeDb();
1324
+ return;
1325
+ }
1326
+
1327
+ // Calculate max widths for alignment
1328
+ const maxSize = Math.max(...files.map(f => formatBytes(f.size).length));
1329
+
1330
+ // Output in ls -l style
1331
+ for (const file of files) {
1332
+ const sizeStr = formatBytes(file.size).padStart(maxSize);
1333
+ const date = new Date(file.modified_at);
1334
+ const timeStr = formatLsTime(date);
1335
+
1336
+ // Dim the qmd:// prefix, highlight the filename
1337
+ console.log(`${sizeStr} ${timeStr} ${c.dim}qmd://${collectionName}/${c.reset}${c.cyan}${file.path}${c.reset}`);
1338
+ }
1339
+
1340
+ closeDb();
1341
+ }
1342
+
1343
+ // Format date/time like ls -l
1344
+ function formatLsTime(date: Date): string {
1345
+ const now = new Date();
1346
+ const sixMonthsAgo = new Date(now.getTime() - 6 * 30 * 24 * 60 * 60 * 1000);
1347
+
1348
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1349
+ const month = months[date.getMonth()];
1350
+ const day = date.getDate().toString().padStart(2, ' ');
1351
+
1352
+ // If file is older than 6 months, show year instead of time
1353
+ if (date < sixMonthsAgo) {
1354
+ const year = date.getFullYear();
1355
+ return `${month} ${day} ${year}`;
1356
+ } else {
1357
+ const hours = date.getHours().toString().padStart(2, '0');
1358
+ const minutes = date.getMinutes().toString().padStart(2, '0');
1359
+ return `${month} ${day} ${hours}:${minutes}`;
1360
+ }
1361
+ }
1362
+
1363
+ // Collection management commands
1364
+ function collectionList(): void {
1365
+ const db = getDb();
1366
+ const collections = listCollections(db);
1367
+
1368
+ if (collections.length === 0) {
1369
+ console.log("No collections found. Run 'qmd add .' to create one.");
1370
+ closeDb();
1371
+ return;
1372
+ }
1373
+
1374
+ console.log(`${c.bold}Collections (${collections.length}):${c.reset}\n`);
1375
+
1376
+ for (const coll of collections) {
1377
+ const updatedAt = coll.last_modified ? new Date(coll.last_modified) : new Date();
1378
+ const timeAgo = formatTimeAgo(updatedAt);
1379
+
1380
+ console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(qmd://${coll.name}/)${c.reset}`);
1381
+ console.log(` ${c.dim}Pattern:${c.reset} ${coll.glob_pattern}`);
1382
+ console.log(` ${c.dim}Files:${c.reset} ${coll.active_count}`);
1383
+ console.log(` ${c.dim}Updated:${c.reset} ${timeAgo}`);
1384
+ console.log();
1385
+ }
1386
+
1387
+ closeDb();
1388
+ }
1389
+
1390
+ async function collectionAdd(pwd: string, globPattern: string, name?: string): Promise<void> {
1391
+ // If name not provided, generate from pwd basename
1392
+ let collName = name;
1393
+ if (!collName) {
1394
+ const parts = pwd.split('/').filter(Boolean);
1395
+ collName = parts[parts.length - 1] || 'root';
1396
+ }
1397
+
1398
+ // Check if collection with this name already exists in YAML
1399
+ const existing = getCollectionFromYaml(collName);
1400
+ if (existing) {
1401
+ console.error(`${c.yellow}Collection '${collName}' already exists.${c.reset}`);
1402
+ console.error(`Use a different name with --name <name>`);
1403
+ process.exit(1);
1404
+ }
1405
+
1406
+ // Check if a collection with this pwd+glob already exists in YAML
1407
+ const allCollections = yamlListCollections();
1408
+ const existingPwdGlob = allCollections.find(c => c.path === pwd && c.pattern === globPattern);
1409
+
1410
+ if (existingPwdGlob) {
1411
+ console.error(`${c.yellow}A collection already exists for this path and pattern:${c.reset}`);
1412
+ console.error(` Name: ${existingPwdGlob.name} (qmd://${existingPwdGlob.name}/)`);
1413
+ console.error(` Pattern: ${globPattern}`);
1414
+ console.error(`\nUse 'qmd update' to re-index it, or remove it first with 'qmd collection remove ${existingPwdGlob.name}'`);
1415
+ process.exit(1);
1416
+ }
1417
+
1418
+ // Add to YAML config
1419
+ const { addCollection } = await import("./collections.js");
1420
+ addCollection(collName, pwd, globPattern);
1421
+
1422
+ // Create the collection and index files
1423
+ console.log(`Creating collection '${collName}'...`);
1424
+ await indexFiles(pwd, globPattern, collName);
1425
+ console.log(`${c.green}✓${c.reset} Collection '${collName}' created successfully`);
1426
+ }
1427
+
1428
+ function collectionRemove(name: string): void {
1429
+ // Check if collection exists in YAML
1430
+ const coll = getCollectionFromYaml(name);
1431
+ if (!coll) {
1432
+ console.error(`${c.yellow}Collection not found: ${name}${c.reset}`);
1433
+ console.error(`Run 'qmd collection list' to see available collections.`);
1434
+ process.exit(1);
1435
+ }
1436
+
1437
+ const db = getDb();
1438
+ const result = removeCollection(db, name);
1439
+ closeDb();
1440
+
1441
+ console.log(`${c.green}✓${c.reset} Removed collection '${name}'`);
1442
+ console.log(` Deleted ${result.deletedDocs} documents`);
1443
+ if (result.cleanedHashes > 0) {
1444
+ console.log(` Cleaned up ${result.cleanedHashes} orphaned content hashes`);
1445
+ }
1446
+ }
1447
+
1448
+ function collectionRename(oldName: string, newName: string): void {
1449
+ // Check if old collection exists in YAML
1450
+ const coll = getCollectionFromYaml(oldName);
1451
+ if (!coll) {
1452
+ console.error(`${c.yellow}Collection not found: ${oldName}${c.reset}`);
1453
+ console.error(`Run 'qmd collection list' to see available collections.`);
1454
+ process.exit(1);
1455
+ }
1456
+
1457
+ // Check if new name already exists in YAML
1458
+ const existing = getCollectionFromYaml(newName);
1459
+ if (existing) {
1460
+ console.error(`${c.yellow}Collection name already exists: ${newName}${c.reset}`);
1461
+ console.error(`Choose a different name or remove the existing collection first.`);
1462
+ process.exit(1);
1463
+ }
1464
+
1465
+ const db = getDb();
1466
+ renameCollection(db, oldName, newName);
1467
+ closeDb();
1468
+
1469
+ console.log(`${c.green}✓${c.reset} Renamed collection '${oldName}' to '${newName}'`);
1470
+ console.log(` Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`);
1471
+ }
1472
+
1473
+ async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, collectionName?: string, suppressEmbedNotice: boolean = false): Promise<void> {
1474
+ const db = getDb();
1475
+ const resolvedPwd = pwd || getPwd();
1476
+ const now = new Date().toISOString();
1477
+ const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
1478
+
1479
+ // Clear Ollama cache on index
1480
+ clearCache(db);
1481
+
1482
+ // Collection name must be provided (from YAML)
1483
+ if (!collectionName) {
1484
+ throw new Error("Collection name is required. Collections must be defined in ~/.config/qmd/index.yml");
1485
+ }
1486
+
1487
+ console.log(`Collection: ${resolvedPwd} (${globPattern})`);
1488
+
1489
+ progress.indeterminate();
1490
+ const glob = new Glob(globPattern);
1491
+ const files: string[] = [];
1492
+ for await (const file of glob.scan({ cwd: resolvedPwd, onlyFiles: true, followSymlinks: true })) {
1493
+ // Skip node_modules, hidden folders (.*), and other common excludes
1494
+ const parts = file.split("/");
1495
+ const shouldSkip = parts.some(part =>
1496
+ part === "node_modules" ||
1497
+ part.startsWith(".") ||
1498
+ excludeDirs.includes(part)
1499
+ );
1500
+ if (!shouldSkip) {
1501
+ files.push(file);
1502
+ }
1503
+ }
1504
+
1505
+ const total = files.length;
1506
+ if (total === 0) {
1507
+ progress.clear();
1508
+ console.log("No files found matching pattern.");
1509
+ closeDb();
1510
+ return;
1511
+ }
1512
+
1513
+ let indexed = 0, updated = 0, unchanged = 0, processed = 0;
1514
+ const seenPaths = new Set<string>();
1515
+ const startTime = Date.now();
1516
+
1517
+ for (const relativeFile of files) {
1518
+ const filepath = getRealPath(resolve(resolvedPwd, relativeFile));
1519
+ const path = handelize(relativeFile); // Normalize path for token-friendliness
1520
+ seenPaths.add(path);
1521
+
1522
+ const content = readFileSync(filepath, "utf-8");
1523
+
1524
+ // Skip empty files - nothing useful to index
1525
+ if (!content.trim()) {
1526
+ processed++;
1527
+ continue;
1528
+ }
1529
+
1530
+ const hash = await hashContent(content);
1531
+ const title = extractTitle(content, relativeFile);
1532
+
1533
+ // Check if document exists in this collection with this path
1534
+ const existing = findActiveDocument(db, collectionName, path);
1535
+
1536
+ if (existing) {
1537
+ if (existing.hash === hash) {
1538
+ // Hash unchanged, but check if title needs updating
1539
+ if (existing.title !== title) {
1540
+ updateDocumentTitle(db, existing.id, title, now);
1541
+ updated++;
1542
+ } else {
1543
+ unchanged++;
1544
+ }
1545
+ } else {
1546
+ // Content changed - insert new content hash and update document
1547
+ insertContent(db, hash, content, now);
1548
+ const stat = statSync(filepath);
1549
+ updateDocument(db, existing.id, title, hash,
1550
+ stat ? new Date(stat.mtime).toISOString() : now);
1551
+ updated++;
1552
+ }
1553
+ } else {
1554
+ // New document - insert content and document
1555
+ indexed++;
1556
+ insertContent(db, hash, content, now);
1557
+ const stat = statSync(filepath);
1558
+ insertDocument(db, collectionName, path, title, hash,
1559
+ stat ? new Date(stat.birthtime).toISOString() : now,
1560
+ stat ? new Date(stat.mtime).toISOString() : now);
1561
+ }
1562
+
1563
+ processed++;
1564
+ progress.set((processed / total) * 100);
1565
+ const elapsed = (Date.now() - startTime) / 1000;
1566
+ const rate = processed / elapsed;
1567
+ const remaining = (total - processed) / rate;
1568
+ const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
1569
+ process.stderr.write(`\rIndexing: ${processed}/${total}${eta} `);
1570
+ }
1571
+
1572
+ // Deactivate documents in this collection that no longer exist
1573
+ const allActive = getActiveDocumentPaths(db, collectionName);
1574
+ let removed = 0;
1575
+ for (const path of allActive) {
1576
+ if (!seenPaths.has(path)) {
1577
+ deactivateDocument(db, collectionName, path);
1578
+ removed++;
1579
+ }
1580
+ }
1581
+
1582
+ // Clean up orphaned content hashes (content not referenced by any document)
1583
+ const orphanedContent = cleanupOrphanedContent(db);
1584
+
1585
+ // Check if vector index needs updating
1586
+ const needsEmbedding = getHashesNeedingEmbedding(db);
1587
+
1588
+ progress.clear();
1589
+ console.log(`\nIndexed: ${indexed} new, ${updated} updated, ${unchanged} unchanged, ${removed} removed`);
1590
+ if (orphanedContent > 0) {
1591
+ console.log(`Cleaned up ${orphanedContent} orphaned content hash(es)`);
1592
+ }
1593
+
1594
+ if (needsEmbedding > 0 && !suppressEmbedNotice) {
1595
+ console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
1596
+ }
1597
+
1598
+ closeDb();
1599
+ }
1600
+
1601
+ function renderProgressBar(percent: number, width: number = 30): string {
1602
+ const filled = Math.round((percent / 100) * width);
1603
+ const empty = width - filled;
1604
+ const bar = "█".repeat(filled) + "░".repeat(empty);
1605
+ return bar;
1606
+ }
1607
+
1608
+ async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = false): Promise<void> {
1609
+ const db = getDb();
1610
+ const now = new Date().toISOString();
1611
+
1612
+ // If force, clear all vectors
1613
+ if (force) {
1614
+ console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
1615
+ clearAllEmbeddings(db);
1616
+ }
1617
+
1618
+ // Find unique hashes that need embedding (from active documents)
1619
+ const hashesToEmbed = getHashesForEmbedding(db);
1620
+
1621
+ if (hashesToEmbed.length === 0) {
1622
+ console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
1623
+ closeDb();
1624
+ return;
1625
+ }
1626
+
1627
+ // Prepare documents with chunks
1628
+ type ChunkItem = { hash: string; title: string; text: string; seq: number; pos: number; tokens: number; bytes: number; displayName: string };
1629
+ const allChunks: ChunkItem[] = [];
1630
+ let multiChunkDocs = 0;
1631
+
1632
+ // Chunk all documents using actual token counts
1633
+ process.stderr.write(`Chunking ${hashesToEmbed.length} documents by token count...\n`);
1634
+ for (const item of hashesToEmbed) {
1635
+ const encoder = new TextEncoder();
1636
+ const bodyBytes = encoder.encode(item.body).length;
1637
+ if (bodyBytes === 0) continue; // Skip empty
1638
+
1639
+ const title = extractTitle(item.body, item.path);
1640
+ const displayName = item.path;
1641
+ const chunks = await chunkDocumentByTokens(item.body); // Uses actual tokenizer
1642
+
1643
+ if (chunks.length > 1) multiChunkDocs++;
1644
+
1645
+ for (let seq = 0; seq < chunks.length; seq++) {
1646
+ allChunks.push({
1647
+ hash: item.hash,
1648
+ title,
1649
+ text: chunks[seq]!.text, // Chunk is guaranteed to exist by seq loop
1650
+ seq,
1651
+ pos: chunks[seq]!.pos,
1652
+ tokens: chunks[seq]!.tokens,
1653
+ bytes: encoder.encode(chunks[seq]!.text).length,
1654
+ displayName,
1655
+ });
1656
+ }
1657
+ }
1658
+
1659
+ if (allChunks.length === 0) {
1660
+ console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
1661
+ closeDb();
1662
+ return;
1663
+ }
1664
+
1665
+ const totalBytes = allChunks.reduce((sum, chk) => sum + chk.bytes, 0);
1666
+ const totalChunks = allChunks.length;
1667
+ const totalDocs = hashesToEmbed.length;
1668
+
1669
+ console.log(`${c.bold}Embedding ${totalDocs} documents${c.reset} ${c.dim}(${totalChunks} chunks, ${formatBytes(totalBytes)})${c.reset}`);
1670
+ if (multiChunkDocs > 0) {
1671
+ console.log(`${c.dim}${multiChunkDocs} documents split into multiple chunks${c.reset}`);
1672
+ }
1673
+ console.log(`${c.dim}Model: ${model}${c.reset}\n`);
1674
+
1675
+ // Hide cursor during embedding
1676
+ cursor.hide();
1677
+
1678
+ // Check if remote embedding is available
1679
+ const remote = getRemoteLLM();
1680
+ const useRemoteEmbed = !!remote && (
1681
+ process.env.QMD_EMBED_PROVIDER === 'siliconflow' ||
1682
+ (!!process.env.QMD_SILICONFLOW_API_KEY && !process.env.QMD_EMBED_PROVIDER)
1683
+ );
1684
+
1685
+ if (useRemoteEmbed) {
1686
+ // Use remote embedding (no local GGUF model needed)
1687
+ const remoteModel = process.env.QMD_SILICONFLOW_EMBED_MODEL || 'Qwen/Qwen3-Embedding-8B';
1688
+ console.log(`${c.dim}Using remote embedding: SiliconFlow/${remoteModel}${c.reset}\n`);
1689
+
1690
+ // Get embedding dimensions from first chunk
1691
+ progress.indeterminate();
1692
+ const firstChunk = allChunks[0];
1693
+ if (!firstChunk) {
1694
+ throw new Error("No chunks available to embed");
1695
+ }
1696
+ const firstText = formatDocForEmbedding(firstChunk.text, firstChunk.title);
1697
+ const firstResult = await remote.embed(firstText, { model: remoteModel, isQuery: false });
1698
+ if (!firstResult) {
1699
+ throw new Error("Failed to get embedding dimensions from remote provider");
1700
+ }
1701
+ ensureVecTable(db, firstResult.embedding.length);
1702
+
1703
+ let chunksEmbedded = 0, errors = 0, bytesProcessed = 0;
1704
+ const startTime = Date.now();
1705
+ const BATCH_SIZE = parseInt(process.env.QMD_EMBED_BATCH_SIZE || "32", 10);
1706
+
1707
+ for (let batchStart = 0; batchStart < allChunks.length; batchStart += BATCH_SIZE) {
1708
+ const batchEnd = Math.min(batchStart + BATCH_SIZE, allChunks.length);
1709
+ const batch = allChunks.slice(batchStart, batchEnd);
1710
+ const texts = batch.map(chunk => formatDocForEmbedding(chunk.text, chunk.title));
1711
+
1712
+ try {
1713
+ const embeddings = await remote.embedBatch(texts);
1714
+ for (let i = 0; i < batch.length; i++) {
1715
+ const chunk = batch[i]!;
1716
+ const embedding = embeddings[i];
1717
+ if (embedding) {
1718
+ insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(embedding.embedding), remoteModel, now);
1719
+ chunksEmbedded++;
1720
+ } else {
1721
+ errors++;
1722
+ console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}${c.reset}`);
1723
+ }
1724
+ bytesProcessed += chunk.bytes;
1725
+ }
1726
+ } catch (err) {
1727
+ for (const chunk of batch) {
1728
+ try {
1729
+ const text = formatDocForEmbedding(chunk.text, chunk.title);
1730
+ const result = await remote.embed(text, { model: remoteModel, isQuery: false });
1731
+ if (result) {
1732
+ insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(result.embedding), remoteModel, now);
1733
+ chunksEmbedded++;
1734
+ } else {
1735
+ errors++;
1736
+ }
1737
+ } catch (innerErr) {
1738
+ errors++;
1739
+ console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${innerErr}${c.reset}`);
1740
+ }
1741
+ bytesProcessed += chunk.bytes;
1742
+ }
1743
+ }
1744
+
1745
+ const percent = (bytesProcessed / totalBytes) * 100;
1746
+ progress.set(percent);
1747
+
1748
+ const elapsed = (Date.now() - startTime) / 1000;
1749
+ const bytesPerSec = bytesProcessed / elapsed;
1750
+ const remainingBytes = totalBytes - bytesProcessed;
1751
+ const etaSec = remainingBytes / bytesPerSec;
1752
+
1753
+ const bar = renderProgressBar(percent);
1754
+ const percentStr = percent.toFixed(0).padStart(3);
1755
+ const throughput = `${formatBytes(bytesPerSec)}/s`;
1756
+ const eta = etaSec > 0 && isFinite(etaSec) ? ` · ETA ${Math.ceil(etaSec)}s` : '';
1757
+ process.stderr.write(`\r${bar} ${percentStr}% · ${chunksEmbedded}/${totalChunks} chunks · ${throughput}${eta} `);
1758
+ }
1759
+
1760
+ progress.clear();
1761
+ cursor.show();
1762
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
1763
+ console.log(`\n\n${c.green}✓ Embedded ${chunksEmbedded}/${totalChunks} chunks in ${totalTime}s${c.reset}`);
1764
+ if (errors > 0) {
1765
+ console.log(`${c.yellow} ${errors} errors${c.reset}`);
1766
+ }
1767
+ closeDb();
1768
+ return;
1769
+ }
1770
+
1771
+ // Wrap all LLM embedding operations in a session for lifecycle management
1772
+ // Use 30 minute timeout for large collections
1773
+ await withLLMSession(async (session) => {
1774
+ // Get embedding dimensions from first chunk
1775
+ progress.indeterminate();
1776
+ const firstChunk = allChunks[0];
1777
+ if (!firstChunk) {
1778
+ throw new Error("No chunks available to embed");
1779
+ }
1780
+ const firstText = formatDocForEmbedding(firstChunk.text, firstChunk.title);
1781
+ const firstResult = await session.embed(firstText);
1782
+ if (!firstResult) {
1783
+ throw new Error("Failed to get embedding dimensions from first chunk");
1784
+ }
1785
+ ensureVecTable(db, firstResult.embedding.length);
1786
+
1787
+ let chunksEmbedded = 0, errors = 0, bytesProcessed = 0;
1788
+ const startTime = Date.now();
1789
+
1790
+ // Batch embedding for better throughput
1791
+ const BATCH_SIZE = parseInt(process.env.QMD_EMBED_BATCH_SIZE || "32", 10);
1792
+
1793
+ for (let batchStart = 0; batchStart < allChunks.length; batchStart += BATCH_SIZE) {
1794
+ const batchEnd = Math.min(batchStart + BATCH_SIZE, allChunks.length);
1795
+ const batch = allChunks.slice(batchStart, batchEnd);
1796
+
1797
+ // Format texts for embedding
1798
+ const texts = batch.map(chunk => formatDocForEmbedding(chunk.text, chunk.title));
1799
+
1800
+ try {
1801
+ // Batch embed all texts at once
1802
+ const embeddings = await session.embedBatch(texts);
1803
+
1804
+ // Insert each embedding
1805
+ for (let i = 0; i < batch.length; i++) {
1806
+ const chunk = batch[i]!;
1807
+ const embedding = embeddings[i];
1808
+
1809
+ if (embedding) {
1810
+ insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(embedding.embedding), model, now);
1811
+ chunksEmbedded++;
1812
+ } else {
1813
+ errors++;
1814
+ console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}${c.reset}`);
1815
+ }
1816
+ bytesProcessed += chunk.bytes;
1817
+ }
1818
+ } catch (err) {
1819
+ // If batch fails, try individual embeddings as fallback
1820
+ for (const chunk of batch) {
1821
+ try {
1822
+ const text = formatDocForEmbedding(chunk.text, chunk.title);
1823
+ const result = await session.embed(text);
1824
+ if (result) {
1825
+ insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(result.embedding), model, now);
1826
+ chunksEmbedded++;
1827
+ } else {
1828
+ errors++;
1829
+ }
1830
+ } catch (innerErr) {
1831
+ errors++;
1832
+ console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${innerErr}${c.reset}`);
1833
+ }
1834
+ bytesProcessed += chunk.bytes;
1835
+ }
1836
+ }
1837
+
1838
+ const percent = (bytesProcessed / totalBytes) * 100;
1839
+ progress.set(percent);
1840
+
1841
+ const elapsed = (Date.now() - startTime) / 1000;
1842
+ const bytesPerSec = bytesProcessed / elapsed;
1843
+ const remainingBytes = totalBytes - bytesProcessed;
1844
+ const etaSec = remainingBytes / bytesPerSec;
1845
+
1846
+ const bar = renderProgressBar(percent);
1847
+ const percentStr = percent.toFixed(0).padStart(3);
1848
+ const throughput = `${formatBytes(bytesPerSec)}/s`;
1849
+ const eta = elapsed > 2 ? formatETA(etaSec) : "...";
1850
+ const errStr = errors > 0 ? ` ${c.yellow}${errors} err${c.reset}` : "";
1851
+
1852
+ process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${chunksEmbedded}/${totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset} `);
1853
+ }
1854
+
1855
+ progress.clear();
1856
+ cursor.show();
1857
+ const totalTimeSec = (Date.now() - startTime) / 1000;
1858
+ const avgThroughput = formatBytes(totalBytes / totalTimeSec);
1859
+
1860
+ console.log(`\r${c.green}${renderProgressBar(100)}${c.reset} ${c.bold}100%${c.reset} `);
1861
+ console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${chunksEmbedded}${c.reset} chunks from ${c.bold}${totalDocs}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset} ${c.dim}(${avgThroughput}/s)${c.reset}`);
1862
+ if (errors > 0) {
1863
+ console.log(`${c.yellow}⚠ ${errors} chunks failed${c.reset}`);
1864
+ }
1865
+ }, { maxDuration: 30 * 60 * 1000, name: 'embed-command' });
1866
+
1867
+ closeDb();
1868
+ }
1869
+
1870
+ // Sanitize a term for FTS5: remove punctuation except apostrophes
1871
+ function sanitizeFTS5Term(term: string): string {
1872
+ // Remove all non-alphanumeric except apostrophes (for contractions like "don't")
1873
+ return term.replace(/[^\w']/g, '').trim();
1874
+ }
1875
+
1876
+ // Build FTS5 query: phrase-aware with fallback to individual terms
1877
+ function buildFTS5Query(query: string): string {
1878
+ // Sanitize the full query for phrase matching
1879
+ const sanitizedQuery = query.replace(/[^\w\s']/g, '').trim();
1880
+
1881
+ const terms = query
1882
+ .split(/\s+/)
1883
+ .map(sanitizeFTS5Term)
1884
+ .filter(term => term.length >= 2); // Skip single chars and empty
1885
+
1886
+ if (terms.length === 0) return "";
1887
+ if (terms.length === 1) return `"${terms[0]!.replace(/"/g, '""')}"`;
1888
+
1889
+ // Strategy: exact phrase OR proximity match OR individual terms
1890
+ // Exact phrase matches rank highest, then close proximity, then any term
1891
+ const phrase = `"${sanitizedQuery.replace(/"/g, '""')}"`;
1892
+ const quotedTerms = terms.map(t => `"${t.replace(/"/g, '""')}"`);
1893
+
1894
+ // FTS5 NEAR syntax: NEAR(term1 term2, distance)
1895
+ const nearPhrase = `NEAR(${quotedTerms.join(' ')}, 10)`;
1896
+ const orTerms = quotedTerms.join(' OR ');
1897
+
1898
+ // Exact phrase > proximity > any term
1899
+ return `(${phrase}) OR (${nearPhrase}) OR (${orTerms})`;
1900
+ }
1901
+
1902
+ // Normalize BM25 score to 0-1 range using sigmoid
1903
+ function normalizeBM25(score: number): number {
1904
+ // BM25 scores are negative in SQLite (lower = better)
1905
+ // Typical range: -15 (excellent) to -2 (weak match)
1906
+ // Map to 0-1 where higher is better
1907
+ const absScore = Math.abs(score);
1908
+ // Sigmoid-ish normalization: maps ~2-15 range to ~0.1-0.95
1909
+ return 1 / (1 + Math.exp(-(absScore - 5) / 3));
1910
+ }
1911
+
1912
+ function normalizeScores(results: SearchResult[]): SearchResult[] {
1913
+ if (results.length === 0) return results;
1914
+ const maxScore = Math.max(...results.map(r => r.score));
1915
+ const minScore = Math.min(...results.map(r => r.score));
1916
+ const range = maxScore - minScore || 1;
1917
+ return results.map(r => ({ ...r, score: (r.score - minScore) / range }));
1918
+ }
1919
+
1920
+ // Reciprocal Rank Fusion: combines multiple ranked lists
1921
+ // RRF score = sum(1 / (k + rank)) across all lists where doc appears
1922
+ // k=60 is standard, provides good balance between top and lower ranks
1923
+
1924
+ function reciprocalRankFusion(
1925
+ resultLists: RankedResult[][],
1926
+ weights: number[] = [], // Weight per result list (default 1.0)
1927
+ k: number = 60
1928
+ ): RankedResult[] {
1929
+ const scores = new Map<string, { score: number; displayPath: string; title: string; body: string; bestRank: number }>();
1930
+
1931
+ for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
1932
+ const results = resultLists[listIdx];
1933
+ if (!results) continue;
1934
+ const weight = weights[listIdx] ?? 1.0;
1935
+ for (let rank = 0; rank < results.length; rank++) {
1936
+ const doc = results[rank];
1937
+ if (!doc) continue; // Ensure doc is not undefined
1938
+ const rrfScore = weight / (k + rank + 1);
1939
+ const existing = scores.get(doc.file);
1940
+ if (existing) {
1941
+ existing.score += rrfScore;
1942
+ if (rank < existing.bestRank) {
1943
+ // Update body to the chunk with best individual rank
1944
+ existing.bestRank = rank;
1945
+ existing.body = doc.body;
1946
+ existing.displayPath = doc.displayPath;
1947
+ existing.title = doc.title;
1948
+ }
1949
+ } else {
1950
+ scores.set(doc.file, { score: rrfScore, displayPath: doc.displayPath, title: doc.title, body: doc.body, bestRank: rank });
1951
+ }
1952
+ }
1953
+ }
1954
+
1955
+ // Add bonus for best rank: documents that ranked #1-3 in any list get a boost
1956
+ // This prevents dilution of exact matches by expansion queries
1957
+ return Array.from(scores.entries())
1958
+ .map(([file, { score, displayPath, title, body, bestRank }]) => {
1959
+ let bonus = 0;
1960
+ if (bestRank === 0) bonus = 0.05; // Ranked #1 somewhere
1961
+ else if (bestRank <= 2) bonus = 0.02; // Ranked top-3 somewhere
1962
+ return { file, displayPath, title, body, score: score + bonus };
1963
+ })
1964
+ .sort((a, b) => b.score - a.score);
1965
+ }
1966
+
1967
+ type OutputOptions = {
1968
+ format: OutputFormat;
1969
+ full: boolean;
1970
+ limit: number;
1971
+ minScore: number;
1972
+ all?: boolean;
1973
+ collection?: string; // Filter by collection name (pwd suffix match)
1974
+ lineNumbers?: boolean; // Add line numbers to output
1975
+ context?: string; // Optional context for query expansion
1976
+ };
1977
+
1978
+ // Highlight query terms in text (skip short words < 3 chars)
1979
+ function highlightTerms(text: string, query: string): string {
1980
+ if (!useColor) return text;
1981
+ const terms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
1982
+ let result = text;
1983
+ for (const term of terms) {
1984
+ const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
1985
+ result = result.replace(regex, `${c.yellow}${c.bold}$1${c.reset}`);
1986
+ }
1987
+ return result;
1988
+ }
1989
+
1990
+ // Format score with color based on value
1991
+ function formatScore(score: number): string {
1992
+ const pct = (score * 100).toFixed(0).padStart(3);
1993
+ if (!useColor) return `${pct}%`;
1994
+ if (score >= 0.7) return `${c.green}${pct}%${c.reset}`;
1995
+ if (score >= 0.4) return `${c.yellow}${pct}%${c.reset}`;
1996
+ return `${c.dim}${pct}%${c.reset}`;
1997
+ }
1998
+
1999
+ // Shorten directory path for display - relative to $HOME (used for context paths, not documents)
2000
+ function shortPath(dirpath: string): string {
2001
+ const home = homedir();
2002
+ if (dirpath.startsWith(home)) {
2003
+ return '~' + dirpath.slice(home.length);
2004
+ }
2005
+ return dirpath;
2006
+ }
2007
+
2008
+ // Add line numbers to text content
2009
+ function addLineNumbers(text: string, startLine: number = 1): string {
2010
+ const lines = text.split('\n');
2011
+ return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
2012
+ }
2013
+
2014
+ function outputResults(results: { file: string; displayPath: string; title: string; body: string; score: number; context?: string | null; chunkPos?: number; hash?: string; docid?: string }[], query: string, opts: OutputOptions): void {
2015
+ // Filter by score, deduplicate by content hash (docid), then apply limit
2016
+ const aboveScore = results.filter(r => r.score >= opts.minScore);
2017
+ const seenDocids = new Set<string>();
2018
+ const deduped: typeof aboveScore = [];
2019
+ for (const r of aboveScore) {
2020
+ const key = r.docid || r.hash || r.displayPath;
2021
+ if (seenDocids.has(key)) continue;
2022
+ seenDocids.add(key);
2023
+ deduped.push(r);
2024
+ }
2025
+
2026
+ // Content-level dedup: merge results with ≥90% text similarity
2027
+ // Jaccard similarity on character bigrams — fast, no LLM needed
2028
+ function bigramSet(text: string): Set<string> {
2029
+ const s = new Set<string>();
2030
+ for (let i = 0; i < text.length - 1; i++) s.add(text.slice(i, i + 2));
2031
+ return s;
2032
+ }
2033
+ function textSimilarity(a: string, b: string): number {
2034
+ if (a === b) return 1;
2035
+ const sa = bigramSet(a), sb = bigramSet(b);
2036
+ let intersection = 0;
2037
+ for (const bg of sa) if (sb.has(bg)) intersection++;
2038
+ const union = sa.size + sb.size - intersection;
2039
+ return union === 0 ? 0 : intersection / union;
2040
+ }
2041
+
2042
+ const DEDUP_THRESHOLD = 0.9; // ≥90% similar → merge
2043
+ const contentDeduped: (typeof deduped[0] & { alsoIn?: string[] })[] = [];
2044
+ const normBodies: string[] = []; // parallel array of normalized bodies for comparison
2045
+ for (const r of deduped) {
2046
+ const normBody = (r.body || "").trim().replace(/\s+/g, " ");
2047
+ if (normBody.length < 10) {
2048
+ contentDeduped.push(r);
2049
+ normBodies.push("");
2050
+ continue;
2051
+ }
2052
+ // Check against all existing entries for similarity
2053
+ let mergedIdx = -1;
2054
+ for (let i = 0; i < normBodies.length; i++) {
2055
+ if (normBodies[i].length < 10) continue;
2056
+ if (textSimilarity(normBody, normBodies[i]) >= DEDUP_THRESHOLD) {
2057
+ mergedIdx = i;
2058
+ break;
2059
+ }
2060
+ }
2061
+ if (mergedIdx >= 0) {
2062
+ // Similar content already seen — record the duplicate source
2063
+ const existing = contentDeduped[mergedIdx] as typeof r & { alsoIn?: string[] };
2064
+ if (!existing.alsoIn) existing.alsoIn = [];
2065
+ existing.alsoIn.push(r.displayPath);
2066
+ // Keep the higher score
2067
+ if (r.score > existing.score) existing.score = r.score;
2068
+ } else {
2069
+ normBodies.push(normBody);
2070
+ contentDeduped.push(r);
2071
+ }
2072
+ }
2073
+
2074
+ const filtered = contentDeduped.slice(0, opts.limit);
2075
+
2076
+ if (filtered.length === 0) {
2077
+ console.log("No results found above minimum score threshold.");
2078
+ return;
2079
+ }
2080
+
2081
+ // Helper to create qmd:// URI from displayPath
2082
+ const toQmdPath = (displayPath: string) => `qmd://${displayPath}`;
2083
+
2084
+ if (opts.format === "json") {
2085
+ // JSON output for LLM consumption — always include full chunk body
2086
+ const output = filtered.map(row => {
2087
+ const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
2088
+ let body = row.body;
2089
+ const { line, snippet: snippetText } = extractSnippet(row.body, query, 300, row.chunkPos);
2090
+ if (opts.lineNumbers && body) {
2091
+ body = addLineNumbers(body);
2092
+ }
2093
+ return {
2094
+ ...(docid && { docid: `#${docid}` }),
2095
+ score: Math.round(row.score * 100) / 100,
2096
+ file: toQmdPath(row.displayPath),
2097
+ title: row.title,
2098
+ ...(row.context && { context: row.context }),
2099
+ ...((row as any).alsoIn?.length && { alsoIn: (row as any).alsoIn.map(toQmdPath) }),
2100
+ body,
2101
+ snippet: body || snippetText, // Use body as snippet so OpenClaw gets the full content
2102
+ };
2103
+ });
2104
+ // Use process.stdout.write directly to bypass console.log→stderr redirect in JSON mode
2105
+ process.stdout.write(JSON.stringify(output, null, 2) + "\n");
2106
+ } else if (opts.format === "files") {
2107
+ // Simple docid,score,filepath,context output
2108
+ for (const row of filtered) {
2109
+ const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
2110
+ const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
2111
+ console.log(`#${docid},${row.score.toFixed(2)},${toQmdPath(row.displayPath)}${ctx}`);
2112
+ }
2113
+ } else if (opts.format === "cli") {
2114
+ for (let i = 0; i < filtered.length; i++) {
2115
+ const row = filtered[i];
2116
+ if (!row) continue;
2117
+ const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
2118
+ const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
2119
+
2120
+ // Line 1: filepath with docid
2121
+ const path = toQmdPath(row.displayPath);
2122
+ // Only show :line if we actually found a term match in the snippet body (exclude header line).
2123
+ const snippetBody = snippet.split("\n").slice(1).join("\n").toLowerCase();
2124
+ const hasMatch = query.toLowerCase().split(/\s+/).some(t => t.length > 0 && snippetBody.includes(t));
2125
+ const lineInfo = hasMatch ? `:${line}` : "";
2126
+ const docidStr = docid ? ` ${c.dim}#${docid}${c.reset}` : "";
2127
+ console.log(`${c.cyan}${path}${c.dim}${lineInfo}${c.reset}${docidStr}`);
2128
+
2129
+ // Line 2: Title (if available)
2130
+ if (row.title) {
2131
+ console.log(`${c.bold}Title: ${row.title}${c.reset}`);
2132
+ }
2133
+
2134
+ // Line 3: Context (if available)
2135
+ if (row.context) {
2136
+ console.log(`${c.dim}Context: ${row.context}${c.reset}`);
2137
+ }
2138
+
2139
+ // Line 4: Score
2140
+ const score = formatScore(row.score);
2141
+ console.log(`Score: ${c.bold}${score}${c.reset}`);
2142
+ console.log();
2143
+
2144
+ // Snippet with highlighting (diff-style header included)
2145
+ let displaySnippet = opts.lineNumbers ? addLineNumbers(snippet, line) : snippet;
2146
+ const highlighted = highlightTerms(displaySnippet, query);
2147
+ console.log(highlighted);
2148
+
2149
+ // Double empty line between results
2150
+ if (i < filtered.length - 1) console.log('\n');
2151
+ }
2152
+ } else if (opts.format === "md") {
2153
+ for (let i = 0; i < filtered.length; i++) {
2154
+ const row = filtered[i];
2155
+ if (!row) continue;
2156
+ const heading = row.title || row.displayPath;
2157
+ const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
2158
+ let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet;
2159
+ if (opts.lineNumbers) {
2160
+ content = addLineNumbers(content);
2161
+ }
2162
+ const docidLine = docid ? `**docid:** \`#${docid}\`\n` : "";
2163
+ const contextLine = row.context ? `**context:** ${row.context}\n` : "";
2164
+ console.log(`---\n# ${heading}\n${docidLine}${contextLine}\n${content}\n`);
2165
+ }
2166
+ } else if (opts.format === "xml") {
2167
+ for (const row of filtered) {
2168
+ const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
2169
+ const contextAttr = row.context ? ` context="${row.context.replace(/"/g, '&quot;')}"` : "";
2170
+ const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
2171
+ let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet;
2172
+ if (opts.lineNumbers) {
2173
+ content = addLineNumbers(content);
2174
+ }
2175
+ console.log(`<file docid="#${docid}" name="${toQmdPath(row.displayPath)}"${titleAttr}${contextAttr}>\n${content}\n</file>\n`);
2176
+ }
2177
+ } else {
2178
+ // CSV format
2179
+ console.log("docid,score,file,title,context,line,snippet");
2180
+ for (const row of filtered) {
2181
+ const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
2182
+ let content = opts.full ? row.body : snippet;
2183
+ if (opts.lineNumbers) {
2184
+ content = addLineNumbers(content, line);
2185
+ }
2186
+ const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
2187
+ const snippetText = content || "";
2188
+ console.log(`#${docid},${row.score.toFixed(4)},${escapeCSV(toQmdPath(row.displayPath))},${escapeCSV(row.title || "")},${escapeCSV(row.context || "")},${line},${escapeCSV(snippetText)}`);
2189
+ }
2190
+ }
2191
+ }
2192
+
2193
+ function search(query: string, opts: OutputOptions): void {
2194
+ const db = getDb();
2195
+
2196
+ // Validate collection filter if specified
2197
+ let collectionName: string | undefined;
2198
+ if (opts.collection) {
2199
+ const coll = getCollectionFromYaml(opts.collection);
2200
+ if (!coll) {
2201
+ console.error(`Collection not found: ${opts.collection}`);
2202
+ closeDb();
2203
+ process.exit(1);
2204
+ }
2205
+ collectionName = opts.collection;
2206
+ }
2207
+
2208
+ // Use large limit for --all, otherwise fetch more than needed and let outputResults filter
2209
+ const fetchLimit = opts.all ? 100000 : Math.max(50, opts.limit * 2);
2210
+ // searchFTS accepts collection name as number parameter for legacy reasons (will be fixed in store.ts)
2211
+ const results = searchFTS(db, query, fetchLimit, collectionName as any);
2212
+
2213
+ // Add context to results
2214
+ const resultsWithContext = results.map(r => ({
2215
+ file: r.filepath,
2216
+ displayPath: r.displayPath,
2217
+ title: r.title,
2218
+ body: r.body || "",
2219
+ score: r.score,
2220
+ context: getContextForFile(db, r.filepath),
2221
+ hash: r.hash,
2222
+ docid: r.docid,
2223
+ }));
2224
+
2225
+ closeDb();
2226
+
2227
+ if (resultsWithContext.length === 0) {
2228
+ console.log("No results found.");
2229
+ return;
2230
+ }
2231
+ outputResults(resultsWithContext, query, opts);
2232
+ }
2233
+
2234
+ async function vectorSearch(query: string, opts: OutputOptions, model: string = DEFAULT_EMBED_MODEL): Promise<void> {
2235
+ const db = getDb();
2236
+
2237
+ // Validate collection filter if specified
2238
+ let collectionName: string | undefined;
2239
+ if (opts.collection) {
2240
+ const coll = getCollectionFromYaml(opts.collection);
2241
+ if (!coll) {
2242
+ console.error(`Collection not found: ${opts.collection}`);
2243
+ closeDb();
2244
+ process.exit(1);
2245
+ }
2246
+ collectionName = opts.collection;
2247
+ }
2248
+
2249
+ const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
2250
+ if (!tableExists) {
2251
+ console.error("Vector index not found. Run 'qmd embed' first to create embeddings.");
2252
+ closeDb();
2253
+ return;
2254
+ }
2255
+
2256
+ // Check index health and warn about issues
2257
+ checkIndexHealth(db);
2258
+
2259
+ // Shared query logic (works with either remote or local session)
2260
+ const runQuery = async (expandFn: (q: string, opts?: any) => Promise<Queryable[]>) => {
2261
+ // Expand query using structured output (no lexical for vector-only search)
2262
+ const queryables = await expandFn(query, { includeLexical: false, context: opts.context });
2263
+
2264
+ // Build list of queries for vector search: original, vec, and hyde
2265
+ const vectorQueries: string[] = [query];
2266
+ for (const q of queryables) {
2267
+ if (q.type === 'vec' || q.type === 'hyde') {
2268
+ if (q.text && q.text !== query) {
2269
+ vectorQueries.push(q.text);
2270
+ }
2271
+ }
2272
+ }
2273
+
2274
+ process.stderr.write(`${c.dim}Searching ${vectorQueries.length} vector queries...${c.reset}\n`);
2275
+
2276
+ // Collect results from all query variations
2277
+ const perQueryLimit = opts.all ? 500 : 20;
2278
+ const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number; hash: string }>();
2279
+
2280
+ for (const q of vectorQueries) {
2281
+ const vecResults = await searchVec(db, q, model, perQueryLimit, collectionName as any, undefined);
2282
+ for (const r of vecResults) {
2283
+ const existing = allResults.get(r.filepath);
2284
+ if (!existing || r.score > existing.score) {
2285
+ allResults.set(r.filepath, { file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score, hash: r.hash });
2286
+ }
2287
+ }
2288
+ }
2289
+
2290
+ // Sort by max score and limit to requested count
2291
+ const results = Array.from(allResults.values())
2292
+ .sort((a, b) => b.score - a.score)
2293
+ .slice(0, opts.limit)
2294
+ .map(r => ({ ...r, context: getContextForFile(db, r.file) }));
2295
+
2296
+ closeDb();
2297
+
2298
+ if (results.length === 0) {
2299
+ console.log("No results found.");
2300
+ return;
2301
+ }
2302
+ outputResults(results, query, { ...opts, limit: results.length });
2303
+ };
2304
+
2305
+ await llmService.withSession(async (session) => {
2306
+ const expandFn = (q: string, expandOpts?: any) =>
2307
+ expandQueryStructured(q, expandOpts?.includeLexical ?? false, expandOpts?.context, session);
2308
+ await runQuery(expandFn);
2309
+ }, { maxDuration: 10 * 60 * 1000, name: 'vectorSearch' });
2310
+ }
2311
+
2312
+ // Expand query using structured output with GBNF grammar
2313
+ async function expandQueryStructured(query: string, includeLexical: boolean = true, context?: string, session?: ILLMSession): Promise<Queryable[]> {
2314
+ process.stderr.write(`${c.dim}Expanding query...${c.reset}\n`);
2315
+ const queryables = await llmService.expandQuery(query, { includeLexical, context }, session);
2316
+
2317
+ // Log the expansion as a tree
2318
+ const lines: string[] = [];
2319
+ const bothLabel = includeLexical ? ' · (lexical+vector)' : ' · (vector)';
2320
+ lines.push(`${c.dim}├─ ${query}${bothLabel}${c.reset}`);
2321
+
2322
+ for (let i = 0; i < queryables.length; i++) {
2323
+ const q = queryables[i];
2324
+ if (!q || q.text === query) continue;
2325
+
2326
+ let textPreview = q.text.replace(/\n/g, ' ');
2327
+ if (textPreview.length > 80) {
2328
+ textPreview = textPreview.substring(0, 77) + '...';
2329
+ }
2330
+
2331
+ const label = q.type === 'lex' ? 'lexical' : (q.type === 'hyde' ? 'hyde' : 'vector');
2332
+ lines.push(`${c.dim}├─ ${textPreview} · (${label})${c.reset}`);
2333
+ }
2334
+
2335
+ // Fix last item to use └─ instead of ├─
2336
+ if (lines.length > 0) {
2337
+ lines[lines.length - 1] = lines[lines.length - 1]!.replace('├─', '└─');
2338
+ }
2339
+
2340
+ for (const line of lines) {
2341
+ process.stderr.write(line + '\n');
2342
+ }
2343
+
2344
+ return queryables;
2345
+ }
2346
+
2347
+ async function expandQuery(query: string, _model: string = DEFAULT_QUERY_MODEL, _db?: Database, session?: ILLMSession): Promise<string[]> {
2348
+ const queryables = await expandQueryStructured(query, true, undefined, session);
2349
+ const queries = new Set<string>([query]);
2350
+ for (const q of queryables) {
2351
+ queries.add(q.text);
2352
+ }
2353
+ return Array.from(queries);
2354
+ }
2355
+
2356
+ async function querySearch(query: string, opts: OutputOptions, embedModel: string = DEFAULT_EMBED_MODEL, rerankModel: string = DEFAULT_RERANK_MODEL): Promise<void> {
2357
+ // When outputting JSON, redirect debug console.log to stderr so stdout is pure JSON
2358
+ const origLog = console.log;
2359
+ if (opts.format === "json") {
2360
+ console.log = (...args: any[]) => console.error(...args);
2361
+ }
2362
+ try {
2363
+ await _querySearchImpl(query, opts, embedModel, rerankModel);
2364
+ } finally {
2365
+ console.log = origLog;
2366
+ }
2367
+ }
2368
+
2369
+ async function _querySearchImpl(query: string, opts: OutputOptions, embedModel: string, rerankModel: string): Promise<void> {
2370
+ console.log(`\n=== querySearch START: query="${query}" ===\n`);
2371
+ const db = getDb();
2372
+
2373
+ // Validate collection filter if specified
2374
+ let collectionName: string | undefined;
2375
+ if (opts.collection) {
2376
+ const coll = getCollectionFromYaml(opts.collection);
2377
+ if (!coll) {
2378
+ console.error(`Collection not found: ${opts.collection}`);
2379
+ closeDb();
2380
+ process.exit(1);
2381
+ }
2382
+ collectionName = opts.collection;
2383
+ }
2384
+
2385
+ // Check index health and warn about issues
2386
+ checkIndexHealth(db);
2387
+
2388
+ // Run initial BM25 search (will be reused for retrieval)
2389
+ const initialFts = searchFTS(db, query, 20, collectionName as any);
2390
+ let hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
2391
+
2392
+ // Check if initial results have strong signals (skip expansion if so)
2393
+ // Strong signal = top result is strong AND clearly separated from runner-up.
2394
+ // This avoids skipping expansion when BM25 has lots of mediocre matches.
2395
+ const topScore = initialFts[0]?.score ?? 0;
2396
+ const secondScore = initialFts[1]?.score ?? 0;
2397
+ const hasStrongSignal = initialFts.length > 0 && topScore >= 0.85 && (topScore - secondScore) >= 0.15;
2398
+
2399
+ // Core query logic - extracted so it can run with or without session
2400
+ const runQuerySearch = async (session?: ILLMSession) => {
2401
+ let ftsQueries: string[] = [query];
2402
+ let vectorQueries: string[] = [query];
2403
+
2404
+ if (hasStrongSignal) {
2405
+ // Strong BM25 signal - skip expensive LLM expansion
2406
+ process.stderr.write(`${c.dim}Strong BM25 signal (${topScore.toFixed(2)}) - skipping expansion${c.reset}\n`);
2407
+ {
2408
+ const lines: string[] = [];
2409
+ lines.push(`${c.dim}├─ ${query} · (lexical+vector)${c.reset}`);
2410
+ lines[lines.length - 1] = lines[lines.length - 1]!.replace('├─', '└─');
2411
+ for (const line of lines) process.stderr.write(line + '\n');
2412
+ }
2413
+ } else {
2414
+ // Weak signal - expand query for better recall
2415
+ const queryables = await expandQueryStructured(query, true, opts.context, session);
2416
+
2417
+ for (const q of queryables) {
2418
+ if (q.type === 'lex') {
2419
+ if (q.text && q.text !== query) ftsQueries.push(q.text);
2420
+ } else if (q.type === 'vec' || q.type === 'hyde') {
2421
+ if (q.text && q.text !== query) vectorQueries.push(q.text);
2422
+ }
2423
+ }
2424
+ }
2425
+
2426
+ process.stderr.write(`${c.dim}Searching ${ftsQueries.length} lexical + ${vectorQueries.length} vector queries...${c.reset}\n`);
2427
+
2428
+ // Collect ranked result lists for RRF fusion
2429
+ const rankedLists: RankedResult[][] = [];
2430
+
2431
+ // Map to store hash by filepath for final results
2432
+ const hashMap = new Map<string, string>();
2433
+
2434
+ // Run all searches concurrently (FTS + Vector)
2435
+ const searchPromises: Promise<void>[] = [];
2436
+
2437
+ // FTS searches
2438
+ for (const q of ftsQueries) {
2439
+ if (!q) continue;
2440
+ searchPromises.push((async () => {
2441
+ const ftsResults = searchFTS(db, q, 20, (collectionName || "") as any);
2442
+ if (ftsResults.length > 0) {
2443
+ for (const r of ftsResults) {
2444
+ // Mutex for hashMap is not strictly needed as it's just adding values
2445
+ hashMap.set(r.filepath, r.hash);
2446
+ }
2447
+ rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
2448
+ }
2449
+ })());
2450
+ }
2451
+
2452
+ // Vector searches (session ensures contexts stay alive)
2453
+ if (hasVectors) {
2454
+ for (const q of vectorQueries) {
2455
+ if (!q) continue;
2456
+ searchPromises.push((async () => {
2457
+ const vecResults = await searchVec(db, q, embedModel, 20, (collectionName || "") as any, session);
2458
+ if (vecResults.length > 0) {
2459
+ for (const r of vecResults) hashMap.set(r.filepath, r.hash);
2460
+ rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
2461
+ }
2462
+ })());
2463
+ }
2464
+ }
2465
+
2466
+ await Promise.all(searchPromises);
2467
+
2468
+ // Apply Reciprocal Rank Fusion to combine all ranked lists
2469
+ // Give 2x weight to original query results (first 2 lists: FTS + vector)
2470
+ const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
2471
+ const fused = reciprocalRankFusion(rankedLists, weights);
2472
+ // Hard cap reranking for latency/cost. We rerank per-document (best chunk only).
2473
+ const RERANK_DOC_LIMIT = parseInt(process.env.QMD_RERANK_DOC_LIMIT || "40", 10);
2474
+ const candidates = fused.slice(0, RERANK_DOC_LIMIT);
2475
+
2476
+ if (candidates.length === 0) {
2477
+ console.log("No results found.");
2478
+ closeDb();
2479
+ return;
2480
+ }
2481
+
2482
+ // Rerank multiple chunks per document, then aggregate scores.
2483
+ // Avoid top-1 chunk truncation by sending top-N chunks per doc to reranker.
2484
+ const PER_DOC_CHUNK_LIMIT = parseInt(process.env.QMD_RERANK_CHUNKS_PER_DOC || "3", 10);
2485
+ const chunksToRerank: { key: string; file: string; text: string; chunkIdx: number }[] = [];
2486
+ const docChunkMap = new Map<string, { chunks: { text: string; pos: number }[] }>();
2487
+ const chunkMetaByKey = new Map<string, { file: string; chunkIdx: number }>();
2488
+
2489
+ // CJK-aware term extraction: prioritize complete query + trigrams
2490
+ const extractTerms = (text: string): string[] => {
2491
+ const terms: string[] = [];
2492
+ const lowerText = text.toLowerCase();
2493
+
2494
+ // Add complete query as highest-priority term (for exact/substring matches)
2495
+ terms.push(lowerText);
2496
+
2497
+ // Split by whitespace and extract n-grams
2498
+ for (const word of lowerText.split(/\s+/)) {
2499
+ // Check if word contains CJK characters
2500
+ const cjkRanges = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/;
2501
+ if (cjkRanges.test(word)) {
2502
+ // Extract CJK chars for substring matching
2503
+ const chars = [...word].filter(c => cjkRanges.test(c));
2504
+ // Trigrams (most specific, high priority)
2505
+ for (let i = 0; i <= chars.length - 3; i++) {
2506
+ terms.push(chars[i]! + chars[i + 1]! + chars[i + 2]!);
2507
+ }
2508
+ // For short CJK (1-2 chars), add as-is (fallback for queries like "主人")
2509
+ if (chars.length < 3) {
2510
+ terms.push(chars.join(''));
2511
+ }
2512
+ } else if (word.length > 2) {
2513
+ terms.push(word);
2514
+ }
2515
+ }
2516
+ return [...new Set(terms)];
2517
+ };
2518
+ const queryTerms = extractTerms(query);
2519
+ console.log("\n=== QUERY TERMS ===");
2520
+ console.log(` ${queryTerms.join(', ')}`);
2521
+ console.log("");
2522
+
2523
+ for (const cand of candidates) {
2524
+ const chunks = chunkDocument(cand.body);
2525
+ if (chunks.length === 0) continue;
2526
+
2527
+ // Rank chunks by keyword match score (higher is better)
2528
+ const chunkScores: number[] = [];
2529
+ for (let i = 0; i < chunks.length; i++) {
2530
+ const chunkLower = chunks[i]!.text.toLowerCase();
2531
+ const score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
2532
+ chunkScores.push(score);
2533
+ }
2534
+
2535
+ const sortedChunkIndexes = chunkScores
2536
+ .map((score, idx) => ({ score, idx }))
2537
+ .sort((a, b) => b.score - a.score)
2538
+ .map(item => item.idx);
2539
+ const selectedChunkIndexes = sortedChunkIndexes.slice(0, Math.min(PER_DOC_CHUNK_LIMIT, chunks.length));
2540
+
2541
+ // DEBUG: Log chunk selection for MEMORY.md
2542
+ if (cand.file.includes('memory.md') && chunks.length > 1) {
2543
+ console.log(`\n=== CHUNK SELECTION: ${cand.file} ===`);
2544
+ for (let i = 0; i < chunks.length; i++) {
2545
+ const preview = chunks[i]!.text.substring(0, 80).replace(/\n/g, ' ');
2546
+ console.log(` chunk ${i}: score=${chunkScores[i]} preview: ${preview}...`);
2547
+ }
2548
+ const selectedPreview = selectedChunkIndexes
2549
+ .map(idx => `#${idx}(score=${chunkScores[idx]})`)
2550
+ .join(', ');
2551
+ console.log(` → Selected chunks: ${selectedPreview}`);
2552
+ console.log("");
2553
+ }
2554
+
2555
+ for (const chunkIdx of selectedChunkIndexes) {
2556
+ const key = `${cand.file}::${chunkIdx}`;
2557
+ chunksToRerank.push({ key, file: cand.file, text: chunks[chunkIdx]!.text, chunkIdx });
2558
+ chunkMetaByKey.set(key, { file: cand.file, chunkIdx });
2559
+ }
2560
+ docChunkMap.set(cand.file, { chunks });
2561
+ }
2562
+
2563
+ // DEBUG: Log selected chunks for reranking
2564
+ console.log("\n=== CHUNKS SENT TO RERANKER ===");
2565
+ for (let i = 0; i < Math.min(6, chunksToRerank.length); i++) {
2566
+ const ch = chunksToRerank[i]!;
2567
+ const fname = ch.file.split('/').pop();
2568
+ const preview = ch.text.substring(0, 100).replace(/\n/g, ' ');
2569
+ console.log(` ${i+1}. ${fname} chunk#${ch.chunkIdx}: ${preview}...`);
2570
+ }
2571
+ console.log("");
2572
+
2573
+ // Rerank selected chunks (with caching).
2574
+ const reranked = await rerank(
2575
+ query,
2576
+ chunksToRerank.map(ch => ({ file: ch.key, text: ch.text })),
2577
+ rerankModel,
2578
+ db,
2579
+ session
2580
+ );
2581
+
2582
+ // DEBUG: Log reranker scores
2583
+ console.log("\n=== RERANKER SCORES ===");
2584
+ for (let i = 0; i < Math.min(6, reranked.length); i++) {
2585
+ const r = reranked[i]!;
2586
+ console.log(` ${i+1}. score=${r.score.toFixed(4)} ${r.file.split('/').pop()}`);
2587
+ }
2588
+ console.log("");
2589
+
2590
+ // Check if LLM rerank returned extracts (indicates LLM extraction mode)
2591
+ const hasExtracts = reranked.some(r => r.extract);
2592
+
2593
+ const aggregatedScores = new Map<string, { score: number; bestChunkIdx: number; extract?: string }>();
2594
+ for (const r of reranked) {
2595
+ const meta = chunkMetaByKey.get(r.file);
2596
+ if (!meta) continue;
2597
+ const existing = aggregatedScores.get(meta.file);
2598
+ // Keep the highest reranker score across all chunks of the same document
2599
+ if (!existing || r.score > existing.score) {
2600
+ aggregatedScores.set(meta.file, { score: r.score, bestChunkIdx: meta.chunkIdx, extract: r.extract });
2601
+ }
2602
+ }
2603
+
2604
+ // Blend RRF position score with aggregated reranker score using position-aware weights
2605
+ // Top retrieval results get more protection from reranker disagreement
2606
+ const candidateMap = new Map(candidates.map(cand => [cand.file, { displayPath: cand.displayPath, title: cand.title, body: cand.body }]));
2607
+ const rrfRankMap = new Map(candidates.map((cand, i) => [cand.file, i + 1])); // 1-indexed rank
2608
+
2609
+ const finalResults = Array.from(aggregatedScores.entries()).map(([file, { score: rerankScore, bestChunkIdx, extract }]) => {
2610
+ const candidate = candidateMap.get(file);
2611
+ const chunkInfo = docChunkMap.get(file);
2612
+
2613
+ let finalScore: number;
2614
+ let finalBody: string;
2615
+
2616
+ if (hasExtracts) {
2617
+ // LLM extraction mode: trust LLM's ordering directly, use extract as body
2618
+ finalScore = rerankScore;
2619
+ finalBody = extract || (chunkInfo ? (chunkInfo.chunks[bestChunkIdx]?.text || chunkInfo.chunks[0]!.text) : candidate?.body || "");
2620
+ } else {
2621
+ // Traditional reranker mode: blend RRF + reranker scores
2622
+ const rrfRank = rrfRankMap.get(file) || 30;
2623
+ let rrfWeight: number;
2624
+ if (rrfRank <= 3) {
2625
+ rrfWeight = 0.75;
2626
+ } else if (rrfRank <= 10) {
2627
+ rrfWeight = 0.60;
2628
+ } else {
2629
+ rrfWeight = 0.40;
2630
+ }
2631
+ const rrfScore = 1 / rrfRank;
2632
+ finalScore = rrfWeight * rrfScore + (1 - rrfWeight) * rerankScore;
2633
+ finalBody = chunkInfo ? (chunkInfo.chunks[bestChunkIdx]?.text || chunkInfo.chunks[0]!.text) : candidate?.body || "";
2634
+ }
2635
+
2636
+ const chunkPos = chunkInfo ? (chunkInfo.chunks[bestChunkIdx]?.pos || 0) : 0;
2637
+ return {
2638
+ file,
2639
+ displayPath: candidate?.displayPath || "",
2640
+ title: candidate?.title || "",
2641
+ body: finalBody,
2642
+ chunkPos,
2643
+ score: finalScore,
2644
+ context: getContextForFile(db, file),
2645
+ hash: hashMap.get(file) || "",
2646
+ };
2647
+ });
2648
+
2649
+ // DEBUG: before sort
2650
+ console.log("\n=== BEFORE SORT ===");
2651
+ for (let i = 0; i < Math.min(3, finalResults.length); i++) {
2652
+ console.log(` ${i}: score=${finalResults[i]!.score.toFixed(4)} ${finalResults[i]!.displayPath}`);
2653
+ }
2654
+
2655
+ finalResults.sort((a, b) => b.score - a.score);
2656
+
2657
+ // DEBUG: after sort
2658
+ console.log("\n=== AFTER SORT ===");
2659
+ for (let i = 0; i < Math.min(6, finalResults.length); i++) {
2660
+ console.log(` ${i}: score=${finalResults[i]!.score.toFixed(4)} ${finalResults[i]!.displayPath}`);
2661
+ }
2662
+ console.log("");
2663
+
2664
+ // File-level dedup DISABLED — allow multiple chunks from same file to surface
2665
+ // const seenFiles = new Set<string>();
2666
+ // const dedupedResults = finalResults.filter(r => {
2667
+ // if (seenFiles.has(r.file)) return false;
2668
+ // seenFiles.add(r.file);
2669
+ // return true;
2670
+ // });
2671
+
2672
+ closeDb();
2673
+ outputResults(finalResults, query, opts);
2674
+ };
2675
+
2676
+ await llmService.withSession(async (session) => {
2677
+ await runQuerySearch(session);
2678
+ }, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
2679
+ }
2680
+
2681
+ // Parse CLI arguments using util.parseArgs
2682
+ function parseCLI() {
2683
+ const { values, positionals } = parseArgs({
2684
+ args: Bun.argv.slice(2), // Skip bun and script path
2685
+ options: {
2686
+ // Global options
2687
+ index: {
2688
+ type: "string",
2689
+ },
2690
+ context: {
2691
+ type: "string",
2692
+ },
2693
+ "no-lex": {
2694
+ type: "boolean",
2695
+ },
2696
+ help: { type: "boolean", short: "h" },
2697
+ // Search options
2698
+ n: { type: "string" },
2699
+ "min-score": { type: "string" },
2700
+ all: { type: "boolean" },
2701
+ full: { type: "boolean" },
2702
+ csv: { type: "boolean" },
2703
+ md: { type: "boolean" },
2704
+ xml: { type: "boolean" },
2705
+ files: { type: "boolean" },
2706
+ json: { type: "boolean" },
2707
+ collection: { type: "string", short: "c" }, // Filter by collection
2708
+ // Collection options
2709
+ name: { type: "string" }, // collection name
2710
+ mask: { type: "string" }, // glob pattern
2711
+ // Embed options
2712
+ force: { type: "boolean", short: "f" },
2713
+ // Update options
2714
+ pull: { type: "boolean" }, // git pull before update
2715
+ refresh: { type: "boolean" },
2716
+ // Get options
2717
+ l: { type: "string" }, // max lines
2718
+ from: { type: "string" }, // start line
2719
+ "max-bytes": { type: "string" }, // max bytes for multi-get
2720
+ "line-numbers": { type: "boolean" }, // add line numbers to output
2721
+ // Doctor options
2722
+ bench: { type: "boolean" }, // enable quality benchmark in doctor
2723
+ },
2724
+ allowPositionals: true,
2725
+ strict: false, // Allow unknown options to pass through
2726
+ });
2727
+
2728
+ // Select index name (default: "index")
2729
+ const indexName = values.index as string | undefined;
2730
+ if (indexName) {
2731
+ setIndexName(indexName);
2732
+ setConfigIndexName(indexName);
2733
+ }
2734
+
2735
+ // Determine output format
2736
+ let format: OutputFormat = "cli";
2737
+ if (values.csv) format = "csv";
2738
+ else if (values.md) format = "md";
2739
+ else if (values.xml) format = "xml";
2740
+ else if (values.files) format = "files";
2741
+ else if (values.json) format = "json";
2742
+
2743
+ // Default limit: 20 for --files/--json, 5 otherwise
2744
+ // --all means return all results (use very large limit)
2745
+ const defaultLimit = (format === "files" || format === "json") ? 20 : 5;
2746
+ const isAll = !!values.all;
2747
+
2748
+ const opts: OutputOptions = {
2749
+ format,
2750
+ full: !!values.full,
2751
+ limit: isAll ? 100000 : (values.n ? parseInt(String(values.n), 10) || defaultLimit : defaultLimit),
2752
+ minScore: values["min-score"] ? parseFloat(String(values["min-score"])) || 0 : 0,
2753
+ all: isAll,
2754
+ collection: values.collection as string | undefined,
2755
+ lineNumbers: !!values["line-numbers"],
2756
+ };
2757
+
2758
+ return {
2759
+ command: positionals[0] || "",
2760
+ args: positionals.slice(1),
2761
+ query: positionals.slice(1).join(" "),
2762
+ opts,
2763
+ values,
2764
+ };
2765
+ }
2766
+
2767
+ function showHelp(): void {
2768
+ console.log("Usage:");
2769
+ console.log(" qmd collection add [path] --name <name> --mask <pattern> - Create/index collection");
2770
+ console.log(" qmd collection list - List all collections with details");
2771
+ console.log(" qmd collection remove <name> - Remove a collection by name");
2772
+ console.log(" qmd collection rename <old> <new> - Rename a collection");
2773
+ console.log(" qmd ls [collection[/path]] - List collections or files in a collection");
2774
+ console.log(" qmd context add [path] \"text\" - Add context for path (defaults to current dir)");
2775
+ console.log(" qmd context list - List all contexts");
2776
+ console.log(" qmd context rm <path> - Remove context");
2777
+ console.log(" qmd get <file>[:line] [-l N] [--from N] - Get document (optionally from line, max N lines)");
2778
+ console.log(" qmd multi-get <pattern> [-l N] [--max-bytes N] - Get multiple docs by glob or comma-separated list");
2779
+ console.log(" qmd status - Show index status and collections");
2780
+ console.log(" qmd update [--pull] - Re-index all collections (--pull: git pull first)");
2781
+ console.log(" qmd embed [-f] - Create vector embeddings (800 tokens/chunk, 15% overlap)");
2782
+ console.log(" qmd cleanup - Remove cache and orphaned data, vacuum DB");
2783
+ console.log(" qmd search <query> - Full-text search (BM25)");
2784
+ console.log(" qmd vsearch <query> - Vector similarity search");
2785
+ console.log(" qmd query <query> - Combined search with query expansion + reranking");
2786
+ console.log(" qmd doctor - Diagnose runtime env, providers/models, and vector dimensions");
2787
+ console.log(" qmd mcp - Start MCP server (for AI agent integration)");
2788
+ console.log("");
2789
+ console.log("Global options:");
2790
+ console.log(" --index <name> - Use custom index name (default: index)");
2791
+ console.log("");
2792
+ console.log("Search options:");
2793
+ console.log(" -n <num> - Number of results (default: 5, or 20 for --files)");
2794
+ console.log(" --all - Return all matches (use with --min-score to filter)");
2795
+ console.log(" --min-score <num> - Minimum similarity score");
2796
+ console.log(" --full - Output full document instead of snippet");
2797
+ console.log(" --line-numbers - Add line numbers to output");
2798
+ console.log(" --files - Output docid,score,filepath,context (default: 20 results)");
2799
+ console.log(" --json - JSON output with snippets (default: 20 results)");
2800
+ console.log(" --csv - CSV output with snippets");
2801
+ console.log(" --md - Markdown output");
2802
+ console.log(" --xml - XML output");
2803
+ console.log(" -c, --collection <name> - Filter results to a specific collection");
2804
+ console.log("");
2805
+ console.log("Multi-get options:");
2806
+ console.log(" -l <num> - Maximum lines per file");
2807
+ console.log(" --max-bytes <num> - Skip files larger than N bytes (default: 10240)");
2808
+ console.log(" --json/--csv/--md/--xml/--files - Output format (same as search)");
2809
+ console.log("");
2810
+ console.log("Models (auto-downloaded from HuggingFace):");
2811
+ console.log(" Embedding: embeddinggemma-300M-Q8_0");
2812
+ console.log(" Reranking: qwen3-reranker-0.6b-q8_0");
2813
+ console.log(" Generation: Qwen3-0.6B-Q8_0");
2814
+ console.log("");
2815
+ console.log(`Index: ${getDbPath()}`);
2816
+ }
2817
+
2818
+ async function pullCommand(refresh: boolean): Promise<void> {
2819
+ const models = [
2820
+ DEFAULT_EMBED_MODEL_URI,
2821
+ DEFAULT_GENERATE_MODEL_URI,
2822
+ DEFAULT_RERANK_MODEL_URI,
2823
+ ];
2824
+ console.log(`${c.bold}Pulling models${c.reset}`);
2825
+ const results = await pullModels(models, {
2826
+ refresh,
2827
+ cacheDir: DEFAULT_MODEL_CACHE_DIR,
2828
+ });
2829
+ for (const result of results) {
2830
+ const size = formatBytes(result.sizeBytes);
2831
+ const note = result.refreshed ? "refreshed" : "cached/checked";
2832
+ console.log(`- ${result.model} -> ${result.path} (${size}, ${note})`);
2833
+ }
2834
+ }
2835
+
2836
+ function cleanupCommand(): void {
2837
+ const db = getDb();
2838
+
2839
+ const cacheCount = deleteLLMCache(db);
2840
+ console.log(`${c.green}✓${c.reset} Cleared ${cacheCount} cached API responses`);
2841
+
2842
+ const orphanedVecs = cleanupOrphanedVectors(db);
2843
+ if (orphanedVecs > 0) {
2844
+ console.log(`${c.green}✓${c.reset} Removed ${orphanedVecs} orphaned embedding chunks`);
2845
+ } else {
2846
+ console.log(`${c.dim}No orphaned embeddings to remove${c.reset}`);
2847
+ }
2848
+
2849
+ const inactiveDocs = deleteInactiveDocuments(db);
2850
+ if (inactiveDocs > 0) {
2851
+ console.log(`${c.green}✓${c.reset} Removed ${inactiveDocs} inactive document records`);
2852
+ }
2853
+
2854
+ vacuumDatabase(db);
2855
+ console.log(`${c.green}✓${c.reset} Database vacuumed`);
2856
+
2857
+ closeDb();
2858
+ }
2859
+
2860
+ function maskValue(value?: string): string {
2861
+ if (!value) return "<unset>";
2862
+ if (value.length <= 8) return "****";
2863
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
2864
+ }
2865
+
2866
+ function parseLaunchdEnvironmentVariables(plistPath: string): Record<string, string> {
2867
+ try {
2868
+ const content = readFileSync(plistPath, "utf8");
2869
+ const blockMatch = content.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
2870
+ if (!blockMatch || !blockMatch[1]) return {};
2871
+ const block = blockMatch[1];
2872
+ const env: Record<string, string> = {};
2873
+ const pairRegex = /<key>([^<]+)<\/key>\s*<string>([\s\S]*?)<\/string>/g;
2874
+ let m: RegExpExecArray | null;
2875
+ while ((m = pairRegex.exec(block)) !== null) {
2876
+ const key = m[1]?.trim();
2877
+ const raw = m[2] || "";
2878
+ const value = raw
2879
+ .replace(/&lt;/g, "<")
2880
+ .replace(/&gt;/g, ">")
2881
+ .replace(/&amp;/g, "&")
2882
+ .replace(/&quot;/g, '"')
2883
+ .replace(/&#39;/g, "'");
2884
+ if (key) env[key] = value;
2885
+ }
2886
+ return env;
2887
+ } catch {
2888
+ return {};
2889
+ }
2890
+ }
2891
+
2892
+ async function doctorCommand(bench = false): Promise<void> {
2893
+ // Discover launchd plist: env override → OpenClaw default location
2894
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
2895
+ const launchdPlist = process.env.QMD_LAUNCHD_PLIST
2896
+ || (homeDir ? `${homeDir}/Library/LaunchAgents/ai.openclaw.gateway.plist` : "");
2897
+ const launchdEnv = launchdPlist ? parseLaunchdEnvironmentVariables(launchdPlist) : {};
2898
+
2899
+ const runtime = process.env;
2900
+ const isLaunchdProcess = !!runtime.OPENCLAW_LAUNCHD_LABEL || !!runtime.OPENCLAW_SERVICE_KIND;
2901
+
2902
+ const sfKeyRuntime = runtime.QMD_SILICONFLOW_API_KEY;
2903
+ const gmKeyRuntime = runtime.QMD_GEMINI_API_KEY;
2904
+ const oaKeyRuntime = runtime.QMD_OPENAI_API_KEY;
2905
+ const rerankMode = (runtime.QMD_RERANK_MODE as "llm" | "rerank" | undefined) || "llm";
2906
+ const sfLlmRerankModel = runtime.QMD_SILICONFLOW_LLM_RERANK_MODEL || runtime.QMD_LLM_RERANK_MODEL || "zai-org/GLM-4.5-Air";
2907
+
2908
+ const configuredRerankProvider = runtime.QMD_RERANK_PROVIDER as "siliconflow" | "gemini" | "openai" | undefined;
2909
+ let rerankProvider: "siliconflow" | "gemini" | "openai" | undefined;
2910
+ if (rerankMode === "rerank") {
2911
+ if (sfKeyRuntime) {
2912
+ rerankProvider = "siliconflow";
2913
+ } else if (configuredRerankProvider === "gemini" && gmKeyRuntime) {
2914
+ rerankProvider = "gemini";
2915
+ } else if (configuredRerankProvider === "openai" && oaKeyRuntime) {
2916
+ rerankProvider = "openai";
2917
+ } else {
2918
+ rerankProvider = gmKeyRuntime ? "gemini" : (oaKeyRuntime ? "openai" : undefined);
2919
+ }
2920
+ } else {
2921
+ if (configuredRerankProvider === "gemini" || configuredRerankProvider === "openai") {
2922
+ rerankProvider = configuredRerankProvider;
2923
+ } else if (configuredRerankProvider === "siliconflow") {
2924
+ rerankProvider = sfKeyRuntime ? "openai" : undefined;
2925
+ } else {
2926
+ rerankProvider = sfKeyRuntime ? "openai" : (gmKeyRuntime ? "gemini" : (oaKeyRuntime ? "openai" : undefined));
2927
+ }
2928
+ }
2929
+ const embedProvider = (runtime.QMD_EMBED_PROVIDER as "siliconflow" | "openai" | undefined)
2930
+ || (sfKeyRuntime ? "siliconflow" : (oaKeyRuntime ? "openai" : undefined));
2931
+ const queryExpansionProvider = (runtime.QMD_QUERY_EXPANSION_PROVIDER as "siliconflow" | "gemini" | "openai" | undefined)
2932
+ || (sfKeyRuntime ? "siliconflow" : (oaKeyRuntime ? "openai" : (gmKeyRuntime ? "gemini" : undefined)));
2933
+
2934
+ const effectiveEmbedModel = embedProvider === "openai"
2935
+ ? (runtime.QMD_OPENAI_EMBED_MODEL || "text-embedding-3-small")
2936
+ : (runtime.QMD_SILICONFLOW_EMBED_MODEL || "Qwen/Qwen3-Embedding-8B");
2937
+ const effectiveSfRerankModel = runtime.QMD_SILICONFLOW_RERANK_MODEL || runtime.QMD_SILICONFLOW_MODEL || "BAAI/bge-reranker-v2-m3";
2938
+ const effectiveGmModel = runtime.QMD_GEMINI_RERANK_MODEL || runtime.QMD_GEMINI_MODEL || "gemini-2.5-flash";
2939
+ const effectiveOaModel = runtime.QMD_OPENAI_MODEL || (sfKeyRuntime ? sfLlmRerankModel : "gpt-4o-mini");
2940
+ const effectiveQueryExpansionModel = queryExpansionProvider === "siliconflow"
2941
+ ? (runtime.QMD_SILICONFLOW_QUERY_EXPANSION_MODEL || "zai-org/GLM-4.5-Air")
2942
+ : (queryExpansionProvider === "openai" ? effectiveOaModel : effectiveGmModel);
2943
+
2944
+ const db = getDb();
2945
+ const vecTableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
2946
+ const vecDim = vecTableInfo?.sql.match(/float\[(\d+)\]/)?.[1] || null;
2947
+
2948
+ let probeDim: number | null = null;
2949
+ let probeError: string | null = null;
2950
+ const canProbeRemoteEmbed = !!embedProvider && (!!sfKeyRuntime || !!oaKeyRuntime);
2951
+ if (canProbeRemoteEmbed) {
2952
+ try {
2953
+ const probe = await llmService.embed("qmd dimension probe", { model: effectiveEmbedModel, isQuery: true });
2954
+ probeDim = probe.embedding.length;
2955
+ } catch (err) {
2956
+ probeError = err instanceof Error ? err.message : String(err);
2957
+ }
2958
+ }
2959
+
2960
+ console.log("QMD 诊断");
2961
+ console.log("");
2962
+ console.log("进程来源:");
2963
+ console.log(` Launchd 进程标记: ${isLaunchdProcess ? "是" : "否"}`);
2964
+ console.log(` Launchd plist: ${launchdPlist}`);
2965
+ console.log(` Launchd 环境变量: ${Object.keys(launchdEnv).length > 0 ? "已检测到" : "未检测到"}`);
2966
+
2967
+ console.log("\n运行时环境变量:");
2968
+ console.log(` QMD_SILICONFLOW_API_KEY: ${sfKeyRuntime ? "<已设置>" : "<未设置>"}`);
2969
+ console.log(` QMD_GEMINI_API_KEY: ${gmKeyRuntime ? "<已设置>" : "<未设置>"}`);
2970
+ console.log(` QMD_OPENAI_API_KEY: ${oaKeyRuntime ? "<已设置>" : "<未设置>"}`);
2971
+ console.log(` QMD_EMBED_PROVIDER: ${runtime.QMD_EMBED_PROVIDER || "<未设置>"}`);
2972
+ console.log(` QMD_QUERY_EXPANSION_PROVIDER: ${runtime.QMD_QUERY_EXPANSION_PROVIDER || "<未设置>"}`);
2973
+ console.log(` QMD_RERANK_PROVIDER: ${runtime.QMD_RERANK_PROVIDER || "<未设置>"}`);
2974
+ console.log(` QMD_RERANK_MODE: ${runtime.QMD_RERANK_MODE || "<未设置,默认llm>"}`);
2975
+ if (sfKeyRuntime) {
2976
+ console.log(` QMD_SILICONFLOW_EMBED_MODEL: ${runtime.QMD_SILICONFLOW_EMBED_MODEL || "<未设置>"}`);
2977
+ console.log(` QMD_SILICONFLOW_QUERY_EXPANSION_MODEL: ${runtime.QMD_SILICONFLOW_QUERY_EXPANSION_MODEL || "<未设置>"}`);
2978
+ console.log(` QMD_SILICONFLOW_RERANK_MODEL: ${runtime.QMD_SILICONFLOW_RERANK_MODEL || runtime.QMD_SILICONFLOW_MODEL || "<未设置>"}`);
2979
+ }
2980
+ if (gmKeyRuntime) {
2981
+ console.log(` QMD_GEMINI_MODEL: ${runtime.QMD_GEMINI_RERANK_MODEL || runtime.QMD_GEMINI_MODEL || "<未设置>"}`);
2982
+ console.log(` QMD_GEMINI_BASE_URL: ${runtime.QMD_GEMINI_BASE_URL || "<默认>"}`);
2983
+ }
2984
+ if (oaKeyRuntime) {
2985
+ console.log(` QMD_OPENAI_MODEL: ${runtime.QMD_OPENAI_MODEL || "<未设置>"}`);
2986
+ console.log(` QMD_OPENAI_BASE_URL: ${runtime.QMD_OPENAI_BASE_URL || "<默认>"}`);
2987
+ console.log(` QMD_OPENAI_EMBED_MODEL: ${runtime.QMD_OPENAI_EMBED_MODEL || "<未设置>"}`);
2988
+ }
2989
+
2990
+ if (Object.keys(launchdEnv).length > 0) {
2991
+ console.log("\nLaunchd plist 环境变量 (已脱敏):");
2992
+ const keysToShow = [
2993
+ "QMD_EMBED_PROVIDER",
2994
+ "QMD_QUERY_EXPANSION_PROVIDER",
2995
+ "QMD_RERANK_PROVIDER",
2996
+ "QMD_RERANK_MODE",
2997
+ "QMD_SILICONFLOW_EMBED_MODEL",
2998
+ "QMD_SILICONFLOW_QUERY_EXPANSION_MODEL",
2999
+ "QMD_SILICONFLOW_LLM_RERANK_MODEL",
3000
+ "QMD_LLM_RERANK_MODEL",
3001
+ "QMD_SILICONFLOW_RERANK_MODEL",
3002
+ "QMD_GEMINI_RERANK_MODEL",
3003
+ "QMD_SILICONFLOW_API_KEY",
3004
+ "QMD_GEMINI_API_KEY",
3005
+ "QMD_OPENAI_API_KEY",
3006
+ ];
3007
+ for (const key of keysToShow) {
3008
+ const value = launchdEnv[key];
3009
+ if (value !== undefined) {
3010
+ const shown = key.includes("API_KEY") ? maskValue(value) : value;
3011
+ console.log(` ${key}: ${shown}`);
3012
+ }
3013
+ }
3014
+ }
3015
+
3016
+ console.log("\n当前生效的 Provider / 模型:");
3017
+ console.log(` 向量化 (embed): ${embedProvider || "本地"} → ${embedProvider ? effectiveEmbedModel : "本地 embeddinggemma"}`);
3018
+ console.log(` 查询扩展 (query expansion): ${queryExpansionProvider || "本地"} → ${queryExpansionProvider ? effectiveQueryExpansionModel : "本地 qmd-query-expansion"}`);
3019
+ const rerankModel = rerankProvider === "siliconflow" ? effectiveSfRerankModel
3020
+ : rerankProvider === "gemini" ? effectiveGmModel
3021
+ : rerankProvider === "openai" ? effectiveOaModel
3022
+ : "本地 qwen3-reranker";
3023
+ console.log(` 重排序模式: ${rerankMode}`);
3024
+ console.log(` 重排序 (rerank): ${rerankProvider || "本地"} → ${rerankModel}`);
3025
+
3026
+ console.log("\n向量索引:");
3027
+ console.log(` 数据库维度: ${vecDim || "<缺失>"}`);
3028
+ if (probeDim !== null) {
3029
+ console.log(` 远程 Embedding 探针维度: ${probeDim}`);
3030
+ if (vecDim && Number(vecDim) !== probeDim) {
3031
+ console.log(` 诊断: ❌ 维度不匹配 (索引=${vecDim}, 远程=${probeDim})`);
3032
+ console.log(" 修复: 在相同环境下运行 `qmd embed -f` 重建索引");
3033
+ } else {
3034
+ console.log(" 诊断: ✅ 维度对齐");
3035
+ }
3036
+ } else if (probeError) {
3037
+ console.log(` 远程 Embedding 探针: ❌ 失败 (${probeError})`);
3038
+ } else {
3039
+ console.log(" 远程 Embedding 探针: 跳过(当前进程未启用远程 Embed)");
3040
+ }
3041
+
3042
+ if (!isLaunchdProcess && Object.keys(launchdEnv).length > 0) {
3043
+ console.log("\n提示:");
3044
+ console.log(" 当前是从终端运行,不是 OpenClaw launchd 服务。");
3045
+ console.log(" 环境变量可能与网关进程不同。");
3046
+ }
3047
+
3048
+ // 速度诊断
3049
+ const hasAnyRemote = !!(sfKeyRuntime || gmKeyRuntime || runtime.QMD_OPENAI_API_KEY);
3050
+ if (hasAnyRemote) {
3051
+ console.log("\n速度诊断:");
3052
+ const SPEED_THRESHOLD_MS = 10000;
3053
+
3054
+ // 测试 embed
3055
+ if (embedProvider) {
3056
+ const t0 = Date.now();
3057
+ try {
3058
+ await llmService.embed("QMDR 速度测试", { isQuery: true });
3059
+ const elapsed = Date.now() - t0;
3060
+ const status = elapsed > SPEED_THRESHOLD_MS ? "⚠️ 慢" : "✅";
3061
+ console.log(` 向量化 (${embedProvider}): ${elapsed}ms ${status}`);
3062
+ if (elapsed > SPEED_THRESHOLD_MS) {
3063
+ console.log(` → 建议换用更快的非思考模型`);
3064
+ }
3065
+ } catch (err) {
3066
+ console.log(` 向量化 (${embedProvider}): ❌ 失败 - ${err instanceof Error ? err.message : err}`);
3067
+ }
3068
+ } else {
3069
+ console.log(" 向量化: 跳过(本地模式)");
3070
+ }
3071
+
3072
+ // 测试 query expansion
3073
+ let expansionResult: { type: string; text: string }[] | null = null;
3074
+ if (queryExpansionProvider) {
3075
+ const t0 = Date.now();
3076
+ try {
3077
+ expansionResult = await llmService.expandQuery("买过哪些日本的VPS服务器") as { type: string; text: string }[];
3078
+ const elapsed = Date.now() - t0;
3079
+ const status = elapsed > SPEED_THRESHOLD_MS ? "⚠️ 慢" : "✅";
3080
+ console.log(` 查询扩展 (${queryExpansionProvider}): ${elapsed}ms ${status}`);
3081
+ if (elapsed > SPEED_THRESHOLD_MS) {
3082
+ console.log(` → 建议换用更快的非思考模型`);
3083
+ }
3084
+ } catch (err) {
3085
+ console.log(` 查询扩展 (${queryExpansionProvider}): ❌ 失败 - ${err instanceof Error ? err.message : err}`);
3086
+ }
3087
+ } else {
3088
+ console.log(" 查询扩展: 跳过(本地模式)");
3089
+ }
3090
+
3091
+ // 测试 rerank
3092
+ if (rerankProvider) {
3093
+ const t0 = Date.now();
3094
+ try {
3095
+ await llmService.rerank("买过哪些日本的VPS服务器", [
3096
+ { file: "memory/2026-01-15.md", text: "在绿云买了一台东京软银VPS,4C8G,三年$88,用来做备用机。" },
3097
+ { file: "memory/2026-01-20.md", text: "今天做了一顿咖喱鸡,味道还不错。" },
3098
+ ]);
3099
+ const elapsed = Date.now() - t0;
3100
+ const status = elapsed > SPEED_THRESHOLD_MS ? "⚠️ 慢" : "✅";
3101
+ console.log(` 重排序 (${rerankProvider}): ${elapsed}ms ${status}`);
3102
+ if (elapsed > SPEED_THRESHOLD_MS) {
3103
+ console.log(` → 建议换用更快的非思考模型`);
3104
+ }
3105
+ } catch (err) {
3106
+ console.log(` 重排序 (${rerankProvider}): ❌ 失败 - ${err instanceof Error ? err.message : err}`);
3107
+ }
3108
+ } else {
3109
+ console.log(" 重排序: 跳过(本地模式)");
3110
+ }
3111
+
3112
+ console.log(`\n 建议: 每个步骤应在 ${SPEED_THRESHOLD_MS / 1000} 秒内完成。`);
3113
+ console.log(" 如果慢,请尝试非思考或更小的模型。");
3114
+ } else {
3115
+ console.log("\n速度诊断: 跳过(未配置远程 Provider)");
3116
+ }
3117
+
3118
+ // --bench 评分模式
3119
+ if (bench && hasAnyRemote && queryExpansionProvider && rerankProvider) {
3120
+ console.log("\n质量评估 (--bench):");
3121
+ // 评委优先用 Gemini(最强),其次 OpenAI,最后 SiliconFlow
3122
+ let judgeProvider: "gemini" | "siliconflow" | "openai" = "siliconflow";
3123
+ let judgeApiKey = sfKeyRuntime || "";
3124
+ let judgeBaseUrl = (runtime.QMD_SILICONFLOW_BASE_URL || "https://api.siliconflow.cn/v1").replace(/\/$/, "");
3125
+ let judgeModel = runtime.QMD_SILICONFLOW_QUERY_EXPANSION_MODEL || "zai-org/GLM-4.5-Air";
3126
+
3127
+ if (gmKeyRuntime) {
3128
+ judgeProvider = "gemini";
3129
+ judgeApiKey = gmKeyRuntime;
3130
+ judgeBaseUrl = (runtime.QMD_GEMINI_BASE_URL || "https://generativelanguage.googleapis.com").replace(/\/$/, "");
3131
+ judgeModel = runtime.QMD_GEMINI_MODEL || "gemini-2.5-flash";
3132
+ } else if (oaKeyRuntime) {
3133
+ judgeProvider = "openai";
3134
+ judgeApiKey = oaKeyRuntime;
3135
+ judgeBaseUrl = (runtime.QMD_OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/$/, "");
3136
+ judgeModel = runtime.QMD_OPENAI_MODEL || "gpt-4o-mini";
3137
+ }
3138
+ console.log(` 评委模型: ${judgeModel} (${judgeProvider})`);
3139
+ console.log(" 对查询扩展结果评分...\n");
3140
+
3141
+ const benchQueries = [
3142
+ "昨天讨论了什么话题",
3143
+ "前天我让你装了什么 skill",
3144
+ "如何配置环境变量",
3145
+ ];
3146
+
3147
+ let totalScore = 0;
3148
+ let totalTests = 0;
3149
+
3150
+ for (const q of benchQueries) {
3151
+ console.log(` 查询: "${q}"`);
3152
+ try {
3153
+ const expanded = await llmService.expandQuery(q) as { type: string; text: string }[];
3154
+ const lexItems = expanded.filter((e: { type: string }) => e.type === "lex");
3155
+ const vecItems = expanded.filter((e: { type: string }) => e.type === "vec");
3156
+ const hydeItems = expanded.filter((e: { type: string }) => e.type === "hyde");
3157
+
3158
+ const expandedText = expanded.map((e: { type: string; text: string }) => `${e.type}: ${e.text}`).join("\n");
3159
+ console.log(` 扩展结果 (lex:${lexItems.length} vec:${vecItems.length} hyde:${hydeItems.length}):`);
3160
+ for (const e of expanded) {
3161
+ console.log(` ${e.type}: ${(e.text || "").slice(0, 80)}`);
3162
+ }
3163
+
3164
+ // 用 chat completions 直接评分
3165
+ const evalPrompt = `对以下查询扩展结果评分(0-10分)。
3166
+
3167
+ 原始查询: "${q}"
3168
+ 扩展结果:
3169
+ ${expandedText}
3170
+
3171
+ 评分标准:
3172
+ - lex 是否为空格分隔的关键词(不是句子)?
3173
+ - vec 是否是语义相关的改写?
3174
+ - hyde 是否像一段真实的文档内容?
3175
+ - 整体是否覆盖了查询的核心意图?
3176
+
3177
+ 请只输出一个数字(0-10),不要其他内容。`;
3178
+
3179
+ let score = -1;
3180
+ if (judgeApiKey) {
3181
+ try {
3182
+ // Gemini 原生 API 格式不同,统一走 OpenAI-compatible endpoint
3183
+ const chatUrl = judgeProvider === "gemini"
3184
+ ? `${judgeBaseUrl}/v1/chat/completions`
3185
+ : `${judgeBaseUrl}/chat/completions`;
3186
+ const scoreResp = await fetch(chatUrl, {
3187
+ method: "POST",
3188
+ headers: { Authorization: `Bearer ${judgeApiKey}`, "Content-Type": "application/json" },
3189
+ body: JSON.stringify({
3190
+ model: judgeModel,
3191
+ messages: [{ role: "user", content: evalPrompt }],
3192
+ max_tokens: 10,
3193
+ temperature: 0,
3194
+ }),
3195
+ });
3196
+ if (scoreResp.ok) {
3197
+ const scoreData = await scoreResp.json() as { choices?: Array<{ message?: { content?: string } }> };
3198
+ const scoreText = scoreData.choices?.[0]?.message?.content || "";
3199
+ const scoreMatch = scoreText.match(/(\d+)/);
3200
+ score = scoreMatch ? Math.min(10, parseInt(scoreMatch[1]!)) : -1;
3201
+ }
3202
+ } catch {}
3203
+ }
3204
+
3205
+ if (score >= 0) {
3206
+ console.log(` 评分: ${score}/10 ${"★".repeat(Math.round(score / 2))}${"☆".repeat(5 - Math.round(score / 2))}`);
3207
+ totalScore += score;
3208
+ totalTests++;
3209
+ } else {
3210
+ console.log(` 评分: 无法解析`);
3211
+ }
3212
+ } catch (err) {
3213
+ console.log(` ❌ 失败: ${err instanceof Error ? err.message : err}`);
3214
+ }
3215
+ console.log("");
3216
+ }
3217
+
3218
+ if (totalTests > 0) {
3219
+ const avg = (totalScore / totalTests).toFixed(1);
3220
+ console.log(` 综合评分: ${avg}/10 (${totalTests} 题)`);
3221
+ if (Number(avg) >= 8) console.log(" 评价: 🌟 优秀");
3222
+ else if (Number(avg) >= 6) console.log(" 评价: 👍 良好");
3223
+ else if (Number(avg) >= 4) console.log(" 评价: ⚠️ 一般,建议换更好的模型");
3224
+ else console.log(" 评价: ❌ 较差,建议更换模型");
3225
+ }
3226
+ } else if (bench && !hasAnyRemote) {
3227
+ console.log("\n质量评估: 跳过(未配置远程 Provider)");
3228
+ } else if (bench && !queryExpansionProvider) {
3229
+ console.log("\n质量评估: 跳过(未配置查询扩展 Provider)");
3230
+ }
3231
+
3232
+ closeDb();
3233
+ }
3234
+
3235
+ // Main CLI - only run if this is the main module
3236
+ if (import.meta.main) {
3237
+ const cli = parseCLI();
3238
+
3239
+ if (!cli.command || cli.values.help) {
3240
+ showHelp();
3241
+ process.exit(cli.values.help ? 0 : 1);
3242
+ }
3243
+
3244
+ switch (cli.command) {
3245
+ case "context":
3246
+ await handleContextCommand(cli.args, { contextAdd, contextList, contextCheck, contextRemove });
3247
+ break;
3248
+
3249
+ case "get":
3250
+ handleGetCommand(cli.args, cli.values as Record<string, unknown>, cli.opts, { getDocument });
3251
+ break;
3252
+
3253
+ case "multi-get":
3254
+ handleMultiGetCommand(cli.args, cli.values as Record<string, unknown>, cli.opts.format, DEFAULT_MULTI_GET_MAX_BYTES, { multiGet });
3255
+ break;
3256
+
3257
+ case "ls":
3258
+ handleLsCommand(cli.args, { listFiles });
3259
+ break;
3260
+
3261
+ case "collection":
3262
+ await handleCollectionCommand(cli.args, cli.values as Record<string, unknown>, {
3263
+ getPwd,
3264
+ getRealPath,
3265
+ resolve,
3266
+ defaultGlob: DEFAULT_GLOB,
3267
+ collectionList,
3268
+ collectionAdd,
3269
+ collectionRemove,
3270
+ collectionRename,
3271
+ });
3272
+ break;
3273
+
3274
+ case "status":
3275
+ handleStatusCommand({ showStatus });
3276
+ break;
3277
+
3278
+ case "update":
3279
+ await handleUpdateCommand({ updateCollections });
3280
+ break;
3281
+
3282
+ case "embed":
3283
+ await handleEmbedCommand(cli.values as Record<string, unknown>, { vectorIndex, defaultModel: DEFAULT_EMBED_MODEL });
3284
+ break;
3285
+
3286
+ case "pull":
3287
+ await handlePullCommand(cli.values as Record<string, unknown>, { pull: pullCommand });
3288
+ break;
3289
+
3290
+ case "search":
3291
+ handleSearchCommand(cli.query, cli.opts, { search });
3292
+ break;
3293
+
3294
+ case "vsearch":
3295
+ await handleVSearchCommand(cli.query, cli.values as Record<string, unknown>, cli.opts, { vectorSearch });
3296
+ break;
3297
+
3298
+ case "query":
3299
+ await handleQueryCommand(cli.query, cli.opts, { querySearch });
3300
+ break;
3301
+
3302
+ case "mcp": {
3303
+ const { startMcpServer } = await import("./mcp.js");
3304
+ await handleMcpCommand({ startMcpServer });
3305
+ break;
3306
+ }
3307
+
3308
+ case "cleanup": {
3309
+ handleCleanupCommand({ cleanup: cleanupCommand });
3310
+ break;
3311
+ }
3312
+
3313
+ case "doctor": {
3314
+ const bench = !!(cli.values as Record<string, unknown>).bench;
3315
+ await handleDoctorCommand({ doctor: doctorCommand }, bench);
3316
+ break;
3317
+ }
3318
+
3319
+ default:
3320
+ console.error(`Unknown command: ${cli.command}`);
3321
+ console.error("Run 'qmd --help' for usage.");
3322
+ process.exit(1);
3323
+ }
3324
+
3325
+ if (cli.command !== "mcp") {
3326
+ await disposeDefaultLlamaCpp();
3327
+ process.exit(0);
3328
+ }
3329
+
3330
+ } // end if (import.meta.main)