@tobilu/qmd 0.9.0 → 1.0.5

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.
@@ -0,0 +1,282 @@
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
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+ import YAML from "yaml";
11
+ // ============================================================================
12
+ // Configuration paths
13
+ // ============================================================================
14
+ // Current index name (default: "index")
15
+ let currentIndexName = "index";
16
+ /**
17
+ * Set the current index name for config file lookup
18
+ * Config file will be ~/.config/qmd/{indexName}.yml
19
+ */
20
+ export function setConfigIndexName(name) {
21
+ currentIndexName = name;
22
+ }
23
+ function getConfigDir() {
24
+ // Allow override via QMD_CONFIG_DIR for testing
25
+ if (process.env.QMD_CONFIG_DIR) {
26
+ return process.env.QMD_CONFIG_DIR;
27
+ }
28
+ return join(homedir(), ".config", "qmd");
29
+ }
30
+ function getConfigFilePath() {
31
+ return join(getConfigDir(), `${currentIndexName}.yml`);
32
+ }
33
+ /**
34
+ * Ensure config directory exists
35
+ */
36
+ function ensureConfigDir() {
37
+ const configDir = getConfigDir();
38
+ if (!existsSync(configDir)) {
39
+ mkdirSync(configDir, { recursive: true });
40
+ }
41
+ }
42
+ // ============================================================================
43
+ // Core functions
44
+ // ============================================================================
45
+ /**
46
+ * Load configuration from ~/.config/qmd/index.yml
47
+ * Returns empty config if file doesn't exist
48
+ */
49
+ export function loadConfig() {
50
+ const configPath = getConfigFilePath();
51
+ if (!existsSync(configPath)) {
52
+ return { collections: {} };
53
+ }
54
+ try {
55
+ const content = readFileSync(configPath, "utf-8");
56
+ const config = YAML.parse(content);
57
+ // Ensure collections object exists
58
+ if (!config.collections) {
59
+ config.collections = {};
60
+ }
61
+ return config;
62
+ }
63
+ catch (error) {
64
+ throw new Error(`Failed to parse ${configPath}: ${error}`);
65
+ }
66
+ }
67
+ /**
68
+ * Save configuration to ~/.config/qmd/index.yml
69
+ */
70
+ export function saveConfig(config) {
71
+ ensureConfigDir();
72
+ const configPath = getConfigFilePath();
73
+ try {
74
+ const yaml = YAML.stringify(config, {
75
+ indent: 2,
76
+ lineWidth: 0, // Don't wrap lines
77
+ });
78
+ writeFileSync(configPath, yaml, "utf-8");
79
+ }
80
+ catch (error) {
81
+ throw new Error(`Failed to write ${configPath}: ${error}`);
82
+ }
83
+ }
84
+ /**
85
+ * Get a specific collection by name
86
+ * Returns null if not found
87
+ */
88
+ export function getCollection(name) {
89
+ const config = loadConfig();
90
+ const collection = config.collections[name];
91
+ if (!collection) {
92
+ return null;
93
+ }
94
+ return { name, ...collection };
95
+ }
96
+ /**
97
+ * List all collections
98
+ */
99
+ export function listCollections() {
100
+ const config = loadConfig();
101
+ return Object.entries(config.collections).map(([name, collection]) => ({
102
+ name,
103
+ ...collection,
104
+ }));
105
+ }
106
+ /**
107
+ * Add or update a collection
108
+ */
109
+ export function addCollection(name, path, pattern = "**/*.md") {
110
+ const config = loadConfig();
111
+ config.collections[name] = {
112
+ path,
113
+ pattern,
114
+ context: config.collections[name]?.context, // Preserve existing context
115
+ };
116
+ saveConfig(config);
117
+ }
118
+ /**
119
+ * Remove a collection
120
+ */
121
+ export function removeCollection(name) {
122
+ const config = loadConfig();
123
+ if (!config.collections[name]) {
124
+ return false;
125
+ }
126
+ delete config.collections[name];
127
+ saveConfig(config);
128
+ return true;
129
+ }
130
+ /**
131
+ * Rename a collection
132
+ */
133
+ export function renameCollection(oldName, newName) {
134
+ const config = loadConfig();
135
+ if (!config.collections[oldName]) {
136
+ return false;
137
+ }
138
+ if (config.collections[newName]) {
139
+ throw new Error(`Collection '${newName}' already exists`);
140
+ }
141
+ config.collections[newName] = config.collections[oldName];
142
+ delete config.collections[oldName];
143
+ saveConfig(config);
144
+ return true;
145
+ }
146
+ // ============================================================================
147
+ // Context management
148
+ // ============================================================================
149
+ /**
150
+ * Get global context
151
+ */
152
+ export function getGlobalContext() {
153
+ const config = loadConfig();
154
+ return config.global_context;
155
+ }
156
+ /**
157
+ * Set global context
158
+ */
159
+ export function setGlobalContext(context) {
160
+ const config = loadConfig();
161
+ config.global_context = context;
162
+ saveConfig(config);
163
+ }
164
+ /**
165
+ * Get all contexts for a collection
166
+ */
167
+ export function getContexts(collectionName) {
168
+ const collection = getCollection(collectionName);
169
+ return collection?.context;
170
+ }
171
+ /**
172
+ * Add or update a context for a specific path in a collection
173
+ */
174
+ export function addContext(collectionName, pathPrefix, contextText) {
175
+ const config = loadConfig();
176
+ const collection = config.collections[collectionName];
177
+ if (!collection) {
178
+ return false;
179
+ }
180
+ if (!collection.context) {
181
+ collection.context = {};
182
+ }
183
+ collection.context[pathPrefix] = contextText;
184
+ saveConfig(config);
185
+ return true;
186
+ }
187
+ /**
188
+ * Remove a context from a collection
189
+ */
190
+ export function removeContext(collectionName, pathPrefix) {
191
+ const config = loadConfig();
192
+ const collection = config.collections[collectionName];
193
+ if (!collection?.context?.[pathPrefix]) {
194
+ return false;
195
+ }
196
+ delete collection.context[pathPrefix];
197
+ // Remove empty context object
198
+ if (Object.keys(collection.context).length === 0) {
199
+ delete collection.context;
200
+ }
201
+ saveConfig(config);
202
+ return true;
203
+ }
204
+ /**
205
+ * List all contexts across all collections
206
+ */
207
+ export function listAllContexts() {
208
+ const config = loadConfig();
209
+ const results = [];
210
+ // Add global context if present
211
+ if (config.global_context) {
212
+ results.push({
213
+ collection: "*",
214
+ path: "/",
215
+ context: config.global_context,
216
+ });
217
+ }
218
+ // Add collection contexts
219
+ for (const [name, collection] of Object.entries(config.collections)) {
220
+ if (collection.context) {
221
+ for (const [path, context] of Object.entries(collection.context)) {
222
+ results.push({
223
+ collection: name,
224
+ path,
225
+ context,
226
+ });
227
+ }
228
+ }
229
+ }
230
+ return results;
231
+ }
232
+ /**
233
+ * Find best matching context for a given collection and path
234
+ * Returns the most specific matching context (longest path prefix match)
235
+ */
236
+ export function findContextForPath(collectionName, filePath) {
237
+ const config = loadConfig();
238
+ const collection = config.collections[collectionName];
239
+ if (!collection?.context) {
240
+ return config.global_context;
241
+ }
242
+ // Find all matching prefixes
243
+ const matches = [];
244
+ for (const [prefix, context] of Object.entries(collection.context)) {
245
+ // Normalize paths for comparison
246
+ const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
247
+ const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
248
+ if (normalizedPath.startsWith(normalizedPrefix)) {
249
+ matches.push({ prefix: normalizedPrefix, context });
250
+ }
251
+ }
252
+ // Return most specific match (longest prefix)
253
+ if (matches.length > 0) {
254
+ matches.sort((a, b) => b.prefix.length - a.prefix.length);
255
+ return matches[0].context;
256
+ }
257
+ // Fallback to global context
258
+ return config.global_context;
259
+ }
260
+ // ============================================================================
261
+ // Utility functions
262
+ // ============================================================================
263
+ /**
264
+ * Get the config file path (useful for error messages)
265
+ */
266
+ export function getConfigPath() {
267
+ return getConfigFilePath();
268
+ }
269
+ /**
270
+ * Check if config file exists
271
+ */
272
+ export function configExists() {
273
+ return existsSync(getConfigFilePath());
274
+ }
275
+ /**
276
+ * Validate a collection name
277
+ * Collection names must be valid and not contain special characters
278
+ */
279
+ export function isValidCollectionName(name) {
280
+ // Allow alphanumeric, hyphens, underscores
281
+ return /^[a-zA-Z0-9_-]+$/.test(name);
282
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * db.ts - Cross-runtime SQLite compatibility layer
3
+ *
4
+ * Provides a unified Database export that works under both Bun (bun:sqlite)
5
+ * and Node.js (better-sqlite3). The APIs are nearly identical — the main
6
+ * difference is the import path.
7
+ */
8
+ export declare const isBun: boolean;
9
+ /**
10
+ * Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
11
+ */
12
+ export declare function openDatabase(path: string): Database;
13
+ /**
14
+ * Common subset of the Database interface used throughout QMD.
15
+ */
16
+ export interface Database {
17
+ exec(sql: string): void;
18
+ prepare(sql: string): Statement;
19
+ loadExtension(path: string): void;
20
+ close(): void;
21
+ }
22
+ export interface Statement {
23
+ run(...params: any[]): {
24
+ changes: number;
25
+ lastInsertRowid: number | bigint;
26
+ };
27
+ get(...params: any[]): any;
28
+ all(...params: any[]): any[];
29
+ }
30
+ /**
31
+ * Load the sqlite-vec extension into a database.
32
+ */
33
+ export declare function loadSqliteVec(db: Database): void;
package/dist/db.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * db.ts - Cross-runtime SQLite compatibility layer
3
+ *
4
+ * Provides a unified Database export that works under both Bun (bun:sqlite)
5
+ * and Node.js (better-sqlite3). The APIs are nearly identical — the main
6
+ * difference is the import path.
7
+ */
8
+ export const isBun = typeof globalThis.Bun !== "undefined";
9
+ let _Database;
10
+ let _sqliteVecLoad;
11
+ if (isBun) {
12
+ // Dynamic string prevents tsc from resolving bun:sqlite on Node.js builds
13
+ const bunSqlite = "bun:" + "sqlite";
14
+ _Database = (await import(/* @vite-ignore */ bunSqlite)).Database;
15
+ const { getLoadablePath } = await import("sqlite-vec");
16
+ _sqliteVecLoad = (db) => db.loadExtension(getLoadablePath());
17
+ }
18
+ else {
19
+ _Database = (await import("better-sqlite3")).default;
20
+ const sqliteVec = await import("sqlite-vec");
21
+ _sqliteVecLoad = (db) => sqliteVec.load(db);
22
+ }
23
+ /**
24
+ * Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
25
+ */
26
+ export function openDatabase(path) {
27
+ return new _Database(path);
28
+ }
29
+ /**
30
+ * Load the sqlite-vec extension into a database.
31
+ */
32
+ export function loadSqliteVec(db) {
33
+ _sqliteVecLoad(db);
34
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * formatter.ts - Output formatting utilities for QMD
3
+ *
4
+ * Provides methods to format search results and documents into various output formats:
5
+ * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
6
+ */
7
+ import type { SearchResult, MultiGetResult, DocumentResult } from "./store.js";
8
+ export type { SearchResult, MultiGetResult, DocumentResult };
9
+ export type MultiGetFile = {
10
+ filepath: string;
11
+ displayPath: string;
12
+ title: string;
13
+ body: string;
14
+ context?: string | null;
15
+ skipped: false;
16
+ } | {
17
+ filepath: string;
18
+ displayPath: string;
19
+ title: string;
20
+ body: string;
21
+ context?: string | null;
22
+ skipped: true;
23
+ skipReason: string;
24
+ };
25
+ export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
26
+ export type FormatOptions = {
27
+ full?: boolean;
28
+ query?: string;
29
+ useColor?: boolean;
30
+ lineNumbers?: boolean;
31
+ };
32
+ /**
33
+ * Add line numbers to text content.
34
+ * Each line becomes: "{lineNum}: {content}"
35
+ * @param text The text to add line numbers to
36
+ * @param startLine Optional starting line number (default: 1)
37
+ */
38
+ export declare function addLineNumbers(text: string, startLine?: number): string;
39
+ /**
40
+ * Extract short docid from a full hash (first 6 characters).
41
+ */
42
+ export declare function getDocid(hash: string): string;
43
+ export declare function escapeCSV(value: string | null | number): string;
44
+ export declare function escapeXml(str: string): string;
45
+ /**
46
+ * Format search results as JSON
47
+ */
48
+ export declare function searchResultsToJson(results: SearchResult[], opts?: FormatOptions): string;
49
+ /**
50
+ * Format search results as CSV
51
+ */
52
+ export declare function searchResultsToCsv(results: SearchResult[], opts?: FormatOptions): string;
53
+ /**
54
+ * Format search results as simple files list (docid,score,filepath,context)
55
+ */
56
+ export declare function searchResultsToFiles(results: SearchResult[]): string;
57
+ /**
58
+ * Format search results as Markdown
59
+ */
60
+ export declare function searchResultsToMarkdown(results: SearchResult[], opts?: FormatOptions): string;
61
+ /**
62
+ * Format search results as XML
63
+ */
64
+ export declare function searchResultsToXml(results: SearchResult[], opts?: FormatOptions): string;
65
+ /**
66
+ * Format search results for MCP (simpler CSV format with pre-extracted snippets)
67
+ */
68
+ export declare function searchResultsToMcpCsv(results: {
69
+ docid: string;
70
+ file: string;
71
+ title: string;
72
+ score: number;
73
+ context: string | null;
74
+ snippet: string;
75
+ }[]): string;
76
+ /**
77
+ * Format documents as JSON
78
+ */
79
+ export declare function documentsToJson(results: MultiGetFile[]): string;
80
+ /**
81
+ * Format documents as CSV
82
+ */
83
+ export declare function documentsToCsv(results: MultiGetFile[]): string;
84
+ /**
85
+ * Format documents as files list
86
+ */
87
+ export declare function documentsToFiles(results: MultiGetFile[]): string;
88
+ /**
89
+ * Format documents as Markdown
90
+ */
91
+ export declare function documentsToMarkdown(results: MultiGetFile[]): string;
92
+ /**
93
+ * Format documents as XML
94
+ */
95
+ export declare function documentsToXml(results: MultiGetFile[]): string;
96
+ /**
97
+ * Format a single DocumentResult as JSON
98
+ */
99
+ export declare function documentToJson(doc: DocumentResult): string;
100
+ /**
101
+ * Format a single DocumentResult as Markdown
102
+ */
103
+ export declare function documentToMarkdown(doc: DocumentResult): string;
104
+ /**
105
+ * Format a single DocumentResult as XML
106
+ */
107
+ export declare function documentToXml(doc: DocumentResult): string;
108
+ /**
109
+ * Format a single document to the specified format
110
+ */
111
+ export declare function formatDocument(doc: DocumentResult, format: OutputFormat): string;
112
+ /**
113
+ * Format search results to the specified output format
114
+ */
115
+ export declare function formatSearchResults(results: SearchResult[], format: OutputFormat, opts?: FormatOptions): string;
116
+ /**
117
+ * Format documents to the specified output format
118
+ */
119
+ export declare function formatDocuments(results: MultiGetFile[], format: OutputFormat): string;