@tobilu/qmd 0.9.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/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@tobilu/qmd",
3
+ "version": "0.9.0",
4
+ "description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
5
+ "type": "module",
6
+ "bin": {
7
+ "qmd": "./qmd"
8
+ },
9
+ "files": [
10
+ "src/**/*.ts",
11
+ "!src/**/*.test.ts",
12
+ "!src/test-preload.ts",
13
+ "qmd",
14
+ "LICENSE",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "bun test --preload ./src/test-preload.ts",
19
+ "qmd": "bun src/qmd.ts",
20
+ "index": "bun src/qmd.ts index",
21
+ "vector": "bun src/qmd.ts vector",
22
+ "search": "bun src/qmd.ts search",
23
+ "vsearch": "bun src/qmd.ts vsearch",
24
+ "rerank": "bun src/qmd.ts rerank",
25
+ "link": "bun link",
26
+ "inspector": "npx @modelcontextprotocol/inspector bun src/qmd.ts mcp",
27
+ "release": "./scripts/release.sh"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/tobi/qmd.git"
35
+ },
36
+ "homepage": "https://github.com/tobi/qmd#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/tobi/qmd/issues"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.25.1",
42
+ "node-llama-cpp": "^3.14.5",
43
+ "sqlite-vec": "^0.1.7-alpha.2",
44
+ "yaml": "^2.8.2",
45
+ "zod": "^4.2.1"
46
+ },
47
+ "optionalDependencies": {
48
+ "sqlite-vec-darwin-arm64": "^0.1.7-alpha.2",
49
+ "sqlite-vec-darwin-x64": "^0.1.7-alpha.2",
50
+ "sqlite-vec-linux-x64": "^0.1.7-alpha.2",
51
+ "sqlite-vec-win32-x64": "^0.1.7-alpha.2"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "latest"
55
+ },
56
+ "peerDependencies": {
57
+ "typescript": "^5.9.3"
58
+ },
59
+ "engines": {
60
+ "bun": ">=1.0.0"
61
+ },
62
+ "keywords": [
63
+ "markdown",
64
+ "search",
65
+ "fts",
66
+ "full-text-search",
67
+ "vector",
68
+ "semantic-search",
69
+ "sqlite",
70
+ "bm25",
71
+ "embeddings",
72
+ "rag",
73
+ "mcp",
74
+ "reranking",
75
+ "knowledge-base",
76
+ "local-ai",
77
+ "llm"
78
+ ],
79
+ "license": "MIT"
80
+ }
package/qmd ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # qmd - Quick Markdown Search
3
+ set -euo pipefail
4
+
5
+ # Find bun - prefer PATH, fallback to known locations
6
+ find_bun() {
7
+ # First: check if bun is in PATH and modern enough
8
+ if command -v bun &>/dev/null; then
9
+ local ver=$(bun --version 2>/dev/null || echo "0")
10
+ if [[ "$ver" =~ ^1\. ]]; then
11
+ command -v bun
12
+ return 0
13
+ fi
14
+ fi
15
+
16
+ # Fallback: derive paths (need HOME)
17
+ : "${HOME:=$(eval echo ~)}"
18
+
19
+ # If running from .bun tree, use that bun
20
+ if [[ "${BASH_SOURCE[0]}" == */.bun/* ]]; then
21
+ local bun_home="${BASH_SOURCE[0]%%/.bun/*}/.bun"
22
+ if [[ -x "$bun_home/bin/bun" ]]; then
23
+ echo "$bun_home/bin/bun"
24
+ return 0
25
+ fi
26
+ fi
27
+
28
+ # Check known locations
29
+ local candidates=(
30
+ "$HOME/.local/share/mise/installs/bun/latest/bin/bun"
31
+ "$HOME/.local/share/mise/shims/bun"
32
+ "$HOME/.asdf/shims/bun"
33
+ "/opt/homebrew/bin/bun"
34
+ "/usr/local/bin/bun"
35
+ "$HOME/.bun/bin/bun"
36
+ )
37
+ for c in "${candidates[@]}"; do
38
+ [[ -x "$c" ]] && { echo "$c"; return 0; }
39
+ done
40
+
41
+ return 1
42
+ }
43
+
44
+ BUN=$(find_bun) || { echo "Error: bun not found. Install from https://bun.sh" >&2; exit 1; }
45
+
46
+ # Resolve symlinks to find script location
47
+ SOURCE="${BASH_SOURCE[0]}"
48
+ while [[ -L "$SOURCE" ]]; do
49
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
50
+ SOURCE="$(readlink "$SOURCE")"
51
+ [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
52
+ done
53
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
54
+
55
+ exec "$BUN" "$SCRIPT_DIR/src/qmd.ts" "$@"
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Collections configuration management
3
+ *
4
+ * This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml.
5
+ * Collections define which directories to index and their associated contexts.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
+ import { join } from "path";
10
+ import { homedir } from "os";
11
+ import YAML from "yaml";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Context definitions for a collection
19
+ * Key is path prefix (e.g., "/", "/2024", "/Board of Directors")
20
+ * Value is the context description
21
+ */
22
+ export type ContextMap = Record<string, string>;
23
+
24
+ /**
25
+ * A single collection configuration
26
+ */
27
+ export interface Collection {
28
+ path: string; // Absolute path to index
29
+ pattern: string; // Glob pattern (e.g., "**/*.md")
30
+ context?: ContextMap; // Optional context definitions
31
+ update?: string; // Optional bash command to run during qmd update
32
+ }
33
+
34
+ /**
35
+ * The complete configuration file structure
36
+ */
37
+ export interface CollectionConfig {
38
+ global_context?: string; // Context applied to all collections
39
+ collections: Record<string, Collection>; // Collection name -> config
40
+ }
41
+
42
+ /**
43
+ * Collection with its name (for return values)
44
+ */
45
+ export interface NamedCollection extends Collection {
46
+ name: string;
47
+ }
48
+
49
+ // ============================================================================
50
+ // Configuration paths
51
+ // ============================================================================
52
+
53
+ // Current index name (default: "index")
54
+ let currentIndexName: string = "index";
55
+
56
+ /**
57
+ * Set the current index name for config file lookup
58
+ * Config file will be ~/.config/qmd/{indexName}.yml
59
+ */
60
+ export function setConfigIndexName(name: string): void {
61
+ currentIndexName = name;
62
+ }
63
+
64
+ function getConfigDir(): string {
65
+ // Allow override via QMD_CONFIG_DIR for testing
66
+ if (process.env.QMD_CONFIG_DIR) {
67
+ return process.env.QMD_CONFIG_DIR;
68
+ }
69
+ return join(homedir(), ".config", "qmd");
70
+ }
71
+
72
+ function getConfigFilePath(): string {
73
+ return join(getConfigDir(), `${currentIndexName}.yml`);
74
+ }
75
+
76
+ /**
77
+ * Ensure config directory exists
78
+ */
79
+ function ensureConfigDir(): void {
80
+ const configDir = getConfigDir();
81
+ if (!existsSync(configDir)) {
82
+ mkdirSync(configDir, { recursive: true });
83
+ }
84
+ }
85
+
86
+ // ============================================================================
87
+ // Core functions
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Load configuration from ~/.config/qmd/index.yml
92
+ * Returns empty config if file doesn't exist
93
+ */
94
+ export function loadConfig(): CollectionConfig {
95
+ const configPath = getConfigFilePath();
96
+ if (!existsSync(configPath)) {
97
+ return { collections: {} };
98
+ }
99
+
100
+ try {
101
+ const content = readFileSync(configPath, "utf-8");
102
+ const config = YAML.parse(content) as CollectionConfig;
103
+
104
+ // Ensure collections object exists
105
+ if (!config.collections) {
106
+ config.collections = {};
107
+ }
108
+
109
+ return config;
110
+ } catch (error) {
111
+ throw new Error(`Failed to parse ${configPath}: ${error}`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Save configuration to ~/.config/qmd/index.yml
117
+ */
118
+ export function saveConfig(config: CollectionConfig): void {
119
+ ensureConfigDir();
120
+ const configPath = getConfigFilePath();
121
+
122
+ try {
123
+ const yaml = YAML.stringify(config, {
124
+ indent: 2,
125
+ lineWidth: 0, // Don't wrap lines
126
+ });
127
+ writeFileSync(configPath, yaml, "utf-8");
128
+ } catch (error) {
129
+ throw new Error(`Failed to write ${configPath}: ${error}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Get a specific collection by name
135
+ * Returns null if not found
136
+ */
137
+ export function getCollection(name: string): NamedCollection | null {
138
+ const config = loadConfig();
139
+ const collection = config.collections[name];
140
+
141
+ if (!collection) {
142
+ return null;
143
+ }
144
+
145
+ return { name, ...collection };
146
+ }
147
+
148
+ /**
149
+ * List all collections
150
+ */
151
+ export function listCollections(): NamedCollection[] {
152
+ const config = loadConfig();
153
+ return Object.entries(config.collections).map(([name, collection]) => ({
154
+ name,
155
+ ...collection,
156
+ }));
157
+ }
158
+
159
+ /**
160
+ * Add or update a collection
161
+ */
162
+ export function addCollection(
163
+ name: string,
164
+ path: string,
165
+ pattern: string = "**/*.md"
166
+ ): void {
167
+ const config = loadConfig();
168
+
169
+ config.collections[name] = {
170
+ path,
171
+ pattern,
172
+ context: config.collections[name]?.context, // Preserve existing context
173
+ };
174
+
175
+ saveConfig(config);
176
+ }
177
+
178
+ /**
179
+ * Remove a collection
180
+ */
181
+ export function removeCollection(name: string): boolean {
182
+ const config = loadConfig();
183
+
184
+ if (!config.collections[name]) {
185
+ return false;
186
+ }
187
+
188
+ delete config.collections[name];
189
+ saveConfig(config);
190
+ return true;
191
+ }
192
+
193
+ /**
194
+ * Rename a collection
195
+ */
196
+ export function renameCollection(oldName: string, newName: string): boolean {
197
+ const config = loadConfig();
198
+
199
+ if (!config.collections[oldName]) {
200
+ return false;
201
+ }
202
+
203
+ if (config.collections[newName]) {
204
+ throw new Error(`Collection '${newName}' already exists`);
205
+ }
206
+
207
+ config.collections[newName] = config.collections[oldName];
208
+ delete config.collections[oldName];
209
+ saveConfig(config);
210
+ return true;
211
+ }
212
+
213
+ // ============================================================================
214
+ // Context management
215
+ // ============================================================================
216
+
217
+ /**
218
+ * Get global context
219
+ */
220
+ export function getGlobalContext(): string | undefined {
221
+ const config = loadConfig();
222
+ return config.global_context;
223
+ }
224
+
225
+ /**
226
+ * Set global context
227
+ */
228
+ export function setGlobalContext(context: string | undefined): void {
229
+ const config = loadConfig();
230
+ config.global_context = context;
231
+ saveConfig(config);
232
+ }
233
+
234
+ /**
235
+ * Get all contexts for a collection
236
+ */
237
+ export function getContexts(collectionName: string): ContextMap | undefined {
238
+ const collection = getCollection(collectionName);
239
+ return collection?.context;
240
+ }
241
+
242
+ /**
243
+ * Add or update a context for a specific path in a collection
244
+ */
245
+ export function addContext(
246
+ collectionName: string,
247
+ pathPrefix: string,
248
+ contextText: string
249
+ ): boolean {
250
+ const config = loadConfig();
251
+ const collection = config.collections[collectionName];
252
+
253
+ if (!collection) {
254
+ return false;
255
+ }
256
+
257
+ if (!collection.context) {
258
+ collection.context = {};
259
+ }
260
+
261
+ collection.context[pathPrefix] = contextText;
262
+ saveConfig(config);
263
+ return true;
264
+ }
265
+
266
+ /**
267
+ * Remove a context from a collection
268
+ */
269
+ export function removeContext(
270
+ collectionName: string,
271
+ pathPrefix: string
272
+ ): boolean {
273
+ const config = loadConfig();
274
+ const collection = config.collections[collectionName];
275
+
276
+ if (!collection?.context?.[pathPrefix]) {
277
+ return false;
278
+ }
279
+
280
+ delete collection.context[pathPrefix];
281
+
282
+ // Remove empty context object
283
+ if (Object.keys(collection.context).length === 0) {
284
+ delete collection.context;
285
+ }
286
+
287
+ saveConfig(config);
288
+ return true;
289
+ }
290
+
291
+ /**
292
+ * List all contexts across all collections
293
+ */
294
+ export function listAllContexts(): Array<{
295
+ collection: string;
296
+ path: string;
297
+ context: string;
298
+ }> {
299
+ const config = loadConfig();
300
+ const results: Array<{ collection: string; path: string; context: string }> = [];
301
+
302
+ // Add global context if present
303
+ if (config.global_context) {
304
+ results.push({
305
+ collection: "*",
306
+ path: "/",
307
+ context: config.global_context,
308
+ });
309
+ }
310
+
311
+ // Add collection contexts
312
+ for (const [name, collection] of Object.entries(config.collections)) {
313
+ if (collection.context) {
314
+ for (const [path, context] of Object.entries(collection.context)) {
315
+ results.push({
316
+ collection: name,
317
+ path,
318
+ context,
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ return results;
325
+ }
326
+
327
+ /**
328
+ * Find best matching context for a given collection and path
329
+ * Returns the most specific matching context (longest path prefix match)
330
+ */
331
+ export function findContextForPath(
332
+ collectionName: string,
333
+ filePath: string
334
+ ): string | undefined {
335
+ const config = loadConfig();
336
+ const collection = config.collections[collectionName];
337
+
338
+ if (!collection?.context) {
339
+ return config.global_context;
340
+ }
341
+
342
+ // Find all matching prefixes
343
+ const matches: Array<{ prefix: string; context: string }> = [];
344
+
345
+ for (const [prefix, context] of Object.entries(collection.context)) {
346
+ // Normalize paths for comparison
347
+ const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
348
+ const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
349
+
350
+ if (normalizedPath.startsWith(normalizedPrefix)) {
351
+ matches.push({ prefix: normalizedPrefix, context });
352
+ }
353
+ }
354
+
355
+ // Return most specific match (longest prefix)
356
+ if (matches.length > 0) {
357
+ matches.sort((a, b) => b.prefix.length - a.prefix.length);
358
+ return matches[0]!.context;
359
+ }
360
+
361
+ // Fallback to global context
362
+ return config.global_context;
363
+ }
364
+
365
+ // ============================================================================
366
+ // Utility functions
367
+ // ============================================================================
368
+
369
+ /**
370
+ * Get the config file path (useful for error messages)
371
+ */
372
+ export function getConfigPath(): string {
373
+ return getConfigFilePath();
374
+ }
375
+
376
+ /**
377
+ * Check if config file exists
378
+ */
379
+ export function configExists(): boolean {
380
+ return existsSync(getConfigFilePath());
381
+ }
382
+
383
+ /**
384
+ * Validate a collection name
385
+ * Collection names must be valid and not contain special characters
386
+ */
387
+ export function isValidCollectionName(name: string): boolean {
388
+ // Allow alphanumeric, hyphens, underscores
389
+ return /^[a-zA-Z0-9_-]+$/.test(name);
390
+ }