ko-qmd 2.8.3-ko.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +1937 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +184 -0
  4. package/README.md +292 -0
  5. package/bin/qmd +192 -0
  6. package/dist/ast.d.ts +65 -0
  7. package/dist/ast.js +51 -0
  8. package/dist/bench/bench.d.ts +41 -0
  9. package/dist/bench/bench.js +12 -0
  10. package/dist/bench/score.d.ts +33 -0
  11. package/dist/bench/score.js +1 -0
  12. package/dist/bench/types.d.ts +101 -0
  13. package/dist/bench/types.js +1 -0
  14. package/dist/cli/build-info.json +4 -0
  15. package/dist/cli/embed-lock.d.ts +24 -0
  16. package/dist/cli/embed-lock.js +2 -0
  17. package/dist/cli/formatter.d.ts +120 -0
  18. package/dist/cli/formatter.js +60 -0
  19. package/dist/cli/mcp-pid.d.ts +33 -0
  20. package/dist/cli/mcp-pid.js +1 -0
  21. package/dist/cli/qmd.d.ts +101 -0
  22. package/dist/cli/qmd.js +165 -0
  23. package/dist/cli/version.d.ts +42 -0
  24. package/dist/cli/version.js +1 -0
  25. package/dist/collections.d.ts +162 -0
  26. package/dist/collections.js +1 -0
  27. package/dist/db.d.ts +63 -0
  28. package/dist/db.js +2 -0
  29. package/dist/filters.d.ts +46 -0
  30. package/dist/filters.js +1 -0
  31. package/dist/grep.d.ts +85 -0
  32. package/dist/grep.js +9 -0
  33. package/dist/hangul.d.ts +102 -0
  34. package/dist/hangul.js +1 -0
  35. package/dist/index.d.ts +270 -0
  36. package/dist/index.js +1 -0
  37. package/dist/llm.d.ts +584 -0
  38. package/dist/llm.js +40 -0
  39. package/dist/maintenance.d.ts +33 -0
  40. package/dist/maintenance.js +1 -0
  41. package/dist/mcp/daemon-health.d.ts +17 -0
  42. package/dist/mcp/daemon-health.js +1 -0
  43. package/dist/mcp/origin-guard.d.ts +67 -0
  44. package/dist/mcp/origin-guard.js +1 -0
  45. package/dist/mcp/server.d.ts +131 -0
  46. package/dist/mcp/server.js +35 -0
  47. package/dist/memory.d.ts +74 -0
  48. package/dist/memory.js +13 -0
  49. package/dist/metadata-filter.d.ts +74 -0
  50. package/dist/metadata-filter.js +1 -0
  51. package/dist/metadata-store.d.ts +45 -0
  52. package/dist/metadata-store.js +68 -0
  53. package/dist/metadata.d.ts +61 -0
  54. package/dist/metadata.js +1 -0
  55. package/dist/paths.d.ts +1 -0
  56. package/dist/paths.js +1 -0
  57. package/dist/query-log.d.ts +156 -0
  58. package/dist/query-log.js +2 -0
  59. package/dist/refresh.d.ts +60 -0
  60. package/dist/refresh.js +1 -0
  61. package/dist/rerank-input.d.ts +46 -0
  62. package/dist/rerank-input.js +5 -0
  63. package/dist/store.d.ts +1267 -0
  64. package/dist/store.js +421 -0
  65. package/dist/trust.d.ts +152 -0
  66. package/dist/trust.js +2 -0
  67. package/package.json +124 -0
  68. package/scripts/build.mjs +130 -0
  69. package/scripts/check-package-grammars.mjs +29 -0
  70. package/scripts/package-smoke.mjs +65 -0
  71. package/scripts/test-all.mjs +42 -0
  72. package/skills/qmd/SKILL.md +333 -0
  73. package/skills/qmd/references/mcp-setup.md +122 -0
  74. package/skills/release/SKILL.md +141 -0
  75. package/skills/release/scripts/install-hooks.sh +38 -0
  76. package/skills/release/scripts/release-context.sh +129 -0
package/bin/qmd ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // 2>/dev/null; if command -v node >/dev/null 2>&1; then exec node "$0" "$@"; else exec bun "$0" "$@"; fi
3
+ // Cross-platform launcher for qmd.
4
+ //
5
+ // Previously this was a POSIX shell script with `#!/bin/sh`, which meant npm
6
+ // on Windows generated shims that tried to route through `/bin/sh` — a path
7
+ // that doesn't exist on Windows, so `qmd` failed immediately after a global
8
+ // install. Rewriting the launcher in Node.js lets npm generate native
9
+ // cmd/ps1/sh shims that invoke `node` directly on every platform.
10
+
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { existsSync, realpathSync } from "node:fs";
13
+ import { dirname, resolve, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ // Resolve symlinks so global installs (npm link / npm install -g) can find
17
+ // the actual package directory instead of the global bin directory.
18
+ const self = realpathSync(fileURLToPath(import.meta.url));
19
+ const pkgDir = resolve(dirname(self), "..");
20
+ const jsEntry = resolve(pkgDir, "dist/cli/qmd.js");
21
+ const tsEntry = resolve(pkgDir, "src/cli/qmd.ts");
22
+
23
+ // MCP stdio reserves stdout exclusively for JSON-RPC frames. node-llama-cpp
24
+ // / llama.cpp / ggml can write native logs directly to stdout before JS-level
25
+ // log handlers are attached, so seed the native quiet env before Node/Bun imports
26
+ // the CLI and its LLM modules. Preserve explicit user values when provided.
27
+ if (process.argv[2] === "mcp") {
28
+ process.env.LLAMA_LOG_LEVEL = process.env.LLAMA_LOG_LEVEL || "error";
29
+ process.env.GGML_LOG_LEVEL = process.env.GGML_LOG_LEVEL || "error";
30
+ process.env.GGML_BACKEND_SILENT = process.env.GGML_BACKEND_SILENT || "1";
31
+ }
32
+
33
+ // libggml-metal on macOS uses "residency sets" to keep allocated model memory
34
+ // resident across inference requests (180-second keep_alive timer). The
35
+ // process-static device destructor that runs during libc exit() asserts the
36
+ // residency set is empty (ggml-org/llama.cpp#22593); the keep_alive hasn't
37
+ // expired by exit, so the assertion fails and ggml_abort dumps a multi-kB
38
+ // stack trace to stderr even when the user-visible results were already
39
+ // emitted correctly. No JS-side dispose can prevent it because the static
40
+ // destructor runs in __cxa_finalize_ranges, after every JS-reachable cleanup.
41
+ //
42
+ // For QMD's short-lived CLI workflow, residency sets provide no observable
43
+ // performance benefit (subsequent requests don't reuse the warm mapping —
44
+ // measured: identical wall time with and without on M3 Pro), so disable them
45
+ // by default on darwin. The env var must be set BEFORE the native llama.cpp
46
+ // binding loads, which is why it lives here in the launcher rather than in
47
+ // the JS entry point. Opt back in with QMD_METAL_KEEP_RESIDENCY=1 if you
48
+ // run long-lived qmd processes (the MCP daemon may benefit on hot reload)
49
+ // or are triaging an upstream Metal teardown fix.
50
+ if (process.platform === "darwin" && process.env.QMD_METAL_KEEP_RESIDENCY !== "1") {
51
+ process.env.GGML_METAL_NO_RESIDENCY = process.env.GGML_METAL_NO_RESIDENCY || "1";
52
+ }
53
+
54
+ function hasBun() {
55
+ try {
56
+ const res = spawnSync("bun", ["--version"], { stdio: "ignore", shell: process.platform === "win32" });
57
+ return res.status === 0;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // In published packages, bin/qmd must run dist/. In a git checkout, however,
64
+ // dist/ is often ignored and can be stale after git reset or branch switches.
65
+ // Prefer source mode only for checkouts so ./bin/qmd reflects the checked-out
66
+ // source without changing packaged/runtime behavior.
67
+ //
68
+ // Critical: source-mode detection must NOT trigger when a package manager
69
+ // installed us. `pnpm install -g .` (and `npm install -g .`) copy the entire
70
+ // working tree — including .git/, bun.lock, package-lock.json, src/, and even
71
+ // node_modules/ — into <prefix>/node_modules/@tobilu/qmd/, so .git and a
72
+ // lockfile being present is not a reliable "this is a working tree" signal.
73
+ // What IS reliable: a package-manager install always lands the package
74
+ // directory inside a `node_modules/` segment; a bare working-tree checkout
75
+ // (with `bun link` or a direct path invocation) does not. Gate source mode
76
+ // on that. Allow QMD_SOURCE_MODE=1 / =0 as an explicit override for the
77
+ // rare case where the heuristic disagrees with the user.
78
+ const sourceOverride = process.env.QMD_SOURCE_MODE;
79
+ const looksInstalled = pkgDir.split("/").includes("node_modules");
80
+ const sourceAllowed = sourceOverride === "1"
81
+ || (sourceOverride !== "0" && !looksInstalled);
82
+
83
+ let useSourceMode = false;
84
+ let sourceRunner = null;
85
+ let sourceArgs = [];
86
+
87
+ if (sourceAllowed && existsSync(resolve(pkgDir, ".git")) && existsSync(tsEntry)) {
88
+ // Lockfile-driven runner selection — mirror the dist-mode logic below so
89
+ // source mode picks the same runtime the user's deps were installed for.
90
+ // package-lock.json wins over bun.lock when both are present: pnpm/npm
91
+ // installs ship the Node-ABI native modules (better-sqlite3, sqlite-vec),
92
+ // and running Bun against them produces ABI mismatches. This also fixes
93
+ // pnpm-global installs, which copy the whole working tree — including .git
94
+ // and bun.lock — into the install dir and used to route through Bun even
95
+ // when the user installed via npm/pnpm.
96
+ const hasNpmLock = existsSync(resolve(pkgDir, "package-lock.json"));
97
+ const hasBunLock = existsSync(resolve(pkgDir, "bun.lock")) || existsSync(resolve(pkgDir, "bun.lockb"));
98
+ const tsxEntry = resolve(pkgDir, "node_modules/tsx/dist/cli.mjs");
99
+ const tsxAvailable = existsSync(tsxEntry);
100
+
101
+ if (hasNpmLock && tsxAvailable) {
102
+ useSourceMode = true;
103
+ sourceRunner = "node";
104
+ sourceArgs = [tsxEntry, tsEntry, ...process.argv.slice(2)];
105
+ } else if (hasBunLock && hasBun()) {
106
+ useSourceMode = true;
107
+ sourceRunner = "bun";
108
+ sourceArgs = [tsEntry, ...process.argv.slice(2)];
109
+ } else if (tsxAvailable) {
110
+ useSourceMode = true;
111
+ sourceRunner = "node";
112
+ sourceArgs = [tsxEntry, tsEntry, ...process.argv.slice(2)];
113
+ }
114
+ }
115
+
116
+ if (!useSourceMode && !existsSync(jsEntry)) {
117
+ console.error(`qmd is not built: missing ${jsEntry}`);
118
+ console.error("Run: bun install && bun run build");
119
+ console.error("Or: npm install && npm run build");
120
+ console.error("After building, run: qmd doctor");
121
+ process.exit(1);
122
+ }
123
+
124
+ // Detect the package manager that installed dependencies by checking lockfiles.
125
+ // $BUN_INSTALL is intentionally NOT checked — it only indicates that bun exists
126
+ // on the system, not that it was used to install this package (see #361).
127
+ //
128
+ // package-lock.json takes priority: if it exists, npm installed the native
129
+ // modules for Node. The repo ships bun.lock, so without this check, source
130
+ // builds that use npm would be incorrectly routed to bun, causing ABI
131
+ // mismatches with better-sqlite3 / sqlite-vec (see #381).
132
+ //
133
+ // Package-manager installs keep their lockfile at the install root, above
134
+ // node_modules/ — never inside the package directory itself. A bun global
135
+ // install writes $BUN_INSTALL/install/global/bun.lock while the package
136
+ // lands in .../install/global/node_modules/@tobilu/qmd/, so the package-dir
137
+ // checks below can't match and qmd would run under whatever node is first
138
+ // on PATH. The native modules were built by the installing runtime; after
139
+ // the next Node major upgrade they fail to load (ERR_DLOPEN_FAILED,
140
+ // NODE_MODULE_VERSION mismatch). Apply the same npm-priority rule at the
141
+ // install root when the package directory itself has no lockfile.
142
+ const pathParts = pkgDir.split(sep);
143
+ const nodeModulesIndex = pathParts.lastIndexOf("node_modules");
144
+ const installRoot = nodeModulesIndex === -1 ? null : pathParts.slice(0, nodeModulesIndex).join(sep);
145
+ const hasRootNpmLock = installRoot !== null && existsSync(resolve(installRoot, "package-lock.json"));
146
+ const hasRootBunLock = installRoot !== null
147
+ && (existsSync(resolve(installRoot, "bun.lock")) || existsSync(resolve(installRoot, "bun.lockb")));
148
+
149
+ let runnerName = "node";
150
+ if (existsSync(resolve(pkgDir, "package-lock.json"))) {
151
+ runnerName = "node";
152
+ } else if (existsSync(resolve(pkgDir, "bun.lock")) || existsSync(resolve(pkgDir, "bun.lockb"))) {
153
+ runnerName = "bun";
154
+ } else if (hasRootNpmLock) {
155
+ runnerName = "node";
156
+ } else if (hasRootBunLock) {
157
+ runnerName = "bun";
158
+ } else {
159
+ runnerName = "node";
160
+ }
161
+
162
+ const selected = useSourceMode ? sourceRunner : (runnerName === "node" ? "node" : "bun");
163
+ // Pin Node to the binary that launched this trampoline. Native addons
164
+ // (better-sqlite3) are compiled for that install's ABI; spawning PATH
165
+ // `node` picks up nvm/fnm/mise shims in the project directory and fails
166
+ // with NODE_MODULE_VERSION / ERR_DLOPEN_FAILED (leftover from #577; #319).
167
+ // If the trampoline itself is running under bun (`bun bin/qmd`), keep
168
+ // PATH `node` so we don't load Node-ABI addons into bun.
169
+ const runner = selected === "node" && typeof process.versions.bun !== "string"
170
+ ? process.execPath
171
+ : selected;
172
+ const args = useSourceMode ? sourceArgs : [jsEntry, ...process.argv.slice(2)];
173
+ const needsShell = (runner === "bun") && process.platform === "win32";
174
+
175
+ const child = spawn(runner, args, {
176
+ stdio: "inherit",
177
+ shell: needsShell,
178
+ });
179
+
180
+ child.on("exit", (code, signal) => {
181
+ if (signal) {
182
+ process.kill(process.pid, signal);
183
+ } else {
184
+ process.exit(code ?? 0);
185
+ }
186
+ });
187
+
188
+ child.on("error", (err) => {
189
+ const name = useSourceMode ? sourceRunner : runnerName;
190
+ console.error(`qmd: failed to launch ${name}: ${err.message}`);
191
+ process.exit(1);
192
+ });
package/dist/ast.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * AST-aware chunking support via web-tree-sitter.
3
+ *
4
+ * Provides language detection, AST break point extraction for supported
5
+ * code file types, and a stub for future symbol extraction.
6
+ *
7
+ * All functions degrade gracefully: parse failures or unsupported languages
8
+ * return empty arrays, falling back to regex-only chunking.
9
+ *
10
+ * ## Dependency Note
11
+ *
12
+ * Grammar packages (tree-sitter-typescript, etc.) are listed as
13
+ * optionalDependencies with pinned versions. They ship native prebuilds
14
+ * and source files (~72 MB total) but QMD only uses the .wasm files
15
+ * (~5 MB). If install size becomes a concern, the .wasm files can be
16
+ * bundled directly in the repo (e.g. assets/grammars/) and resolved
17
+ * via import.meta.url instead of require.resolve(), eliminating the
18
+ * grammar packages entirely.
19
+ */
20
+ import type { BreakPoint } from "./store.js";
21
+ export type SupportedLanguage = "typescript" | "tsx" | "javascript" | "python" | "go" | "rust";
22
+ /**
23
+ * Detect language from file path extension.
24
+ * Returns null for unsupported or unknown extensions (including .md).
25
+ */
26
+ export declare function detectLanguage(filepath: string): SupportedLanguage | null;
27
+ export declare function formatGrammarLoadError(language: SupportedLanguage, err: unknown): string;
28
+ /**
29
+ * Parse a source file and return break points at AST node boundaries.
30
+ *
31
+ * Returns an empty array for unsupported languages, parse failures,
32
+ * or grammar loading failures. Never throws.
33
+ *
34
+ * @param content - The file content to parse.
35
+ * @param filepath - The file path (used for language detection).
36
+ * @returns Array of BreakPoint objects suitable for merging with regex break points.
37
+ */
38
+ export declare function getASTBreakPoints(content: string, filepath: string): Promise<BreakPoint[]>;
39
+ /**
40
+ * Check which tree-sitter grammars are available.
41
+ * Returns a status object for each supported language.
42
+ */
43
+ export declare function getASTStatus(): Promise<{
44
+ available: boolean;
45
+ languages: {
46
+ language: SupportedLanguage;
47
+ available: boolean;
48
+ error?: string;
49
+ }[];
50
+ }>;
51
+ /**
52
+ * Metadata about a code symbol within a chunk.
53
+ * Stubbed for Phase 2 — always returns empty array in Phase 1.
54
+ */
55
+ export interface SymbolInfo {
56
+ name: string;
57
+ kind: string;
58
+ signature?: string;
59
+ line: number;
60
+ }
61
+ /**
62
+ * Extract symbol metadata for code within a byte range.
63
+ * Stubbed for Phase 2 — returns empty array.
64
+ */
65
+ export declare function extractSymbols(_content: string, _language: string, _startPos: number, _endPos: number): SymbolInfo[];
package/dist/ast.js ADDED
@@ -0,0 +1,51 @@
1
+ var E=Object.defineProperty;var n=(t,e)=>E(t,"name",{value:e,configurable:!0});import{createRequire as S}from"node:module";import{extname as j}from"node:path";const P={".ts":"typescript",".tsx":"tsx",".js":"javascript",".jsx":"tsx",".mts":"typescript",".cts":"typescript",".mjs":"javascript",".cjs":"javascript",".py":"python",".go":"go",".rs":"rust"};function q(t){const e=j(t).toLowerCase();return P[e]??null}n(q,"detectLanguage");const i={typescript:{pkg:"tree-sitter-typescript",wasm:"tree-sitter-typescript.wasm",version:"0.23.2"},tsx:{pkg:"tree-sitter-typescript",wasm:"tree-sitter-tsx.wasm",version:"0.23.2"},javascript:{pkg:"tree-sitter-typescript",wasm:"tree-sitter-typescript.wasm",version:"0.23.2"},python:{pkg:"tree-sitter-python",wasm:"tree-sitter-python.wasm",version:"0.25.0"},go:{pkg:"tree-sitter-go",wasm:"tree-sitter-go.wasm",version:"0.25.0"},rust:{pkg:"tree-sitter-rust",wasm:"tree-sitter-rust.wasm",version:"0.24.0"}};function L(t,e){const a=i[t],r=e instanceof Error?e.message:String(e);return`${a.pkg}/${a.wasm} failed to load (${r}); falling back to regex chunking. Repair a broken global install with: bun add ${a.pkg}@${a.version}`}n(L,"formatGrammarLoadError");const M={typescript:`
2
+ (export_statement) @export
3
+ (class_declaration) @class
4
+ (function_declaration) @func
5
+ (method_definition) @method
6
+ (interface_declaration) @iface
7
+ (type_alias_declaration) @type
8
+ (enum_declaration) @enum
9
+ (import_statement) @import
10
+ (lexical_declaration (variable_declarator value: (arrow_function))) @func
11
+ (lexical_declaration (variable_declarator value: (function_expression))) @func
12
+ `,tsx:`
13
+ (export_statement) @export
14
+ (class_declaration) @class
15
+ (function_declaration) @func
16
+ (method_definition) @method
17
+ (interface_declaration) @iface
18
+ (type_alias_declaration) @type
19
+ (enum_declaration) @enum
20
+ (import_statement) @import
21
+ (lexical_declaration (variable_declarator value: (arrow_function))) @func
22
+ (lexical_declaration (variable_declarator value: (function_expression))) @func
23
+ `,javascript:`
24
+ (export_statement) @export
25
+ (class_declaration) @class
26
+ (function_declaration) @func
27
+ (method_definition) @method
28
+ (import_statement) @import
29
+ (lexical_declaration (variable_declarator value: (arrow_function))) @func
30
+ (lexical_declaration (variable_declarator value: (function_expression))) @func
31
+ `,python:`
32
+ (class_definition) @class
33
+ (function_definition) @func
34
+ (decorated_definition) @decorated
35
+ (import_statement) @import
36
+ (import_from_statement) @import
37
+ `,go:`
38
+ (type_declaration) @type
39
+ (function_declaration) @func
40
+ (method_declaration) @method
41
+ (import_declaration) @import
42
+ `,rust:`
43
+ (struct_item) @struct
44
+ (impl_item) @impl
45
+ (function_item) @func
46
+ (trait_item) @trait
47
+ (enum_item) @enum
48
+ (use_declaration) @import
49
+ (type_item) @type
50
+ (mod_item) @mod
51
+ `},C={class:100,iface:100,struct:100,trait:100,impl:100,mod:100,export:90,func:90,method:90,decorated:90,type:80,enum:80,import:60};let p=null,y=null,w=null,d=null;const v=new Set,x=new Map,l=new Map,f=new Map;async function h(){return d||(d=(async()=>{const t=await import("web-tree-sitter");p=t.Parser,y=t.Language,w=t.Query,await p.init()})()),d}n(h,"ensureInit");function G(t){const{pkg:e,wasm:a}=i[t];return S(import.meta.url).resolve(`${e}/${a}`)}n(G,"resolveGrammarPath");async function b(t){if(v.has(t))return null;const e=i[t].wasm;l.has(e)||l.set(e,(async()=>{const a=G(t);return y.load(a)})());try{return await l.get(e)}catch(a){v.add(t),l.delete(e);const r=L(t,a);return x.set(t,r),console.warn(`[qmd] AST grammar unavailable for ${t}: ${r}`),null}}n(b,"loadGrammar");function k(t,e){if(!f.has(t)){const a=M[t],r=new w(e,a);f.set(t,r)}return f.get(t)}n(k,"getQuery");async function O(t,e){const a=q(e);if(!a)return[];try{await h();const r=await b(a);if(!r)return[];const c=new p;c.setLanguage(r);const m=c.parse(t);if(!m)return c.delete(),[];const $=k(a,r).captures(m.rootNode),u=new Map;for(const s of $){const o=s.node.startIndex,_=C[s.name]??20,A=`ast:${s.name}`,g=u.get(o);(!g||_>g.score)&&u.set(o,{pos:o,score:_,type:A})}return m.delete(),c.delete(),Array.from(u.values()).sort((s,o)=>s.pos-o.pos)}catch(r){return console.warn(`[qmd] AST parse failed for ${e}, falling back to regex: ${r instanceof Error?r.message:r}`),[]}}n(O,"getASTBreakPoints");async function Q(){const t=[];try{await h()}catch(e){return{available:!1,languages:Object.keys(i).map(a=>({language:a,available:!1,error:`web-tree-sitter init failed: ${e instanceof Error?e.message:e}`}))}}for(const e of Object.keys(i))try{const a=await b(e);a?(k(e,a),t.push({language:e,available:!0})):t.push({language:e,available:!1,error:x.get(e)??"grammar failed to load"})}catch(a){t.push({language:e,available:!1,error:a instanceof Error?a.message:String(a)})}return{available:t.some(e=>e.available),languages:t}}n(Q,"getASTStatus");function U(t,e,a,r){return[]}n(U,"extractSymbols");export{q as detectLanguage,U as extractSymbols,L as formatGrammarLoadError,O as getASTBreakPoints,Q as getASTStatus};
@@ -0,0 +1,41 @@
1
+ /**
2
+ * QMD Benchmark Harness
3
+ *
4
+ * Runs queries from a fixture file against multiple search backends
5
+ * and measures precision@k, recall, MRR, F1, and latency.
6
+ *
7
+ * Usage:
8
+ * qmd bench <fixture.json> [--json] [--collection <name>]
9
+ *
10
+ * Backends tested:
11
+ * - bm25: BM25 keyword search (searchLex)
12
+ * - vector: Vector similarity search (searchVector)
13
+ * - hybrid: BM25 + vector RRF fusion without reranking
14
+ * - full: Full hybrid pipeline with LLM reranking
15
+ */
16
+ import type { BackendSummaryByType, HardSummary, QueryResult, BenchmarkResult } from "./types.js";
17
+ /** Per-type summary built from the existing computeSummary on each type's slice of results.
18
+ * A type with zero queries gets no entry — pooling must skip it, never divide by zero. */
19
+ export declare const computeSummaryByType: (results: QueryResult[]) => BackendSummaryByType;
20
+ /** Count-weighted pool of the hard query types over the per-type summary — the numbers behind
21
+ * the `RESULT-HARD` line bench-ko.sh prints and dogfood.sh gates. Nulls when empty. */
22
+ export declare const computeHardSummary: (byType: BackendSummaryByType) => HardSummary;
23
+ export type BenchCollectionInfo = {
24
+ name: string;
25
+ active_count: number;
26
+ };
27
+ /**
28
+ * Fail fast when the fixture's collection is missing or empty so `qmd bench`
29
+ * does not spend minutes printing a wall of 0.00 (#716).
30
+ */
31
+ export declare function assertBenchCollectionReady(collections: BenchCollectionInfo[], collection?: string): void;
32
+ export declare function benchSummaryAllZero(summary: BenchmarkResult["summary"]): boolean;
33
+ export declare function allZeroBenchWarning(collection?: string): string;
34
+ export declare function runBenchmark(fixturePath: string, options?: {
35
+ json?: boolean;
36
+ collection?: string;
37
+ backends?: string[];
38
+ dbPath?: string;
39
+ configPath?: string;
40
+ config?: import("../collections.js").CollectionConfig;
41
+ }): Promise<BenchmarkResult>;
@@ -0,0 +1,12 @@
1
+ var v=Object.defineProperty;var i=(a,e)=>v(a,"name",{value:e,configurable:!0});import{readFileSync as x}from"node:fs";import{resolve as k}from"node:path";import{createStore as R,getDefaultDbPath as q}from"../index.js";import{scoreResults as S}from"./score.js";import{HARD_QUERY_TYPES as w}from"./types.js";function y(a){const e=a.split(`
2
+ `).map((s,o)=>({trimmed:s.trim(),number:o+1})).filter(s=>s.trimmed.length>0);if(e.length===0)return;const t=/^(lex|vec|hyde):\s*/i,n=/^intent:\s*/i,r=[];let c;for(const s of e){if(n.test(s.trimmed)){if(c!==void 0)throw new Error(`Line ${s.number}: only one intent: line is allowed per benchmark query.`);if(c=s.trimmed.replace(n,"").trim(),!c)throw new Error(`Line ${s.number}: intent: must include text.`);continue}const o=s.trimmed.match(t);if(o){const f=o[1].toLowerCase(),m=s.trimmed.slice(o[0].length).trim();if(!m)throw new Error(`Line ${s.number} (${f}:) must include text.`);r.push({type:f,query:m,line:s.number});continue}if(e.length===1)return;throw new Error(`Line ${s.number} is missing a lex:/vec:/hyde:/intent: prefix.`)}if(c&&r.length===0)throw new Error("intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.");return r.length>0?{searches:r,intent:c}:void 0}i(y,"parseStructuredQuery");function b(a,e){const t=new Set,n=[];for(const r of a)if(!t.has(r)&&(t.add(r),n.push(r),n.length>=e))break;return n}i(b,"uniqueFiles");const g=[{name:"bm25",run:i(async(a,e,t,n)=>{const r=y(e.query),c=r?.searches.filter(o=>o.type==="lex");if(r){const o=[];for(const f of c??[]){const m=await a.searchLex(f.query,{limit:t,collection:n});o.push(...m.map(_=>_.filepath))}return b(o,t)}return(await a.searchLex(e.query,{limit:t,collection:n})).map(o=>o.filepath)},"run")},{name:"vector",run:i(async(a,e,t,n)=>{const r=y(e.query),c=r?.searches.filter(o=>o.type==="vec"||o.type==="hyde");if(r){const o=[];for(const f of c??[]){const m=await a.searchVector(f.query,{limit:t,collection:n});o.push(...m.map(_=>_.filepath))}return b(o,t)}return(await a.searchVector(e.query,{limit:t,collection:n})).map(o=>o.filepath)},"run")},{name:"hybrid",run:i(async(a,e,t,n)=>{const r=y(e.query);return(r?await a.search({queries:r.searches,intent:r.intent,limit:t,collection:n,rerank:!1}):await a.search({query:e.query,limit:t,collection:n,rerank:!1})).map(s=>s.file)},"run")},{name:"full",run:i(async(a,e,t,n)=>{const r=y(e.query);return(r?await a.search({queries:r.searches,intent:r.intent,limit:t,collection:n,rerank:!0}):await a.search({query:e.query,limit:t,collection:n,rerank:!0})).map(s=>s.file)},"run")}];async function E(a,e,t,n){const r=t.expected_in_top_k??t.expected_files.length,c=Math.max(Number.isFinite(r)?r:0,10),s=Date.now();let o;try{o=await e.run(a,t,c,n)}catch{return{precision_at_k:0,recall:0,recall_at_1:0,recall_at_3:0,recall_at_5:0,mrr:0,f1:0,hits_at_k:0,total_expected:t.expected_files.length,latency_ms:Date.now()-s,top_files:[],matched_files:[],unmatched_expected_files:t.expected_files}}const f=Date.now()-s;return{...S(o,t.expected_files,r),total_expected:t.expected_files.length,latency_ms:f,top_files:o.slice(0,10)}}i(E,"runQuery");function j(a){const e=[],t=i((r,c)=>r.slice(0,c).padEnd(c),"pad"),n=i(r=>r.toFixed(2).padStart(5),"num");e.push(`${t("Query",25)} ${t("Backend",8)} ${t("P@k",6)} ${t("R@1",6)} ${t("R@3",6)} ${t("R@5",6)} ${t("MRR",6)} ${t("F1",6)} ${t("ms",8)}`),e.push("-".repeat(88));for(const r of a){for(const[c,s]of Object.entries(r.backends))e.push(`${t(r.id,25)} ${t(c,8)} ${n(s.precision_at_k)} ${n(s.recall_at_1)} ${n(s.recall_at_3)} ${n(s.recall_at_5)} ${n(s.mrr)} ${n(s.f1)} ${String(Math.round(s.latency_ms)).padStart(7)}ms`);e.push("")}return e.join(`
3
+ `)}i(j,"formatTable");function $(a){const e={},t=new Set;for(const n of a)for(const r of Object.keys(n.backends))t.add(r);for(const n of Array.from(t)){let r=0,c=0,s=0,o=0,f=0,m=0,_=0,p=0,u=0;for(const d of a){const l=d.backends[n];l&&(r+=l.precision_at_k,c+=l.recall,s+=l.recall_at_1,o+=l.recall_at_3,f+=l.recall_at_5,m+=l.mrr,_+=l.f1,p+=l.latency_ms,u++)}u>0&&(e[n]={avg_precision:r/u,avg_recall:c/u,avg_recall_at_1:s/u,avg_recall_at_3:o/u,avg_recall_at_5:f/u,avg_mrr:m/u,avg_f1:_/u,avg_latency_ms:p/u})}return e}i($,"computeSummary");const A=i(a=>{const e={},t=Array.from(new Set(a.map(n=>n.type))).sort();for(const n of t){const r=a.filter(c=>c.type===n);e[n]={};for(const[c,s]of Object.entries($(r)))e[n][c]={...s,count:r.length}}return e},"computeSummaryByType"),F=i(a=>{const e=[["hybrid_r1","hybrid","avg_recall_at_1"],["hybrid_mrr","hybrid","avg_mrr"],["full_r1","full","avg_recall_at_1"],["full_mrr","full","avg_mrr"]],t={hybrid_r1:null,hybrid_mrr:null,full_r1:null,full_mrr:null,n:0};for(const n of w)t.n+=Object.values(a[n]??{})[0]?.count??0;for(const[n,r,c]of e){let s=0,o=0;for(const f of w){const m=a[f]?.[r];m&&(s+=m[c]*m.count,o+=m.count)}t[n]=o>0?s/o:null}return t},"computeHardSummary");function O(a,e){if(e){const n=a.find(r=>r.name===e);if(!n){const r=a.map(s=>s.name),c=r.length>0?`Available: ${r.join(", ")}. Run 'qmd ls' to inspect.`:"Run 'qmd ls' to see available collections.";throw new Error(`Collection not found: ${e}
4
+ ${c}`)}if(n.active_count===0)throw new Error(`Collection '${e}' has no indexed documents.
5
+ Run 'qmd update', then 'qmd ls ${e}' to confirm files are indexed before bench.`);return}const t=a.reduce((n,r)=>n+r.active_count,0);if(a.length===0||t===0)throw new Error(`No indexed documents found.
6
+ Index a collection with 'qmd collection add' / 'qmd update' before running bench.`)}i(O,"assertBenchCollectionReady");function B(a){const e=Object.values(a);return e.length===0?!1:e.every(t=>t.avg_precision===0&&t.avg_recall===0&&t.avg_mrr===0)}i(B,"benchSummaryAllZero");function C(a){return`All benchmark scores were 0.00 \u2014 the collection is likely unindexed or the fixture's expected files are missing. Check with '${a?`qmd ls ${a}`:"qmd ls"}'.`}i(C,"allZeroBenchWarning");async function N(a,e={}){const t=x(k(a),"utf-8"),n=JSON.parse(t);if(!n.queries||!Array.isArray(n.queries))throw new Error("Invalid fixture: missing 'queries' array");if(n.queries.length===0)throw new Error(`Invalid fixture: '${a}' has no queries \u2014 nothing to run.
7
+ Add at least one query; an empty fixture would otherwise report success with no results.`);const r=e.backends?g.filter(u=>e.backends.includes(u.name)):g;if(r.length===0){const u=(e.backends??[]).join(", ")||"(empty backend list)";throw new Error(`Unknown backend(s): ${u}.
8
+ Available: ${g.map(d=>d.name).join(", ")}.`)}const c=await R({dbPath:e.dbPath??q(),...e.configPath?{configPath:e.configPath}:{},...e.config?{config:e.config}:{}}),s=e.collection??n.collection,o=[];try{const u=await c.listCollections();O(u,s);for(const d of n.queries){const l={};for(const h of r)e.json||process.stderr.write(` ${d.id} / ${h.name}...`),l[h.name]=await E(c,h,d,s),e.json||process.stderr.write(` ${Math.round(l[h.name].latency_ms)}ms
9
+ `);o.push({id:d.id,query:d.query,type:d.type,backends:l})}}finally{await c.close()}const f=$(o),m=new Date().toISOString().replace(/[:.]/g,"").slice(0,15),_=A(o),p={timestamp:m,fixture:a,results:o,summary:f,summary_by_type:_,summary_hard:F(_)};if(B(f)&&process.stderr.write(`
10
+ ${C(s)}
11
+ `),e.json)console.log(JSON.stringify(p,null,2));else{console.log(`
12
+ `+j(o)),console.log("Summary:"),console.log("-".repeat(70));const u=i((l,h)=>l.slice(0,h).padEnd(h),"pad"),d=i(l=>l.toFixed(3).padStart(6),"num");for(const[l,h]of Object.entries(f))console.log(` ${u(l,8)} P@k=${d(h.avg_precision)} R@1=${d(h.avg_recall_at_1)} R@3=${d(h.avg_recall_at_3)} R@5=${d(h.avg_recall_at_5)} MRR=${d(h.avg_mrr)} F1=${d(h.avg_f1)} Avg=${Math.round(h.avg_latency_ms)}ms`)}return p}i(N,"runBenchmark");export{C as allZeroBenchWarning,O as assertBenchCollectionReady,B as benchSummaryAllZero,F as computeHardSummary,A as computeSummaryByType,N as runBenchmark};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Scoring functions for the QMD benchmark harness.
3
+ *
4
+ * Computes precision@k, recall, MRR, and F1 for search results
5
+ * against ground-truth expected files.
6
+ */
7
+ /**
8
+ * Normalize a file path for comparison.
9
+ * Strips qmd:// prefix, lowercases, removes leading/trailing slashes.
10
+ */
11
+ export declare function normalizePath(p: string): string;
12
+ /**
13
+ * Check if two paths refer to the same file.
14
+ * Handles different path formats by comparing normalized suffixes.
15
+ */
16
+ export declare function pathsMatch(result: string, expected: string): boolean;
17
+ type ScoreMetrics = {
18
+ precision_at_k: number;
19
+ recall: number;
20
+ recall_at_1: number;
21
+ recall_at_3: number;
22
+ recall_at_5: number;
23
+ mrr: number;
24
+ f1: number;
25
+ hits_at_k: number;
26
+ matched_files: string[];
27
+ unmatched_expected_files: string[];
28
+ };
29
+ /**
30
+ * Score a set of search results against expected files.
31
+ */
32
+ export declare function scoreResults(resultFiles: string[], expectedFiles: string[], topK: number): ScoreMetrics;
33
+ export {};
@@ -0,0 +1 @@
1
+ var M=Object.defineProperty;var a=(n,t)=>M(n,"name",{value:t,configurable:!0});function e(n){if(n.startsWith("qmd://")){const t=n.slice(6),o=t.indexOf("/");n=o>=0?t.slice(o+1):t}return n.toLowerCase().replace(/^\/+|\/+$/g,"")}a(e,"normalizePath");function _(n,t){const o=e(n),h=e(t);return!!(o===h||o.endsWith("/"+h)||h.endsWith("/"+o))}a(_,"pathsMatch");function g(n,t,o){const h=n.slice(0,o);let c=0;for(const s of t)h.some(f=>_(f,s))&&c++;return c}a(g,"hitsWithin");function b(n,t,o){const h=g(n,t,o),c=[],s=[];for(const r of t)n.some(u=>_(u,r))?c.push(r):s.push(r);let f=0;for(let r=0;r<n.length;r++)if(t.some(u=>_(n[r],u))){f=1/(r+1);break}const l=Math.min(o,t.length),i=l>0?h/l:0,m=t.length>0?c.length/t.length:0,W=t.length>0?g(n,t,1)/t.length:0,k=t.length>0?g(n,t,3)/t.length:0,q=t.length>0?g(n,t,5)/t.length:0,w=i+m>0?2*(i*m)/(i+m):0;return{precision_at_k:i,recall:m,recall_at_1:W,recall_at_3:k,recall_at_5:q,mrr:f,f1:w,hits_at_k:h,matched_files:c,unmatched_expected_files:s}}a(b,"scoreResults");export{e as normalizePath,_ as pathsMatch,b as scoreResults};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Types for the QMD benchmark harness.
3
+ *
4
+ * A benchmark fixture defines queries with expected results.
5
+ * The harness runs each query through multiple search backends
6
+ * and measures precision, recall, MRR, and latency.
7
+ */
8
+ export type BenchmarkQueryType = "exact" | "semantic" | "topical" | "cross-domain" | "alias" | "sem-hard" | "multi" | "neg";
9
+ /** Query types pooled into the hard `RESULT-HARD` gate line (bench-ko.sh / dogfood.sh). */
10
+ export declare const HARD_QUERY_TYPES: readonly BenchmarkQueryType[];
11
+ export interface BenchmarkQuery {
12
+ /** Unique identifier for the query */
13
+ id: string;
14
+ /** The search query text */
15
+ query: string;
16
+ /** Query difficulty/type for grouping results */
17
+ type: BenchmarkQueryType;
18
+ /** Human-readable description of what this tests */
19
+ description: string;
20
+ /** File paths (relative to collection) that should appear in results */
21
+ expected_files: string[];
22
+ /** How many of expected_files should appear in top-k results */
23
+ expected_in_top_k?: number;
24
+ /** For `neg` queries: the named-but-excluded concept's path (fixture-test invariant only) */
25
+ excluded?: string;
26
+ }
27
+ export interface BenchmarkFixture {
28
+ /** Description of the benchmark */
29
+ description: string;
30
+ /** Fixture format version */
31
+ version: number;
32
+ /** Optional collection to search within */
33
+ collection?: string;
34
+ /** The test queries */
35
+ queries: BenchmarkQuery[];
36
+ }
37
+ export interface BackendResult {
38
+ /** Fraction of top-k results that are relevant */
39
+ precision_at_k: number;
40
+ /** Fraction of expected files found anywhere in results */
41
+ recall: number;
42
+ /** Fraction of expected files found in the first result */
43
+ recall_at_1: number;
44
+ /** Fraction of expected files found in the top 3 results */
45
+ recall_at_3: number;
46
+ /** Fraction of expected files found in the top 5 results */
47
+ recall_at_5: number;
48
+ /** Reciprocal rank of first relevant result (1/rank, 0 if not found) */
49
+ mrr: number;
50
+ /** Harmonic mean of precision_at_k and recall */
51
+ f1: number;
52
+ /** Number of expected files found in top-k */
53
+ hits_at_k: number;
54
+ /** Total expected files */
55
+ total_expected: number;
56
+ /** Wall-clock latency in milliseconds */
57
+ latency_ms: number;
58
+ /** Top result file paths (for inspection) */
59
+ top_files: string[];
60
+ /** Expected files that were found anywhere in the returned result set */
61
+ matched_files: string[];
62
+ /** Expected files missing from the returned result set */
63
+ unmatched_expected_files: string[];
64
+ }
65
+ export interface QueryResult {
66
+ id: string;
67
+ query: string;
68
+ type: string;
69
+ backends: Record<string, BackendResult>;
70
+ }
71
+ /** Per-backend averages over a group of queries (the shape of summary[backend]). */
72
+ export interface BackendSummary {
73
+ avg_precision: number;
74
+ avg_recall: number;
75
+ avg_recall_at_1: number;
76
+ avg_recall_at_3: number;
77
+ avg_recall_at_5: number;
78
+ avg_mrr: number;
79
+ avg_f1: number;
80
+ avg_latency_ms: number;
81
+ }
82
+ /** Per-backend averages over one query type; count = queries of that type (the same for every backend). */
83
+ export type BackendSummaryByType = Record<string, Record<string, BackendSummary & {
84
+ count: number;
85
+ }>>;
86
+ /** Count-weighted pool over the hard query types; nulls when no hard queries ran (printed as nan). */
87
+ export interface HardSummary {
88
+ hybrid_r1: number | null;
89
+ hybrid_mrr: number | null;
90
+ full_r1: number | null;
91
+ full_mrr: number | null;
92
+ n: number;
93
+ }
94
+ export interface BenchmarkResult {
95
+ timestamp: string;
96
+ fixture: string;
97
+ results: QueryResult[];
98
+ summary: Record<string, BackendSummary>;
99
+ summary_by_type: BackendSummaryByType;
100
+ summary_hard: HardSummary;
101
+ }
@@ -0,0 +1 @@
1
+ const e=["sem-hard","multi","neg"];export{e as HARD_QUERY_TYPES};
@@ -0,0 +1,4 @@
1
+ {
2
+ "commit": "0f8df3f",
3
+ "builtAt": "2026-10-15T04:39:30.671Z"
4
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Process-level exclusive lock for `qmd embed`.
3
+ *
4
+ * Concurrent embed runs against the same index can race on vectors_vec
5
+ * (UNIQUE constraint on hash_seq). This lockfile keeps a second process from
6
+ * starting while another embed holds the lock. Stale files left by crashed
7
+ * processes are recovered via PID identity checks (same spirit as mcp-pid.ts).
8
+ */
9
+ export type EmbedLockHandle = {
10
+ lockPath: string;
11
+ release: () => void;
12
+ };
13
+ /** Lockfile path sibling to the index database. */
14
+ export declare function embedLockPathForDb(dbPath: string): string;
15
+ /** True if `pid` still owns a live embed/qmd process (or is this process). */
16
+ export declare function isLiveEmbedLockHolder(pid: number): boolean;
17
+ /**
18
+ * Try to acquire an exclusive embed lock at `lockPath`.
19
+ * Returns a handle with `release()` on success, or `null` if another live
20
+ * qmd process already holds the lock.
21
+ */
22
+ export declare function tryAcquireEmbedLock(lockPath: string): EmbedLockHandle | null;
23
+ /** User-facing message when a second embed is skipped. */
24
+ export declare const EMBED_LOCK_BUSY_MESSAGE = "Another embed process is already running. Skipping.";
@@ -0,0 +1,2 @@
1
+ var d=Object.defineProperty;var t=(e,r)=>d(e,"name",{value:r,configurable:!0});import{existsSync as l,readFileSync as f,unlinkSync as u,writeFileSync as m}from"node:fs";import{dirname as p,join as a}from"node:path";import{isQmdMcpPid as y}from"./mcp-pid.js";function g(e){return a(p(e),".qmd-embed.lock")}t(g,"embedLockPathForDb");function o(e){try{const r=f(e,"utf-8").trim(),n=parseInt(r,10);return!Number.isInteger(n)||n<=0?null:n}catch{return null}}t(o,"readLockPid");function s(e){return!Number.isInteger(e)||e<=0?!1:e===process.pid?!0:y(e)}t(s,"isLiveEmbedLockHolder");function b(e){m(e,`${process.pid}
2
+ `,{flag:"wx"});let r=!1;return{lockPath:e,release:t(()=>{if(!r){r=!0;try{if(!l(e))return;o(e)===process.pid&&u(e)}catch{}}},"release")}}t(b,"createOwnedLock");function I(e){for(let n=0;n<2;n++)try{return b(e)}catch(i){if((typeof i=="object"&&i!==null&&"code"in i?i.code:void 0)!=="EEXIST")throw i;const c=o(e);if(c!==null&&s(c))return null;try{u(e)}catch{}}const r=o(e);return r!==null&&s(r),null}t(I,"tryAcquireEmbedLock");const A="Another embed process is already running. Skipping.";export{A as EMBED_LOCK_BUSY_MESSAGE,g as embedLockPathForDb,s as isLiveEmbedLockHolder,I as tryAcquireEmbedLock};