@tobilu/qmd 1.1.5 → 2.0.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.
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { openDatabase } from "./db.js";
2
+ import { openDatabase } from "../db.js";
3
3
  import fastGlob from "fast-glob";
4
4
  import { execSync, spawn as nodeSpawn } from "child_process";
5
5
  import { fileURLToPath } from "url";
6
6
  import { dirname, join as pathJoin } from "path";
7
7
  import { parseArgs } from "util";
8
8
  import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs";
9
- import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, getHashesForEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, structuredSearch, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, } from "./store.js";
10
- import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "./llm.js";
9
+ import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, getHashesForEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, structuredSearch, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, reindexCollection, generateEmbeddings, syncConfigToDb, } from "../store.js";
10
+ import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "../llm.js";
11
11
  import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
12
- import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, setGlobalContext, listAllContexts, setConfigIndexName, } from "./collections.js";
12
+ import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, removeCollection as yamlRemoveCollectionFn, renameCollection as yamlRenameCollectionFn, setGlobalContext, listAllContexts, setConfigIndexName, loadConfig, } from "../collections.js";
13
13
  // Enable production mode - allows using default database path
14
14
  // Tests must set INDEX_PATH or use createStore() with explicit path
15
15
  enableProductionMode();
@@ -21,12 +21,33 @@ let storeDbPathOverride;
21
21
  function getStore() {
22
22
  if (!store) {
23
23
  store = createStore(storeDbPathOverride);
24
+ // Sync YAML config into SQLite store_collections so store.ts reads from DB
25
+ try {
26
+ const config = loadConfig();
27
+ syncConfigToDb(store.db, config);
28
+ }
29
+ catch {
30
+ // Config may not exist yet — that's fine, DB works without it
31
+ }
24
32
  }
25
33
  return store;
26
34
  }
27
35
  function getDb() {
28
36
  return getStore().db;
29
37
  }
38
+ /** Re-sync YAML config into SQLite after CLI mutations (add/remove/rename collection, context changes) */
39
+ function resyncConfig() {
40
+ const s = getStore();
41
+ try {
42
+ const config = loadConfig();
43
+ // Clear config hash to force re-sync
44
+ s.db.prepare(`DELETE FROM store_config WHERE key = 'config_hash'`).run();
45
+ syncConfigToDb(s.db, config);
46
+ }
47
+ catch {
48
+ // Config may not exist — that's fine
49
+ }
50
+ }
30
51
  function closeDb() {
31
52
  if (store) {
32
53
  store.close();
@@ -354,6 +375,7 @@ async function showStatus() {
354
375
  }
355
376
  async function updateCollections() {
356
377
  const db = getDb();
378
+ const storeInstance = getStore();
357
379
  // Collections are defined in YAML; no duplicate cleanup needed.
358
380
  // Clear Ollama cache on update
359
381
  clearCache(db);
@@ -363,7 +385,6 @@ async function updateCollections() {
363
385
  closeDb();
364
386
  return;
365
387
  }
366
- // Don't close db here - indexFiles will reuse it and close at the end
367
388
  console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
368
389
  for (let i = 0; i < collections.length; i++) {
369
390
  const col = collections[i];
@@ -403,12 +424,30 @@ async function updateCollections() {
403
424
  process.exit(1);
404
425
  }
405
426
  }
406
- await indexFiles(col.pwd, col.glob_pattern, col.name, true, yamlCol?.ignore);
427
+ const startTime = Date.now();
428
+ console.log(`Collection: ${col.pwd} (${col.glob_pattern})`);
429
+ progress.indeterminate();
430
+ const result = await reindexCollection(storeInstance, col.pwd, col.glob_pattern, col.name, {
431
+ ignorePatterns: yamlCol?.ignore,
432
+ onProgress: (info) => {
433
+ progress.set((info.current / info.total) * 100);
434
+ const elapsed = (Date.now() - startTime) / 1000;
435
+ const rate = info.current / elapsed;
436
+ const remaining = (info.total - info.current) / rate;
437
+ const eta = info.current > 2 ? ` ETA: ${formatETA(remaining)}` : "";
438
+ if (isTTY)
439
+ process.stderr.write(`\rIndexing: ${info.current}/${info.total}${eta} `);
440
+ },
441
+ });
442
+ progress.clear();
443
+ console.log(`\nIndexed: ${result.indexed} new, ${result.updated} updated, ${result.unchanged} unchanged, ${result.removed} removed`);
444
+ if (result.orphanedCleaned > 0) {
445
+ console.log(`Cleaned up ${result.orphanedCleaned} orphaned content hash(es)`);
446
+ }
407
447
  console.log("");
408
448
  }
409
449
  // Check if any documents need embedding (show once at end)
410
- const finalDb = getDb();
411
- const needsEmbedding = getHashesNeedingEmbedding(finalDb);
450
+ const needsEmbedding = getHashesNeedingEmbedding(db);
412
451
  closeDb();
413
452
  console.log(`${c.green}✓ All collections updated.${c.reset}`);
414
453
  if (needsEmbedding > 0) {
@@ -452,6 +491,7 @@ async function contextAdd(pathArg, contextText) {
452
491
  // Handle "/" as global context (applies to all collections)
453
492
  if (pathArg === '/') {
454
493
  setGlobalContext(contextText);
494
+ resyncConfig();
455
495
  console.log(`${c.green}✓${c.reset} Set global context`);
456
496
  console.log(`${c.dim}Context: ${contextText}${c.reset}`);
457
497
  closeDb();
@@ -481,6 +521,7 @@ async function contextAdd(pathArg, contextText) {
481
521
  process.exit(1);
482
522
  }
483
523
  yamlAddContext(parsed.collectionName, parsed.path, contextText);
524
+ resyncConfig();
484
525
  const displayPath = parsed.path
485
526
  ? `qmd://${parsed.collectionName}/${parsed.path}`
486
527
  : `qmd://${parsed.collectionName}/ (collection root)`;
@@ -497,6 +538,7 @@ async function contextAdd(pathArg, contextText) {
497
538
  process.exit(1);
498
539
  }
499
540
  yamlAddContext(detected.collectionName, detected.relativePath, contextText);
541
+ resyncConfig();
500
542
  const displayPath = detected.relativePath ? `qmd://${detected.collectionName}/${detected.relativePath}` : `qmd://${detected.collectionName}/`;
501
543
  console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
502
544
  console.log(`${c.dim}Context: ${contextText}${c.reset}`);
@@ -527,6 +569,10 @@ function contextRemove(pathArg) {
527
569
  if (pathArg === '/') {
528
570
  // Remove global context
529
571
  setGlobalContext(undefined);
572
+ // Resync so SQLite store_config is updated
573
+ const s = getStore();
574
+ resyncConfig();
575
+ closeDb();
530
576
  console.log(`${c.green}✓${c.reset} Removed global context`);
531
577
  return;
532
578
  }
@@ -1141,9 +1187,10 @@ async function collectionAdd(pwd, globPattern, name) {
1141
1187
  console.error(`\nUse 'qmd update' to re-index it, or remove it first with 'qmd collection remove ${existingPwdGlob.name}'`);
1142
1188
  process.exit(1);
1143
1189
  }
1144
- // Add to YAML config
1145
- const { addCollection } = await import("./collections.js");
1190
+ // Add to YAML config + sync to SQLite
1191
+ const { addCollection } = await import("../collections.js");
1146
1192
  addCollection(collName, pwd, globPattern);
1193
+ resyncConfig();
1147
1194
  // Create the collection and index files
1148
1195
  console.log(`Creating collection '${collName}'...`);
1149
1196
  const newColl = getCollectionFromYaml(collName);
@@ -1160,6 +1207,8 @@ function collectionRemove(name) {
1160
1207
  }
1161
1208
  const db = getDb();
1162
1209
  const result = removeCollection(db, name);
1210
+ // Also remove from YAML config
1211
+ yamlRemoveCollectionFn(name);
1163
1212
  closeDb();
1164
1213
  console.log(`${c.green}✓${c.reset} Removed collection '${name}'`);
1165
1214
  console.log(` Deleted ${result.deletedDocs} documents`);
@@ -1184,6 +1233,8 @@ function collectionRename(oldName, newName) {
1184
1233
  }
1185
1234
  const db = getDb();
1186
1235
  renameCollection(db, oldName, newName);
1236
+ // Also rename in YAML config
1237
+ yamlRenameCollectionFn(oldName, newName);
1187
1238
  closeDb();
1188
1239
  console.log(`${c.green}✓${c.reset} Renamed collection '${oldName}' to '${newName}'`);
1189
1240
  console.log(` Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`);
@@ -1315,150 +1366,56 @@ function renderProgressBar(percent, width = 30) {
1315
1366
  return bar;
1316
1367
  }
1317
1368
  async function vectorIndex(model = DEFAULT_EMBED_MODEL, force = false) {
1318
- const db = getDb();
1319
- const now = new Date().toISOString();
1320
- // If force, clear all vectors
1369
+ const storeInstance = getStore();
1370
+ const db = storeInstance.db;
1321
1371
  if (force) {
1322
1372
  console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
1323
- clearAllEmbeddings(db);
1324
1373
  }
1325
- // Find unique hashes that need embedding (from active documents)
1374
+ // Check if there's work to do before starting
1326
1375
  const hashesToEmbed = getHashesForEmbedding(db);
1327
- if (hashesToEmbed.length === 0) {
1376
+ if (hashesToEmbed.length === 0 && !force) {
1328
1377
  console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
1329
1378
  closeDb();
1330
1379
  return;
1331
1380
  }
1332
- const allChunks = [];
1333
- let multiChunkDocs = 0;
1334
- // Chunk all documents using actual token counts
1335
- process.stderr.write(`Chunking ${hashesToEmbed.length} documents by token count...\n`);
1336
- for (const item of hashesToEmbed) {
1337
- const encoder = new TextEncoder();
1338
- const bodyBytes = encoder.encode(item.body).length;
1339
- if (bodyBytes === 0)
1340
- continue; // Skip empty
1341
- const title = extractTitle(item.body, item.path);
1342
- const displayName = item.path;
1343
- const chunks = await chunkDocumentByTokens(item.body); // Uses actual tokenizer
1344
- if (chunks.length > 1)
1345
- multiChunkDocs++;
1346
- for (let seq = 0; seq < chunks.length; seq++) {
1347
- allChunks.push({
1348
- hash: item.hash,
1349
- title,
1350
- text: chunks[seq].text, // Chunk is guaranteed to exist by seq loop
1351
- seq,
1352
- pos: chunks[seq].pos,
1353
- tokens: chunks[seq].tokens,
1354
- bytes: encoder.encode(chunks[seq].text).length,
1355
- displayName,
1356
- });
1357
- }
1358
- }
1359
- if (allChunks.length === 0) {
1360
- console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
1361
- closeDb();
1362
- return;
1363
- }
1364
- const totalBytes = allChunks.reduce((sum, chk) => sum + chk.bytes, 0);
1365
- const totalChunks = allChunks.length;
1366
- const totalDocs = hashesToEmbed.length;
1367
- console.log(`${c.bold}Embedding ${totalDocs} documents${c.reset} ${c.dim}(${totalChunks} chunks, ${formatBytes(totalBytes)})${c.reset}`);
1368
- if (multiChunkDocs > 0) {
1369
- console.log(`${c.dim}${multiChunkDocs} documents split into multiple chunks${c.reset}`);
1370
- }
1371
1381
  console.log(`${c.dim}Model: ${model}${c.reset}\n`);
1372
- // Hide cursor during embedding
1373
1382
  cursor.hide();
1374
- // Wrap all LLM embedding operations in a session for lifecycle management
1375
- // Use 30 minute timeout for large collections
1376
- await withLLMSession(async (session) => {
1377
- // Get embedding dimensions from first chunk
1378
- progress.indeterminate();
1379
- const firstChunk = allChunks[0];
1380
- if (!firstChunk) {
1381
- throw new Error("No chunks available to embed");
1382
- }
1383
- const firstText = formatDocForEmbedding(firstChunk.text, firstChunk.title);
1384
- const firstResult = await session.embed(firstText);
1385
- if (!firstResult) {
1386
- throw new Error("Failed to get embedding dimensions from first chunk");
1387
- }
1388
- ensureVecTable(db, firstResult.embedding.length);
1389
- let chunksEmbedded = 0, errors = 0, bytesProcessed = 0;
1390
- const startTime = Date.now();
1391
- // Batch embedding for better throughput
1392
- // Process in batches of 32 to balance memory usage and efficiency
1393
- const BATCH_SIZE = 32;
1394
- for (let batchStart = 0; batchStart < allChunks.length; batchStart += BATCH_SIZE) {
1395
- const batchEnd = Math.min(batchStart + BATCH_SIZE, allChunks.length);
1396
- const batch = allChunks.slice(batchStart, batchEnd);
1397
- // Format texts for embedding
1398
- const texts = batch.map(chunk => formatDocForEmbedding(chunk.text, chunk.title));
1399
- try {
1400
- // Batch embed all texts at once
1401
- const embeddings = await session.embedBatch(texts);
1402
- // Insert each embedding
1403
- for (let i = 0; i < batch.length; i++) {
1404
- const chunk = batch[i];
1405
- const embedding = embeddings[i];
1406
- if (embedding) {
1407
- insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(embedding.embedding), model, now);
1408
- chunksEmbedded++;
1409
- }
1410
- else {
1411
- errors++;
1412
- console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}${c.reset}`);
1413
- }
1414
- bytesProcessed += chunk.bytes;
1415
- }
1416
- }
1417
- catch (err) {
1418
- // If batch fails, try individual embeddings as fallback
1419
- for (const chunk of batch) {
1420
- try {
1421
- const text = formatDocForEmbedding(chunk.text, chunk.title);
1422
- const result = await session.embed(text);
1423
- if (result) {
1424
- insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(result.embedding), model, now);
1425
- chunksEmbedded++;
1426
- }
1427
- else {
1428
- errors++;
1429
- }
1430
- }
1431
- catch (innerErr) {
1432
- errors++;
1433
- console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${innerErr}${c.reset}`);
1434
- }
1435
- bytesProcessed += chunk.bytes;
1436
- }
1437
- }
1438
- const percent = (bytesProcessed / totalBytes) * 100;
1383
+ progress.indeterminate();
1384
+ const startTime = Date.now();
1385
+ const result = await generateEmbeddings(storeInstance, {
1386
+ force,
1387
+ model,
1388
+ onProgress: (info) => {
1389
+ if (info.totalBytes === 0)
1390
+ return;
1391
+ const percent = (info.bytesProcessed / info.totalBytes) * 100;
1439
1392
  progress.set(percent);
1440
1393
  const elapsed = (Date.now() - startTime) / 1000;
1441
- const bytesPerSec = bytesProcessed / elapsed;
1442
- const remainingBytes = totalBytes - bytesProcessed;
1394
+ const bytesPerSec = info.bytesProcessed / elapsed;
1395
+ const remainingBytes = info.totalBytes - info.bytesProcessed;
1443
1396
  const etaSec = remainingBytes / bytesPerSec;
1444
1397
  const bar = renderProgressBar(percent);
1445
1398
  const percentStr = percent.toFixed(0).padStart(3);
1446
1399
  const throughput = `${formatBytes(bytesPerSec)}/s`;
1447
1400
  const eta = elapsed > 2 ? formatETA(etaSec) : "...";
1448
- const errStr = errors > 0 ? ` ${c.yellow}${errors} err${c.reset}` : "";
1401
+ const errStr = info.errors > 0 ? ` ${c.yellow}${info.errors} err${c.reset}` : "";
1449
1402
  if (isTTY)
1450
- process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${chunksEmbedded}/${totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset} `);
1451
- }
1452
- progress.clear();
1453
- cursor.show();
1454
- const totalTimeSec = (Date.now() - startTime) / 1000;
1455
- const avgThroughput = formatBytes(totalBytes / totalTimeSec);
1403
+ process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${info.chunksEmbedded}/${info.totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset} `);
1404
+ },
1405
+ });
1406
+ progress.clear();
1407
+ cursor.show();
1408
+ const totalTimeSec = result.durationMs / 1000;
1409
+ if (result.chunksEmbedded === 0 && result.docsProcessed === 0) {
1410
+ console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
1411
+ }
1412
+ else {
1456
1413
  console.log(`\r${c.green}${renderProgressBar(100)}${c.reset} ${c.bold}100%${c.reset} `);
1457
- console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${chunksEmbedded}${c.reset} chunks from ${c.bold}${totalDocs}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset} ${c.dim}(${avgThroughput}/s)${c.reset}`);
1458
- if (errors > 0) {
1459
- console.log(`${c.yellow}⚠ ${errors} chunks failed${c.reset}`);
1414
+ console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${result.chunksEmbedded}${c.reset} chunks from ${c.bold}${result.docsProcessed}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset}`);
1415
+ if (result.errors > 0) {
1416
+ console.log(`${c.yellow}⚠ ${result.errors} chunks failed${c.reset}`);
1460
1417
  }
1461
- }, { maxDuration: 30 * 60 * 1000, name: 'embed-command' });
1418
+ }
1462
1419
  closeDb();
1463
1420
  }
1464
1421
  // Sanitize a term for FTS5: remove punctuation except apostrophes
@@ -1820,7 +1777,7 @@ function logExpansionTree(originalQuery, expanded) {
1820
1777
  const lines = [];
1821
1778
  lines.push(`${c.dim}├─ ${originalQuery}${c.reset}`);
1822
1779
  for (const q of expanded) {
1823
- let preview = q.text.replace(/\n/g, ' ');
1780
+ let preview = q.query.replace(/\n/g, ' ');
1824
1781
  if (preview.length > 72)
1825
1782
  preview = preview.substring(0, 69) + '...';
1826
1783
  lines.push(`${c.dim}├─ ${q.type}: ${preview}${c.reset}`);
@@ -2093,7 +2050,7 @@ function parseCLI() {
2093
2050
  function showSkill() {
2094
2051
  const scriptDir = dirname(fileURLToPath(import.meta.url));
2095
2052
  const relativePath = pathJoin("skills", "qmd", "SKILL.md");
2096
- const skillPath = pathJoin(scriptDir, "..", relativePath);
2053
+ const skillPath = pathJoin(scriptDir, "..", "..", relativePath);
2097
2054
  console.log(`QMD Skill (${relativePath})`);
2098
2055
  console.log(`Location: ${skillPath}`);
2099
2056
  console.log("");
@@ -2192,7 +2149,7 @@ function showHelp() {
2192
2149
  }
2193
2150
  async function showVersion() {
2194
2151
  const scriptDir = dirname(fileURLToPath(import.meta.url));
2195
- const pkgPath = resolve(scriptDir, "..", "package.json");
2152
+ const pkgPath = resolve(scriptDir, "..", "..", "package.json");
2196
2153
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
2197
2154
  let commit = "";
2198
2155
  try {
@@ -2365,7 +2322,7 @@ if (isMain) {
2365
2322
  console.error(" Omit command to clear it");
2366
2323
  process.exit(1);
2367
2324
  }
2368
- const { updateCollectionSettings, getCollection } = await import("./collections.js");
2325
+ const { updateCollectionSettings, getCollection } = await import("../collections.js");
2369
2326
  const col = getCollection(name);
2370
2327
  if (!col) {
2371
2328
  console.error(`Collection not found: ${name}`);
@@ -2388,7 +2345,7 @@ if (isMain) {
2388
2345
  console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`);
2389
2346
  process.exit(1);
2390
2347
  }
2391
- const { updateCollectionSettings, getCollection } = await import("./collections.js");
2348
+ const { updateCollectionSettings, getCollection } = await import("../collections.js");
2392
2349
  const col = getCollection(name);
2393
2350
  if (!col) {
2394
2351
  console.error(`Collection not found: ${name}`);
@@ -2406,7 +2363,7 @@ if (isMain) {
2406
2363
  console.error("Usage: qmd collection show <name>");
2407
2364
  process.exit(1);
2408
2365
  }
2409
- const { getCollection } = await import("./collections.js");
2366
+ const { getCollection } = await import("../collections.js");
2410
2367
  const col = getCollection(name);
2411
2368
  if (!col) {
2412
2369
  console.error(`Collection not found: ${name}`);
@@ -2553,7 +2510,7 @@ if (isMain) {
2553
2510
  const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run
2554
2511
  const selfPath = fileURLToPath(import.meta.url);
2555
2512
  const spawnArgs = selfPath.endsWith(".ts")
2556
- ? ["--import", pathJoin(dirname(selfPath), "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
2513
+ ? ["--import", pathJoin(dirname(selfPath), "..", "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
2557
2514
  : [selfPath, "mcp", "--http", "--port", String(port)];
2558
2515
  const child = nodeSpawn(process.execPath, spawnArgs, {
2559
2516
  stdio: ["ignore", logFd, logFd],
@@ -2570,7 +2527,7 @@ if (isMain) {
2570
2527
  // async cleanup handlers in startMcpHttpServer actually run.
2571
2528
  process.removeAllListeners("SIGTERM");
2572
2529
  process.removeAllListeners("SIGINT");
2573
- const { startMcpHttpServer } = await import("./mcp.js");
2530
+ const { startMcpHttpServer } = await import("../mcp/server.js");
2574
2531
  try {
2575
2532
  await startMcpHttpServer(port);
2576
2533
  }
@@ -2584,7 +2541,7 @@ if (isMain) {
2584
2541
  }
2585
2542
  else {
2586
2543
  // Default: stdio transport
2587
- const { startMcpServer } = await import("./mcp.js");
2544
+ const { startMcpServer } = await import("../mcp/server.js");
2588
2545
  await startMcpServer();
2589
2546
  }
2590
2547
  break;
@@ -34,18 +34,32 @@ export interface CollectionConfig {
34
34
  export interface NamedCollection extends Collection {
35
35
  name: string;
36
36
  }
37
+ /**
38
+ * Set the config source for SDK mode.
39
+ * - File path: load/save from a specific YAML file
40
+ * - Inline config: use an in-memory CollectionConfig (saveConfig updates in place, no file I/O)
41
+ * - undefined: reset to default file-based config
42
+ */
43
+ export declare function setConfigSource(source?: {
44
+ configPath?: string;
45
+ config?: CollectionConfig;
46
+ }): void;
37
47
  /**
38
48
  * Set the current index name for config file lookup
39
49
  * Config file will be ~/.config/qmd/{indexName}.yml
40
50
  */
41
51
  export declare function setConfigIndexName(name: string): void;
42
52
  /**
43
- * Load configuration from ~/.config/qmd/index.yml
53
+ * Load configuration from the configured source.
54
+ * - Inline config: returns the in-memory object directly
55
+ * - File-based: reads from YAML file (default ~/.config/qmd/index.yml)
44
56
  * Returns empty config if file doesn't exist
45
57
  */
46
58
  export declare function loadConfig(): CollectionConfig;
47
59
  /**
48
- * Save configuration to ~/.config/qmd/index.yml
60
+ * Save configuration to the configured source.
61
+ * - Inline config: updates the in-memory object (no file I/O)
62
+ * - File-based: writes to YAML file (default ~/.config/qmd/index.yml)
49
63
  */
50
64
  export declare function saveConfig(config: CollectionConfig): void;
51
65
  /**
@@ -5,7 +5,7 @@
5
5
  * Collections define which directories to index and their associated contexts.
6
6
  */
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
- import { join } from "path";
8
+ import { join, dirname } from "path";
9
9
  import { homedir } from "os";
10
10
  import YAML from "yaml";
11
11
  // ============================================================================
@@ -13,6 +13,33 @@ import YAML from "yaml";
13
13
  // ============================================================================
14
14
  // Current index name (default: "index")
15
15
  let currentIndexName = "index";
16
+ // SDK mode: optional in-memory config or custom config path
17
+ let configSource = { type: 'file' };
18
+ /**
19
+ * Set the config source for SDK mode.
20
+ * - File path: load/save from a specific YAML file
21
+ * - Inline config: use an in-memory CollectionConfig (saveConfig updates in place, no file I/O)
22
+ * - undefined: reset to default file-based config
23
+ */
24
+ export function setConfigSource(source) {
25
+ if (!source) {
26
+ configSource = { type: 'file' };
27
+ return;
28
+ }
29
+ if (source.config) {
30
+ // Ensure collections object exists
31
+ if (!source.config.collections) {
32
+ source.config.collections = {};
33
+ }
34
+ configSource = { type: 'inline', config: source.config };
35
+ }
36
+ else if (source.configPath) {
37
+ configSource = { type: 'file', path: source.configPath };
38
+ }
39
+ else {
40
+ configSource = { type: 'file' };
41
+ }
42
+ }
16
43
  /**
17
44
  * Set the current index name for config file lookup
18
45
  * Config file will be ~/.config/qmd/{indexName}.yml
@@ -57,11 +84,18 @@ function ensureConfigDir() {
57
84
  // Core functions
58
85
  // ============================================================================
59
86
  /**
60
- * Load configuration from ~/.config/qmd/index.yml
87
+ * Load configuration from the configured source.
88
+ * - Inline config: returns the in-memory object directly
89
+ * - File-based: reads from YAML file (default ~/.config/qmd/index.yml)
61
90
  * Returns empty config if file doesn't exist
62
91
  */
63
92
  export function loadConfig() {
64
- const configPath = getConfigFilePath();
93
+ // SDK inline config mode
94
+ if (configSource.type === 'inline') {
95
+ return configSource.config;
96
+ }
97
+ // File-based config (SDK custom path or default)
98
+ const configPath = configSource.path || getConfigFilePath();
65
99
  if (!existsSync(configPath)) {
66
100
  return { collections: {} };
67
101
  }
@@ -79,11 +113,21 @@ export function loadConfig() {
79
113
  }
80
114
  }
81
115
  /**
82
- * Save configuration to ~/.config/qmd/index.yml
116
+ * Save configuration to the configured source.
117
+ * - Inline config: updates the in-memory object (no file I/O)
118
+ * - File-based: writes to YAML file (default ~/.config/qmd/index.yml)
83
119
  */
84
120
  export function saveConfig(config) {
85
- ensureConfigDir();
86
- const configPath = getConfigFilePath();
121
+ // SDK inline config mode: update in place, no file I/O
122
+ if (configSource.type === 'inline') {
123
+ configSource.config = config;
124
+ return;
125
+ }
126
+ const configPath = configSource.path || getConfigFilePath();
127
+ const configDir = dirname(configPath);
128
+ if (!existsSync(configDir)) {
129
+ mkdirSync(configDir, { recursive: true });
130
+ }
87
131
  try {
88
132
  const yaml = YAML.stringify(config, {
89
133
  indent: 2,
@@ -318,13 +362,18 @@ export function findContextForPath(collectionName, filePath) {
318
362
  * Get the config file path (useful for error messages)
319
363
  */
320
364
  export function getConfigPath() {
321
- return getConfigFilePath();
365
+ if (configSource.type === 'inline')
366
+ return '<inline>';
367
+ return configSource.path || getConfigFilePath();
322
368
  }
323
369
  /**
324
370
  * Check if config file exists
325
371
  */
326
372
  export function configExists() {
327
- return existsSync(getConfigFilePath());
373
+ if (configSource.type === 'inline')
374
+ return true;
375
+ const path = configSource.path || getConfigFilePath();
376
+ return existsSync(path);
328
377
  }
329
378
  /**
330
379
  * Validate a collection name