@wei840222/qmd 2026.8.23

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 (94) hide show
  1. package/CHANGELOG.md +1373 -0
  2. package/LICENSE +45 -0
  3. package/README.md +1439 -0
  4. package/THIRD_PARTY_NOTICES.md +31 -0
  5. package/bin/qmd +192 -0
  6. package/dist/ast.d.ts +65 -0
  7. package/dist/ast.js +334 -0
  8. package/dist/bench/bench.d.ts +35 -0
  9. package/dist/bench/bench.js +338 -0
  10. package/dist/bench/cjk-baseline.d.ts +36 -0
  11. package/dist/bench/cjk-baseline.js +111 -0
  12. package/dist/bench/fixture.d.ts +2 -0
  13. package/dist/bench/fixture.js +84 -0
  14. package/dist/bench/score.d.ts +38 -0
  15. package/dist/bench/score.js +107 -0
  16. package/dist/bench/types.d.ts +110 -0
  17. package/dist/bench/types.js +8 -0
  18. package/dist/cli/build-info.json +4 -0
  19. package/dist/cli/embed-lock.d.ts +24 -0
  20. package/dist/cli/embed-lock.js +94 -0
  21. package/dist/cli/embedding-owner.d.ts +10 -0
  22. package/dist/cli/embedding-owner.js +20 -0
  23. package/dist/cli/formatter.d.ts +120 -0
  24. package/dist/cli/formatter.js +355 -0
  25. package/dist/cli/mcp-pid.d.ts +25 -0
  26. package/dist/cli/mcp-pid.js +86 -0
  27. package/dist/cli/qmd.d.ts +72 -0
  28. package/dist/cli/qmd.js +4806 -0
  29. package/dist/cli/version.d.ts +42 -0
  30. package/dist/cli/version.js +80 -0
  31. package/dist/collections.d.ts +200 -0
  32. package/dist/collections.js +433 -0
  33. package/dist/db.d.ts +65 -0
  34. package/dist/db.js +143 -0
  35. package/dist/diagnostics.d.ts +62 -0
  36. package/dist/diagnostics.js +260 -0
  37. package/dist/embedding/config.d.ts +52 -0
  38. package/dist/embedding/config.js +229 -0
  39. package/dist/embedding/identity.d.ts +58 -0
  40. package/dist/embedding/identity.js +321 -0
  41. package/dist/embedding/local-identity.d.ts +1 -0
  42. package/dist/embedding/local-identity.js +15 -0
  43. package/dist/embedding/local.d.ts +34 -0
  44. package/dist/embedding/local.js +290 -0
  45. package/dist/embedding/openai.d.ts +79 -0
  46. package/dist/embedding/openai.js +477 -0
  47. package/dist/embedding/owner.d.ts +13 -0
  48. package/dist/embedding/owner.js +36 -0
  49. package/dist/embedding/provider.d.ts +68 -0
  50. package/dist/embedding/provider.js +16 -0
  51. package/dist/embedding/remote-chunking.d.ts +22 -0
  52. package/dist/embedding/remote-chunking.js +83 -0
  53. package/dist/embedding/remote-embedding.d.ts +15 -0
  54. package/dist/embedding/remote-embedding.js +77 -0
  55. package/dist/hybrid-llm.d.ts +18 -0
  56. package/dist/hybrid-llm.js +53 -0
  57. package/dist/index.d.ts +244 -0
  58. package/dist/index.js +418 -0
  59. package/dist/llm.d.ts +566 -0
  60. package/dist/llm.js +1847 -0
  61. package/dist/maintenance.d.ts +33 -0
  62. package/dist/maintenance.js +52 -0
  63. package/dist/mcp/origin-guard.d.ts +67 -0
  64. package/dist/mcp/origin-guard.js +137 -0
  65. package/dist/mcp/server.d.ts +116 -0
  66. package/dist/mcp/server.js +919 -0
  67. package/dist/paths.d.ts +1 -0
  68. package/dist/paths.js +4 -0
  69. package/dist/remote-llm.d.ts +52 -0
  70. package/dist/remote-llm.js +464 -0
  71. package/dist/search/cjk-analyzer.d.ts +33 -0
  72. package/dist/search/cjk-analyzer.js +158 -0
  73. package/dist/search/cjk-index.d.ts +104 -0
  74. package/dist/search/cjk-index.js +1031 -0
  75. package/dist/search/jieba-loader.d.ts +23 -0
  76. package/dist/search/jieba-loader.js +79 -0
  77. package/dist/search/query-expansion.d.ts +23 -0
  78. package/dist/search/query-expansion.js +43 -0
  79. package/dist/search/zh-dict.txt +624013 -0
  80. package/dist/store.d.ts +1218 -0
  81. package/dist/store.js +6076 -0
  82. package/dist/trust.d.ts +152 -0
  83. package/dist/trust.js +249 -0
  84. package/package.json +139 -0
  85. package/scripts/build.mjs +83 -0
  86. package/scripts/check-package-grammars.mjs +29 -0
  87. package/scripts/package-smoke.mjs +205 -0
  88. package/scripts/sync-zh-dict.mjs +187 -0
  89. package/scripts/test-all.mjs +45 -0
  90. package/skills/qmd/SKILL.md +324 -0
  91. package/skills/qmd/references/mcp-setup.md +119 -0
  92. package/skills/release/SKILL.md +141 -0
  93. package/skills/release/scripts/install-hooks.sh +38 -0
  94. package/skills/release/scripts/release-context.sh +129 -0
@@ -0,0 +1,433 @@
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 { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "fs";
8
+ import { randomUUID } from "node:crypto";
9
+ import { basename, join, dirname, resolve } from "path";
10
+ import { qmdHomedir } from "./paths.js";
11
+ import YAML from "yaml";
12
+ // ============================================================================
13
+ // Configuration paths
14
+ // ============================================================================
15
+ // Current index name (default: "index")
16
+ let currentIndexName = "index";
17
+ export function createCollectionConfigSource(source) {
18
+ if (source?.config) {
19
+ source.config.collections ??= {};
20
+ return { type: "inline", config: source.config };
21
+ }
22
+ return { type: "file", path: source?.configPath };
23
+ }
24
+ let configSource = createCollectionConfigSource();
25
+ let configWriteFaultInjector;
26
+ /** @internal Test-only fault injection for crash-safety verification. */
27
+ export function setConfigWriteFaultInjectorForTests(injector) {
28
+ configWriteFaultInjector = injector;
29
+ }
30
+ /**
31
+ * Set the config source for SDK mode.
32
+ * - File path: load/save from a specific YAML file
33
+ * - Inline config: use an in-memory CollectionConfig (saveConfig updates in place, no file I/O)
34
+ * - undefined: reset to default file-based config
35
+ */
36
+ export function setConfigSource(source) {
37
+ configSource = createCollectionConfigSource(source);
38
+ }
39
+ /**
40
+ * Set the current index name for config file lookup
41
+ * Config file will be ~/.config/qmd/{indexName}.yml
42
+ */
43
+ export function setConfigIndexName(name) {
44
+ // Resolve relative paths to absolute paths and sanitize for use as filename
45
+ if (name.includes('/')) {
46
+ const absolutePath = resolve(process.cwd(), name);
47
+ // Replace path separators with underscores to create a valid filename
48
+ currentIndexName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
49
+ }
50
+ else {
51
+ currentIndexName = name;
52
+ }
53
+ }
54
+ export function getConfigDir() {
55
+ // Allow override via QMD_CONFIG_DIR for testing
56
+ if (process.env.QMD_CONFIG_DIR) {
57
+ return process.env.QMD_CONFIG_DIR;
58
+ }
59
+ // Respect XDG Base Directory specification (consistent with store.ts)
60
+ if (process.env.XDG_CONFIG_HOME) {
61
+ return join(process.env.XDG_CONFIG_HOME, "qmd");
62
+ }
63
+ return join(qmdHomedir(), ".config", "qmd");
64
+ }
65
+ function getConfigFilePath() {
66
+ return join(getConfigDir(), `${currentIndexName}.yml`);
67
+ }
68
+ /**
69
+ * Find a project-local QMD config by walking upward from startDir.
70
+ * The local config lives at .qmd/index.yaml or .qmd/index.yml and,
71
+ * when used by the CLI, keeps both config and index DB writes inside
72
+ * the project instead of the global ~/.config / ~/.cache locations.
73
+ */
74
+ export function findLocalConfigPath(startDir = process.cwd()) {
75
+ let dir = resolve(startDir);
76
+ while (true) {
77
+ const qmdDir = join(dir, ".qmd");
78
+ const yamlPath = join(qmdDir, "index.yaml");
79
+ if (existsSync(yamlPath))
80
+ return yamlPath;
81
+ const ymlPath = join(qmdDir, "index.yml");
82
+ if (existsSync(ymlPath))
83
+ return ymlPath;
84
+ const parent = dirname(dir);
85
+ if (parent === dir)
86
+ return undefined;
87
+ dir = parent;
88
+ }
89
+ }
90
+ /** Return the local SQLite index path paired with a local .qmd/index.yaml file. */
91
+ export function getLocalDbPath(configPath) {
92
+ return join(dirname(configPath), "index.sqlite");
93
+ }
94
+ /**
95
+ * Ensure config directory exists
96
+ */
97
+ function ensureConfigDir() {
98
+ const configDir = getConfigDir();
99
+ if (!existsSync(configDir)) {
100
+ mkdirSync(configDir, { recursive: true });
101
+ }
102
+ }
103
+ // ============================================================================
104
+ // Core functions
105
+ // ============================================================================
106
+ /**
107
+ * Load configuration from the configured source.
108
+ * - Inline config: returns the in-memory object directly
109
+ * - File-based: reads from YAML file (default ~/.config/qmd/index.yml)
110
+ * Returns empty config if file doesn't exist
111
+ */
112
+ export function loadConfig(source = configSource) {
113
+ // SDK inline config mode
114
+ if (source.type === 'inline') {
115
+ return source.config;
116
+ }
117
+ // File-based config (SDK custom path or default)
118
+ const configPath = source.path || getConfigFilePath();
119
+ if (!existsSync(configPath)) {
120
+ return { collections: {} };
121
+ }
122
+ try {
123
+ const content = readFileSync(configPath, "utf-8");
124
+ const parsed = YAML.parse(content);
125
+ const config = parsed ?? { collections: {} };
126
+ // Ensure collections object exists
127
+ if (!config.collections) {
128
+ config.collections = {};
129
+ }
130
+ return config;
131
+ }
132
+ catch (error) {
133
+ throw new Error(`Failed to parse ${configPath}: ${error}`);
134
+ }
135
+ }
136
+ /**
137
+ * Save configuration to the configured source.
138
+ * - Inline config: updates the in-memory object (no file I/O)
139
+ * - File-based: writes to YAML file (default ~/.config/qmd/index.yml)
140
+ */
141
+ export function saveConfig(config, source = configSource) {
142
+ // SDK inline config mode: update in place, no file I/O
143
+ if (source.type === 'inline') {
144
+ source.config = config;
145
+ return;
146
+ }
147
+ const configPath = source.path || getConfigFilePath();
148
+ const configDir = dirname(configPath);
149
+ if (!existsSync(configDir)) {
150
+ mkdirSync(configDir, { recursive: true });
151
+ }
152
+ try {
153
+ const yaml = YAML.stringify(config, {
154
+ indent: 2,
155
+ lineWidth: 0, // Don't wrap lines
156
+ });
157
+ const temporaryPath = join(configDir, `.${basename(configPath)}.${process.pid}.${randomUUID()}.tmp`);
158
+ let temporaryFd;
159
+ let renamed = false;
160
+ try {
161
+ temporaryFd = openSync(temporaryPath, "wx", 0o600);
162
+ configWriteFaultInjector?.("before-temp-write");
163
+ writeFileSync(temporaryFd, yaml, "utf8");
164
+ fsyncSync(temporaryFd);
165
+ closeSync(temporaryFd);
166
+ temporaryFd = undefined;
167
+ configWriteFaultInjector?.("before-rename");
168
+ renameSync(temporaryPath, configPath);
169
+ renamed = true;
170
+ const directoryFd = openSync(configDir, "r");
171
+ try {
172
+ fsyncSync(directoryFd);
173
+ }
174
+ finally {
175
+ closeSync(directoryFd);
176
+ }
177
+ }
178
+ finally {
179
+ if (temporaryFd !== undefined)
180
+ closeSync(temporaryFd);
181
+ if (!renamed && existsSync(temporaryPath))
182
+ unlinkSync(temporaryPath);
183
+ }
184
+ }
185
+ catch (error) {
186
+ throw new Error(`Failed to write ${configPath}: ${error}`);
187
+ }
188
+ }
189
+ /**
190
+ * Get a specific collection by name
191
+ * Returns null if not found
192
+ */
193
+ export function getCollection(name) {
194
+ const config = loadConfig();
195
+ const collection = config.collections[name];
196
+ if (!collection) {
197
+ return null;
198
+ }
199
+ return { name, ...collection };
200
+ }
201
+ /**
202
+ * List all collections
203
+ */
204
+ export function listCollections() {
205
+ const config = loadConfig();
206
+ return Object.entries(config.collections).map(([name, collection]) => ({
207
+ name,
208
+ ...collection,
209
+ }));
210
+ }
211
+ /**
212
+ * Get collections that are included by default in queries
213
+ */
214
+ export function getDefaultCollections() {
215
+ return listCollections().filter(c => c.includeByDefault !== false);
216
+ }
217
+ /**
218
+ * Get collection names that are included by default
219
+ */
220
+ export function getDefaultCollectionNames() {
221
+ return getDefaultCollections().map(c => c.name);
222
+ }
223
+ /**
224
+ * Update a collection's settings
225
+ */
226
+ export function updateCollectionSettings(name, settings) {
227
+ const config = loadConfig();
228
+ const collection = config.collections[name];
229
+ if (!collection)
230
+ return false;
231
+ if (settings.update !== undefined) {
232
+ if (settings.update === null) {
233
+ delete collection.update;
234
+ }
235
+ else {
236
+ collection.update = settings.update;
237
+ }
238
+ }
239
+ if (settings.includeByDefault !== undefined) {
240
+ if (settings.includeByDefault === true) {
241
+ // true is default, remove the field
242
+ delete collection.includeByDefault;
243
+ }
244
+ else {
245
+ collection.includeByDefault = settings.includeByDefault;
246
+ }
247
+ }
248
+ saveConfig(config);
249
+ return true;
250
+ }
251
+ /**
252
+ * Add or update a collection
253
+ */
254
+ export function addCollection(name, path, pattern = "**/*.md", ignore, source = configSource) {
255
+ const config = loadConfig(source);
256
+ config.collections[name] = {
257
+ path,
258
+ pattern,
259
+ ignore: ignore ?? config.collections[name]?.ignore,
260
+ context: config.collections[name]?.context, // Preserve existing context
261
+ };
262
+ saveConfig(config, source);
263
+ }
264
+ /**
265
+ * Remove a collection
266
+ */
267
+ export function removeCollection(name, source = configSource) {
268
+ const config = loadConfig(source);
269
+ if (!config.collections[name]) {
270
+ return false;
271
+ }
272
+ delete config.collections[name];
273
+ saveConfig(config, source);
274
+ return true;
275
+ }
276
+ /**
277
+ * Rename a collection
278
+ */
279
+ export function renameCollection(oldName, newName, source = configSource) {
280
+ const config = loadConfig(source);
281
+ if (!config.collections[oldName]) {
282
+ return false;
283
+ }
284
+ if (config.collections[newName]) {
285
+ throw new Error(`Collection '${newName}' already exists`);
286
+ }
287
+ config.collections[newName] = config.collections[oldName];
288
+ delete config.collections[oldName];
289
+ saveConfig(config, source);
290
+ return true;
291
+ }
292
+ // ============================================================================
293
+ // Context management
294
+ // ============================================================================
295
+ /**
296
+ * Get global context
297
+ */
298
+ export function getGlobalContext() {
299
+ const config = loadConfig();
300
+ return config.global_context;
301
+ }
302
+ /**
303
+ * Set global context
304
+ */
305
+ export function setGlobalContext(context, source = configSource) {
306
+ const config = loadConfig(source);
307
+ config.global_context = context;
308
+ saveConfig(config, source);
309
+ }
310
+ /**
311
+ * Get all contexts for a collection
312
+ */
313
+ export function getContexts(collectionName) {
314
+ const collection = getCollection(collectionName);
315
+ return collection?.context;
316
+ }
317
+ /**
318
+ * Add or update a context for a specific path in a collection
319
+ */
320
+ export function addContext(collectionName, pathPrefix, contextText, source = configSource) {
321
+ const config = loadConfig(source);
322
+ const collection = config.collections[collectionName];
323
+ if (!collection) {
324
+ return false;
325
+ }
326
+ if (!collection.context) {
327
+ collection.context = {};
328
+ }
329
+ collection.context[pathPrefix] = contextText;
330
+ saveConfig(config, source);
331
+ return true;
332
+ }
333
+ /**
334
+ * Remove a context from a collection
335
+ */
336
+ export function removeContext(collectionName, pathPrefix, source = configSource) {
337
+ const config = loadConfig(source);
338
+ const collection = config.collections[collectionName];
339
+ if (!collection?.context?.[pathPrefix]) {
340
+ return false;
341
+ }
342
+ delete collection.context[pathPrefix];
343
+ // Remove empty context object
344
+ if (Object.keys(collection.context).length === 0) {
345
+ delete collection.context;
346
+ }
347
+ saveConfig(config, source);
348
+ return true;
349
+ }
350
+ /**
351
+ * List all contexts across all collections
352
+ */
353
+ export function listAllContexts() {
354
+ const config = loadConfig();
355
+ const results = [];
356
+ // Add global context if present
357
+ if (config.global_context) {
358
+ results.push({
359
+ collection: "*",
360
+ path: "/",
361
+ context: config.global_context,
362
+ });
363
+ }
364
+ // Add collection contexts
365
+ for (const [name, collection] of Object.entries(config.collections)) {
366
+ if (collection.context) {
367
+ for (const [path, context] of Object.entries(collection.context)) {
368
+ results.push({
369
+ collection: name,
370
+ path,
371
+ context,
372
+ });
373
+ }
374
+ }
375
+ }
376
+ return results;
377
+ }
378
+ /**
379
+ * Find best matching context for a given collection and path
380
+ * Returns the most specific matching context (longest path prefix match)
381
+ */
382
+ export function findContextForPath(collectionName, filePath) {
383
+ const config = loadConfig();
384
+ const collection = config.collections[collectionName];
385
+ if (!collection?.context) {
386
+ return config.global_context;
387
+ }
388
+ // Find all matching prefixes
389
+ const matches = [];
390
+ for (const [prefix, context] of Object.entries(collection.context)) {
391
+ // Normalize paths for comparison
392
+ const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
393
+ const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
394
+ if (normalizedPath.startsWith(normalizedPrefix)) {
395
+ matches.push({ prefix: normalizedPrefix, context });
396
+ }
397
+ }
398
+ // Return most specific match (longest prefix)
399
+ if (matches.length > 0) {
400
+ matches.sort((a, b) => b.prefix.length - a.prefix.length);
401
+ return matches[0].context;
402
+ }
403
+ // Fallback to global context
404
+ return config.global_context;
405
+ }
406
+ // ============================================================================
407
+ // Utility functions
408
+ // ============================================================================
409
+ /**
410
+ * Get the config file path (useful for error messages)
411
+ */
412
+ export function getConfigPath() {
413
+ if (configSource.type === 'inline')
414
+ return '<inline>';
415
+ return configSource.path || getConfigFilePath();
416
+ }
417
+ /**
418
+ * Check if config file exists
419
+ */
420
+ export function configExists() {
421
+ if (configSource.type === 'inline')
422
+ return true;
423
+ const path = configSource.path || getConfigFilePath();
424
+ return existsSync(path);
425
+ }
426
+ /**
427
+ * Validate a collection name
428
+ * Collection names must be valid and not contain special characters
429
+ */
430
+ export function isValidCollectionName(name) {
431
+ // Allow alphanumeric, hyphens, underscores
432
+ return /^[a-zA-Z0-9_-]+$/.test(name);
433
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,65 @@
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
+ * 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.
12
+ */
13
+ export declare const isBun: boolean;
14
+ export type SQLiteValue = string | number | bigint | Buffer | Uint8Array | Float32Array | null;
15
+ export type SQLiteParams = readonly SQLiteValue[];
16
+ /**
17
+ * Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
18
+ *
19
+ * `bun:sqlite` and `better-sqlite3` both default `busy_timeout` to 0, so
20
+ * concurrent writers throw `SQLITE_BUSY` instead of waiting. WAL improves
21
+ * read-while-write concurrency but does not serialise writers. Setting the
22
+ * timeout at connection open makes parallel processes (e.g. an `update` or
23
+ * `query` racing a long `embed`, or a first-open schema migration racing any
24
+ * routine command) queue at batch boundaries instead of failing on contact.
25
+ *
26
+ * WAL is enabled here too (with a bounded retry) so connection-level pragmas
27
+ * live in one place and the cold-database journal migration survives concurrent
28
+ * opens.
29
+ *
30
+ * Default 120_000 ms outlasts the worst-case batch commit on a multi-GB
31
+ * index. Override with `QMD_SQLITE_BUSY_TIMEOUT` (value in milliseconds; `0`
32
+ * restores the upstream fail-fast behaviour). See
33
+ * https://bun.sh/docs/api/sqlite#busy-timeout.
34
+ */
35
+ export declare function openDatabase(path: string): Database;
36
+ /** Open an existing database without changing journal mode, schema, or user data. */
37
+ export declare function openReadOnlyDatabase(path: string): Database;
38
+ /**
39
+ * Common subset of the Database interface used throughout QMD.
40
+ */
41
+ export interface Database {
42
+ exec(sql: string): void;
43
+ prepare(sql: string): Statement;
44
+ loadExtension(path: string): void;
45
+ transaction<T extends (...args: SQLiteValue[]) => unknown>(fn: T): T & {
46
+ immediate: T;
47
+ };
48
+ close(): void;
49
+ }
50
+ export interface Statement {
51
+ run(...params: SQLiteValue[]): {
52
+ changes: number;
53
+ lastInsertRowid: number | bigint;
54
+ };
55
+ get<T = unknown>(...params: SQLiteValue[]): T | undefined;
56
+ all<T = unknown>(...params: SQLiteValue[]): T[];
57
+ iterate<T = unknown>(...params: SQLiteValue[]): IterableIterator<T>;
58
+ }
59
+ /**
60
+ * Load the sqlite-vec extension into a database.
61
+ *
62
+ * Throws with platform-specific fix instructions when the extension is
63
+ * unavailable.
64
+ */
65
+ export declare function loadSqliteVec(db: Database): void;
package/dist/db.js ADDED
@@ -0,0 +1,143 @@
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
+ * 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.
12
+ */
13
+ export const isBun = "Bun" in globalThis;
14
+ let _Database;
15
+ let _sqliteVecLoad;
16
+ if (isBun) {
17
+ // Dynamic string prevents tsc from resolving bun:sqlite on Node.js builds
18
+ const bunSqlite = "bun:" + "sqlite";
19
+ const BunDatabase = (await import(/* @vite-ignore */ bunSqlite)).Database;
20
+ // See: https://bun.com/docs/runtime/sqlite#setcustomsqlite
21
+ if (process.platform === "darwin") {
22
+ const homebrewPaths = [
23
+ "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", // Apple Silicon
24
+ "/usr/local/opt/sqlite/lib/libsqlite3.dylib", // Intel
25
+ ];
26
+ for (const p of homebrewPaths) {
27
+ try {
28
+ BunDatabase.setCustomSQLite(p);
29
+ break;
30
+ }
31
+ catch { }
32
+ }
33
+ }
34
+ _Database = BunDatabase;
35
+ // setCustomSQLite may have silently failed — test that extensions actually work.
36
+ try {
37
+ const { getLoadablePath } = await import("sqlite-vec");
38
+ const vecPath = getLoadablePath();
39
+ const testDb = new BunDatabase(":memory:");
40
+ testDb.loadExtension(vecPath);
41
+ testDb.close();
42
+ _sqliteVecLoad = (db) => db.loadExtension(vecPath);
43
+ }
44
+ catch {
45
+ // Vector search won't work, but BM25 and other operations are unaffected.
46
+ _sqliteVecLoad = null;
47
+ }
48
+ }
49
+ else {
50
+ // Dual-runtime: better-sqlite3 matches Database at runtime; published types do not share an interface with bun:sqlite.
51
+ _Database = (await import("better-sqlite3")).default;
52
+ const sqliteVec = await import("sqlite-vec");
53
+ _sqliteVecLoad = (db) => sqliteVec.load(db);
54
+ }
55
+ function isBusyError(err) {
56
+ if (typeof err !== "object" || err === null)
57
+ return false;
58
+ const code = err.code;
59
+ if (code === "SQLITE_BUSY" || code === "SQLITE_BUSY_SNAPSHOT")
60
+ return true;
61
+ const message = err.message;
62
+ return typeof message === "string" && /database is locked|database is busy|SQLITE_BUSY/i.test(message);
63
+ }
64
+ function sleepSync(ms) {
65
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
66
+ }
67
+ /**
68
+ * Switch a connection to WAL, retrying on `SQLITE_BUSY` within the busy-timeout
69
+ * budget. Unlike ordinary writes, migrating the journal needs a brief exclusive
70
+ * lock and does NOT invoke the busy handler, so concurrent first-time opens of a
71
+ * cold database throw "database is locked" even with `busy_timeout` set. Once the
72
+ * database is already WAL the pragma is a cheap no-op that does not contend.
73
+ */
74
+ function enableWal(db, budgetMs) {
75
+ const deadline = Date.now() + Math.max(budgetMs, 0);
76
+ for (let attempt = 0;; attempt++) {
77
+ try {
78
+ db.exec("PRAGMA journal_mode = WAL");
79
+ return;
80
+ }
81
+ catch (err) {
82
+ if (!isBusyError(err) || Date.now() >= deadline)
83
+ throw err;
84
+ sleepSync(Math.min(5 + attempt, 25));
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
90
+ *
91
+ * `bun:sqlite` and `better-sqlite3` both default `busy_timeout` to 0, so
92
+ * concurrent writers throw `SQLITE_BUSY` instead of waiting. WAL improves
93
+ * read-while-write concurrency but does not serialise writers. Setting the
94
+ * timeout at connection open makes parallel processes (e.g. an `update` or
95
+ * `query` racing a long `embed`, or a first-open schema migration racing any
96
+ * routine command) queue at batch boundaries instead of failing on contact.
97
+ *
98
+ * WAL is enabled here too (with a bounded retry) so connection-level pragmas
99
+ * live in one place and the cold-database journal migration survives concurrent
100
+ * opens.
101
+ *
102
+ * Default 120_000 ms outlasts the worst-case batch commit on a multi-GB
103
+ * index. Override with `QMD_SQLITE_BUSY_TIMEOUT` (value in milliseconds; `0`
104
+ * restores the upstream fail-fast behaviour). See
105
+ * https://bun.sh/docs/api/sqlite#busy-timeout.
106
+ */
107
+ export function openDatabase(path) {
108
+ const db = new _Database(path);
109
+ const raw = process.env.QMD_SQLITE_BUSY_TIMEOUT;
110
+ const parsed = raw !== undefined && raw !== "" ? Number(raw) : Number.NaN;
111
+ const busyTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 120_000;
112
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
113
+ enableWal(db, busyTimeoutMs);
114
+ return db;
115
+ }
116
+ /** Open an existing database without changing journal mode, schema, or user data. */
117
+ export function openReadOnlyDatabase(path) {
118
+ const options = isBun
119
+ ? { readonly: true, create: false }
120
+ : { readonly: true, fileMustExist: true };
121
+ const db = new _Database(path, options);
122
+ const raw = process.env.QMD_SQLITE_BUSY_TIMEOUT;
123
+ const parsed = raw !== undefined && raw !== "" ? Number(raw) : Number.NaN;
124
+ const busyTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 120_000;
125
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
126
+ return db;
127
+ }
128
+ /**
129
+ * Load the sqlite-vec extension into a database.
130
+ *
131
+ * Throws with platform-specific fix instructions when the extension is
132
+ * unavailable.
133
+ */
134
+ export function loadSqliteVec(db) {
135
+ if (!_sqliteVecLoad) {
136
+ const hint = isBun && process.platform === "darwin"
137
+ ? "On macOS with Bun, install Homebrew SQLite: brew install sqlite\n" +
138
+ "Or install qmd with npm instead: npm install -g @wei840222/qmd"
139
+ : "Ensure the sqlite-vec native module is installed correctly.";
140
+ throw new Error(`sqlite-vec extension is unavailable. ${hint}`);
141
+ }
142
+ _sqliteVecLoad(db);
143
+ }
@@ -0,0 +1,62 @@
1
+ import type { Database } from "./db.js";
2
+ import type { EmbeddingProvider } from "./embedding/provider.js";
3
+ export type DiagnosticReadiness = "missing" | "ready" | "stale" | "partial" | "inconsistent" | "incompatible";
4
+ export interface EmbeddingDiagnostics {
5
+ provider: {
6
+ id: string | null;
7
+ remote: boolean;
8
+ model: string;
9
+ dimension: number | null;
10
+ keyConfigured: boolean;
11
+ };
12
+ identity: {
13
+ shortFingerprint: string | null;
14
+ fullFingerprint: string | null;
15
+ storedShortFingerprint: string | null;
16
+ storedFullFingerprint: string | null;
17
+ compatible: boolean;
18
+ };
19
+ build: {
20
+ state: "missing" | "empty" | "building" | "ready" | "partial" | "incompatible";
21
+ generation: number | null;
22
+ leaseExpiresAt: number | null;
23
+ };
24
+ chunks: {
25
+ pendingDocuments: number;
26
+ metadataOnly: number;
27
+ vectorOnly: number;
28
+ incompleteLayouts: number;
29
+ };
30
+ repairCommand: string | null;
31
+ }
32
+ export interface LexicalDiagnostics {
33
+ jiebaCapability: "unknown" | "available" | "unavailable";
34
+ analyzerFingerprint: string;
35
+ storedAnalyzerFingerprint: string | null;
36
+ state: "missing" | "empty" | "building" | "ready" | "unavailable" | "dirty";
37
+ channels: {
38
+ char: DiagnosticReadiness;
39
+ word: DiagnosticReadiness;
40
+ bigram: DiagnosticReadiness;
41
+ };
42
+ dirtySinceMutationSeq: number | null;
43
+ rebuildReason: string | null;
44
+ repairCommand: string | null;
45
+ }
46
+ export interface IndexDiagnostics {
47
+ embedding: EmbeddingDiagnostics;
48
+ lexical: LexicalDiagnostics;
49
+ }
50
+ export interface ConfiguredEmbeddingProviderDiagnostics {
51
+ id: string;
52
+ remote: boolean;
53
+ model: string;
54
+ dimension: number | null;
55
+ }
56
+ export declare function inspectIndexDiagnostics(db: Database, options: {
57
+ fallbackModel: string;
58
+ provider?: EmbeddingProvider;
59
+ keyConfigured?: boolean;
60
+ configuredProvider?: ConfiguredEmbeddingProviderDiagnostics;
61
+ }): IndexDiagnostics;
62
+ export declare function diagnosticsSnapshotHash(diagnostics: IndexDiagnostics): string;