@tobilu/qmd 2.0.0 → 2.1.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.
package/dist/cli/qmd.js CHANGED
@@ -3,13 +3,15 @@ import { openDatabase } from "../db.js";
3
3
  import fastGlob from "fast-glob";
4
4
  import { execSync, spawn as nodeSpawn } from "child_process";
5
5
  import { fileURLToPath } from "url";
6
- import { dirname, join as pathJoin } from "path";
6
+ import { dirname, join as pathJoin, relative as relativePath } from "path";
7
7
  import { parseArgs } from "util";
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, reindexCollection, generateEmbeddings, syncConfigToDb, } from "../store.js";
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";
8
+ import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync } from "fs";
9
+ import { createInterface } from "readline/promises";
10
+ import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, structuredSearch, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_EMBED_MAX_BATCH_BYTES, DEFAULT_EMBED_MAX_DOCS_PER_BATCH, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, reindexCollection, generateEmbeddings, syncConfigToDb, } from "../store.js";
11
+ import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, setDefaultLlamaCpp, LlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "../llm.js";
11
12
  import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
12
13
  import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, removeCollection as yamlRemoveCollectionFn, renameCollection as yamlRenameCollectionFn, setGlobalContext, listAllContexts, setConfigIndexName, loadConfig, } from "../collections.js";
14
+ import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
13
15
  // Enable production mode - allows using default database path
14
16
  // Tests must set INDEX_PATH or use createStore() with explicit path
15
17
  enableProductionMode();
@@ -25,6 +27,13 @@ function getStore() {
25
27
  try {
26
28
  const config = loadConfig();
27
29
  syncConfigToDb(store.db, config);
30
+ if (config.models) {
31
+ setDefaultLlamaCpp(new LlamaCpp({
32
+ embedModel: config.models.embed,
33
+ generateModel: config.models.generate,
34
+ rerankModel: config.models.rerank,
35
+ }));
36
+ }
28
37
  }
29
38
  catch {
30
39
  // Config may not exist yet — that's fine, DB works without it
@@ -259,6 +268,34 @@ async function showStatus() {
259
268
  context: ctx.context
260
269
  });
261
270
  }
271
+ // AST chunking status
272
+ try {
273
+ const { getASTStatus } = await import("../ast.js");
274
+ const ast = await getASTStatus();
275
+ console.log(`\n${c.bold}AST Chunking${c.reset}`);
276
+ if (ast.available) {
277
+ const ok = ast.languages.filter(l => l.available).map(l => l.language);
278
+ const fail = ast.languages.filter(l => !l.available);
279
+ console.log(` Status: ${c.green}active${c.reset}`);
280
+ console.log(` Languages: ${ok.join(", ")}`);
281
+ if (fail.length > 0) {
282
+ for (const f of fail) {
283
+ console.log(` ${c.yellow}Unavailable: ${f.language} (${f.error})${c.reset}`);
284
+ }
285
+ }
286
+ }
287
+ else {
288
+ console.log(` Status: ${c.yellow}unavailable${c.reset} (falling back to regex chunking)`);
289
+ for (const l of ast.languages) {
290
+ if (l.error)
291
+ console.log(` ${c.dim}${l.language}: ${l.error}${c.reset}`);
292
+ }
293
+ }
294
+ }
295
+ catch {
296
+ console.log(`\n${c.bold}AST Chunking${c.reset}`);
297
+ console.log(` Status: ${c.dim}not available${c.reset}`);
298
+ }
262
299
  if (collections.length > 0) {
263
300
  console.log(`\n${c.bold}Collections${c.reset}`);
264
301
  for (const col of collections) {
@@ -785,7 +822,7 @@ function getDocument(filename, fromLine, maxLines, lineNumbers) {
785
822
  function multiGet(pattern, maxLines, maxBytes = DEFAULT_MULTI_GET_MAX_BYTES, format = "cli") {
786
823
  const db = getDb();
787
824
  // Check if it's a comma-separated list or a glob pattern
788
- const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?');
825
+ const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?') && !pattern.includes('{');
789
826
  let files;
790
827
  if (isCommaSeparated) {
791
828
  // Comma-separated list of files (can be virtual paths or relative paths)
@@ -1365,26 +1402,51 @@ function renderProgressBar(percent, width = 30) {
1365
1402
  const bar = "█".repeat(filled) + "░".repeat(empty);
1366
1403
  return bar;
1367
1404
  }
1368
- async function vectorIndex(model = DEFAULT_EMBED_MODEL, force = false) {
1405
+ function parseEmbedBatchOption(name, value) {
1406
+ if (value === undefined)
1407
+ return undefined;
1408
+ const parsed = Number(value);
1409
+ if (!Number.isInteger(parsed) || parsed < 1) {
1410
+ throw new Error(`${name} must be a positive integer`);
1411
+ }
1412
+ return parsed;
1413
+ }
1414
+ function parseChunkStrategy(value) {
1415
+ if (value === undefined)
1416
+ return undefined;
1417
+ const s = String(value);
1418
+ if (s === "auto" || s === "regex")
1419
+ return s;
1420
+ throw new Error(`--chunk-strategy must be "auto" or "regex" (got "${s}")`);
1421
+ }
1422
+ async function vectorIndex(model = DEFAULT_EMBED_MODEL_URI, force = false, batchOptions) {
1369
1423
  const storeInstance = getStore();
1370
1424
  const db = storeInstance.db;
1371
1425
  if (force) {
1372
1426
  console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
1373
1427
  }
1374
1428
  // Check if there's work to do before starting
1375
- const hashesToEmbed = getHashesForEmbedding(db);
1376
- if (hashesToEmbed.length === 0 && !force) {
1429
+ const hashesToEmbed = getHashesNeedingEmbedding(db);
1430
+ if (hashesToEmbed === 0 && !force) {
1377
1431
  console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
1378
1432
  closeDb();
1379
1433
  return;
1380
1434
  }
1381
1435
  console.log(`${c.dim}Model: ${model}${c.reset}\n`);
1436
+ if (batchOptions?.maxDocsPerBatch !== undefined || batchOptions?.maxBatchBytes !== undefined) {
1437
+ const maxDocsPerBatch = batchOptions.maxDocsPerBatch ?? DEFAULT_EMBED_MAX_DOCS_PER_BATCH;
1438
+ const maxBatchBytes = batchOptions.maxBatchBytes ?? DEFAULT_EMBED_MAX_BATCH_BYTES;
1439
+ console.log(`${c.dim}Batch: ${maxDocsPerBatch} docs / ${formatBytes(maxBatchBytes)}${c.reset}\n`);
1440
+ }
1382
1441
  cursor.hide();
1383
1442
  progress.indeterminate();
1384
1443
  const startTime = Date.now();
1385
1444
  const result = await generateEmbeddings(storeInstance, {
1386
1445
  force,
1387
1446
  model,
1447
+ maxDocsPerBatch: batchOptions?.maxDocsPerBatch,
1448
+ maxBatchBytes: batchOptions?.maxBatchBytes,
1449
+ chunkStrategy: batchOptions?.chunkStrategy,
1388
1450
  onProgress: (info) => {
1389
1451
  if (info.totalBytes === 0)
1390
1452
  return;
@@ -1511,6 +1573,45 @@ function printEmptySearchResults(format, reason = "no_results") {
1511
1573
  }
1512
1574
  console.log("No results found.");
1513
1575
  }
1576
+ const DEFAULT_EDITOR_URI_TEMPLATE = "vscode://file/{path}:{line}:{col}";
1577
+ function encodePathForEditorUri(absolutePath) {
1578
+ return encodeURI(absolutePath)
1579
+ .replace(/\?/g, "%3F")
1580
+ .replace(/#/g, "%23");
1581
+ }
1582
+ function getEditorUriTemplate() {
1583
+ const envTemplate = process.env.QMD_EDITOR_URI?.trim();
1584
+ if (envTemplate)
1585
+ return envTemplate;
1586
+ try {
1587
+ const config = loadConfig();
1588
+ const configTemplate = (config.editor_uri
1589
+ || config.editor_uri_template
1590
+ || config.editorUri
1591
+ || (typeof config["editor-uri"] === "string" ? config["editor-uri"] : undefined))?.trim();
1592
+ if (configTemplate)
1593
+ return configTemplate;
1594
+ }
1595
+ catch {
1596
+ // Ignore config parsing issues and use default template.
1597
+ }
1598
+ return DEFAULT_EDITOR_URI_TEMPLATE;
1599
+ }
1600
+ export function buildEditorUri(template, absolutePath, line, col) {
1601
+ const safeLine = Number.isFinite(line) && line > 0 ? Math.floor(line) : 1;
1602
+ const safeCol = Number.isFinite(col) && col > 0 ? Math.floor(col) : 1;
1603
+ const encodedPath = encodePathForEditorUri(absolutePath);
1604
+ return template
1605
+ .replace(/\{path\}/g, encodedPath)
1606
+ .replace(/\{line\}/g, String(safeLine))
1607
+ .replace(/\{col\}/g, String(safeCol))
1608
+ .replace(/\{column\}/g, String(safeCol));
1609
+ }
1610
+ export function termLink(text, url, isTTY = !!process.stdout.isTTY) {
1611
+ if (!isTTY)
1612
+ return text;
1613
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
1614
+ }
1514
1615
  function outputResults(results, query, opts) {
1515
1616
  const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
1516
1617
  if (filtered.length === 0) {
@@ -1553,6 +1654,8 @@ function outputResults(results, query, opts) {
1553
1654
  }
1554
1655
  }
1555
1656
  else if (opts.format === "cli") {
1657
+ const editorUriTemplate = getEditorUriTemplate();
1658
+ const linkDb = getDb();
1556
1659
  for (let i = 0; i < filtered.length; i++) {
1557
1660
  const row = filtered[i];
1558
1661
  if (!row)
@@ -1560,13 +1663,25 @@ function outputResults(results, query, opts) {
1560
1663
  const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent);
1561
1664
  const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
1562
1665
  // Line 1: filepath with docid
1563
- const path = toQmdPath(row.displayPath);
1666
+ const virtualPath = row.file.startsWith("qmd://") ? row.file : toQmdPath(row.displayPath);
1667
+ const parsed = parseVirtualPath(virtualPath);
1668
+ const absolutePath = resolveVirtualPath(linkDb, virtualPath);
1669
+ const legacyPath = toQmdPath(row.displayPath);
1670
+ const displayPath = parsed?.path || row.displayPath;
1564
1671
  // Only show :line if we actually found a term match in the snippet body (exclude header line).
1565
1672
  const snippetBody = snippet.split("\n").slice(1).join("\n").toLowerCase();
1566
1673
  const hasMatch = query.toLowerCase().split(/\s+/).some(t => t.length > 0 && snippetBody.includes(t));
1567
1674
  const lineInfo = hasMatch ? `:${line}` : "";
1568
1675
  const docidStr = docid ? ` ${c.dim}#${docid}${c.reset}` : "";
1569
- console.log(`${c.cyan}${path}${c.dim}${lineInfo}${c.reset}${docidStr}`);
1676
+ if (process.stdout.isTTY && absolutePath && parsed?.path) {
1677
+ const linkLine = hasMatch ? line : 1;
1678
+ const linkTarget = buildEditorUri(editorUriTemplate, absolutePath, linkLine, 1);
1679
+ const clickable = termLink(`${displayPath}${lineInfo}`, linkTarget);
1680
+ console.log(`${c.cyan}${clickable}${c.reset}${docidStr}`);
1681
+ }
1682
+ else {
1683
+ console.log(`${c.cyan}${legacyPath}${c.dim}${lineInfo}${c.reset}${docidStr}`);
1684
+ }
1570
1685
  // Line 2: Title (if available)
1571
1686
  if (row.title) {
1572
1687
  console.log(`${c.bold}Title: ${row.title}${c.reset}`);
@@ -1865,8 +1980,10 @@ async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rera
1865
1980
  limit: opts.all ? 500 : (opts.limit || 10),
1866
1981
  minScore: opts.minScore || 0,
1867
1982
  candidateLimit: opts.candidateLimit,
1983
+ skipRerank: opts.skipRerank,
1868
1984
  explain: !!opts.explain,
1869
1985
  intent,
1986
+ chunkStrategy: opts.chunkStrategy,
1870
1987
  hooks: {
1871
1988
  onEmbedStart: (count) => {
1872
1989
  process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
@@ -1892,8 +2009,10 @@ async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rera
1892
2009
  limit: opts.all ? 500 : (opts.limit || 10),
1893
2010
  minScore: opts.minScore || 0,
1894
2011
  candidateLimit: opts.candidateLimit,
2012
+ skipRerank: opts.skipRerank,
1895
2013
  explain: !!opts.explain,
1896
2014
  intent,
2015
+ chunkStrategy: opts.chunkStrategy,
1897
2016
  hooks: {
1898
2017
  onStrongSignal: (score) => {
1899
2018
  process.stderr.write(`${c.dim}Strong BM25 signal (${score.toFixed(2)}) — skipping expansion${c.reset}\n`);
@@ -1969,6 +2088,8 @@ function parseCLI() {
1969
2088
  help: { type: "boolean", short: "h" },
1970
2089
  version: { type: "boolean", short: "v" },
1971
2090
  skill: { type: "boolean" },
2091
+ global: { type: "boolean" },
2092
+ yes: { type: "boolean" },
1972
2093
  // Search options
1973
2094
  n: { type: "string" },
1974
2095
  "min-score": { type: "string" },
@@ -1986,6 +2107,8 @@ function parseCLI() {
1986
2107
  mask: { type: "string" }, // glob pattern
1987
2108
  // Embed options
1988
2109
  force: { type: "boolean", short: "f" },
2110
+ "max-docs-per-batch": { type: "string" },
2111
+ "max-batch-mb": { type: "string" },
1989
2112
  // Update options
1990
2113
  pull: { type: "boolean" }, // git pull before update
1991
2114
  refresh: { type: "boolean" },
@@ -1996,7 +2119,10 @@ function parseCLI() {
1996
2119
  "line-numbers": { type: "boolean" }, // add line numbers to output
1997
2120
  // Query options
1998
2121
  "candidate-limit": { type: "string", short: "C" },
2122
+ "no-rerank": { type: "boolean", default: false },
1999
2123
  intent: { type: "string" },
2124
+ // Chunking options
2125
+ "chunk-strategy": { type: "string" }, // "regex" (default) or "auto" (AST for code files)
2000
2126
  // MCP HTTP transport options
2001
2127
  http: { type: "boolean" },
2002
2128
  daemon: { type: "boolean" },
@@ -2036,8 +2162,10 @@ function parseCLI() {
2036
2162
  collection: values.collection,
2037
2163
  lineNumbers: !!values["line-numbers"],
2038
2164
  candidateLimit: values["candidate-limit"] ? parseInt(String(values["candidate-limit"]), 10) : undefined,
2165
+ skipRerank: !!values["no-rerank"],
2039
2166
  explain: !!values.explain,
2040
2167
  intent: values.intent,
2168
+ chunkStrategy: parseChunkStrategy(values["chunk-strategy"]),
2041
2169
  };
2042
2170
  return {
2043
2171
  command: positionals[0] || "",
@@ -2047,19 +2175,116 @@ function parseCLI() {
2047
2175
  values,
2048
2176
  };
2049
2177
  }
2178
+ function getSkillInstallDir(globalInstall) {
2179
+ return globalInstall
2180
+ ? resolve(homedir(), ".agents", "skills", "qmd")
2181
+ : resolve(getPwd(), ".agents", "skills", "qmd");
2182
+ }
2183
+ function getClaudeSkillLinkPath(globalInstall) {
2184
+ return globalInstall
2185
+ ? resolve(homedir(), ".claude", "skills", "qmd")
2186
+ : resolve(getPwd(), ".claude", "skills", "qmd");
2187
+ }
2188
+ function pathExists(path) {
2189
+ try {
2190
+ lstatSync(path);
2191
+ return true;
2192
+ }
2193
+ catch {
2194
+ return false;
2195
+ }
2196
+ }
2197
+ function removePath(path) {
2198
+ const stat = lstatSync(path);
2199
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
2200
+ rmSync(path, { recursive: true, force: true });
2201
+ }
2202
+ else {
2203
+ unlinkSync(path);
2204
+ }
2205
+ }
2050
2206
  function showSkill() {
2051
- const scriptDir = dirname(fileURLToPath(import.meta.url));
2052
- const relativePath = pathJoin("skills", "qmd", "SKILL.md");
2053
- const skillPath = pathJoin(scriptDir, "..", "..", relativePath);
2054
- console.log(`QMD Skill (${relativePath})`);
2055
- console.log(`Location: ${skillPath}`);
2207
+ console.log("QMD Skill (embedded)");
2056
2208
  console.log("");
2057
- if (!existsSync(skillPath)) {
2058
- console.error("SKILL.md not found. If you built from source, ensure skills/qmd/SKILL.md exists.");
2209
+ const content = getEmbeddedQmdSkillContent();
2210
+ process.stdout.write(content.endsWith("\n") ? content : content + "\n");
2211
+ }
2212
+ function writeEmbeddedSkill(targetDir, force) {
2213
+ if (pathExists(targetDir)) {
2214
+ if (!force) {
2215
+ throw new Error(`Skill already exists: ${targetDir} (use --force to replace it)`);
2216
+ }
2217
+ removePath(targetDir);
2218
+ }
2219
+ mkdirSync(targetDir, { recursive: true });
2220
+ for (const file of getEmbeddedQmdSkillFiles()) {
2221
+ const destination = resolve(targetDir, file.relativePath);
2222
+ mkdirSync(dirname(destination), { recursive: true });
2223
+ writeFileSync(destination, file.content, "utf-8");
2224
+ }
2225
+ }
2226
+ function ensureClaudeSymlink(linkPath, targetDir, force) {
2227
+ const parentDir = dirname(linkPath);
2228
+ if (pathExists(parentDir)) {
2229
+ const resolvedTargetDir = realpathSync(dirname(targetDir));
2230
+ const resolvedLinkParent = realpathSync(parentDir);
2231
+ // If .claude/skills already resolves to the same directory as .agents/skills,
2232
+ // the skill is already visible to Claude and creating qmd -> qmd would loop.
2233
+ if (resolvedTargetDir === resolvedLinkParent) {
2234
+ return false;
2235
+ }
2236
+ }
2237
+ const linkTarget = relativePath(parentDir, targetDir) || ".";
2238
+ mkdirSync(parentDir, { recursive: true });
2239
+ if (pathExists(linkPath)) {
2240
+ const stat = lstatSync(linkPath);
2241
+ if (stat.isSymbolicLink() && readlinkSync(linkPath) === linkTarget) {
2242
+ return true;
2243
+ }
2244
+ if (!force) {
2245
+ throw new Error(`Claude skill path already exists: ${linkPath} (use --force to replace it)`);
2246
+ }
2247
+ removePath(linkPath);
2248
+ }
2249
+ symlinkSync(linkTarget, linkPath, "dir");
2250
+ return true;
2251
+ }
2252
+ async function shouldCreateClaudeSymlink(linkPath, autoYes) {
2253
+ if (autoYes) {
2254
+ return true;
2255
+ }
2256
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2257
+ console.log(`Tip: create a Claude symlink manually at ${linkPath}`);
2258
+ return false;
2259
+ }
2260
+ const rl = createInterface({
2261
+ input: process.stdin,
2262
+ output: process.stdout,
2263
+ });
2264
+ try {
2265
+ const answer = await rl.question(`Create a symlink in ${linkPath}? [y/N] `);
2266
+ const normalized = answer.trim().toLowerCase();
2267
+ return normalized === "y" || normalized === "yes";
2268
+ }
2269
+ finally {
2270
+ rl.close();
2271
+ }
2272
+ }
2273
+ async function installSkill(globalInstall, force, autoYes) {
2274
+ const installDir = getSkillInstallDir(globalInstall);
2275
+ writeEmbeddedSkill(installDir, force);
2276
+ console.log(`✓ Installed QMD skill to ${installDir}`);
2277
+ const claudeLinkPath = getClaudeSkillLinkPath(globalInstall);
2278
+ if (!(await shouldCreateClaudeSymlink(claudeLinkPath, autoYes))) {
2059
2279
  return;
2060
2280
  }
2061
- const content = readFileSync(skillPath, "utf-8");
2062
- process.stdout.write(content.endsWith("\n") ? content : content + "\n");
2281
+ const linked = ensureClaudeSymlink(claudeLinkPath, installDir, force);
2282
+ if (linked) {
2283
+ console.log(`✓ Linked Claude skill at ${claudeLinkPath}`);
2284
+ }
2285
+ else {
2286
+ console.log(`✓ Claude already sees the skill via ${dirname(claudeLinkPath)}`);
2287
+ }
2063
2288
  }
2064
2289
  function showHelp() {
2065
2290
  console.log("qmd — Quick Markdown Search");
@@ -2074,7 +2299,9 @@ function showHelp() {
2074
2299
  console.log(" qmd vsearch <query> - Vector similarity only");
2075
2300
  console.log(" qmd get <file>[:line] [-l N] - Show a single document, optional line slice");
2076
2301
  console.log(" qmd multi-get <pattern> - Batch fetch via glob or comma-separated list");
2302
+ console.log(" qmd skill show/install - Show or install the packaged QMD skill");
2077
2303
  console.log(" qmd mcp - Start the MCP server (stdio transport for AI agents)");
2304
+ console.log(" qmd bench <fixture.json> - Run search quality benchmarks against a fixture file");
2078
2305
  console.log("");
2079
2306
  console.log("Collections & context:");
2080
2307
  console.log(" qmd collection add/list/remove/rename/show - Manage indexed folders");
@@ -2085,6 +2312,8 @@ function showHelp() {
2085
2312
  console.log(" qmd status - View index + collection health");
2086
2313
  console.log(" qmd update [--pull] - Re-index collections (optionally git pull first)");
2087
2314
  console.log(" qmd embed [-f] - Generate/refresh vector embeddings");
2315
+ console.log(" --max-docs-per-batch <n> - Cap docs loaded into memory per embedding batch");
2316
+ console.log(" --max-batch-mb <n> - Cap UTF-8 MB loaded into memory per embedding batch");
2088
2317
  console.log(" qmd cleanup - Clear caches, vacuum DB");
2089
2318
  console.log("");
2090
2319
  console.log("Query syntax (qmd query):");
@@ -2123,11 +2352,14 @@ function showHelp() {
2123
2352
  console.log("");
2124
2353
  console.log("AI agents & integrations:");
2125
2354
  console.log(" - Run `qmd mcp` to expose the MCP server (stdio) to agents/IDEs.");
2126
- console.log(" - `qmd --skill` prints the packaged skills/qmd/SKILL.md (path + contents).");
2355
+ console.log(" - `qmd skill install` installs the QMD skill into ./.agents/skills/qmd.");
2356
+ console.log(" - Use `qmd skill install --global` for ~/.agents/skills/qmd.");
2357
+ console.log(" - `qmd --skill` is kept as an alias for `qmd skill show`.");
2127
2358
  console.log(" - Advanced: `qmd mcp --http ...` and `qmd mcp --http --daemon` are optional for custom transports.");
2128
2359
  console.log("");
2129
2360
  console.log("Global options:");
2130
2361
  console.log(" --index <name> - Use a named index (default: index)");
2362
+ console.log(" QMD_EDITOR_URI - Editor link template for clickable TTY search output");
2131
2363
  console.log("");
2132
2364
  console.log("Search options:");
2133
2365
  console.log(" -n <num> - Max results (default 5, or 20 for --files/--json)");
@@ -2135,11 +2367,15 @@ function showHelp() {
2135
2367
  console.log(" --min-score <num> - Minimum similarity score");
2136
2368
  console.log(" --full - Output full document instead of snippet");
2137
2369
  console.log(" -C, --candidate-limit <n> - Max candidates to rerank (default 40, lower = faster)");
2370
+ console.log(" --no-rerank - Skip LLM reranking (use RRF scores only, much faster on CPU)");
2138
2371
  console.log(" --line-numbers - Include line numbers in output");
2139
2372
  console.log(" --explain - Include retrieval score traces (query --json/CLI)");
2140
2373
  console.log(" --files | --json | --csv | --md | --xml - Output format");
2141
2374
  console.log(" -c, --collection <name> - Filter by one or more collections");
2142
2375
  console.log("");
2376
+ console.log("Embed/query options:");
2377
+ console.log(" --chunk-strategy <auto|regex> - Chunking mode (default: regex; auto uses AST for code files)");
2378
+ console.log("");
2143
2379
  console.log("Multi-get options:");
2144
2380
  console.log(" -l <num> - Maximum lines per file");
2145
2381
  console.log(" --max-bytes <num> - Skip files larger than N bytes (default 10240)");
@@ -2178,6 +2414,19 @@ if (isMain) {
2178
2414
  showSkill();
2179
2415
  process.exit(0);
2180
2416
  }
2417
+ if (cli.values.help && cli.command === "skill") {
2418
+ console.log("Usage: qmd skill <show|install> [options]");
2419
+ console.log("");
2420
+ console.log("Commands:");
2421
+ console.log(" show Print the packaged QMD skill");
2422
+ console.log(" install Install into ./.agents/skills/qmd");
2423
+ console.log("");
2424
+ console.log("Options:");
2425
+ console.log(" --global Install into ~/.agents/skills/qmd");
2426
+ console.log(" --yes Also create the .claude/skills/qmd symlink");
2427
+ console.log(" -f, --force Replace existing install or symlink");
2428
+ process.exit(0);
2429
+ }
2181
2430
  if (!cli.command || cli.values.help) {
2182
2431
  showHelp();
2183
2432
  process.exit(cli.values.help ? 0 : 1);
@@ -2416,7 +2665,20 @@ if (isMain) {
2416
2665
  await updateCollections();
2417
2666
  break;
2418
2667
  case "embed":
2419
- await vectorIndex(DEFAULT_EMBED_MODEL, !!cli.values.force);
2668
+ try {
2669
+ const maxDocsPerBatch = parseEmbedBatchOption("maxDocsPerBatch", cli.values["max-docs-per-batch"]);
2670
+ const maxBatchMb = parseEmbedBatchOption("maxBatchBytes", cli.values["max-batch-mb"]);
2671
+ const embedChunkStrategy = parseChunkStrategy(cli.values["chunk-strategy"]);
2672
+ await vectorIndex(DEFAULT_EMBED_MODEL_URI, !!cli.values.force, {
2673
+ maxDocsPerBatch,
2674
+ maxBatchBytes: maxBatchMb === undefined ? undefined : maxBatchMb * 1024 * 1024,
2675
+ chunkStrategy: embedChunkStrategy,
2676
+ });
2677
+ }
2678
+ catch (error) {
2679
+ console.error(error instanceof Error ? error.message : String(error));
2680
+ process.exit(1);
2681
+ }
2420
2682
  break;
2421
2683
  case "pull": {
2422
2684
  const refresh = cli.values.refresh === undefined ? false : Boolean(cli.values.refresh);
@@ -2464,6 +2726,23 @@ if (isMain) {
2464
2726
  }
2465
2727
  await querySearch(cli.query, cli.opts);
2466
2728
  break;
2729
+ case "bench": {
2730
+ const fixturePath = cli.args[0];
2731
+ if (!fixturePath) {
2732
+ console.error("Usage: qmd bench <fixture.json> [--json] [-c collection]");
2733
+ console.error("");
2734
+ console.error("Run search quality benchmarks against a fixture file.");
2735
+ console.error("See src/bench/fixtures/example.json for the fixture format.");
2736
+ process.exit(1);
2737
+ }
2738
+ const { runBenchmark } = await import("../bench/bench.js");
2739
+ const benchCollection = cli.opts.collection;
2740
+ await runBenchmark(fixturePath, {
2741
+ json: !!cli.opts.json,
2742
+ collection: Array.isArray(benchCollection) ? benchCollection[0] : benchCollection,
2743
+ });
2744
+ break;
2745
+ }
2467
2746
  case "mcp": {
2468
2747
  const sub = cli.args[0]; // stop | status | undefined
2469
2748
  // Cache dir for PID/log files — same dir as the index
@@ -2546,6 +2825,44 @@ if (isMain) {
2546
2825
  }
2547
2826
  break;
2548
2827
  }
2828
+ case "skill": {
2829
+ const subcommand = cli.args[0];
2830
+ switch (subcommand) {
2831
+ case "show": {
2832
+ showSkill();
2833
+ break;
2834
+ }
2835
+ case "install": {
2836
+ try {
2837
+ await installSkill(Boolean(cli.values.global), Boolean(cli.values.force), Boolean(cli.values.yes));
2838
+ }
2839
+ catch (error) {
2840
+ console.error(error instanceof Error ? error.message : String(error));
2841
+ process.exit(1);
2842
+ }
2843
+ break;
2844
+ }
2845
+ case "help":
2846
+ case undefined: {
2847
+ console.log("Usage: qmd skill <show|install> [options]");
2848
+ console.log("");
2849
+ console.log("Commands:");
2850
+ console.log(" show Print the packaged QMD skill");
2851
+ console.log(" install Install into ./.agents/skills/qmd");
2852
+ console.log("");
2853
+ console.log("Options:");
2854
+ console.log(" --global Install into ~/.agents/skills/qmd");
2855
+ console.log(" --yes Also create the .claude/skills/qmd symlink");
2856
+ console.log(" -f, --force Replace existing install or symlink");
2857
+ process.exit(0);
2858
+ }
2859
+ default:
2860
+ console.error(`Unknown subcommand: ${subcommand}`);
2861
+ console.error("Run 'qmd skill help' for usage");
2862
+ process.exit(1);
2863
+ }
2864
+ break;
2865
+ }
2549
2866
  case "cleanup": {
2550
2867
  const db = getDb();
2551
2868
  // 1. Clear llm_cache
@@ -21,12 +21,23 @@ export interface Collection {
21
21
  update?: string;
22
22
  includeByDefault?: boolean;
23
23
  }
24
+ /**
25
+ * Model configuration for embedding, reranking, and generation
26
+ */
27
+ export interface ModelsConfig {
28
+ embed?: string;
29
+ rerank?: string;
30
+ generate?: string;
31
+ }
24
32
  /**
25
33
  * The complete configuration file structure
26
34
  */
27
35
  export interface CollectionConfig {
28
36
  global_context?: string;
37
+ editor_uri?: string;
38
+ editor_uri_template?: string;
29
39
  collections: Record<string, Collection>;
40
+ models?: ModelsConfig;
30
41
  }
31
42
  /**
32
43
  * Collection with its name (for return values)
package/dist/db.d.ts CHANGED
@@ -4,6 +4,11 @@
4
4
  * Provides a unified Database export that works under both Bun (bun:sqlite)
5
5
  * and Node.js (better-sqlite3). The APIs are nearly identical — the main
6
6
  * difference is the import path.
7
+ *
8
+ * On macOS, Apple's system SQLite is compiled with SQLITE_OMIT_LOAD_EXTENSION,
9
+ * which prevents loading native extensions like sqlite-vec. When running under
10
+ * Bun we call Database.setCustomSQLite() to swap in Homebrew's full-featured
11
+ * SQLite build before creating any database instances.
7
12
  */
8
13
  export declare const isBun: boolean;
9
14
  /**
@@ -29,5 +34,8 @@ export interface Statement {
29
34
  }
30
35
  /**
31
36
  * Load the sqlite-vec extension into a database.
37
+ *
38
+ * Throws with platform-specific fix instructions when the extension is
39
+ * unavailable.
32
40
  */
33
41
  export declare function loadSqliteVec(db: Database): void;