diff --git a/CLAUDE.md b/CLAUDE.md index 028005b..181e66c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ qmd multi-get "#abc123, #def456" ## Development ```sh -bun src/qmd.ts # Run from source +bun src/cli/qmd.ts # Run from source bun link # Install globally as 'qmd' ``` diff --git a/package.json b/package.json index a47a93b..faefbdd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ } }, "bin": { - "qmd": "dist/qmd.js" + "qmd": "dist/cli/qmd.js" }, "files": [ "dist/", @@ -21,15 +21,15 @@ ], "scripts": { "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/", - "qmd": "tsx src/qmd.ts", - "index": "tsx src/qmd.ts index", - "vector": "tsx src/qmd.ts vector", - "search": "tsx src/qmd.ts search", - "vsearch": "tsx src/qmd.ts vsearch", - "rerank": "tsx src/qmd.ts rerank", - "inspector": "npx @modelcontextprotocol/inspector tsx src/qmd.ts mcp", + "qmd": "tsx src/cli/qmd.ts", + "index": "tsx src/cli/qmd.ts index", + "vector": "tsx src/cli/qmd.ts vector", + "search": "tsx src/cli/qmd.ts search", + "vsearch": "tsx src/cli/qmd.ts vsearch", + "rerank": "tsx src/cli/qmd.ts rerank", + "inspector": "npx @modelcontextprotocol/inspector tsx src/cli/qmd.ts mcp", "release": "./scripts/release.sh" }, "publishConfig": { diff --git a/src/formatter.ts b/src/cli/formatter.ts similarity index 99% rename from src/formatter.ts rename to src/cli/formatter.ts index bf37316..14586a8 100644 --- a/src/formatter.ts +++ b/src/cli/formatter.ts @@ -5,8 +5,8 @@ * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output). */ -import { extractSnippet } from "./store.js"; -import type { SearchResult, MultiGetResult, DocumentResult } from "./store.js"; +import { extractSnippet } from "../store.js"; +import type { SearchResult, MultiGetResult, DocumentResult } from "../store.js"; // ============================================================================= // Types diff --git a/src/qmd.ts b/src/cli/qmd.ts similarity index 99% rename from src/qmd.ts rename to src/cli/qmd.ts index 45a239b..7ee82cc 100755 --- a/src/qmd.ts +++ b/src/cli/qmd.ts @@ -1,5 +1,5 @@ -import { openDatabase } from "./db.js"; -import type { Database } from "./db.js"; +import { openDatabase } from "../db.js"; +import type { Database } from "../db.js"; import fastGlob from "fast-glob"; import { execSync, spawn as nodeSpawn } from "child_process"; import { fileURLToPath } from "url"; @@ -73,8 +73,8 @@ import { generateEmbeddings, syncConfigToDb, type ReindexResult, -} 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"; +} 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 { formatSearchResults, formatDocuments, @@ -94,7 +94,7 @@ import { listAllContexts, setConfigIndexName, loadConfig, -} from "./collections.js"; +} from "../collections.js"; // Enable production mode - allows using default database 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 - const { addCollection } = await import("./collections.js"); + const { addCollection } = await import("../collections.js"); addCollection(collName, pwd, globPattern); resyncConfig(); @@ -2395,7 +2395,7 @@ function parseCLI() { function showSkill(): void { const scriptDir = dirname(fileURLToPath(import.meta.url)); const relativePath = pathJoin("skills", "qmd", "SKILL.md"); - const skillPath = pathJoin(scriptDir, "..", relativePath); + const skillPath = pathJoin(scriptDir, "..", "..", relativePath); console.log(`QMD Skill (${relativePath})`); console.log(`Location: ${skillPath}`); @@ -2499,7 +2499,7 @@ function showHelp(): void { async function showVersion(): Promise { 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")); let commit = ""; @@ -2694,7 +2694,7 @@ if (isMain) { console.error(" Omit command to clear it"); process.exit(1); } - const { updateCollectionSettings, getCollection } = await import("./collections.js"); + const { updateCollectionSettings, getCollection } = await import("../collections.js"); const col = getCollection(name); if (!col) { console.error(`Collection not found: ${name}`); @@ -2717,7 +2717,7 @@ if (isMain) { console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`); process.exit(1); } - const { updateCollectionSettings, getCollection } = await import("./collections.js"); + const { updateCollectionSettings, getCollection } = await import("../collections.js"); const col = getCollection(name); if (!col) { console.error(`Collection not found: ${name}`); @@ -2736,7 +2736,7 @@ if (isMain) { console.error("Usage: qmd collection show "); process.exit(1); } - const { getCollection } = await import("./collections.js"); + const { getCollection } = await import("../collections.js"); const col = getCollection(name); if (!col) { console.error(`Collection not found: ${name}`); @@ -2896,7 +2896,7 @@ if (isMain) { const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run const selfPath = fileURLToPath(import.meta.url); 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)]; const child = nodeSpawn(process.execPath, spawnArgs, { stdio: ["ignore", logFd, logFd], @@ -2915,7 +2915,7 @@ if (isMain) { // async cleanup handlers in startMcpHttpServer actually run. process.removeAllListeners("SIGTERM"); process.removeAllListeners("SIGINT"); - const { startMcpHttpServer } = await import("./mcp.js"); + const { startMcpHttpServer } = await import("../mcp/server.js"); try { await startMcpHttpServer(port); } catch (e: any) { @@ -2927,7 +2927,7 @@ if (isMain) { } } else { // Default: stdio transport - const { startMcpServer } = await import("./mcp.js"); + const { startMcpServer } = await import("../mcp/server.js"); await startMcpServer(); } break; diff --git a/src/index.ts b/src/index.ts index d3818cd..b921b51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,10 @@ import { createStore as createStoreInternal, hybridQuery, structuredSearch, + extractSnippet, + addLineNumbers, DEFAULT_EMBED_MODEL, + DEFAULT_MULTI_GET_MAX_BYTES, reindexCollection, generateEmbeddings, listCollections as storeListCollections, @@ -36,6 +39,12 @@ import { updateStoreContext, removeStoreContext, setStoreGlobalContext, + vacuumDatabase, + cleanupOrphanedContent, + cleanupOrphanedVectors, + deleteLLMCache, + deleteInactiveDocuments, + clearAllEmbeddings, type Store as InternalStore, type DocumentResult, type DocumentNotFound, @@ -96,6 +105,18 @@ export type { 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. */ @@ -213,6 +234,9 @@ export interface QMDStore { /** Get a single document by path or docid */ get(pathOrDocid: string, options?: { includeBody?: boolean }): Promise; + /** Get the body content of a document, optionally sliced by line range */ + getDocumentBody(pathOrDocid: string, opts?: { fromLine?: number; maxLines?: number }): Promise; + /** Get multiple documents by glob pattern or comma-separated list */ 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; /** 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; // ── Context Management ────────────────────────────────────────────── @@ -379,6 +406,11 @@ export async function createStore(options: StoreOptions): Promise { searchVector: async (q, opts) => internal.searchVec(q, DEFAULT_EMBED_MODEL, opts?.limit, opts?.collection), expandQuery: async (q, opts) => internal.expandQuery(q, undefined, opts?.intent), 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), // Collection Management — write to SQLite + write-through to YAML/inline if configured @@ -403,6 +435,10 @@ export async function createStore(options: StoreOptions): Promise { return result; }, 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 addContext: async (collectionName, pathPrefix, contextText) => { diff --git a/src/llm.ts b/src/llm.ts index 100a1ec..7b5b8d0 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -1440,6 +1440,25 @@ export async function withLLMSession( } } +/** + * 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( + llm: LlamaCpp, + fn: (session: ILLMSession) => Promise, + options?: LLMSessionOptions +): Promise { + 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). * Used internally by LlamaCpp idle timer. diff --git a/src/maintenance.ts b/src/maintenance.ts new file mode 100644 index 0000000..d8ddade --- /dev/null +++ b/src/maintenance.ts @@ -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); + } +} diff --git a/src/mcp.ts b/src/mcp/server.ts similarity index 89% rename from src/mcp.ts rename to src/mcp/server.ts index 2d2f36d..f1cc2a9 100644 --- a/src/mcp.ts +++ b/src/mcp/server.ts @@ -20,12 +20,12 @@ import { createStore, extractSnippet, addLineNumbers, - structuredSearch, + getDefaultDbPath, DEFAULT_MULTI_GET_MAX_BYTES, -} from "./store.js"; -import type { Store, ExpandedQuery } from "./store.js"; -import { getCollection, getGlobalContext, getDefaultCollectionNames } from "./collections.js"; -import { disposeDefaultLlamaCpp } from "./llm.js"; + type QMDStore, + type ExpandedQuery, + type IndexStatus, +} from "../index.js"; // ============================================================================= // Types for structured content @@ -46,8 +46,8 @@ type StatusResult = { hasVectorIndex: boolean; collections: { name: string; - path: string; - pattern: string; + path: string | null; + pattern: string | null; documents: number; lastUpdated: string; }[]; @@ -89,12 +89,13 @@ function formatSearchSummary(results: SearchResultItem[], query: string): string * Injected into the LLM's system prompt via MCP initialize response — * gives the LLM immediate context about what's searchable without a tool call. */ -function buildInstructions(store: Store): string { - const status = store.getStatus(); +async function buildInstructions(store: QMDStore): Promise { + const status = await store.getStatus(); + const contexts = await store.listContexts(); + const globalCtx = await store.getGlobalContext(); const lines: string[] = []; // --- What is this? --- - const globalCtx = getGlobalContext(); lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`); if (globalCtx) lines.push(`Context: ${globalCtx}`); @@ -103,9 +104,9 @@ function buildInstructions(store: Store): string { lines.push(""); lines.push("Collections (scope with `collection` parameter):"); for (const col of status.collections) { - const collConfig = getCollection(col.name); - const rootCtx = collConfig?.context?.[""] || collConfig?.context?.["/"]; - const desc = rootCtx ? ` — ${rootCtx}` : ""; + // Find root context for this collection + const rootCtx = contexts.find(c => c.collection === col.name && (c.path === "" || c.path === "/")); + const desc = rootCtx ? ` — ${rootCtx.context}` : ""; 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. * Shared by both stdio and HTTP transports. */ -function createMcpServer(store: Store): McpServer { +async function createMcpServer(store: QMDStore): Promise { const server = new McpServer( { 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 // 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 decodedPath = decodeURIComponent(pathStr); - // Parse virtual path: collection/relative/path - const parts = decodedPath.split('/'); - const collection = parts[0] || ''; - const relativePath = parts.slice(1).join('/'); + // Use SDK to find document — findDocument handles collection/path resolution + const result = await store.get(decodedPath, { includeBody: true }); - // Find document by collection and path, join with content table - 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) { + if ("error" in result) { return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] }; } - // Construct virtual path for context lookup - const virtualPath = `qmd://${doc.collection}/${doc.path}`; - const context = store.getContextForFile(virtualPath); - - let text = addLineNumbers(doc.body); // Default to line numbers - if (context) { - text = `\n\n` + text; + let text = addLineNumbers(result.body || ""); // Default to line numbers + if (result.context) { + text = `\n\n` + text; } - const displayName = `${doc.collection}/${doc.path}`; return { contents: [{ uri: uri.href, - name: displayName, - title: doc.title || doc.path, + name: result.displayPath, + title: result.title || result.displayPath, mimeType: "text/markdown", text, }], @@ -322,19 +300,19 @@ Intent-aware lex (C++ performance, not sports): }, async ({ searches, limit, minScore, candidateLimit, collections, intent }) => { // Map to internal format - const subSearches: ExpandedQuery[] = searches.map(s => ({ + const queries: ExpandedQuery[] = searches.map(s => ({ type: s.type, query: s.query, })); // 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, limit, minScore, - candidateLimit, intent, }); @@ -389,7 +367,7 @@ Intent-aware lex (C++ performance, not sports): 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) { 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; if (lineNumbers) { const startLine = parsedFromLine || 1; @@ -445,7 +423,7 @@ Intent-aware lex (C++ performance, not sports): }, }, 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) { return { @@ -513,7 +491,7 @@ Intent-aware lex (C++ performance, not sports): inputSchema: {}, }, async () => { - const status: StatusResult = store.getStatus(); + const status: StatusResult = await store.getStatus(); const summary = [ `QMD Index Status:`, @@ -542,8 +520,8 @@ Intent-aware lex (C++ performance, not sports): // ============================================================================= export async function startMcpServer(): Promise { - const store = createStore(); - const server = createMcpServer(store); + const store = await createStore({ dbPath: getDefaultDbPath() }); + const server = await createMcpServer(store); const transport = new StdioServerTransport(); await server.connect(transport); } @@ -563,7 +541,10 @@ export type HttpServerHandle = { * Binds to localhost only. Returns a handle for shutdown and port discovery. */ export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise { - 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). // 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)`); }, }); - const server = createMcpServer(store); + const server = await createMcpServer(store); await server.connect(transport); transport.onclose = () => { @@ -645,7 +626,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") { const rawBody = await collectBody(nodeReq); const params = JSON.parse(rawBody); - + // Validate required fields if (!params.searches || !Array.isArray(params.searches)) { nodeRes.writeHead(400, { "Content-Type": "application/json" }); @@ -654,19 +635,20 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole } // 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', query: String(s.query || ""), })); // 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, limit: params.limit ?? 10, minScore: params.minScore ?? 0, - candidateLimit: params.candidateLimit, + intent: params.intent, }); // Use first lex or vec query for snippet extraction @@ -801,8 +783,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole } sessions.clear(); httpServer.close(); - store.close(); - await disposeDefaultLlamaCpp(); + await store.close(); }; process.on("SIGTERM", async () => { @@ -821,6 +802,6 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole } // 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); } diff --git a/src/store.ts b/src/store.ts index 570627a..aa5fae4 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,7 +16,7 @@ import type { Database } from "./db.js"; import picomatch from "picomatch"; import { createHash } from "crypto"; 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 { LlamaCpp, @@ -2267,7 +2267,7 @@ export function getCollectionByName(db: Database, name: string): { name: string; * List all collections with document counts from database. * 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); // 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, active_count: stats?.active_count || 0, last_modified: stats?.last_modified || null, + includeByDefault: coll.includeByDefault !== false, }; }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 10b9a6e..d6ee5b8 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -24,7 +24,7 @@ let testCounter = 0; // Unique counter for each test run // Get the directory where this test file lives const thisDir = dirname(fileURLToPath(import.meta.url)); 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) const tsxBin = (() => { const candidate = join(projectRoot, "node_modules", ".bin", "tsx"); @@ -485,7 +485,7 @@ ${token} const update = await runQmd(["update"], { dbPath, configDir }); 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 }); expect(after.exitCode).toBe(1); diff --git a/test/eval-harness.ts b/test/eval-harness.ts index e90662c..4a6567f 100644 --- a/test/eval-harness.ts +++ b/test/eval-harness.ts @@ -138,7 +138,7 @@ interface SearchResult { function runSearch(query: string): SearchResult[] { try { 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 } ); return JSON.parse(output); @@ -150,7 +150,7 @@ function runSearch(query: string): SearchResult[] { function runQuery(query: string): SearchResult[] { try { 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 } ); return JSON.parse(output); @@ -207,7 +207,7 @@ console.log(`Testing ${evalQueries.length} queries across 6 documents`); // Check if eval-docs collection exists 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")) { console.log("\n⚠️ eval-docs collection not found. Run:"); console.log(" qmd collection add test/eval-docs --name eval-docs"); diff --git a/test/formatter.test.ts b/test/formatter.test.ts index 9fee06e..601682e 100644 --- a/test/formatter.test.ts +++ b/test/formatter.test.ts @@ -27,7 +27,7 @@ import { documentToXml, formatDocument, type MultiGetFile, -} from "../src/formatter.js"; +} from "../src/cli/formatter.js"; import type { SearchResult, DocumentResult } from "../src/store.js"; // ============================================================================= diff --git a/test/intent.test.ts b/test/intent.test.ts index 89adfaf..cfb2f3b 100644 --- a/test/intent.test.ts +++ b/test/intent.test.ts @@ -22,7 +22,7 @@ import { } 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) // ============================================================================= diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 3da0d1b..1fadd75 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -894,7 +894,7 @@ describe("MCP Server", () => { // HTTP Transport Tests // ============================================================================= -import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp"; +import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp/server"; import { enableProductionMode } from "../src/store"; describe("MCP HTTP Transport", () => {