@tobilu/qmd 1.0.5 → 1.0.7

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 CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.0.7] - 2026-02-18
6
+
7
+ ### Changes
8
+
9
+ - LLM: add LiquidAI LFM2-1.2B as an alternative base model for query
10
+ expansion fine-tuning. LFM2's hybrid architecture (convolutions + attention)
11
+ is 2x faster at decode/prefill vs standard transformers — good fit for
12
+ on-device inference.
13
+ - CLI: support multiple `-c` flags to search across several collections at
14
+ once (e.g. `qmd search -c notes -c journals "query"`). #191 (thanks
15
+ @openclaw)
16
+
17
+ ### Fixes
18
+
19
+ - Return empty JSON array `[]` instead of no output when `--json` search
20
+ finds no results.
21
+ - Resolve relative paths passed to `--index` so they don't produce malformed
22
+ config entries.
23
+ - Respect `XDG_CONFIG_HOME` for collection config path instead of always
24
+ using `~/.config`. #190 (thanks @openclaw)
25
+ - CLI: empty-collection hint now shows the correct `collection add` command.
26
+ #200 (thanks @vincentkoc)
27
+
28
+ ## [1.0.6] - 2026-02-16
29
+
30
+ ### Changes
31
+
32
+ - CLI: `qmd status` now shows models with full HuggingFace links instead of
33
+ static names in `--help`. Model info is derived from the actual configured
34
+ URIs so it stays accurate if models change.
35
+ - Release tooling: pre-push hook handles non-interactive shells (CI, editors)
36
+ gracefully — warnings auto-proceed instead of hanging on a tty prompt.
37
+ Annotated tags now resolve correctly for CI checks.
38
+
5
39
  ## [1.0.5] - 2026-02-16
6
40
 
7
41
  The npm package now ships compiled JavaScript instead of raw TypeScript,
@@ -18,13 +18,27 @@ let currentIndexName = "index";
18
18
  * Config file will be ~/.config/qmd/{indexName}.yml
19
19
  */
20
20
  export function setConfigIndexName(name) {
21
- currentIndexName = name;
21
+ // Resolve relative paths to absolute paths and sanitize for use as filename
22
+ if (name.includes('/')) {
23
+ const { resolve } = require('path');
24
+ const { cwd } = require('process');
25
+ const absolutePath = resolve(cwd(), name);
26
+ // Replace path separators with underscores to create a valid filename
27
+ currentIndexName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
28
+ }
29
+ else {
30
+ currentIndexName = name;
31
+ }
22
32
  }
23
33
  function getConfigDir() {
24
34
  // Allow override via QMD_CONFIG_DIR for testing
25
35
  if (process.env.QMD_CONFIG_DIR) {
26
36
  return process.env.QMD_CONFIG_DIR;
27
37
  }
38
+ // Respect XDG Base Directory specification (consistent with store.ts)
39
+ if (process.env.XDG_CONFIG_HOME) {
40
+ return join(process.env.XDG_CONFIG_HOME, "qmd");
41
+ }
28
42
  return join(homedir(), ".config", "qmd");
29
43
  }
30
44
  function getConfigFilePath() {
package/dist/llm.d.ts CHANGED
@@ -128,6 +128,8 @@ export type RerankDocument = {
128
128
  text: string;
129
129
  title?: string;
130
130
  };
131
+ export declare const LFM2_GENERATE_MODEL = "hf:LiquidAI/LFM2-1.2B-GGUF/LFM2-1.2B-Q4_K_M.gguf";
132
+ export declare const LFM2_INSTRUCT_MODEL = "hf:LiquidAI/LFM2.5-1.2B-Instruct-GGUF/LFM2.5-1.2B-Instruct-Q4_K_M.gguf";
131
133
  export declare const DEFAULT_EMBED_MODEL_URI = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
132
134
  export declare const DEFAULT_RERANK_MODEL_URI = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
133
135
  export declare const DEFAULT_GENERATE_MODEL_URI = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
package/dist/llm.js CHANGED
@@ -33,6 +33,11 @@ const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma
33
33
  const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
34
34
  // const DEFAULT_GENERATE_MODEL = "hf:ggml-org/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf";
35
35
  const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
36
+ // Alternative generation models for query expansion:
37
+ // LiquidAI LFM2 - hybrid architecture optimized for edge/on-device inference
38
+ // Use these as base for fine-tuning with configs/sft_lfm2.yaml
39
+ export const LFM2_GENERATE_MODEL = "hf:LiquidAI/LFM2-1.2B-GGUF/LFM2-1.2B-Q4_K_M.gguf";
40
+ export const LFM2_INSTRUCT_MODEL = "hf:LiquidAI/LFM2.5-1.2B-Instruct-GGUF/LFM2.5-1.2B-Instruct-Q4_K_M.gguf";
36
41
  export const DEFAULT_EMBED_MODEL_URI = DEFAULT_EMBED_MODEL;
37
42
  export const DEFAULT_RERANK_MODEL_URI = DEFAULT_RERANK_MODEL;
38
43
  export const DEFAULT_GENERATE_MODEL_URI = DEFAULT_GENERATE_MODEL;
package/dist/qmd.js CHANGED
@@ -36,7 +36,16 @@ function getDbPath() {
36
36
  return store?.dbPath ?? storeDbPathOverride ?? getDefaultDbPath();
37
37
  }
38
38
  function setIndexName(name) {
39
- storeDbPathOverride = name ? getDefaultDbPath(name) : undefined;
39
+ let normalizedName = name;
40
+ // Normalize relative paths to prevent malformed database paths
41
+ if (name && name.includes('/')) {
42
+ const { resolve } = require('path');
43
+ const { cwd } = require('process');
44
+ const absolutePath = resolve(cwd(), name);
45
+ // Replace path separators with underscores to create a valid filename
46
+ normalizedName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
47
+ }
48
+ storeDbPathOverride = normalizedName ? getDefaultDbPath(normalizedName) : undefined;
40
49
  // Reset open handle so next use opens the new index
41
50
  closeDb();
42
51
  }
@@ -256,6 +265,18 @@ async function showStatus() {
256
265
  else {
257
266
  console.log(`\n${c.dim}No collections. Run 'qmd collection add .' to index markdown files.${c.reset}`);
258
267
  }
268
+ // Models
269
+ {
270
+ // hf:org/repo/file.gguf → https://huggingface.co/org/repo
271
+ const hfLink = (uri) => {
272
+ const match = uri.match(/^hf:([^/]+\/[^/]+)\//);
273
+ return match ? `https://huggingface.co/${match[1]}` : uri;
274
+ };
275
+ console.log(`\n${c.bold}Models${c.reset}`);
276
+ console.log(` Embedding: ${hfLink(DEFAULT_EMBED_MODEL_URI)}`);
277
+ console.log(` Reranking: ${hfLink(DEFAULT_RERANK_MODEL_URI)}`);
278
+ console.log(` Generation: ${hfLink(DEFAULT_GENERATE_MODEL_URI)}`);
279
+ }
259
280
  // Device / GPU info
260
281
  try {
261
282
  const llm = getDefaultLlamaCpp();
@@ -950,7 +971,7 @@ function listFiles(pathArg) {
950
971
  // No argument - list all collections
951
972
  const yamlCollections = yamlListCollections();
952
973
  if (yamlCollections.length === 0) {
953
- console.log("No collections found. Run 'qmd add .' to index files.");
974
+ console.log("No collections found. Run 'qmd collection add .' to index files.");
954
975
  closeDb();
955
976
  return;
956
977
  }
@@ -1074,7 +1095,7 @@ function collectionList() {
1074
1095
  const db = getDb();
1075
1096
  const collections = listCollections(db);
1076
1097
  if (collections.length === 0) {
1077
- console.log("No collections found. Run 'qmd add .' to create one.");
1098
+ console.log("No collections found. Run 'qmd collection add .' to create one.");
1078
1099
  closeDb();
1079
1100
  return;
1080
1101
  }
@@ -1604,22 +1625,42 @@ function outputResults(results, query, opts) {
1604
1625
  }
1605
1626
  }
1606
1627
  }
1607
- function search(query, opts) {
1608
- const db = getDb();
1609
- // Validate collection filter if specified
1610
- let collectionName;
1611
- if (opts.collection) {
1612
- const coll = getCollectionFromYaml(opts.collection);
1628
+ // Resolve -c collection filter: supports single string, array, or undefined.
1629
+ // Returns validated collection names (exits on unknown collection).
1630
+ function resolveCollectionFilter(raw) {
1631
+ if (!raw)
1632
+ return [];
1633
+ const names = Array.isArray(raw) ? raw : [raw];
1634
+ const validated = [];
1635
+ for (const name of names) {
1636
+ const coll = getCollectionFromYaml(name);
1613
1637
  if (!coll) {
1614
- console.error(`Collection not found: ${opts.collection}`);
1638
+ console.error(`Collection not found: ${name}`);
1615
1639
  closeDb();
1616
1640
  process.exit(1);
1617
1641
  }
1618
- collectionName = opts.collection;
1642
+ validated.push(name);
1619
1643
  }
1644
+ return validated;
1645
+ }
1646
+ // Post-filter results to only include files from specified collections.
1647
+ function filterByCollections(results, collectionNames) {
1648
+ if (collectionNames.length <= 1)
1649
+ return results;
1650
+ const prefixes = collectionNames.map(n => `qmd://${n}/`);
1651
+ return results.filter(r => {
1652
+ const path = r.filepath || r.file || '';
1653
+ return prefixes.some(p => path.startsWith(p));
1654
+ });
1655
+ }
1656
+ function search(query, opts) {
1657
+ const db = getDb();
1658
+ // Validate collection filter (supports multiple -c flags)
1659
+ const collectionNames = resolveCollectionFilter(opts.collection);
1660
+ const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
1620
1661
  // Use large limit for --all, otherwise fetch more than needed and let outputResults filter
1621
1662
  const fetchLimit = opts.all ? 100000 : Math.max(50, opts.limit * 2);
1622
- const results = searchFTS(db, query, fetchLimit, collectionName);
1663
+ const results = filterByCollections(searchFTS(db, query, fetchLimit, singleCollection), collectionNames);
1623
1664
  // Add context to results
1624
1665
  const resultsWithContext = results.map(r => ({
1625
1666
  file: r.filepath,
@@ -1633,7 +1674,12 @@ function search(query, opts) {
1633
1674
  }));
1634
1675
  closeDb();
1635
1676
  if (resultsWithContext.length === 0) {
1636
- console.log("No results found.");
1677
+ if (opts.format === "json") {
1678
+ console.log("[]");
1679
+ }
1680
+ else {
1681
+ console.log("No results found.");
1682
+ }
1637
1683
  return;
1638
1684
  }
1639
1685
  outputResults(resultsWithContext, query, opts);
@@ -1656,18 +1702,13 @@ function logExpansionTree(originalQuery, expanded) {
1656
1702
  }
1657
1703
  async function vectorSearch(query, opts, _model = DEFAULT_EMBED_MODEL) {
1658
1704
  const store = getStore();
1659
- if (opts.collection) {
1660
- const coll = getCollectionFromYaml(opts.collection);
1661
- if (!coll) {
1662
- console.error(`Collection not found: ${opts.collection}`);
1663
- closeDb();
1664
- process.exit(1);
1665
- }
1666
- }
1705
+ // Validate collection filter (supports multiple -c flags)
1706
+ const collectionNames = resolveCollectionFilter(opts.collection);
1707
+ const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
1667
1708
  checkIndexHealth(store.db);
1668
1709
  await withLLMSession(async () => {
1669
- const results = await vectorSearchQuery(store, query, {
1670
- collection: opts.collection,
1710
+ let results = await vectorSearchQuery(store, query, {
1711
+ collection: singleCollection,
1671
1712
  limit: opts.all ? 500 : (opts.limit || 10),
1672
1713
  minScore: opts.minScore || 0.3,
1673
1714
  hooks: {
@@ -1677,9 +1718,21 @@ async function vectorSearch(query, opts, _model = DEFAULT_EMBED_MODEL) {
1677
1718
  },
1678
1719
  },
1679
1720
  });
1721
+ // Post-filter for multi-collection
1722
+ if (collectionNames.length > 1) {
1723
+ results = results.filter(r => {
1724
+ const prefixes = collectionNames.map(n => `qmd://${n}/`);
1725
+ return prefixes.some(p => r.file.startsWith(p));
1726
+ });
1727
+ }
1680
1728
  closeDb();
1681
1729
  if (results.length === 0) {
1682
- console.log("No results found.");
1730
+ if (opts.format === "json") {
1731
+ console.log("[]");
1732
+ }
1733
+ else {
1734
+ console.log("No results found.");
1735
+ }
1683
1736
  return;
1684
1737
  }
1685
1738
  outputResults(results.map(r => ({
@@ -1695,18 +1748,13 @@ async function vectorSearch(query, opts, _model = DEFAULT_EMBED_MODEL) {
1695
1748
  }
1696
1749
  async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rerankModel = DEFAULT_RERANK_MODEL) {
1697
1750
  const store = getStore();
1698
- if (opts.collection) {
1699
- const coll = getCollectionFromYaml(opts.collection);
1700
- if (!coll) {
1701
- console.error(`Collection not found: ${opts.collection}`);
1702
- closeDb();
1703
- process.exit(1);
1704
- }
1705
- }
1751
+ // Validate collection filter (supports multiple -c flags)
1752
+ const collectionNames = resolveCollectionFilter(opts.collection);
1753
+ const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
1706
1754
  checkIndexHealth(store.db);
1707
1755
  await withLLMSession(async () => {
1708
- const results = await hybridQuery(store, query, {
1709
- collection: opts.collection,
1756
+ let results = await hybridQuery(store, query, {
1757
+ collection: singleCollection,
1710
1758
  limit: opts.all ? 500 : (opts.limit || 10),
1711
1759
  minScore: opts.minScore || 0,
1712
1760
  hooks: {
@@ -1726,9 +1774,21 @@ async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rera
1726
1774
  },
1727
1775
  },
1728
1776
  });
1777
+ // Post-filter for multi-collection
1778
+ if (collectionNames.length > 1) {
1779
+ results = results.filter(r => {
1780
+ const prefixes = collectionNames.map(n => `qmd://${n}/`);
1781
+ return prefixes.some(p => r.file.startsWith(p));
1782
+ });
1783
+ }
1729
1784
  closeDb();
1730
1785
  if (results.length === 0) {
1731
- console.log("No results found.");
1786
+ if (opts.format === "json") {
1787
+ console.log("[]");
1788
+ }
1789
+ else {
1790
+ console.log("No results found.");
1791
+ }
1732
1792
  return;
1733
1793
  }
1734
1794
  // Map to CLI output format — use bestChunk for snippet display
@@ -1756,9 +1816,6 @@ function parseCLI() {
1756
1816
  context: {
1757
1817
  type: "string",
1758
1818
  },
1759
- "no-lex": {
1760
- type: "boolean",
1761
- },
1762
1819
  help: { type: "boolean", short: "h" },
1763
1820
  version: { type: "boolean", short: "v" },
1764
1821
  // Search options
@@ -1771,7 +1828,7 @@ function parseCLI() {
1771
1828
  xml: { type: "boolean" },
1772
1829
  files: { type: "boolean" },
1773
1830
  json: { type: "boolean" },
1774
- collection: { type: "string", short: "c" }, // Filter by collection
1831
+ collection: { type: "string", short: "c", multiple: true }, // Filter by collection(s)
1775
1832
  // Collection options
1776
1833
  name: { type: "string" }, // collection name
1777
1834
  mask: { type: "string" }, // glob pattern
@@ -1877,11 +1934,6 @@ function showHelp() {
1877
1934
  console.log(" --max-bytes <num> - Skip files larger than N bytes (default: 10240)");
1878
1935
  console.log(" --json/--csv/--md/--xml/--files - Output format (same as search)");
1879
1936
  console.log("");
1880
- console.log("Models (auto-downloaded from HuggingFace):");
1881
- console.log(" Embedding: embeddinggemma-300M-Q8_0");
1882
- console.log(" Reranking: qwen3-reranker-0.6b-q8_0");
1883
- console.log(" Generation: Qwen3-0.6B-Q8_0");
1884
- console.log("");
1885
1937
  console.log(`Index: ${getDbPath()}`);
1886
1938
  }
1887
1939
  async function showVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tobilu/qmd",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
5
5
  "type": "module",
6
6
  "bin": {