refactor: move CLI and MCP to subdirectories, MCP consumes SDK

Move frontends into src/cli/ and src/mcp/ to separate them from the
core library. The MCP server is fully rewritten to import only from
the SDK (src/index.ts) — zero direct store.ts/collections.ts/llm.ts
access.

- src/qmd.ts → src/cli/qmd.ts
- src/formatter.ts → src/cli/formatter.ts
- src/mcp.ts → src/mcp/server.ts (rewritten to use QMDStore SDK)
- New src/maintenance.ts: Maintenance class for CLI housekeeping
- SDK gains: getDocumentBody(), getDefaultCollectionNames(),
  extractSnippet/addLineNumbers/DEFAULT_MULTI_GET_MAX_BYTES exports,
  getDefaultDbPath re-export, InternalStore type export
- package.json bin/scripts updated for new paths
- All 692 tests pass
This commit is contained in:
Tobi Lutke 2026-03-10 11:39:55 -04:00
parent 839d774a06
commit c68904fe08
No known key found for this signature in database
14 changed files with 197 additions and 106 deletions

View File

@ -118,7 +118,7 @@ qmd multi-get "#abc123, #def456"
## Development ## Development
```sh ```sh
bun src/qmd.ts <command> # Run from source bun src/cli/qmd.ts <command> # Run from source
bun link # Install globally as 'qmd' bun link # Install globally as 'qmd'
``` ```

View File

@ -12,7 +12,7 @@
} }
}, },
"bin": { "bin": {
"qmd": "dist/qmd.js" "qmd": "dist/cli/qmd.js"
}, },
"files": [ "files": [
"dist/", "dist/",
@ -21,15 +21,15 @@
], ],
"scripts": { "scripts": {
"prepare": "[ -d .git ] && ./scripts/install-hooks.sh || true", "prepare": "[ -d .git ] && ./scripts/install-hooks.sh || true",
"build": "tsc -p tsconfig.build.json && printf '#!/usr/bin/env node\n' | cat - dist/qmd.js > dist/qmd.tmp && mv dist/qmd.tmp dist/qmd.js && chmod +x dist/qmd.js", "build": "tsc -p tsconfig.build.json && printf '#!/usr/bin/env node\n' | cat - dist/cli/qmd.js > dist/cli/qmd.tmp && mv dist/cli/qmd.tmp dist/cli/qmd.js && chmod +x dist/cli/qmd.js",
"test": "vitest run --reporter=verbose test/", "test": "vitest run --reporter=verbose test/",
"qmd": "tsx src/qmd.ts", "qmd": "tsx src/cli/qmd.ts",
"index": "tsx src/qmd.ts index", "index": "tsx src/cli/qmd.ts index",
"vector": "tsx src/qmd.ts vector", "vector": "tsx src/cli/qmd.ts vector",
"search": "tsx src/qmd.ts search", "search": "tsx src/cli/qmd.ts search",
"vsearch": "tsx src/qmd.ts vsearch", "vsearch": "tsx src/cli/qmd.ts vsearch",
"rerank": "tsx src/qmd.ts rerank", "rerank": "tsx src/cli/qmd.ts rerank",
"inspector": "npx @modelcontextprotocol/inspector tsx src/qmd.ts mcp", "inspector": "npx @modelcontextprotocol/inspector tsx src/cli/qmd.ts mcp",
"release": "./scripts/release.sh" "release": "./scripts/release.sh"
}, },
"publishConfig": { "publishConfig": {

View File

@ -5,8 +5,8 @@
* JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output). * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
*/ */
import { extractSnippet } from "./store.js"; import { extractSnippet } from "../store.js";
import type { SearchResult, MultiGetResult, DocumentResult } from "./store.js"; import type { SearchResult, MultiGetResult, DocumentResult } from "../store.js";
// ============================================================================= // =============================================================================
// Types // Types

View File

@ -1,5 +1,5 @@
import { openDatabase } from "./db.js"; import { openDatabase } from "../db.js";
import type { Database } from "./db.js"; import type { Database } from "../db.js";
import fastGlob from "fast-glob"; import fastGlob from "fast-glob";
import { execSync, spawn as nodeSpawn } from "child_process"; import { execSync, spawn as nodeSpawn } from "child_process";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
@ -73,8 +73,8 @@ import {
generateEmbeddings, generateEmbeddings,
syncConfigToDb, syncConfigToDb,
type ReindexResult, type ReindexResult,
} from "./store.js"; } from "../store.js";
import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "./llm.js"; import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "../llm.js";
import { import {
formatSearchResults, formatSearchResults,
formatDocuments, formatDocuments,
@ -94,7 +94,7 @@ import {
listAllContexts, listAllContexts,
setConfigIndexName, setConfigIndexName,
loadConfig, loadConfig,
} from "./collections.js"; } from "../collections.js";
// Enable production mode - allows using default database path // Enable production mode - allows using default database path
// Tests must set INDEX_PATH or use createStore() with explicit path // Tests must set INDEX_PATH or use createStore() with explicit path
@ -1400,7 +1400,7 @@ async function collectionAdd(pwd: string, globPattern: string, name?: string): P
} }
// Add to YAML config + sync to SQLite // Add to YAML config + sync to SQLite
const { addCollection } = await import("./collections.js"); const { addCollection } = await import("../collections.js");
addCollection(collName, pwd, globPattern); addCollection(collName, pwd, globPattern);
resyncConfig(); resyncConfig();
@ -2395,7 +2395,7 @@ function parseCLI() {
function showSkill(): void { function showSkill(): void {
const scriptDir = dirname(fileURLToPath(import.meta.url)); const scriptDir = dirname(fileURLToPath(import.meta.url));
const relativePath = pathJoin("skills", "qmd", "SKILL.md"); const relativePath = pathJoin("skills", "qmd", "SKILL.md");
const skillPath = pathJoin(scriptDir, "..", relativePath); const skillPath = pathJoin(scriptDir, "..", "..", relativePath);
console.log(`QMD Skill (${relativePath})`); console.log(`QMD Skill (${relativePath})`);
console.log(`Location: ${skillPath}`); console.log(`Location: ${skillPath}`);
@ -2499,7 +2499,7 @@ function showHelp(): void {
async function showVersion(): Promise<void> { async function showVersion(): Promise<void> {
const scriptDir = dirname(fileURLToPath(import.meta.url)); const scriptDir = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(scriptDir, "..", "package.json"); const pkgPath = resolve(scriptDir, "..", "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
let commit = ""; let commit = "";
@ -2694,7 +2694,7 @@ if (isMain) {
console.error(" Omit command to clear it"); console.error(" Omit command to clear it");
process.exit(1); process.exit(1);
} }
const { updateCollectionSettings, getCollection } = await import("./collections.js"); const { updateCollectionSettings, getCollection } = await import("../collections.js");
const col = getCollection(name); const col = getCollection(name);
if (!col) { if (!col) {
console.error(`Collection not found: ${name}`); console.error(`Collection not found: ${name}`);
@ -2717,7 +2717,7 @@ if (isMain) {
console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`); console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`);
process.exit(1); process.exit(1);
} }
const { updateCollectionSettings, getCollection } = await import("./collections.js"); const { updateCollectionSettings, getCollection } = await import("../collections.js");
const col = getCollection(name); const col = getCollection(name);
if (!col) { if (!col) {
console.error(`Collection not found: ${name}`); console.error(`Collection not found: ${name}`);
@ -2736,7 +2736,7 @@ if (isMain) {
console.error("Usage: qmd collection show <name>"); console.error("Usage: qmd collection show <name>");
process.exit(1); process.exit(1);
} }
const { getCollection } = await import("./collections.js"); const { getCollection } = await import("../collections.js");
const col = getCollection(name); const col = getCollection(name);
if (!col) { if (!col) {
console.error(`Collection not found: ${name}`); console.error(`Collection not found: ${name}`);
@ -2896,7 +2896,7 @@ if (isMain) {
const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run
const selfPath = fileURLToPath(import.meta.url); const selfPath = fileURLToPath(import.meta.url);
const spawnArgs = selfPath.endsWith(".ts") const spawnArgs = selfPath.endsWith(".ts")
? ["--import", pathJoin(dirname(selfPath), "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)] ? ["--import", pathJoin(dirname(selfPath), "..", "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
: [selfPath, "mcp", "--http", "--port", String(port)]; : [selfPath, "mcp", "--http", "--port", String(port)];
const child = nodeSpawn(process.execPath, spawnArgs, { const child = nodeSpawn(process.execPath, spawnArgs, {
stdio: ["ignore", logFd, logFd], stdio: ["ignore", logFd, logFd],
@ -2915,7 +2915,7 @@ if (isMain) {
// async cleanup handlers in startMcpHttpServer actually run. // async cleanup handlers in startMcpHttpServer actually run.
process.removeAllListeners("SIGTERM"); process.removeAllListeners("SIGTERM");
process.removeAllListeners("SIGINT"); process.removeAllListeners("SIGINT");
const { startMcpHttpServer } = await import("./mcp.js"); const { startMcpHttpServer } = await import("../mcp/server.js");
try { try {
await startMcpHttpServer(port); await startMcpHttpServer(port);
} catch (e: any) { } catch (e: any) {
@ -2927,7 +2927,7 @@ if (isMain) {
} }
} else { } else {
// Default: stdio transport // Default: stdio transport
const { startMcpServer } = await import("./mcp.js"); const { startMcpServer } = await import("../mcp/server.js");
await startMcpServer(); await startMcpServer();
} }
break; break;

View File

@ -21,7 +21,10 @@ import {
createStore as createStoreInternal, createStore as createStoreInternal,
hybridQuery, hybridQuery,
structuredSearch, structuredSearch,
extractSnippet,
addLineNumbers,
DEFAULT_EMBED_MODEL, DEFAULT_EMBED_MODEL,
DEFAULT_MULTI_GET_MAX_BYTES,
reindexCollection, reindexCollection,
generateEmbeddings, generateEmbeddings,
listCollections as storeListCollections, listCollections as storeListCollections,
@ -36,6 +39,12 @@ import {
updateStoreContext, updateStoreContext,
removeStoreContext, removeStoreContext,
setStoreGlobalContext, setStoreGlobalContext,
vacuumDatabase,
cleanupOrphanedContent,
cleanupOrphanedVectors,
deleteLLMCache,
deleteInactiveDocuments,
clearAllEmbeddings,
type Store as InternalStore, type Store as InternalStore,
type DocumentResult, type DocumentResult,
type DocumentNotFound, type DocumentNotFound,
@ -96,6 +105,18 @@ export type {
ContextMap, ContextMap,
}; };
// Re-export the internal Store type for advanced consumers
export type { InternalStore };
// Re-export utility functions used by frontends
export { extractSnippet, addLineNumbers, DEFAULT_MULTI_GET_MAX_BYTES };
// Re-export getDefaultDbPath for CLI/MCP that need the default database location
export { getDefaultDbPath } from "./store.js";
// Re-export Maintenance class for CLI housekeeping operations
export { Maintenance } from "./maintenance.js";
/** /**
* Progress info emitted during update() for each file processed. * Progress info emitted during update() for each file processed.
*/ */
@ -213,6 +234,9 @@ export interface QMDStore {
/** Get a single document by path or docid */ /** Get a single document by path or docid */
get(pathOrDocid: string, options?: { includeBody?: boolean }): Promise<DocumentResult | DocumentNotFound>; get(pathOrDocid: string, options?: { includeBody?: boolean }): Promise<DocumentResult | DocumentNotFound>;
/** Get the body content of a document, optionally sliced by line range */
getDocumentBody(pathOrDocid: string, opts?: { fromLine?: number; maxLines?: number }): Promise<string | null>;
/** Get multiple documents by glob pattern or comma-separated list */ /** Get multiple documents by glob pattern or comma-separated list */
multiGet(pattern: string, options?: { includeBody?: boolean; maxBytes?: number }): Promise<{ docs: MultiGetResult[]; errors: string[] }>; multiGet(pattern: string, options?: { includeBody?: boolean; maxBytes?: number }): Promise<{ docs: MultiGetResult[]; errors: string[] }>;
@ -228,7 +252,10 @@ export interface QMDStore {
renameCollection(oldName: string, newName: string): Promise<boolean>; renameCollection(oldName: string, newName: string): Promise<boolean>;
/** List all collections with document stats */ /** List all collections with document stats */
listCollections(): Promise<{ name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null }[]>; listCollections(): Promise<{ name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null; includeByDefault: boolean }[]>;
/** Get names of collections included by default in queries */
getDefaultCollectionNames(): Promise<string[]>;
// ── Context Management ────────────────────────────────────────────── // ── Context Management ──────────────────────────────────────────────
@ -379,6 +406,11 @@ export async function createStore(options: StoreOptions): Promise<QMDStore> {
searchVector: async (q, opts) => internal.searchVec(q, DEFAULT_EMBED_MODEL, opts?.limit, opts?.collection), searchVector: async (q, opts) => internal.searchVec(q, DEFAULT_EMBED_MODEL, opts?.limit, opts?.collection),
expandQuery: async (q, opts) => internal.expandQuery(q, undefined, opts?.intent), expandQuery: async (q, opts) => internal.expandQuery(q, undefined, opts?.intent),
get: async (pathOrDocid, opts) => internal.findDocument(pathOrDocid, opts), get: async (pathOrDocid, opts) => internal.findDocument(pathOrDocid, opts),
getDocumentBody: async (pathOrDocid, opts) => {
const result = internal.findDocument(pathOrDocid, { includeBody: false });
if ("error" in result) return null;
return internal.getDocumentBody(result, opts?.fromLine, opts?.maxLines);
},
multiGet: async (pattern, opts) => internal.findDocuments(pattern, opts), multiGet: async (pattern, opts) => internal.findDocuments(pattern, opts),
// Collection Management — write to SQLite + write-through to YAML/inline if configured // Collection Management — write to SQLite + write-through to YAML/inline if configured
@ -403,6 +435,10 @@ export async function createStore(options: StoreOptions): Promise<QMDStore> {
return result; return result;
}, },
listCollections: async () => storeListCollections(db), listCollections: async () => storeListCollections(db),
getDefaultCollectionNames: async () => {
const collections = storeListCollections(db);
return collections.filter(c => c.includeByDefault).map(c => c.name);
},
// Context Management — write to SQLite + write-through to YAML/inline if configured // Context Management — write to SQLite + write-through to YAML/inline if configured
addContext: async (collectionName, pathPrefix, contextText) => { addContext: async (collectionName, pathPrefix, contextText) => {

View File

@ -1440,6 +1440,25 @@ export async function withLLMSession<T>(
} }
} }
/**
* Execute a function with a scoped LLM session using a specific LlamaCpp instance.
* Unlike withLLMSession, this does not use the global singleton.
*/
export async function withLLMSessionForLlm<T>(
llm: LlamaCpp,
fn: (session: ILLMSession) => Promise<T>,
options?: LLMSessionOptions
): Promise<T> {
const manager = new LLMSessionManager(llm);
const session = new LLMSession(manager, options);
try {
return await fn(session);
} finally {
session.release();
}
}
/** /**
* Check if idle unload is safe (no active sessions or operations). * Check if idle unload is safe (no active sessions or operations).
* Used internally by LlamaCpp idle timer. * Used internally by LlamaCpp idle timer.

54
src/maintenance.ts Normal file
View File

@ -0,0 +1,54 @@
/**
* Maintenance - Database cleanup operations for QMD.
*
* Wraps low-level store operations that the CLI needs for housekeeping.
* Takes an internal Store in the constructor allowed to access DB directly.
*/
import type { Store } from "./store.js";
import {
vacuumDatabase,
cleanupOrphanedContent,
cleanupOrphanedVectors,
deleteLLMCache,
deleteInactiveDocuments,
clearAllEmbeddings,
} from "./store.js";
export class Maintenance {
private store: Store;
constructor(store: Store) {
this.store = store;
}
/** Run VACUUM on the SQLite database to reclaim space */
vacuum(): void {
vacuumDatabase(this.store.db);
}
/** Remove content rows that are no longer referenced by any document */
cleanupOrphanedContent(): number {
return cleanupOrphanedContent(this.store.db);
}
/** Remove vector embeddings for content that no longer exists */
cleanupOrphanedVectors(): number {
return cleanupOrphanedVectors(this.store.db);
}
/** Clear the LLM response cache (query expansion, reranking) */
clearLLMCache(): number {
return deleteLLMCache(this.store.db);
}
/** Delete documents marked as inactive (removed from filesystem) */
deleteInactiveDocs(): number {
return deleteInactiveDocuments(this.store.db);
}
/** Clear all vector embeddings (forces re-embedding) */
clearEmbeddings(): void {
clearAllEmbeddings(this.store.db);
}
}

View File

@ -20,12 +20,12 @@ import {
createStore, createStore,
extractSnippet, extractSnippet,
addLineNumbers, addLineNumbers,
structuredSearch, getDefaultDbPath,
DEFAULT_MULTI_GET_MAX_BYTES, DEFAULT_MULTI_GET_MAX_BYTES,
} from "./store.js"; type QMDStore,
import type { Store, ExpandedQuery } from "./store.js"; type ExpandedQuery,
import { getCollection, getGlobalContext, getDefaultCollectionNames } from "./collections.js"; type IndexStatus,
import { disposeDefaultLlamaCpp } from "./llm.js"; } from "../index.js";
// ============================================================================= // =============================================================================
// Types for structured content // Types for structured content
@ -46,8 +46,8 @@ type StatusResult = {
hasVectorIndex: boolean; hasVectorIndex: boolean;
collections: { collections: {
name: string; name: string;
path: string; path: string | null;
pattern: string; pattern: string | null;
documents: number; documents: number;
lastUpdated: string; lastUpdated: string;
}[]; }[];
@ -89,12 +89,13 @@ function formatSearchSummary(results: SearchResultItem[], query: string): string
* Injected into the LLM's system prompt via MCP initialize response * Injected into the LLM's system prompt via MCP initialize response
* gives the LLM immediate context about what's searchable without a tool call. * gives the LLM immediate context about what's searchable without a tool call.
*/ */
function buildInstructions(store: Store): string { async function buildInstructions(store: QMDStore): Promise<string> {
const status = store.getStatus(); const status = await store.getStatus();
const contexts = await store.listContexts();
const globalCtx = await store.getGlobalContext();
const lines: string[] = []; const lines: string[] = [];
// --- What is this? --- // --- What is this? ---
const globalCtx = getGlobalContext();
lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`); lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
if (globalCtx) lines.push(`Context: ${globalCtx}`); if (globalCtx) lines.push(`Context: ${globalCtx}`);
@ -103,9 +104,9 @@ function buildInstructions(store: Store): string {
lines.push(""); lines.push("");
lines.push("Collections (scope with `collection` parameter):"); lines.push("Collections (scope with `collection` parameter):");
for (const col of status.collections) { for (const col of status.collections) {
const collConfig = getCollection(col.name); // Find root context for this collection
const rootCtx = collConfig?.context?.[""] || collConfig?.context?.["/"]; const rootCtx = contexts.find(c => c.collection === col.name && (c.path === "" || c.path === "/"));
const desc = rootCtx ? `${rootCtx}` : ""; const desc = rootCtx ? `${rootCtx.context}` : "";
lines.push(` - "${col.name}" (${col.documents} docs)${desc}`); lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
} }
} }
@ -154,12 +155,15 @@ function buildInstructions(store: Store): string {
* Create an MCP server with all QMD tools, resources, and prompts registered. * Create an MCP server with all QMD tools, resources, and prompts registered.
* Shared by both stdio and HTTP transports. * Shared by both stdio and HTTP transports.
*/ */
function createMcpServer(store: Store): McpServer { async function createMcpServer(store: QMDStore): Promise<McpServer> {
const server = new McpServer( const server = new McpServer(
{ name: "qmd", version: "0.9.9" }, { name: "qmd", version: "0.9.9" },
{ instructions: buildInstructions(store) }, { instructions: await buildInstructions(store) },
); );
// Pre-fetch default collection names for search tools
const defaultCollectionNames = await store.getDefaultCollectionNames();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Resource: qmd://{path} - read-only access to documents by path // Resource: qmd://{path} - read-only access to documents by path
// Note: No list() - documents are discovered via search tools // Note: No list() - documents are discovered via search tools
@ -178,49 +182,23 @@ function createMcpServer(store: Store): McpServer {
const pathStr = Array.isArray(path) ? path.join('/') : (path || ''); const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
const decodedPath = decodeURIComponent(pathStr); const decodedPath = decodeURIComponent(pathStr);
// Parse virtual path: collection/relative/path // Use SDK to find document — findDocument handles collection/path resolution
const parts = decodedPath.split('/'); const result = await store.get(decodedPath, { includeBody: true });
const collection = parts[0] || '';
const relativePath = parts.slice(1).join('/');
// Find document by collection and path, join with content table if ("error" in result) {
let doc = store.db.prepare(`
SELECT d.collection, d.path, d.title, c.doc as body
FROM documents d
JOIN content c ON c.hash = d.hash
WHERE d.collection = ? AND d.path = ? AND d.active = 1
`).get(collection, relativePath) as { collection: string; path: string; title: string; body: string } | null;
// Try suffix match if exact match fails
if (!doc) {
doc = store.db.prepare(`
SELECT d.collection, d.path, d.title, c.doc as body
FROM documents d
JOIN content c ON c.hash = d.hash
WHERE d.path LIKE ? AND d.active = 1
LIMIT 1
`).get(`%${relativePath}`) as { collection: string; path: string; title: string; body: string } | null;
}
if (!doc) {
return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] }; return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
} }
// Construct virtual path for context lookup let text = addLineNumbers(result.body || ""); // Default to line numbers
const virtualPath = `qmd://${doc.collection}/${doc.path}`; if (result.context) {
const context = store.getContextForFile(virtualPath); text = `<!-- Context: ${result.context} -->\n\n` + text;
let text = addLineNumbers(doc.body); // Default to line numbers
if (context) {
text = `<!-- Context: ${context} -->\n\n` + text;
} }
const displayName = `${doc.collection}/${doc.path}`;
return { return {
contents: [{ contents: [{
uri: uri.href, uri: uri.href,
name: displayName, name: result.displayPath,
title: doc.title || doc.path, title: result.title || result.displayPath,
mimeType: "text/markdown", mimeType: "text/markdown",
text, text,
}], }],
@ -322,19 +300,19 @@ Intent-aware lex (C++ performance, not sports):
}, },
async ({ searches, limit, minScore, candidateLimit, collections, intent }) => { async ({ searches, limit, minScore, candidateLimit, collections, intent }) => {
// Map to internal format // Map to internal format
const subSearches: ExpandedQuery[] = searches.map(s => ({ const queries: ExpandedQuery[] = searches.map(s => ({
type: s.type, type: s.type,
query: s.query, query: s.query,
})); }));
// Use default collections if none specified // Use default collections if none specified
const effectiveCollections = collections ?? getDefaultCollectionNames(); const effectiveCollections = collections ?? defaultCollectionNames;
const results = await structuredSearch(store, subSearches, { const results = await store.search({
queries,
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined, collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
limit, limit,
minScore, minScore,
candidateLimit,
intent, intent,
}); });
@ -389,7 +367,7 @@ Intent-aware lex (C++ performance, not sports):
lookup = lookup.slice(0, -colonMatch[0].length); lookup = lookup.slice(0, -colonMatch[0].length);
} }
const result = store.findDocument(lookup, { includeBody: false }); const result = await store.get(lookup, { includeBody: false });
if ("error" in result) { if ("error" in result) {
let msg = `Document not found: ${file}`; let msg = `Document not found: ${file}`;
@ -402,7 +380,7 @@ Intent-aware lex (C++ performance, not sports):
}; };
} }
const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? ""; const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines }) ?? "";
let text = body; let text = body;
if (lineNumbers) { if (lineNumbers) {
const startLine = parsedFromLine || 1; const startLine = parsedFromLine || 1;
@ -445,7 +423,7 @@ Intent-aware lex (C++ performance, not sports):
}, },
}, },
async ({ pattern, maxLines, maxBytes, lineNumbers }) => { async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES }); const { docs, errors } = await store.multiGet(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
if (docs.length === 0 && errors.length === 0) { if (docs.length === 0 && errors.length === 0) {
return { return {
@ -513,7 +491,7 @@ Intent-aware lex (C++ performance, not sports):
inputSchema: {}, inputSchema: {},
}, },
async () => { async () => {
const status: StatusResult = store.getStatus(); const status: StatusResult = await store.getStatus();
const summary = [ const summary = [
`QMD Index Status:`, `QMD Index Status:`,
@ -542,8 +520,8 @@ Intent-aware lex (C++ performance, not sports):
// ============================================================================= // =============================================================================
export async function startMcpServer(): Promise<void> { export async function startMcpServer(): Promise<void> {
const store = createStore(); const store = await createStore({ dbPath: getDefaultDbPath() });
const server = createMcpServer(store); const server = await createMcpServer(store);
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
} }
@ -563,7 +541,10 @@ export type HttpServerHandle = {
* Binds to localhost only. Returns a handle for shutdown and port discovery. * Binds to localhost only. Returns a handle for shutdown and port discovery.
*/ */
export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> { export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> {
const store = createStore(); const store = await createStore({ dbPath: getDefaultDbPath() });
// Pre-fetch default collection names for REST endpoint
const defaultCollectionNames = await store.getDefaultCollectionNames();
// Session map: each client gets its own McpServer + Transport pair (MCP spec requirement). // Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
// The store is shared — it's stateless SQLite, safe for concurrent access. // The store is shared — it's stateless SQLite, safe for concurrent access.
@ -578,7 +559,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
log(`${ts()} New session ${sessionId} (${sessions.size} active)`); log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
}, },
}); });
const server = createMcpServer(store); const server = await createMcpServer(store);
await server.connect(transport); await server.connect(transport);
transport.onclose = () => { transport.onclose = () => {
@ -645,7 +626,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") { if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
const rawBody = await collectBody(nodeReq); const rawBody = await collectBody(nodeReq);
const params = JSON.parse(rawBody); const params = JSON.parse(rawBody);
// Validate required fields // Validate required fields
if (!params.searches || !Array.isArray(params.searches)) { if (!params.searches || !Array.isArray(params.searches)) {
nodeRes.writeHead(400, { "Content-Type": "application/json" }); nodeRes.writeHead(400, { "Content-Type": "application/json" });
@ -654,19 +635,20 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
} }
// Map to internal format // Map to internal format
const subSearches: ExpandedQuery[] = params.searches.map((s: any) => ({ const queries: ExpandedQuery[] = params.searches.map((s: any) => ({
type: s.type as 'lex' | 'vec' | 'hyde', type: s.type as 'lex' | 'vec' | 'hyde',
query: String(s.query || ""), query: String(s.query || ""),
})); }));
// Use default collections if none specified // Use default collections if none specified
const effectiveCollections = params.collections ?? getDefaultCollectionNames(); const effectiveCollections = params.collections ?? defaultCollectionNames;
const results = await structuredSearch(store, subSearches, { const results = await store.search({
queries,
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined, collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
limit: params.limit ?? 10, limit: params.limit ?? 10,
minScore: params.minScore ?? 0, minScore: params.minScore ?? 0,
candidateLimit: params.candidateLimit, intent: params.intent,
}); });
// Use first lex or vec query for snippet extraction // Use first lex or vec query for snippet extraction
@ -801,8 +783,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
} }
sessions.clear(); sessions.clear();
httpServer.close(); httpServer.close();
store.close(); await store.close();
await disposeDefaultLlamaCpp();
}; };
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
@ -821,6 +802,6 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
} }
// Run if this is the main module // Run if this is the main module
if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/mcp.ts") || process.argv[1]?.endsWith("/mcp.js")) { if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("/server.js")) {
startMcpServer().catch(console.error); startMcpServer().catch(console.error);
} }

View File

@ -16,7 +16,7 @@ import type { Database } from "./db.js";
import picomatch from "picomatch"; import picomatch from "picomatch";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { readFileSync, realpathSync, statSync, mkdirSync } from "node:fs"; import { readFileSync, realpathSync, statSync, mkdirSync } from "node:fs";
import { resolve } from "node:path"; // Note: node:path resolve is not imported — we export our own cross-platform resolve()
import fastGlob from "fast-glob"; import fastGlob from "fast-glob";
import { import {
LlamaCpp, LlamaCpp,
@ -2267,7 +2267,7 @@ export function getCollectionByName(db: Database, name: string): { name: string;
* List all collections with document counts from database. * List all collections with document counts from database.
* Merges store_collections config with database statistics. * Merges store_collections config with database statistics.
*/ */
export function listCollections(db: Database): { name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null }[] { export function listCollections(db: Database): { name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null; includeByDefault: boolean }[] {
const collections = getStoreCollections(db); const collections = getStoreCollections(db);
// Get document counts from database for each collection // Get document counts from database for each collection
@ -2288,6 +2288,7 @@ export function listCollections(db: Database): { name: string; pwd: string; glob
doc_count: stats?.doc_count || 0, doc_count: stats?.doc_count || 0,
active_count: stats?.active_count || 0, active_count: stats?.active_count || 0,
last_modified: stats?.last_modified || null, last_modified: stats?.last_modified || null,
includeByDefault: coll.includeByDefault !== false,
}; };
}); });

View File

@ -24,7 +24,7 @@ let testCounter = 0; // Unique counter for each test run
// Get the directory where this test file lives // Get the directory where this test file lives
const thisDir = dirname(fileURLToPath(import.meta.url)); const thisDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(thisDir, ".."); const projectRoot = join(thisDir, "..");
const qmdScript = join(projectRoot, "src", "qmd.ts"); const qmdScript = join(projectRoot, "src", "cli", "qmd.ts");
// Resolve tsx binary from project's node_modules (not cwd-dependent) // Resolve tsx binary from project's node_modules (not cwd-dependent)
const tsxBin = (() => { const tsxBin = (() => {
const candidate = join(projectRoot, "node_modules", ".bin", "tsx"); const candidate = join(projectRoot, "node_modules", ".bin", "tsx");
@ -485,7 +485,7 @@ ${token}
const update = await runQmd(["update"], { dbPath, configDir }); const update = await runQmd(["update"], { dbPath, configDir });
expect(update.exitCode).toBe(0); expect(update.exitCode).toBe(0);
expect(update.stdout).toContain("No files found matching pattern."); expect(update.stdout).toContain("0 new, 0 updated, 0 unchanged, 1 removed");
const after = await runQmd(["get", "qmd://empty-check/only.md"], { dbPath, configDir }); const after = await runQmd(["get", "qmd://empty-check/only.md"], { dbPath, configDir });
expect(after.exitCode).toBe(1); expect(after.exitCode).toBe(1);

View File

@ -138,7 +138,7 @@ interface SearchResult {
function runSearch(query: string): SearchResult[] { function runSearch(query: string): SearchResult[] {
try { try {
const output = execSync( const output = execSync(
`bun src/qmd.ts search "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`, `bun src/cli/qmd.ts search "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`,
{ encoding: "utf-8", timeout: 30000 } { encoding: "utf-8", timeout: 30000 }
); );
return JSON.parse(output); return JSON.parse(output);
@ -150,7 +150,7 @@ function runSearch(query: string): SearchResult[] {
function runQuery(query: string): SearchResult[] { function runQuery(query: string): SearchResult[] {
try { try {
const output = execSync( const output = execSync(
`bun src/qmd.ts query "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`, `bun src/cli/qmd.ts query "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`,
{ encoding: "utf-8", timeout: 60000 } { encoding: "utf-8", timeout: 60000 }
); );
return JSON.parse(output); return JSON.parse(output);
@ -207,7 +207,7 @@ console.log(`Testing ${evalQueries.length} queries across 6 documents`);
// Check if eval-docs collection exists // Check if eval-docs collection exists
try { try {
const status = execSync("bun src/qmd.ts status --json 2>/dev/null", { encoding: "utf-8" }); const status = execSync("bun src/cli/qmd.ts status --json 2>/dev/null", { encoding: "utf-8" });
if (!status.includes("eval-docs")) { if (!status.includes("eval-docs")) {
console.log("\n⚠ eval-docs collection not found. Run:"); console.log("\n⚠ eval-docs collection not found. Run:");
console.log(" qmd collection add test/eval-docs --name eval-docs"); console.log(" qmd collection add test/eval-docs --name eval-docs");

View File

@ -27,7 +27,7 @@ import {
documentToXml, documentToXml,
formatDocument, formatDocument,
type MultiGetFile, type MultiGetFile,
} from "../src/formatter.js"; } from "../src/cli/formatter.js";
import type { SearchResult, DocumentResult } from "../src/store.js"; import type { SearchResult, DocumentResult } from "../src/store.js";
// ============================================================================= // =============================================================================

View File

@ -22,7 +22,7 @@ import {
} from "../src/store.js"; } from "../src/store.js";
// ============================================================================= // =============================================================================
// parseStructuredQuery — duplicated from src/qmd.ts for unit testing // parseStructuredQuery — duplicated from src/cli/qmd.ts for unit testing
// (qmd.ts doesn't export it since it's a CLI internal) // (qmd.ts doesn't export it since it's a CLI internal)
// ============================================================================= // =============================================================================

View File

@ -894,7 +894,7 @@ describe("MCP Server", () => {
// HTTP Transport Tests // HTTP Transport Tests
// ============================================================================= // =============================================================================
import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp"; import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp/server";
import { enableProductionMode } from "../src/store"; import { enableProductionMode } from "../src/store";
describe("MCP HTTP Transport", () => { describe("MCP HTTP Transport", () => {