@tobilu/qmd 1.0.7 → 1.1.2
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.
- package/CHANGELOG.md +90 -1
- package/README.md +29 -3
- package/dist/collections.d.ts +17 -0
- package/dist/collections.js +40 -0
- package/dist/llm.d.ts +20 -5
- package/dist/llm.js +111 -49
- package/dist/mcp.js +222 -101
- package/dist/qmd.js +480 -147
- package/dist/store.d.ts +99 -12
- package/dist/store.js +436 -25
- package/package.json +6 -6
- package/qmd +0 -46
package/dist/qmd.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
import { openDatabase } from "./db.js";
|
|
2
3
|
import fastGlob from "fast-glob";
|
|
3
4
|
import { execSync, spawn as nodeSpawn } from "child_process";
|
|
4
5
|
import { fileURLToPath } from "url";
|
|
5
6
|
import { dirname, join as pathJoin } from "path";
|
|
6
7
|
import { parseArgs } from "util";
|
|
7
|
-
import { readFileSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs";
|
|
8
|
-
import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, getHashesForEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, } from "./store.js";
|
|
8
|
+
import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs";
|
|
9
|
+
import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, getHashesForEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, structuredSearch, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, } from "./store.js";
|
|
9
10
|
import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "./llm.js";
|
|
10
11
|
import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
|
|
11
|
-
import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, addContext as yamlAddContext, removeContext as yamlRemoveContext, setGlobalContext, listAllContexts, setConfigIndexName, } from "./collections.js";
|
|
12
|
+
import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, setGlobalContext, listAllContexts, setConfigIndexName, } from "./collections.js";
|
|
12
13
|
// Enable production mode - allows using default database path
|
|
13
14
|
// Tests must set INDEX_PATH or use createStore() with explicit path
|
|
14
15
|
enableProductionMode();
|
|
@@ -73,19 +74,24 @@ const cursor = {
|
|
|
73
74
|
// Ensure cursor is restored on exit
|
|
74
75
|
process.on('SIGINT', () => { cursor.show(); process.exit(130); });
|
|
75
76
|
process.on('SIGTERM', () => { cursor.show(); process.exit(143); });
|
|
76
|
-
// Terminal progress bar using OSC 9;4 escape sequence
|
|
77
|
+
// Terminal progress bar using OSC 9;4 escape sequence (TTY only)
|
|
78
|
+
const isTTY = process.stderr.isTTY;
|
|
77
79
|
const progress = {
|
|
78
80
|
set(percent) {
|
|
79
|
-
|
|
81
|
+
if (isTTY)
|
|
82
|
+
process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
|
|
80
83
|
},
|
|
81
84
|
clear() {
|
|
82
|
-
|
|
85
|
+
if (isTTY)
|
|
86
|
+
process.stderr.write(`\x1b]9;4;0\x07`);
|
|
83
87
|
},
|
|
84
88
|
indeterminate() {
|
|
85
|
-
|
|
89
|
+
if (isTTY)
|
|
90
|
+
process.stderr.write(`\x1b]9;4;3\x07`);
|
|
86
91
|
},
|
|
87
92
|
error() {
|
|
88
|
-
|
|
93
|
+
if (isTTY)
|
|
94
|
+
process.stderr.write(`\x1b]9;4;2\x07`);
|
|
89
95
|
},
|
|
90
96
|
};
|
|
91
97
|
// Format seconds into human-readable ETA
|
|
@@ -155,6 +161,11 @@ function formatTimeAgo(date) {
|
|
|
155
161
|
const days = Math.floor(hours / 24);
|
|
156
162
|
return `${days}d ago`;
|
|
157
163
|
}
|
|
164
|
+
function formatMs(ms) {
|
|
165
|
+
if (ms < 1000)
|
|
166
|
+
return `${ms}ms`;
|
|
167
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
168
|
+
}
|
|
158
169
|
function formatBytes(bytes) {
|
|
159
170
|
if (bytes < 1024)
|
|
160
171
|
return `${bytes} B`;
|
|
@@ -308,6 +319,37 @@ async function showStatus() {
|
|
|
308
319
|
catch {
|
|
309
320
|
// Don't fail status if LLM init fails
|
|
310
321
|
}
|
|
322
|
+
// Tips section
|
|
323
|
+
const tips = [];
|
|
324
|
+
// Check for collections without context
|
|
325
|
+
const collectionsWithoutContext = collections.filter(col => {
|
|
326
|
+
const contexts = contextsByCollection.get(col.name) || [];
|
|
327
|
+
return contexts.length === 0;
|
|
328
|
+
});
|
|
329
|
+
if (collectionsWithoutContext.length > 0) {
|
|
330
|
+
const names = collectionsWithoutContext.map(c => c.name).slice(0, 3).join(', ');
|
|
331
|
+
const more = collectionsWithoutContext.length > 3 ? ` +${collectionsWithoutContext.length - 3} more` : '';
|
|
332
|
+
tips.push(`Add context to collections for better search results: ${names}${more}`);
|
|
333
|
+
tips.push(` ${c.dim}qmd context add qmd://<name>/ "What this collection contains"${c.reset}`);
|
|
334
|
+
tips.push(` ${c.dim}qmd context add qmd://<name>/meeting-notes "Weekly team meeting notes"${c.reset}`);
|
|
335
|
+
}
|
|
336
|
+
// Check for collections without update commands
|
|
337
|
+
const collectionsWithoutUpdate = collections.filter(col => {
|
|
338
|
+
const yamlCol = getCollectionFromYaml(col.name);
|
|
339
|
+
return !yamlCol?.update;
|
|
340
|
+
});
|
|
341
|
+
if (collectionsWithoutUpdate.length > 0 && collections.length > 1) {
|
|
342
|
+
const names = collectionsWithoutUpdate.map(c => c.name).slice(0, 3).join(', ');
|
|
343
|
+
const more = collectionsWithoutUpdate.length > 3 ? ` +${collectionsWithoutUpdate.length - 3} more` : '';
|
|
344
|
+
tips.push(`Add update commands to keep collections fresh: ${names}${more}`);
|
|
345
|
+
tips.push(` ${c.dim}qmd collection update-cmd <name> 'git stash && git pull --rebase --ff-only && git stash pop'${c.reset}`);
|
|
346
|
+
}
|
|
347
|
+
if (tips.length > 0) {
|
|
348
|
+
console.log(`\n${c.bold}Tips${c.reset}`);
|
|
349
|
+
for (const tip of tips) {
|
|
350
|
+
console.log(` ${tip}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
311
353
|
closeDb();
|
|
312
354
|
}
|
|
313
355
|
async function updateCollections() {
|
|
@@ -361,7 +403,7 @@ async function updateCollections() {
|
|
|
361
403
|
process.exit(1);
|
|
362
404
|
}
|
|
363
405
|
}
|
|
364
|
-
await indexFiles(col.pwd, col.glob_pattern, col.name, true);
|
|
406
|
+
await indexFiles(col.pwd, col.glob_pattern, col.name, true, yamlCol?.ignore);
|
|
365
407
|
console.log("");
|
|
366
408
|
}
|
|
367
409
|
// Check if any documents need embedding (show once at end)
|
|
@@ -533,49 +575,6 @@ function contextRemove(pathArg) {
|
|
|
533
575
|
}
|
|
534
576
|
console.log(`${c.green}✓${c.reset} Removed context for: qmd://${detected.collectionName}/${detected.relativePath}`);
|
|
535
577
|
}
|
|
536
|
-
function contextCheck() {
|
|
537
|
-
const db = getDb();
|
|
538
|
-
// Get collections without any context
|
|
539
|
-
const collectionsWithoutContext = getCollectionsWithoutContext(db);
|
|
540
|
-
// Get all collections to check for missing path contexts
|
|
541
|
-
const allCollections = listCollections(db);
|
|
542
|
-
if (collectionsWithoutContext.length === 0 && allCollections.length > 0) {
|
|
543
|
-
// Check if all collections have contexts
|
|
544
|
-
console.log(`\n${c.green}✓${c.reset} ${c.bold}All collections have context configured${c.reset}\n`);
|
|
545
|
-
}
|
|
546
|
-
if (collectionsWithoutContext.length > 0) {
|
|
547
|
-
console.log(`\n${c.yellow}Collections without any context:${c.reset}\n`);
|
|
548
|
-
for (const coll of collectionsWithoutContext) {
|
|
549
|
-
console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(${coll.doc_count} documents)${c.reset}`);
|
|
550
|
-
console.log(` ${c.dim}Suggestion: qmd context add qmd://${coll.name}/ "Description of ${coll.name}"${c.reset}\n`);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
// Check for top-level paths without context within collections that DO have context
|
|
554
|
-
const collectionsWithContext = allCollections.filter(c => c && !collectionsWithoutContext.some(cwc => cwc.name === c.name));
|
|
555
|
-
let hasPathSuggestions = false;
|
|
556
|
-
for (const coll of collectionsWithContext) {
|
|
557
|
-
if (!coll)
|
|
558
|
-
continue;
|
|
559
|
-
const missingPaths = getTopLevelPathsWithoutContext(db, coll.name);
|
|
560
|
-
if (missingPaths.length > 0) {
|
|
561
|
-
if (!hasPathSuggestions) {
|
|
562
|
-
console.log(`${c.yellow}Top-level directories without context:${c.reset}\n`);
|
|
563
|
-
hasPathSuggestions = true;
|
|
564
|
-
}
|
|
565
|
-
console.log(`${c.cyan}${coll.name}${c.reset}`);
|
|
566
|
-
for (const path of missingPaths) {
|
|
567
|
-
console.log(` ${path}`);
|
|
568
|
-
console.log(` ${c.dim}Suggestion: qmd context add qmd://${coll.name}/${path} "Description of ${path}"${c.reset}`);
|
|
569
|
-
}
|
|
570
|
-
console.log('');
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
if (collectionsWithoutContext.length === 0 && !hasPathSuggestions) {
|
|
574
|
-
console.log(`${c.dim}All collections and major paths have context configured.${c.reset}`);
|
|
575
|
-
console.log(`${c.dim}Use 'qmd context list' to see all configured contexts.${c.reset}\n`);
|
|
576
|
-
}
|
|
577
|
-
closeDb();
|
|
578
|
-
}
|
|
579
578
|
function getDocument(filename, fromLine, maxLines, lineNumbers) {
|
|
580
579
|
const db = getDb();
|
|
581
580
|
// Parse :linenum suffix from filename (e.g., "file.md:100")
|
|
@@ -1103,8 +1102,15 @@ function collectionList() {
|
|
|
1103
1102
|
for (const coll of collections) {
|
|
1104
1103
|
const updatedAt = coll.last_modified ? new Date(coll.last_modified) : new Date();
|
|
1105
1104
|
const timeAgo = formatTimeAgo(updatedAt);
|
|
1106
|
-
|
|
1105
|
+
// Get YAML config to check includeByDefault
|
|
1106
|
+
const yamlColl = getCollectionFromYaml(coll.name);
|
|
1107
|
+
const excluded = yamlColl?.includeByDefault === false;
|
|
1108
|
+
const excludeTag = excluded ? ` ${c.yellow}[excluded]${c.reset}` : '';
|
|
1109
|
+
console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(qmd://${coll.name}/)${c.reset}${excludeTag}`);
|
|
1107
1110
|
console.log(` ${c.dim}Pattern:${c.reset} ${coll.glob_pattern}`);
|
|
1111
|
+
if (yamlColl?.ignore?.length) {
|
|
1112
|
+
console.log(` ${c.dim}Ignore:${c.reset} ${yamlColl.ignore.join(', ')}`);
|
|
1113
|
+
}
|
|
1108
1114
|
console.log(` ${c.dim}Files:${c.reset} ${coll.active_count}`);
|
|
1109
1115
|
console.log(` ${c.dim}Updated:${c.reset} ${timeAgo}`);
|
|
1110
1116
|
console.log();
|
|
@@ -1140,7 +1146,8 @@ async function collectionAdd(pwd, globPattern, name) {
|
|
|
1140
1146
|
addCollection(collName, pwd, globPattern);
|
|
1141
1147
|
// Create the collection and index files
|
|
1142
1148
|
console.log(`Creating collection '${collName}'...`);
|
|
1143
|
-
|
|
1149
|
+
const newColl = getCollectionFromYaml(collName);
|
|
1150
|
+
await indexFiles(pwd, globPattern, collName, false, newColl?.ignore);
|
|
1144
1151
|
console.log(`${c.green}✓${c.reset} Collection '${collName}' created successfully`);
|
|
1145
1152
|
}
|
|
1146
1153
|
function collectionRemove(name) {
|
|
@@ -1181,7 +1188,7 @@ function collectionRename(oldName, newName) {
|
|
|
1181
1188
|
console.log(`${c.green}✓${c.reset} Renamed collection '${oldName}' to '${newName}'`);
|
|
1182
1189
|
console.log(` Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`);
|
|
1183
1190
|
}
|
|
1184
|
-
async function indexFiles(pwd, globPattern = DEFAULT_GLOB, collectionName, suppressEmbedNotice = false) {
|
|
1191
|
+
async function indexFiles(pwd, globPattern = DEFAULT_GLOB, collectionName, suppressEmbedNotice = false, ignorePatterns) {
|
|
1185
1192
|
const db = getDb();
|
|
1186
1193
|
const resolvedPwd = pwd || getPwd();
|
|
1187
1194
|
const now = new Date().toISOString();
|
|
@@ -1194,12 +1201,16 @@ async function indexFiles(pwd, globPattern = DEFAULT_GLOB, collectionName, suppr
|
|
|
1194
1201
|
}
|
|
1195
1202
|
console.log(`Collection: ${resolvedPwd} (${globPattern})`);
|
|
1196
1203
|
progress.indeterminate();
|
|
1204
|
+
const allIgnore = [
|
|
1205
|
+
...excludeDirs.map(d => `**/${d}/**`),
|
|
1206
|
+
...(ignorePatterns || []),
|
|
1207
|
+
];
|
|
1197
1208
|
const allFiles = await fastGlob(globPattern, {
|
|
1198
1209
|
cwd: resolvedPwd,
|
|
1199
1210
|
onlyFiles: true,
|
|
1200
1211
|
followSymbolicLinks: false,
|
|
1201
1212
|
dot: false,
|
|
1202
|
-
ignore:
|
|
1213
|
+
ignore: allIgnore,
|
|
1203
1214
|
});
|
|
1204
1215
|
// Filter hidden files/folders (dot: false handles top-level but not nested)
|
|
1205
1216
|
const files = allFiles.filter(file => {
|
|
@@ -1207,11 +1218,11 @@ async function indexFiles(pwd, globPattern = DEFAULT_GLOB, collectionName, suppr
|
|
|
1207
1218
|
return !parts.some(part => part.startsWith("."));
|
|
1208
1219
|
});
|
|
1209
1220
|
const total = files.length;
|
|
1210
|
-
|
|
1221
|
+
const hasNoFiles = total === 0;
|
|
1222
|
+
if (hasNoFiles) {
|
|
1211
1223
|
progress.clear();
|
|
1212
1224
|
console.log("No files found matching pattern.");
|
|
1213
|
-
|
|
1214
|
-
return;
|
|
1225
|
+
// Continue so the deactivation pass can mark previously indexed docs as inactive.
|
|
1215
1226
|
}
|
|
1216
1227
|
let indexed = 0, updated = 0, unchanged = 0, processed = 0;
|
|
1217
1228
|
const seenPaths = new Set();
|
|
@@ -1220,7 +1231,16 @@ async function indexFiles(pwd, globPattern = DEFAULT_GLOB, collectionName, suppr
|
|
|
1220
1231
|
const filepath = getRealPath(resolve(resolvedPwd, relativeFile));
|
|
1221
1232
|
const path = handelize(relativeFile); // Normalize path for token-friendliness
|
|
1222
1233
|
seenPaths.add(path);
|
|
1223
|
-
|
|
1234
|
+
let content;
|
|
1235
|
+
try {
|
|
1236
|
+
content = readFileSync(filepath, "utf-8");
|
|
1237
|
+
}
|
|
1238
|
+
catch (err) {
|
|
1239
|
+
// Skip files that can't be read (e.g. iCloud evicted files returning EAGAIN)
|
|
1240
|
+
processed++;
|
|
1241
|
+
progress.set((processed / total) * 100);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1224
1244
|
// Skip empty files - nothing useful to index
|
|
1225
1245
|
if (!content.trim()) {
|
|
1226
1246
|
processed++;
|
|
@@ -1262,7 +1282,8 @@ async function indexFiles(pwd, globPattern = DEFAULT_GLOB, collectionName, suppr
|
|
|
1262
1282
|
const rate = processed / elapsed;
|
|
1263
1283
|
const remaining = (total - processed) / rate;
|
|
1264
1284
|
const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
|
|
1265
|
-
|
|
1285
|
+
if (isTTY)
|
|
1286
|
+
process.stderr.write(`\rIndexing: ${processed}/${total}${eta} `);
|
|
1266
1287
|
}
|
|
1267
1288
|
// Deactivate documents in this collection that no longer exist
|
|
1268
1289
|
const allActive = getActiveDocumentPaths(db, collectionName);
|
|
@@ -1425,7 +1446,8 @@ async function vectorIndex(model = DEFAULT_EMBED_MODEL, force = false) {
|
|
|
1425
1446
|
const throughput = `${formatBytes(bytesPerSec)}/s`;
|
|
1426
1447
|
const eta = elapsed > 2 ? formatETA(etaSec) : "...";
|
|
1427
1448
|
const errStr = errors > 0 ? ` ${c.yellow}${errors} err${c.reset}` : "";
|
|
1428
|
-
|
|
1449
|
+
if (isTTY)
|
|
1450
|
+
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} `);
|
|
1429
1451
|
}
|
|
1430
1452
|
progress.clear();
|
|
1431
1453
|
cursor.show();
|
|
@@ -1498,6 +1520,9 @@ function formatScore(score) {
|
|
|
1498
1520
|
return `${c.yellow}${pct}%${c.reset}`;
|
|
1499
1521
|
return `${c.dim}${pct}%${c.reset}`;
|
|
1500
1522
|
}
|
|
1523
|
+
function formatExplainNumber(value) {
|
|
1524
|
+
return value.toFixed(4);
|
|
1525
|
+
}
|
|
1501
1526
|
// Shorten directory path for display - relative to $HOME (used for context paths, not documents)
|
|
1502
1527
|
function shortPath(dirpath) {
|
|
1503
1528
|
const home = homedir();
|
|
@@ -1506,10 +1531,33 @@ function shortPath(dirpath) {
|
|
|
1506
1531
|
}
|
|
1507
1532
|
return dirpath;
|
|
1508
1533
|
}
|
|
1534
|
+
// Emit format-safe empty output for search commands.
|
|
1535
|
+
function printEmptySearchResults(format, reason = "no_results") {
|
|
1536
|
+
if (format === "json") {
|
|
1537
|
+
console.log("[]");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
if (format === "csv") {
|
|
1541
|
+
console.log("docid,score,file,title,context,line,snippet");
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (format === "xml") {
|
|
1545
|
+
console.log("<results></results>");
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (format === "md" || format === "files") {
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
if (reason === "min_score") {
|
|
1552
|
+
console.log("No results found above minimum score threshold.");
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
console.log("No results found.");
|
|
1556
|
+
}
|
|
1509
1557
|
function outputResults(results, query, opts) {
|
|
1510
1558
|
const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
|
|
1511
1559
|
if (filtered.length === 0) {
|
|
1512
|
-
|
|
1560
|
+
printEmptySearchResults(opts.format, "min_score");
|
|
1513
1561
|
return;
|
|
1514
1562
|
}
|
|
1515
1563
|
// Helper to create qmd:// URI from displayPath
|
|
@@ -1534,6 +1582,7 @@ function outputResults(results, query, opts) {
|
|
|
1534
1582
|
...(row.context && { context: row.context }),
|
|
1535
1583
|
...(body && { body }),
|
|
1536
1584
|
...(snippet && { snippet }),
|
|
1585
|
+
...(opts.explain && row.explain && { explain: row.explain }),
|
|
1537
1586
|
};
|
|
1538
1587
|
});
|
|
1539
1588
|
console.log(JSON.stringify(output, null, 2));
|
|
@@ -1572,6 +1621,27 @@ function outputResults(results, query, opts) {
|
|
|
1572
1621
|
// Line 4: Score
|
|
1573
1622
|
const score = formatScore(row.score);
|
|
1574
1623
|
console.log(`Score: ${c.bold}${score}${c.reset}`);
|
|
1624
|
+
if (opts.explain && row.explain) {
|
|
1625
|
+
const explain = row.explain;
|
|
1626
|
+
const ftsScores = explain.ftsScores.length > 0
|
|
1627
|
+
? explain.ftsScores.map(formatExplainNumber).join(", ")
|
|
1628
|
+
: "none";
|
|
1629
|
+
const vecScores = explain.vectorScores.length > 0
|
|
1630
|
+
? explain.vectorScores.map(formatExplainNumber).join(", ")
|
|
1631
|
+
: "none";
|
|
1632
|
+
const contribSummary = explain.rrf.contributions
|
|
1633
|
+
.slice()
|
|
1634
|
+
.sort((a, b) => b.rrfContribution - a.rrfContribution)
|
|
1635
|
+
.slice(0, 3)
|
|
1636
|
+
.map(c => `${c.source}/${c.queryType}#${c.rank}:${formatExplainNumber(c.rrfContribution)}`)
|
|
1637
|
+
.join(" | ");
|
|
1638
|
+
console.log(`${c.dim}Explain: fts=[${ftsScores}] vec=[${vecScores}]${c.reset}`);
|
|
1639
|
+
console.log(`${c.dim} RRF: total=${formatExplainNumber(explain.rrf.totalScore)} base=${formatExplainNumber(explain.rrf.baseScore)} bonus=${formatExplainNumber(explain.rrf.topRankBonus)} rank=${explain.rrf.rank}${c.reset}`);
|
|
1640
|
+
console.log(`${c.dim} Blend: ${Math.round(explain.rrf.weight * 100)}%*${formatExplainNumber(explain.rrf.positionScore)} + ${Math.round((1 - explain.rrf.weight) * 100)}%*${formatExplainNumber(explain.rerankScore)} = ${formatExplainNumber(explain.blendedScore)}${c.reset}`);
|
|
1641
|
+
if (contribSummary.length > 0) {
|
|
1642
|
+
console.log(`${c.dim} Top RRF contributions: ${contribSummary}${c.reset}`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1575
1645
|
console.log();
|
|
1576
1646
|
// Snippet with highlighting (diff-style header included)
|
|
1577
1647
|
let displaySnippet = opts.lineNumbers ? addLineNumbers(snippet, line) : snippet;
|
|
@@ -1627,7 +1697,11 @@ function outputResults(results, query, opts) {
|
|
|
1627
1697
|
}
|
|
1628
1698
|
// Resolve -c collection filter: supports single string, array, or undefined.
|
|
1629
1699
|
// Returns validated collection names (exits on unknown collection).
|
|
1630
|
-
function resolveCollectionFilter(raw) {
|
|
1700
|
+
function resolveCollectionFilter(raw, useDefaults = false) {
|
|
1701
|
+
// If no filter specified and useDefaults is true, use default collections
|
|
1702
|
+
if (!raw && useDefaults) {
|
|
1703
|
+
return getDefaultCollectionNames();
|
|
1704
|
+
}
|
|
1631
1705
|
if (!raw)
|
|
1632
1706
|
return [];
|
|
1633
1707
|
const names = Array.isArray(raw) ? raw : [raw];
|
|
@@ -1653,10 +1727,69 @@ function filterByCollections(results, collectionNames) {
|
|
|
1653
1727
|
return prefixes.some(p => path.startsWith(p));
|
|
1654
1728
|
});
|
|
1655
1729
|
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Parse structured search query syntax.
|
|
1732
|
+
* Lines starting with lex:, vec:, or hyde: are routed directly.
|
|
1733
|
+
* Plain lines without prefix go through query expansion.
|
|
1734
|
+
*
|
|
1735
|
+
* Returns null if this is a plain query (single line, no prefix).
|
|
1736
|
+
* Returns StructuredSubSearch[] if structured syntax detected.
|
|
1737
|
+
* Throws if multiple plain lines (ambiguous).
|
|
1738
|
+
*
|
|
1739
|
+
* Examples:
|
|
1740
|
+
* "CAP theorem" -> null (plain query, use expansion)
|
|
1741
|
+
* "lex: CAP theorem" -> [{ type: 'lex', query: 'CAP theorem' }]
|
|
1742
|
+
* "lex: CAP\nvec: consistency" -> [{ type: 'lex', ... }, { type: 'vec', ... }]
|
|
1743
|
+
* "CAP\nconsistency" -> throws (multiple plain lines)
|
|
1744
|
+
*/
|
|
1745
|
+
function parseStructuredQuery(query) {
|
|
1746
|
+
const rawLines = query.split('\n').map((line, idx) => ({
|
|
1747
|
+
raw: line,
|
|
1748
|
+
trimmed: line.trim(),
|
|
1749
|
+
number: idx + 1,
|
|
1750
|
+
})).filter(line => line.trimmed.length > 0);
|
|
1751
|
+
if (rawLines.length === 0)
|
|
1752
|
+
return null;
|
|
1753
|
+
const prefixRe = /^(lex|vec|hyde):\s*/i;
|
|
1754
|
+
const expandRe = /^expand:\s*/i;
|
|
1755
|
+
const typed = [];
|
|
1756
|
+
for (const line of rawLines) {
|
|
1757
|
+
if (expandRe.test(line.trimmed)) {
|
|
1758
|
+
if (rawLines.length > 1) {
|
|
1759
|
+
throw new Error(`Line ${line.number} starts with expand:, but query documents cannot mix expand with typed lines. Submit a single expand query instead.`);
|
|
1760
|
+
}
|
|
1761
|
+
const text = line.trimmed.replace(expandRe, '').trim();
|
|
1762
|
+
if (!text) {
|
|
1763
|
+
throw new Error('expand: query must include text.');
|
|
1764
|
+
}
|
|
1765
|
+
return null; // treat as standalone expand query
|
|
1766
|
+
}
|
|
1767
|
+
const match = line.trimmed.match(prefixRe);
|
|
1768
|
+
if (match) {
|
|
1769
|
+
const type = match[1].toLowerCase();
|
|
1770
|
+
const text = line.trimmed.slice(match[0].length).trim();
|
|
1771
|
+
if (!text) {
|
|
1772
|
+
throw new Error(`Line ${line.number} (${type}:) must include text.`);
|
|
1773
|
+
}
|
|
1774
|
+
if (/\r|\n/.test(text)) {
|
|
1775
|
+
throw new Error(`Line ${line.number} (${type}:) contains a newline. Keep each query on a single line.`);
|
|
1776
|
+
}
|
|
1777
|
+
typed.push({ type, query: text, line: line.number });
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
if (rawLines.length === 1) {
|
|
1781
|
+
// Single plain line -> implicit expand
|
|
1782
|
+
return null;
|
|
1783
|
+
}
|
|
1784
|
+
throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde: prefix. Each line in a query document must start with one.`);
|
|
1785
|
+
}
|
|
1786
|
+
return typed.length > 0 ? typed : null;
|
|
1787
|
+
}
|
|
1656
1788
|
function search(query, opts) {
|
|
1657
1789
|
const db = getDb();
|
|
1658
1790
|
// Validate collection filter (supports multiple -c flags)
|
|
1659
|
-
|
|
1791
|
+
// Use default collections if none specified
|
|
1792
|
+
const collectionNames = resolveCollectionFilter(opts.collection, true);
|
|
1660
1793
|
const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
|
|
1661
1794
|
// Use large limit for --all, otherwise fetch more than needed and let outputResults filter
|
|
1662
1795
|
const fetchLimit = opts.all ? 100000 : Math.max(50, opts.limit * 2);
|
|
@@ -1674,12 +1807,7 @@ function search(query, opts) {
|
|
|
1674
1807
|
}));
|
|
1675
1808
|
closeDb();
|
|
1676
1809
|
if (resultsWithContext.length === 0) {
|
|
1677
|
-
|
|
1678
|
-
console.log("[]");
|
|
1679
|
-
}
|
|
1680
|
-
else {
|
|
1681
|
-
console.log("No results found.");
|
|
1682
|
-
}
|
|
1810
|
+
printEmptySearchResults(opts.format);
|
|
1683
1811
|
return;
|
|
1684
1812
|
}
|
|
1685
1813
|
outputResults(resultsWithContext, query, opts);
|
|
@@ -1703,7 +1831,8 @@ function logExpansionTree(originalQuery, expanded) {
|
|
|
1703
1831
|
async function vectorSearch(query, opts, _model = DEFAULT_EMBED_MODEL) {
|
|
1704
1832
|
const store = getStore();
|
|
1705
1833
|
// Validate collection filter (supports multiple -c flags)
|
|
1706
|
-
|
|
1834
|
+
// Use default collections if none specified
|
|
1835
|
+
const collectionNames = resolveCollectionFilter(opts.collection, true);
|
|
1707
1836
|
const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
|
|
1708
1837
|
checkIndexHealth(store.db);
|
|
1709
1838
|
await withLLMSession(async () => {
|
|
@@ -1727,12 +1856,7 @@ async function vectorSearch(query, opts, _model = DEFAULT_EMBED_MODEL) {
|
|
|
1727
1856
|
}
|
|
1728
1857
|
closeDb();
|
|
1729
1858
|
if (results.length === 0) {
|
|
1730
|
-
|
|
1731
|
-
console.log("[]");
|
|
1732
|
-
}
|
|
1733
|
-
else {
|
|
1734
|
-
console.log("No results found.");
|
|
1735
|
-
}
|
|
1859
|
+
printEmptySearchResults(opts.format);
|
|
1736
1860
|
return;
|
|
1737
1861
|
}
|
|
1738
1862
|
outputResults(results.map(r => ({
|
|
@@ -1749,31 +1873,87 @@ async function vectorSearch(query, opts, _model = DEFAULT_EMBED_MODEL) {
|
|
|
1749
1873
|
async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rerankModel = DEFAULT_RERANK_MODEL) {
|
|
1750
1874
|
const store = getStore();
|
|
1751
1875
|
// Validate collection filter (supports multiple -c flags)
|
|
1752
|
-
|
|
1876
|
+
// Use default collections if none specified
|
|
1877
|
+
const collectionNames = resolveCollectionFilter(opts.collection, true);
|
|
1753
1878
|
const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
|
|
1754
1879
|
checkIndexHealth(store.db);
|
|
1880
|
+
// Check for structured query syntax (lex:/vec:/hyde: prefixes)
|
|
1881
|
+
const structuredQueries = parseStructuredQuery(query);
|
|
1755
1882
|
await withLLMSession(async () => {
|
|
1756
|
-
let results
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1883
|
+
let results;
|
|
1884
|
+
if (structuredQueries) {
|
|
1885
|
+
// Structured search — user provided their own query expansions
|
|
1886
|
+
const typeLabels = structuredQueries.map(s => s.type).join('+');
|
|
1887
|
+
process.stderr.write(`${c.dim}Structured search: ${structuredQueries.length} queries (${typeLabels})${c.reset}\n`);
|
|
1888
|
+
// Log each sub-query
|
|
1889
|
+
for (const s of structuredQueries) {
|
|
1890
|
+
let preview = s.query.replace(/\n/g, ' ');
|
|
1891
|
+
if (preview.length > 72)
|
|
1892
|
+
preview = preview.substring(0, 69) + '...';
|
|
1893
|
+
process.stderr.write(`${c.dim}├─ ${s.type}: ${preview}${c.reset}\n`);
|
|
1894
|
+
}
|
|
1895
|
+
process.stderr.write(`${c.dim}└─ Searching...${c.reset}\n`);
|
|
1896
|
+
results = await structuredSearch(store, structuredQueries, {
|
|
1897
|
+
collections: singleCollection ? [singleCollection] : undefined,
|
|
1898
|
+
limit: opts.all ? 500 : (opts.limit || 10),
|
|
1899
|
+
minScore: opts.minScore || 0,
|
|
1900
|
+
candidateLimit: opts.candidateLimit,
|
|
1901
|
+
explain: !!opts.explain,
|
|
1902
|
+
hooks: {
|
|
1903
|
+
onEmbedStart: (count) => {
|
|
1904
|
+
process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
|
|
1905
|
+
},
|
|
1906
|
+
onEmbedDone: (ms) => {
|
|
1907
|
+
process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
|
|
1908
|
+
},
|
|
1909
|
+
onRerankStart: (chunkCount) => {
|
|
1910
|
+
process.stderr.write(`${c.dim}Reranking ${chunkCount} chunks...${c.reset}`);
|
|
1911
|
+
progress.indeterminate();
|
|
1912
|
+
},
|
|
1913
|
+
onRerankDone: (ms) => {
|
|
1914
|
+
progress.clear();
|
|
1915
|
+
process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
|
|
1916
|
+
},
|
|
1771
1917
|
},
|
|
1772
|
-
|
|
1773
|
-
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
// Standard hybrid query with automatic expansion
|
|
1922
|
+
results = await hybridQuery(store, query, {
|
|
1923
|
+
collection: singleCollection,
|
|
1924
|
+
limit: opts.all ? 500 : (opts.limit || 10),
|
|
1925
|
+
minScore: opts.minScore || 0,
|
|
1926
|
+
candidateLimit: opts.candidateLimit,
|
|
1927
|
+
explain: !!opts.explain,
|
|
1928
|
+
hooks: {
|
|
1929
|
+
onStrongSignal: (score) => {
|
|
1930
|
+
process.stderr.write(`${c.dim}Strong BM25 signal (${score.toFixed(2)}) — skipping expansion${c.reset}\n`);
|
|
1931
|
+
},
|
|
1932
|
+
onExpandStart: () => {
|
|
1933
|
+
process.stderr.write(`${c.dim}Expanding query...${c.reset}`);
|
|
1934
|
+
},
|
|
1935
|
+
onExpand: (original, expanded, ms) => {
|
|
1936
|
+
process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
|
|
1937
|
+
logExpansionTree(original, expanded);
|
|
1938
|
+
process.stderr.write(`${c.dim}Searching ${expanded.length + 1} queries...${c.reset}\n`);
|
|
1939
|
+
},
|
|
1940
|
+
onEmbedStart: (count) => {
|
|
1941
|
+
process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
|
|
1942
|
+
},
|
|
1943
|
+
onEmbedDone: (ms) => {
|
|
1944
|
+
process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
|
|
1945
|
+
},
|
|
1946
|
+
onRerankStart: (chunkCount) => {
|
|
1947
|
+
process.stderr.write(`${c.dim}Reranking ${chunkCount} chunks...${c.reset}`);
|
|
1948
|
+
progress.indeterminate();
|
|
1949
|
+
},
|
|
1950
|
+
onRerankDone: (ms) => {
|
|
1951
|
+
progress.clear();
|
|
1952
|
+
process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
|
|
1953
|
+
},
|
|
1774
1954
|
},
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1777
1957
|
// Post-filter for multi-collection
|
|
1778
1958
|
if (collectionNames.length > 1) {
|
|
1779
1959
|
results = results.filter(r => {
|
|
@@ -1783,14 +1963,13 @@ async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rera
|
|
|
1783
1963
|
}
|
|
1784
1964
|
closeDb();
|
|
1785
1965
|
if (results.length === 0) {
|
|
1786
|
-
|
|
1787
|
-
console.log("[]");
|
|
1788
|
-
}
|
|
1789
|
-
else {
|
|
1790
|
-
console.log("No results found.");
|
|
1791
|
-
}
|
|
1966
|
+
printEmptySearchResults(opts.format);
|
|
1792
1967
|
return;
|
|
1793
1968
|
}
|
|
1969
|
+
// Use first lex/vec query for output context, or original query
|
|
1970
|
+
const displayQuery = structuredQueries
|
|
1971
|
+
? (structuredQueries.find(s => s.type === 'lex')?.query || structuredQueries.find(s => s.type === 'vec')?.query || query)
|
|
1972
|
+
: query;
|
|
1794
1973
|
// Map to CLI output format — use bestChunk for snippet display
|
|
1795
1974
|
outputResults(results.map(r => ({
|
|
1796
1975
|
file: r.file,
|
|
@@ -1801,7 +1980,8 @@ async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rera
|
|
|
1801
1980
|
score: r.score,
|
|
1802
1981
|
context: r.context,
|
|
1803
1982
|
docid: r.docid,
|
|
1804
|
-
|
|
1983
|
+
explain: r.explain,
|
|
1984
|
+
})), displayQuery, { ...opts, limit: results.length });
|
|
1805
1985
|
}, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
|
|
1806
1986
|
}
|
|
1807
1987
|
// Parse CLI arguments using util.parseArgs
|
|
@@ -1818,6 +1998,7 @@ function parseCLI() {
|
|
|
1818
1998
|
},
|
|
1819
1999
|
help: { type: "boolean", short: "h" },
|
|
1820
2000
|
version: { type: "boolean", short: "v" },
|
|
2001
|
+
skill: { type: "boolean" },
|
|
1821
2002
|
// Search options
|
|
1822
2003
|
n: { type: "string" },
|
|
1823
2004
|
"min-score": { type: "string" },
|
|
@@ -1828,6 +2009,7 @@ function parseCLI() {
|
|
|
1828
2009
|
xml: { type: "boolean" },
|
|
1829
2010
|
files: { type: "boolean" },
|
|
1830
2011
|
json: { type: "boolean" },
|
|
2012
|
+
explain: { type: "boolean" },
|
|
1831
2013
|
collection: { type: "string", short: "c", multiple: true }, // Filter by collection(s)
|
|
1832
2014
|
// Collection options
|
|
1833
2015
|
name: { type: "string" }, // collection name
|
|
@@ -1842,6 +2024,8 @@ function parseCLI() {
|
|
|
1842
2024
|
from: { type: "string" }, // start line
|
|
1843
2025
|
"max-bytes": { type: "string" }, // max bytes for multi-get
|
|
1844
2026
|
"line-numbers": { type: "boolean" }, // add line numbers to output
|
|
2027
|
+
// Query options
|
|
2028
|
+
"candidate-limit": { type: "string", short: "C" },
|
|
1845
2029
|
// MCP HTTP transport options
|
|
1846
2030
|
http: { type: "boolean" },
|
|
1847
2031
|
daemon: { type: "boolean" },
|
|
@@ -1880,6 +2064,8 @@ function parseCLI() {
|
|
|
1880
2064
|
all: isAll,
|
|
1881
2065
|
collection: values.collection,
|
|
1882
2066
|
lineNumbers: !!values["line-numbers"],
|
|
2067
|
+
candidateLimit: values["candidate-limit"] ? parseInt(String(values["candidate-limit"]), 10) : undefined,
|
|
2068
|
+
explain: !!values.explain,
|
|
1883
2069
|
};
|
|
1884
2070
|
return {
|
|
1885
2071
|
command: positionals[0] || "",
|
|
@@ -1889,50 +2075,102 @@ function parseCLI() {
|
|
|
1889
2075
|
values,
|
|
1890
2076
|
};
|
|
1891
2077
|
}
|
|
2078
|
+
function showSkill() {
|
|
2079
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
2080
|
+
const relativePath = pathJoin("skills", "qmd", "SKILL.md");
|
|
2081
|
+
const skillPath = pathJoin(scriptDir, "..", relativePath);
|
|
2082
|
+
console.log(`QMD Skill (${relativePath})`);
|
|
2083
|
+
console.log(`Location: ${skillPath}`);
|
|
2084
|
+
console.log("");
|
|
2085
|
+
if (!existsSync(skillPath)) {
|
|
2086
|
+
console.error("SKILL.md not found. If you built from source, ensure skills/qmd/SKILL.md exists.");
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
2090
|
+
process.stdout.write(content.endsWith("\n") ? content : content + "\n");
|
|
2091
|
+
}
|
|
1892
2092
|
function showHelp() {
|
|
2093
|
+
console.log("qmd — Quick Markdown Search");
|
|
2094
|
+
console.log("");
|
|
1893
2095
|
console.log("Usage:");
|
|
1894
|
-
console.log(" qmd
|
|
1895
|
-
console.log("
|
|
1896
|
-
console.log("
|
|
1897
|
-
console.log(" qmd
|
|
1898
|
-
console.log(" qmd
|
|
1899
|
-
console.log(" qmd
|
|
1900
|
-
console.log(" qmd
|
|
1901
|
-
console.log(" qmd
|
|
1902
|
-
console.log(" qmd get <
|
|
1903
|
-
console.log(" qmd
|
|
1904
|
-
console.log("
|
|
1905
|
-
console.log("
|
|
1906
|
-
console.log(" qmd
|
|
1907
|
-
console.log(" qmd
|
|
1908
|
-
console.log(" qmd
|
|
1909
|
-
console.log("
|
|
1910
|
-
console.log("
|
|
1911
|
-
console.log(" qmd
|
|
1912
|
-
console.log(" qmd
|
|
1913
|
-
console.log(" qmd
|
|
1914
|
-
console.log(" qmd
|
|
2096
|
+
console.log(" qmd <command> [options]");
|
|
2097
|
+
console.log("");
|
|
2098
|
+
console.log("Primary commands:");
|
|
2099
|
+
console.log(" qmd query <query> - Hybrid search with auto expansion + reranking (recommended)");
|
|
2100
|
+
console.log(" qmd query 'lex:..\\nvec:...' - Structured query document (you provide lex/vec/hyde lines)");
|
|
2101
|
+
console.log(" qmd search <query> - Full-text BM25 keywords (no LLM)");
|
|
2102
|
+
console.log(" qmd vsearch <query> - Vector similarity only");
|
|
2103
|
+
console.log(" qmd get <file>[:line] [-l N] - Show a single document, optional line slice");
|
|
2104
|
+
console.log(" qmd multi-get <pattern> - Batch fetch via glob or comma-separated list");
|
|
2105
|
+
console.log(" qmd mcp - Start the MCP server (stdio transport for AI agents)");
|
|
2106
|
+
console.log("");
|
|
2107
|
+
console.log("Collections & context:");
|
|
2108
|
+
console.log(" qmd collection add/list/remove/rename/show - Manage indexed folders");
|
|
2109
|
+
console.log(" qmd context add/list/rm - Attach human-written summaries");
|
|
2110
|
+
console.log(" qmd ls [collection[/path]] - Inspect indexed files");
|
|
2111
|
+
console.log("");
|
|
2112
|
+
console.log("Maintenance:");
|
|
2113
|
+
console.log(" qmd status - View index + collection health");
|
|
2114
|
+
console.log(" qmd update [--pull] - Re-index collections (optionally git pull first)");
|
|
2115
|
+
console.log(" qmd embed [-f] - Generate/refresh vector embeddings");
|
|
2116
|
+
console.log(" qmd cleanup - Clear caches, vacuum DB");
|
|
2117
|
+
console.log("");
|
|
2118
|
+
console.log("Query syntax (qmd query):");
|
|
2119
|
+
console.log(" QMD queries are either a single expand query (no prefix) or a multi-line");
|
|
2120
|
+
console.log(" document where every line is typed with lex:, vec:, or hyde:. This grammar");
|
|
2121
|
+
console.log(" matches the docs in docs/SYNTAX.md and is enforced in the CLI.");
|
|
2122
|
+
console.log("");
|
|
2123
|
+
const grammar = [
|
|
2124
|
+
`query = expand_query | query_document ;`,
|
|
2125
|
+
`expand_query = text | explicit_expand ;`,
|
|
2126
|
+
`explicit_expand= "expand:" text ;`,
|
|
2127
|
+
`query_document = { typed_line } ;`,
|
|
2128
|
+
`typed_line = type ":" text newline ;`,
|
|
2129
|
+
`type = "lex" | "vec" | "hyde" ;`,
|
|
2130
|
+
`text = quoted_phrase | plain_text ;`,
|
|
2131
|
+
`quoted_phrase = '"' { character } '"' ;`,
|
|
2132
|
+
`plain_text = { character } ;`,
|
|
2133
|
+
`newline = "\\n" ;`,
|
|
2134
|
+
];
|
|
2135
|
+
console.log(" Grammar:");
|
|
2136
|
+
for (const line of grammar) {
|
|
2137
|
+
console.log(` ${line}`);
|
|
2138
|
+
}
|
|
2139
|
+
console.log("");
|
|
2140
|
+
console.log(" Examples:");
|
|
2141
|
+
console.log(" qmd query \"how does auth work\" # single-line → implicit expand");
|
|
2142
|
+
console.log(" qmd query $'lex: CAP theorem\\nvec: consistency' # typed query document");
|
|
2143
|
+
console.log(" qmd query $'lex: \"exact matches\" sports -baseball' # phrase + negation lex search");
|
|
2144
|
+
console.log(" qmd query $'hyde: Hypothetical answer text' # hyde-only document");
|
|
2145
|
+
console.log("");
|
|
2146
|
+
console.log(" Constraints:");
|
|
2147
|
+
console.log(" - Standalone expand queries cannot mix with typed lines.");
|
|
2148
|
+
console.log(" - Query documents allow only lex:, vec:, or hyde: prefixes.");
|
|
2149
|
+
console.log(" - Each typed line must be single-line text with balanced quotes.");
|
|
2150
|
+
console.log("");
|
|
2151
|
+
console.log("AI agents & integrations:");
|
|
2152
|
+
console.log(" - Run `qmd mcp` to expose the MCP server (stdio) to agents/IDEs.");
|
|
2153
|
+
console.log(" - `qmd --skill` prints the packaged skills/qmd/SKILL.md (path + contents).");
|
|
2154
|
+
console.log(" - Advanced: `qmd mcp --http ...` and `qmd mcp --http --daemon` are optional for custom transports.");
|
|
1915
2155
|
console.log("");
|
|
1916
2156
|
console.log("Global options:");
|
|
1917
|
-
console.log(" --index <name> - Use
|
|
2157
|
+
console.log(" --index <name> - Use a named index (default: index)");
|
|
1918
2158
|
console.log("");
|
|
1919
2159
|
console.log("Search options:");
|
|
1920
|
-
console.log(" -n <num> -
|
|
1921
|
-
console.log(" --all - Return all matches (
|
|
2160
|
+
console.log(" -n <num> - Max results (default 5, or 20 for --files/--json)");
|
|
2161
|
+
console.log(" --all - Return all matches (pair with --min-score)");
|
|
1922
2162
|
console.log(" --min-score <num> - Minimum similarity score");
|
|
1923
2163
|
console.log(" --full - Output full document instead of snippet");
|
|
1924
|
-
console.log(" --
|
|
1925
|
-
console.log(" --
|
|
1926
|
-
console.log(" --
|
|
1927
|
-
console.log(" --csv
|
|
1928
|
-
console.log(" --
|
|
1929
|
-
console.log(" --xml - XML output");
|
|
1930
|
-
console.log(" -c, --collection <name> - Filter results to a specific collection");
|
|
2164
|
+
console.log(" -C, --candidate-limit <n> - Max candidates to rerank (default 40, lower = faster)");
|
|
2165
|
+
console.log(" --line-numbers - Include line numbers in output");
|
|
2166
|
+
console.log(" --explain - Include retrieval score traces (query --json/CLI)");
|
|
2167
|
+
console.log(" --files | --json | --csv | --md | --xml - Output format");
|
|
2168
|
+
console.log(" -c, --collection <name> - Filter by one or more collections");
|
|
1931
2169
|
console.log("");
|
|
1932
2170
|
console.log("Multi-get options:");
|
|
1933
2171
|
console.log(" -l <num> - Maximum lines per file");
|
|
1934
|
-
console.log(" --max-bytes <num> - Skip files larger than N bytes (default
|
|
1935
|
-
console.log(" --json/--csv/--md/--xml/--files -
|
|
2172
|
+
console.log(" --max-bytes <num> - Skip files larger than N bytes (default 10240)");
|
|
2173
|
+
console.log(" --json/--csv/--md/--xml/--files - Same formats as search");
|
|
1936
2174
|
console.log("");
|
|
1937
2175
|
console.log(`Index: ${getDbPath()}`);
|
|
1938
2176
|
}
|
|
@@ -1951,12 +2189,22 @@ async function showVersion() {
|
|
|
1951
2189
|
console.log(`qmd ${versionStr}`);
|
|
1952
2190
|
}
|
|
1953
2191
|
// Main CLI - only run if this is the main module
|
|
1954
|
-
|
|
2192
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
2193
|
+
const argv1 = process.argv[1];
|
|
2194
|
+
const isMain = argv1 === __filename
|
|
2195
|
+
|| argv1?.endsWith("/qmd.ts")
|
|
2196
|
+
|| argv1?.endsWith("/qmd.js")
|
|
2197
|
+
|| (argv1 != null && realpathSync(argv1) === __filename);
|
|
2198
|
+
if (isMain) {
|
|
1955
2199
|
const cli = parseCLI();
|
|
1956
2200
|
if (cli.values.version) {
|
|
1957
2201
|
await showVersion();
|
|
1958
2202
|
process.exit(0);
|
|
1959
2203
|
}
|
|
2204
|
+
if (cli.values.skill) {
|
|
2205
|
+
showSkill();
|
|
2206
|
+
process.exit(0);
|
|
2207
|
+
}
|
|
1960
2208
|
if (!cli.command || cli.values.help) {
|
|
1961
2209
|
showHelp();
|
|
1962
2210
|
process.exit(cli.values.help ? 0 : 1);
|
|
@@ -1965,13 +2213,12 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW
|
|
|
1965
2213
|
case "context": {
|
|
1966
2214
|
const subcommand = cli.args[0];
|
|
1967
2215
|
if (!subcommand) {
|
|
1968
|
-
console.error("Usage: qmd context <add|list|
|
|
2216
|
+
console.error("Usage: qmd context <add|list|rm>");
|
|
1969
2217
|
console.error("");
|
|
1970
2218
|
console.error("Commands:");
|
|
1971
2219
|
console.error(" qmd context add [path] \"text\" - Add context (defaults to current dir)");
|
|
1972
2220
|
console.error(" qmd context add / \"text\" - Add global context to all collections");
|
|
1973
2221
|
console.error(" qmd context list - List all contexts");
|
|
1974
|
-
console.error(" qmd context check - Check for missing contexts");
|
|
1975
2222
|
console.error(" qmd context rm <path> - Remove context");
|
|
1976
2223
|
process.exit(1);
|
|
1977
2224
|
}
|
|
@@ -2013,10 +2260,6 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW
|
|
|
2013
2260
|
contextList();
|
|
2014
2261
|
break;
|
|
2015
2262
|
}
|
|
2016
|
-
case "check": {
|
|
2017
|
-
contextCheck();
|
|
2018
|
-
break;
|
|
2019
|
-
}
|
|
2020
2263
|
case "rm":
|
|
2021
2264
|
case "remove": {
|
|
2022
2265
|
if (cli.args.length < 2 || !cli.args[1]) {
|
|
@@ -2031,7 +2274,7 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW
|
|
|
2031
2274
|
}
|
|
2032
2275
|
default:
|
|
2033
2276
|
console.error(`Unknown subcommand: ${subcommand}`);
|
|
2034
|
-
console.error("Available: add, list,
|
|
2277
|
+
console.error("Available: add, list, rm");
|
|
2035
2278
|
process.exit(1);
|
|
2036
2279
|
}
|
|
2037
2280
|
break;
|
|
@@ -2096,9 +2339,99 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW
|
|
|
2096
2339
|
collectionRename(cli.args[1], cli.args[2]);
|
|
2097
2340
|
break;
|
|
2098
2341
|
}
|
|
2342
|
+
case "set-update":
|
|
2343
|
+
case "update-cmd": {
|
|
2344
|
+
const name = cli.args[1];
|
|
2345
|
+
const cmd = cli.args.slice(2).join(' ') || null;
|
|
2346
|
+
if (!name) {
|
|
2347
|
+
console.error("Usage: qmd collection update-cmd <name> [command]");
|
|
2348
|
+
console.error(" Set the command to run before indexing (e.g., 'git pull')");
|
|
2349
|
+
console.error(" Omit command to clear it");
|
|
2350
|
+
process.exit(1);
|
|
2351
|
+
}
|
|
2352
|
+
const { updateCollectionSettings, getCollection } = await import("./collections.js");
|
|
2353
|
+
const col = getCollection(name);
|
|
2354
|
+
if (!col) {
|
|
2355
|
+
console.error(`Collection not found: ${name}`);
|
|
2356
|
+
process.exit(1);
|
|
2357
|
+
}
|
|
2358
|
+
updateCollectionSettings(name, { update: cmd });
|
|
2359
|
+
if (cmd) {
|
|
2360
|
+
console.log(`✓ Set update command for '${name}': ${cmd}`);
|
|
2361
|
+
}
|
|
2362
|
+
else {
|
|
2363
|
+
console.log(`✓ Cleared update command for '${name}'`);
|
|
2364
|
+
}
|
|
2365
|
+
break;
|
|
2366
|
+
}
|
|
2367
|
+
case "include":
|
|
2368
|
+
case "exclude": {
|
|
2369
|
+
const name = cli.args[1];
|
|
2370
|
+
if (!name) {
|
|
2371
|
+
console.error(`Usage: qmd collection ${subcommand} <name>`);
|
|
2372
|
+
console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`);
|
|
2373
|
+
process.exit(1);
|
|
2374
|
+
}
|
|
2375
|
+
const { updateCollectionSettings, getCollection } = await import("./collections.js");
|
|
2376
|
+
const col = getCollection(name);
|
|
2377
|
+
if (!col) {
|
|
2378
|
+
console.error(`Collection not found: ${name}`);
|
|
2379
|
+
process.exit(1);
|
|
2380
|
+
}
|
|
2381
|
+
const include = subcommand === 'include';
|
|
2382
|
+
updateCollectionSettings(name, { includeByDefault: include });
|
|
2383
|
+
console.log(`✓ Collection '${name}' ${include ? 'included in' : 'excluded from'} default queries`);
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
case "show":
|
|
2387
|
+
case "info": {
|
|
2388
|
+
const name = cli.args[1];
|
|
2389
|
+
if (!name) {
|
|
2390
|
+
console.error("Usage: qmd collection show <name>");
|
|
2391
|
+
process.exit(1);
|
|
2392
|
+
}
|
|
2393
|
+
const { getCollection } = await import("./collections.js");
|
|
2394
|
+
const col = getCollection(name);
|
|
2395
|
+
if (!col) {
|
|
2396
|
+
console.error(`Collection not found: ${name}`);
|
|
2397
|
+
process.exit(1);
|
|
2398
|
+
}
|
|
2399
|
+
console.log(`Collection: ${name}`);
|
|
2400
|
+
console.log(` Path: ${col.path}`);
|
|
2401
|
+
console.log(` Pattern: ${col.pattern}`);
|
|
2402
|
+
console.log(` Include: ${col.includeByDefault !== false ? 'yes (default)' : 'no'}`);
|
|
2403
|
+
if (col.update) {
|
|
2404
|
+
console.log(` Update: ${col.update}`);
|
|
2405
|
+
}
|
|
2406
|
+
if (col.context) {
|
|
2407
|
+
const ctxCount = Object.keys(col.context).length;
|
|
2408
|
+
console.log(` Contexts: ${ctxCount}`);
|
|
2409
|
+
}
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2412
|
+
case "help":
|
|
2413
|
+
case undefined: {
|
|
2414
|
+
console.log("Usage: qmd collection <command> [options]");
|
|
2415
|
+
console.log("");
|
|
2416
|
+
console.log("Commands:");
|
|
2417
|
+
console.log(" list List all collections");
|
|
2418
|
+
console.log(" add <path> [--name NAME] Add a collection");
|
|
2419
|
+
console.log(" remove <name> Remove a collection");
|
|
2420
|
+
console.log(" rename <old> <new> Rename a collection");
|
|
2421
|
+
console.log(" show <name> Show collection details");
|
|
2422
|
+
console.log(" update-cmd <name> [cmd] Set pre-update command (e.g., 'git pull')");
|
|
2423
|
+
console.log(" include <name> Include in default queries");
|
|
2424
|
+
console.log(" exclude <name> Exclude from default queries");
|
|
2425
|
+
console.log("");
|
|
2426
|
+
console.log("Examples:");
|
|
2427
|
+
console.log(" qmd collection add ~/notes --name notes");
|
|
2428
|
+
console.log(" qmd collection update-cmd brain 'git pull'");
|
|
2429
|
+
console.log(" qmd collection exclude archive");
|
|
2430
|
+
process.exit(0);
|
|
2431
|
+
}
|
|
2099
2432
|
default:
|
|
2100
2433
|
console.error(`Unknown subcommand: ${subcommand}`);
|
|
2101
|
-
console.error("
|
|
2434
|
+
console.error("Run 'qmd collection help' for usage");
|
|
2102
2435
|
process.exit(1);
|
|
2103
2436
|
}
|
|
2104
2437
|
break;
|