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:
parent
839d774a06
commit
c68904fe08
@ -118,7 +118,7 @@ qmd multi-get "#abc123, #def456"
|
||||
## Development
|
||||
|
||||
```sh
|
||||
bun src/qmd.ts <command> # Run from source
|
||||
bun src/cli/qmd.ts <command> # Run from source
|
||||
bun link # Install globally as 'qmd'
|
||||
```
|
||||
|
||||
|
||||
18
package.json
18
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": {
|
||||
|
||||
@ -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
|
||||
@ -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<void> {
|
||||
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 <name>");
|
||||
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;
|
||||
38
src/index.ts
38
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<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 */
|
||||
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>;
|
||||
|
||||
/** 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 ──────────────────────────────────────────────
|
||||
|
||||
@ -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),
|
||||
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<QMDStore> {
|
||||
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) => {
|
||||
|
||||
19
src/llm.ts
19
src/llm.ts
@ -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).
|
||||
* Used internally by LlamaCpp idle timer.
|
||||
|
||||
54
src/maintenance.ts
Normal file
54
src/maintenance.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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<string> {
|
||||
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<McpServer> {
|
||||
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 = `<!-- Context: ${context} -->\n\n` + text;
|
||||
let text = addLineNumbers(result.body || ""); // Default to line numbers
|
||||
if (result.context) {
|
||||
text = `<!-- Context: ${result.context} -->\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<void> {
|
||||
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<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).
|
||||
// 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);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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";
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@ -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)
|
||||
// =============================================================================
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user