@tobilu/qmd
Advanced tools
Sorry, the diff of this file is not supported yet
| /** | ||
| * formatter.ts - Output formatting utilities for QMD | ||
| * | ||
| * Provides methods to format search results and documents into various output formats: | ||
| * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output). | ||
| */ | ||
| import type { SearchResult, MultiGetResult, DocumentResult } from "../store.js"; | ||
| export type { SearchResult, MultiGetResult, DocumentResult }; | ||
| export type MultiGetFile = { | ||
| filepath: string; | ||
| displayPath: string; | ||
| title: string; | ||
| body: string; | ||
| context?: string | null; | ||
| skipped: false; | ||
| } | { | ||
| filepath: string; | ||
| displayPath: string; | ||
| title: string; | ||
| body: string; | ||
| context?: string | null; | ||
| skipped: true; | ||
| skipReason: string; | ||
| }; | ||
| export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json"; | ||
| export type FormatOptions = { | ||
| full?: boolean; | ||
| query?: string; | ||
| useColor?: boolean; | ||
| lineNumbers?: boolean; | ||
| intent?: string; | ||
| }; | ||
| /** | ||
| * Add line numbers to text content. | ||
| * Each line becomes: "{lineNum}: {content}" | ||
| * @param text The text to add line numbers to | ||
| * @param startLine Optional starting line number (default: 1) | ||
| */ | ||
| export declare function addLineNumbers(text: string, startLine?: number): string; | ||
| /** | ||
| * Extract short docid from a full hash (first 6 characters). | ||
| */ | ||
| export declare function getDocid(hash: string): string; | ||
| export declare function escapeCSV(value: string | null | number): string; | ||
| export declare function escapeXml(str: string): string; | ||
| /** | ||
| * Format search results as JSON | ||
| */ | ||
| export declare function searchResultsToJson(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results as CSV | ||
| */ | ||
| export declare function searchResultsToCsv(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results as simple files list (docid,score,filepath,context) | ||
| */ | ||
| export declare function searchResultsToFiles(results: SearchResult[]): string; | ||
| /** | ||
| * Format search results as Markdown | ||
| */ | ||
| export declare function searchResultsToMarkdown(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results as XML | ||
| */ | ||
| export declare function searchResultsToXml(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results for MCP (simpler CSV format with pre-extracted snippets) | ||
| */ | ||
| export declare function searchResultsToMcpCsv(results: { | ||
| docid: string; | ||
| file: string; | ||
| title: string; | ||
| score: number; | ||
| context: string | null; | ||
| snippet: string; | ||
| }[]): string; | ||
| /** | ||
| * Format documents as JSON | ||
| */ | ||
| export declare function documentsToJson(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as CSV | ||
| */ | ||
| export declare function documentsToCsv(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as files list | ||
| */ | ||
| export declare function documentsToFiles(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as Markdown | ||
| */ | ||
| export declare function documentsToMarkdown(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as XML | ||
| */ | ||
| export declare function documentsToXml(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format a single DocumentResult as JSON | ||
| */ | ||
| export declare function documentToJson(doc: DocumentResult): string; | ||
| /** | ||
| * Format a single DocumentResult as Markdown | ||
| */ | ||
| export declare function documentToMarkdown(doc: DocumentResult): string; | ||
| /** | ||
| * Format a single DocumentResult as XML | ||
| */ | ||
| export declare function documentToXml(doc: DocumentResult): string; | ||
| /** | ||
| * Format a single document to the specified format | ||
| */ | ||
| export declare function formatDocument(doc: DocumentResult, format: OutputFormat): string; | ||
| /** | ||
| * Format search results to the specified output format | ||
| */ | ||
| export declare function formatSearchResults(results: SearchResult[], format: OutputFormat, opts?: FormatOptions): string; | ||
| /** | ||
| * Format documents to the specified output format | ||
| */ | ||
| export declare function formatDocuments(results: MultiGetFile[], format: OutputFormat): string; |
| /** | ||
| * formatter.ts - Output formatting utilities for QMD | ||
| * | ||
| * Provides methods to format search results and documents into various output formats: | ||
| * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output). | ||
| */ | ||
| import { extractSnippet } from "../store.js"; | ||
| // ============================================================================= | ||
| // Helper Functions | ||
| // ============================================================================= | ||
| /** | ||
| * Add line numbers to text content. | ||
| * Each line becomes: "{lineNum}: {content}" | ||
| * @param text The text to add line numbers to | ||
| * @param startLine Optional starting line number (default: 1) | ||
| */ | ||
| export function addLineNumbers(text, startLine = 1) { | ||
| const lines = text.split('\n'); | ||
| return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n'); | ||
| } | ||
| /** | ||
| * Extract short docid from a full hash (first 6 characters). | ||
| */ | ||
| export function getDocid(hash) { | ||
| return hash.slice(0, 6); | ||
| } | ||
| // ============================================================================= | ||
| // Escape Helpers | ||
| // ============================================================================= | ||
| export function escapeCSV(value) { | ||
| if (value === null || value === undefined) | ||
| return ""; | ||
| const str = String(value); | ||
| if (str.includes(",") || str.includes('"') || str.includes("\n")) { | ||
| return `"${str.replace(/"/g, '""')}"`; | ||
| } | ||
| return str; | ||
| } | ||
| export function escapeXml(str) { | ||
| return str | ||
| .replace(/&/g, "&") | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, """) | ||
| .replace(/'/g, "'"); | ||
| } | ||
| // ============================================================================= | ||
| // Search Results Formatters | ||
| // ============================================================================= | ||
| /** | ||
| * Format search results as JSON | ||
| */ | ||
| export function searchResultsToJson(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| const output = results.map(row => { | ||
| const bodyStr = row.body || ""; | ||
| let body = opts.full ? bodyStr : undefined; | ||
| let snippet = !opts.full ? extractSnippet(bodyStr, query, 300, row.chunkPos, undefined, opts.intent).snippet : undefined; | ||
| if (opts.lineNumbers) { | ||
| if (body) | ||
| body = addLineNumbers(body); | ||
| if (snippet) | ||
| snippet = addLineNumbers(snippet); | ||
| } | ||
| return { | ||
| docid: `#${row.docid}`, | ||
| score: Math.round(row.score * 100) / 100, | ||
| file: row.displayPath, | ||
| title: row.title, | ||
| ...(row.context && { context: row.context }), | ||
| ...(body && { body }), | ||
| ...(snippet && { snippet }), | ||
| }; | ||
| }); | ||
| return JSON.stringify(output, null, 2); | ||
| } | ||
| /** | ||
| * Format search results as CSV | ||
| */ | ||
| export function searchResultsToCsv(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| const header = "docid,score,file,title,context,line,snippet"; | ||
| const rows = results.map(row => { | ||
| const bodyStr = row.body || ""; | ||
| const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent); | ||
| let content = opts.full ? bodyStr : snippet; | ||
| if (opts.lineNumbers && content) { | ||
| content = addLineNumbers(content); | ||
| } | ||
| return [ | ||
| `#${row.docid}`, | ||
| row.score.toFixed(4), | ||
| escapeCSV(row.displayPath), | ||
| escapeCSV(row.title), | ||
| escapeCSV(row.context || ""), | ||
| line, | ||
| escapeCSV(content), | ||
| ].join(","); | ||
| }); | ||
| return [header, ...rows].join("\n"); | ||
| } | ||
| /** | ||
| * Format search results as simple files list (docid,score,filepath,context) | ||
| */ | ||
| export function searchResultsToFiles(results) { | ||
| return results.map(row => { | ||
| const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : ""; | ||
| return `#${row.docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format search results as Markdown | ||
| */ | ||
| export function searchResultsToMarkdown(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| return results.map(row => { | ||
| const heading = row.title || row.displayPath; | ||
| const bodyStr = row.body || ""; | ||
| let content; | ||
| if (opts.full) { | ||
| content = bodyStr; | ||
| } | ||
| else { | ||
| content = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet; | ||
| } | ||
| if (opts.lineNumbers) { | ||
| content = addLineNumbers(content); | ||
| } | ||
| const contextLine = row.context ? `**context:** ${row.context}\n` : ""; | ||
| return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n${contextLine}\n${content}\n`; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format search results as XML | ||
| */ | ||
| export function searchResultsToXml(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| const items = results.map(row => { | ||
| const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : ""; | ||
| const bodyStr = row.body || ""; | ||
| let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet; | ||
| if (opts.lineNumbers) { | ||
| content = addLineNumbers(content); | ||
| } | ||
| const contextAttr = row.context ? ` context="${escapeXml(row.context)}"` : ""; | ||
| return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}${contextAttr}>\n${escapeXml(content)}\n</file>`; | ||
| }); | ||
| return items.join("\n\n"); | ||
| } | ||
| /** | ||
| * Format search results for MCP (simpler CSV format with pre-extracted snippets) | ||
| */ | ||
| export function searchResultsToMcpCsv(results) { | ||
| const header = "docid,file,title,score,context,snippet"; | ||
| const rows = results.map(r => [`#${r.docid}`, r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",")); | ||
| return [header, ...rows].join("\n"); | ||
| } | ||
| // ============================================================================= | ||
| // Document Formatters (for multi-get using MultiGetFile from store) | ||
| // ============================================================================= | ||
| /** | ||
| * Format documents as JSON | ||
| */ | ||
| export function documentsToJson(results) { | ||
| const output = results.map(r => ({ | ||
| file: r.displayPath, | ||
| title: r.title, | ||
| ...(r.context && { context: r.context }), | ||
| ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }), | ||
| })); | ||
| return JSON.stringify(output, null, 2); | ||
| } | ||
| /** | ||
| * Format documents as CSV | ||
| */ | ||
| export function documentsToCsv(results) { | ||
| const header = "file,title,context,skipped,body"; | ||
| const rows = results.map(r => [ | ||
| r.displayPath, | ||
| r.title, | ||
| r.context || "", | ||
| r.skipped ? "true" : "false", | ||
| r.skipped ? (r.skipReason || "") : r.body | ||
| ].map(escapeCSV).join(",")); | ||
| return [header, ...rows].join("\n"); | ||
| } | ||
| /** | ||
| * Format documents as files list | ||
| */ | ||
| export function documentsToFiles(results) { | ||
| return results.map(r => { | ||
| const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : ""; | ||
| const status = r.skipped ? ",[SKIPPED]" : ""; | ||
| return `${r.displayPath}${ctx}${status}`; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format documents as Markdown | ||
| */ | ||
| export function documentsToMarkdown(results) { | ||
| return results.map(r => { | ||
| let md = `## ${r.displayPath}\n\n`; | ||
| if (r.title && r.title !== r.displayPath) | ||
| md += `**Title:** ${r.title}\n\n`; | ||
| if (r.context) | ||
| md += `**Context:** ${r.context}\n\n`; | ||
| if (r.skipped) { | ||
| md += `> ${r.skipReason}\n`; | ||
| } | ||
| else { | ||
| md += "```\n" + r.body + "\n```\n"; | ||
| } | ||
| return md; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format documents as XML | ||
| */ | ||
| export function documentsToXml(results) { | ||
| const items = results.map(r => { | ||
| let xml = " <document>\n"; | ||
| xml += ` <file>${escapeXml(r.displayPath)}</file>\n`; | ||
| xml += ` <title>${escapeXml(r.title)}</title>\n`; | ||
| if (r.context) | ||
| xml += ` <context>${escapeXml(r.context)}</context>\n`; | ||
| if (r.skipped) { | ||
| xml += ` <skipped>true</skipped>\n`; | ||
| xml += ` <reason>${escapeXml(r.skipReason || "")}</reason>\n`; | ||
| } | ||
| else { | ||
| xml += ` <body>${escapeXml(r.body)}</body>\n`; | ||
| } | ||
| xml += " </document>"; | ||
| return xml; | ||
| }); | ||
| return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`; | ||
| } | ||
| // ============================================================================= | ||
| // Single Document Formatters | ||
| // ============================================================================= | ||
| /** | ||
| * Format a single DocumentResult as JSON | ||
| */ | ||
| export function documentToJson(doc) { | ||
| return JSON.stringify({ | ||
| file: doc.displayPath, | ||
| title: doc.title, | ||
| ...(doc.context && { context: doc.context }), | ||
| hash: doc.hash, | ||
| modifiedAt: doc.modifiedAt, | ||
| bodyLength: doc.bodyLength, | ||
| ...(doc.body !== undefined && { body: doc.body }), | ||
| }, null, 2); | ||
| } | ||
| /** | ||
| * Format a single DocumentResult as Markdown | ||
| */ | ||
| export function documentToMarkdown(doc) { | ||
| let md = `# ${doc.title || doc.displayPath}\n\n`; | ||
| if (doc.context) | ||
| md += `**Context:** ${doc.context}\n\n`; | ||
| md += `**File:** ${doc.displayPath}\n`; | ||
| md += `**Modified:** ${doc.modifiedAt}\n\n`; | ||
| if (doc.body !== undefined) { | ||
| md += "---\n\n" + doc.body + "\n"; | ||
| } | ||
| return md; | ||
| } | ||
| /** | ||
| * Format a single DocumentResult as XML | ||
| */ | ||
| export function documentToXml(doc) { | ||
| let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`; | ||
| xml += ` <file>${escapeXml(doc.displayPath)}</file>\n`; | ||
| xml += ` <title>${escapeXml(doc.title)}</title>\n`; | ||
| if (doc.context) | ||
| xml += ` <context>${escapeXml(doc.context)}</context>\n`; | ||
| xml += ` <hash>${escapeXml(doc.hash)}</hash>\n`; | ||
| xml += ` <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`; | ||
| xml += ` <bodyLength>${doc.bodyLength}</bodyLength>\n`; | ||
| if (doc.body !== undefined) { | ||
| xml += ` <body>${escapeXml(doc.body)}</body>\n`; | ||
| } | ||
| xml += `</document>`; | ||
| return xml; | ||
| } | ||
| /** | ||
| * Format a single document to the specified format | ||
| */ | ||
| export function formatDocument(doc, format) { | ||
| switch (format) { | ||
| case "json": | ||
| return documentToJson(doc); | ||
| case "md": | ||
| return documentToMarkdown(doc); | ||
| case "xml": | ||
| return documentToXml(doc); | ||
| default: | ||
| // Default to markdown for CLI and other formats | ||
| return documentToMarkdown(doc); | ||
| } | ||
| } | ||
| // ============================================================================= | ||
| // Universal Format Function | ||
| // ============================================================================= | ||
| /** | ||
| * Format search results to the specified output format | ||
| */ | ||
| export function formatSearchResults(results, format, opts = {}) { | ||
| switch (format) { | ||
| case "json": | ||
| return searchResultsToJson(results, opts); | ||
| case "csv": | ||
| return searchResultsToCsv(results, opts); | ||
| case "files": | ||
| return searchResultsToFiles(results); | ||
| case "md": | ||
| return searchResultsToMarkdown(results, opts); | ||
| case "xml": | ||
| return searchResultsToXml(results, opts); | ||
| case "cli": | ||
| // CLI format should be handled separately with colors | ||
| // Return a simple text version as fallback | ||
| return searchResultsToMarkdown(results, opts); | ||
| default: | ||
| return searchResultsToJson(results, opts); | ||
| } | ||
| } | ||
| /** | ||
| * Format documents to the specified output format | ||
| */ | ||
| export function formatDocuments(results, format) { | ||
| switch (format) { | ||
| case "json": | ||
| return documentsToJson(results); | ||
| case "csv": | ||
| return documentsToCsv(results); | ||
| case "files": | ||
| return documentsToFiles(results); | ||
| case "md": | ||
| return documentsToMarkdown(results); | ||
| case "xml": | ||
| return documentsToXml(results); | ||
| case "cli": | ||
| // CLI format should be handled separately with colors | ||
| return documentsToMarkdown(results); | ||
| default: | ||
| return documentsToJson(results); | ||
| } | ||
| } |
| export {}; |
Sorry, the diff of this file is too big to display
| /** | ||
| * 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"; | ||
| export declare class Maintenance { | ||
| private store; | ||
| constructor(store: Store); | ||
| /** Run VACUUM on the SQLite database to reclaim space */ | ||
| vacuum(): void; | ||
| /** Remove content rows that are no longer referenced by any document */ | ||
| cleanupOrphanedContent(): number; | ||
| /** Remove vector embeddings for content that no longer exists */ | ||
| cleanupOrphanedVectors(): number; | ||
| /** Clear the LLM response cache (query expansion, reranking) */ | ||
| clearLLMCache(): number; | ||
| /** Delete documents marked as inactive (removed from filesystem) */ | ||
| deleteInactiveDocs(): number; | ||
| /** Clear all vector embeddings (forces re-embedding) */ | ||
| clearEmbeddings(): void; | ||
| } |
| /** | ||
| * 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 { vacuumDatabase, cleanupOrphanedContent, cleanupOrphanedVectors, deleteLLMCache, deleteInactiveDocuments, clearAllEmbeddings, } from "./store.js"; | ||
| export class Maintenance { | ||
| store; | ||
| constructor(store) { | ||
| this.store = store; | ||
| } | ||
| /** Run VACUUM on the SQLite database to reclaim space */ | ||
| vacuum() { | ||
| vacuumDatabase(this.store.db); | ||
| } | ||
| /** Remove content rows that are no longer referenced by any document */ | ||
| cleanupOrphanedContent() { | ||
| return cleanupOrphanedContent(this.store.db); | ||
| } | ||
| /** Remove vector embeddings for content that no longer exists */ | ||
| cleanupOrphanedVectors() { | ||
| return cleanupOrphanedVectors(this.store.db); | ||
| } | ||
| /** Clear the LLM response cache (query expansion, reranking) */ | ||
| clearLLMCache() { | ||
| return deleteLLMCache(this.store.db); | ||
| } | ||
| /** Delete documents marked as inactive (removed from filesystem) */ | ||
| deleteInactiveDocs() { | ||
| return deleteInactiveDocuments(this.store.db); | ||
| } | ||
| /** Clear all vector embeddings (forces re-embedding) */ | ||
| clearEmbeddings() { | ||
| clearAllEmbeddings(this.store.db); | ||
| } | ||
| } |
| /** | ||
| * QMD MCP Server - Model Context Protocol server for QMD | ||
| * | ||
| * Exposes QMD search and document retrieval as MCP tools and resources. | ||
| * Documents are accessible via qmd:// URIs. | ||
| * | ||
| * Follows MCP spec 2025-06-18 for proper response types. | ||
| */ | ||
| export declare function startMcpServer(): Promise<void>; | ||
| export type HttpServerHandle = { | ||
| httpServer: import("http").Server; | ||
| port: number; | ||
| stop: () => Promise<void>; | ||
| }; | ||
| /** | ||
| * Start MCP server over Streamable HTTP (JSON responses, no SSE). | ||
| * Binds to localhost only. Returns a handle for shutdown and port discovery. | ||
| */ | ||
| export declare function startMcpHttpServer(port: number, options?: { | ||
| quiet?: boolean; | ||
| }): Promise<HttpServerHandle>; |
| /** | ||
| * QMD MCP Server - Model Context Protocol server for QMD | ||
| * | ||
| * Exposes QMD search and document retrieval as MCP tools and resources. | ||
| * Documents are accessible via qmd:// URIs. | ||
| * | ||
| * Follows MCP spec 2025-06-18 for proper response types. | ||
| */ | ||
| import { createServer } from "node:http"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { fileURLToPath } from "url"; | ||
| import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | ||
| import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { z } from "zod"; | ||
| import { createStore, extractSnippet, addLineNumbers, getDefaultDbPath, DEFAULT_MULTI_GET_MAX_BYTES, } from "../index.js"; | ||
| // ============================================================================= | ||
| // Helper functions | ||
| // ============================================================================= | ||
| /** | ||
| * Encode a path for use in qmd:// URIs. | ||
| * Encodes special characters but preserves forward slashes for readability. | ||
| */ | ||
| function encodeQmdPath(path) { | ||
| // Encode each path segment separately to preserve slashes | ||
| return path.split('/').map(segment => encodeURIComponent(segment)).join('/'); | ||
| } | ||
| /** | ||
| * Format search results as human-readable text summary | ||
| */ | ||
| function formatSearchSummary(results, query) { | ||
| if (results.length === 0) { | ||
| return `No results found for "${query}"`; | ||
| } | ||
| const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`]; | ||
| for (const r of results) { | ||
| lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // MCP Server | ||
| // ============================================================================= | ||
| /** | ||
| * Build dynamic server instructions from actual index state. | ||
| * Injected into the LLM's system prompt via MCP initialize response — | ||
| * gives the LLM immediate context about what's searchable without a tool call. | ||
| */ | ||
| async function buildInstructions(store) { | ||
| const status = await store.getStatus(); | ||
| const contexts = await store.listContexts(); | ||
| const globalCtx = await store.getGlobalContext(); | ||
| const lines = []; | ||
| // --- What is this? --- | ||
| lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`); | ||
| if (globalCtx) | ||
| lines.push(`Context: ${globalCtx}`); | ||
| // --- What's searchable? --- | ||
| if (status.collections.length > 0) { | ||
| lines.push(""); | ||
| lines.push("Collections (scope with `collection` parameter):"); | ||
| for (const col of status.collections) { | ||
| // 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}`); | ||
| } | ||
| } | ||
| // --- Capability gaps --- | ||
| if (!status.hasVectorIndex) { | ||
| lines.push(""); | ||
| lines.push("Note: No vector embeddings yet. Run `qmd embed` to enable semantic search (vec/hyde)."); | ||
| } | ||
| else if (status.needsEmbedding > 0) { | ||
| lines.push(""); | ||
| lines.push(`Note: ${status.needsEmbedding} documents need embedding. Run \`qmd embed\` to update.`); | ||
| } | ||
| // --- Search tool --- | ||
| lines.push(""); | ||
| lines.push("Search: Use `query` with sub-queries (lex/vec/hyde):"); | ||
| lines.push(" - type:'lex' — BM25 keyword search (exact terms, fast)"); | ||
| lines.push(" - type:'vec' — semantic vector search (meaning-based)"); | ||
| lines.push(" - type:'hyde' — hypothetical document (write what the answer looks like)"); | ||
| lines.push(""); | ||
| lines.push(" Always provide `intent` on every search call to disambiguate and improve snippets."); | ||
| lines.push(""); | ||
| lines.push("Examples:"); | ||
| lines.push(" Quick keyword lookup: [{type:'lex', query:'error handling'}]"); | ||
| lines.push(" Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]"); | ||
| lines.push(" Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]"); | ||
| lines.push(" With intent: searches=[{type:'lex', query:'performance'}], intent='web page load times'"); | ||
| // --- Retrieval workflow --- | ||
| lines.push(""); | ||
| lines.push("Retrieval:"); | ||
| lines.push(" - `get` — single document by path or docid (#abc123). Supports line offset (`file.md:100`)."); | ||
| lines.push(" - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`) or comma-separated list."); | ||
| // --- Non-obvious things that prevent mistakes --- | ||
| lines.push(""); | ||
| lines.push("Tips:"); | ||
| lines.push(" - File paths in results are relative to their collection."); | ||
| lines.push(" - Use `minScore: 0.5` to filter low-confidence results."); | ||
| lines.push(" - Results include a `context` field describing the content type."); | ||
| return lines.join("\n"); | ||
| } | ||
| /** | ||
| * Create an MCP server with all QMD tools, resources, and prompts registered. | ||
| * Shared by both stdio and HTTP transports. | ||
| */ | ||
| async function createMcpServer(store) { | ||
| const server = new McpServer({ name: "qmd", version: "0.9.9" }, { 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 | ||
| // --------------------------------------------------------------------------- | ||
| server.registerResource("document", new ResourceTemplate("qmd://{+path}", { list: undefined }), { | ||
| title: "QMD Document", | ||
| description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.", | ||
| mimeType: "text/markdown", | ||
| }, async (uri, { path }) => { | ||
| // Decode URL-encoded path (MCP clients send encoded URIs) | ||
| const pathStr = Array.isArray(path) ? path.join('/') : (path || ''); | ||
| const decodedPath = decodeURIComponent(pathStr); | ||
| // Use SDK to find document — findDocument handles collection/path resolution | ||
| const result = await store.get(decodedPath, { includeBody: true }); | ||
| if ("error" in result) { | ||
| return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] }; | ||
| } | ||
| let text = addLineNumbers(result.body || ""); // Default to line numbers | ||
| if (result.context) { | ||
| text = `<!-- Context: ${result.context} -->\n\n` + text; | ||
| } | ||
| return { | ||
| contents: [{ | ||
| uri: uri.href, | ||
| name: result.displayPath, | ||
| title: result.title || result.displayPath, | ||
| mimeType: "text/markdown", | ||
| text, | ||
| }], | ||
| }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: query (Primary search tool) | ||
| // --------------------------------------------------------------------------- | ||
| const subSearchSchema = z.object({ | ||
| type: z.enum(['lex', 'vec', 'hyde']).describe("lex = BM25 keywords (supports \"phrase\" and -negation); " + | ||
| "vec = semantic question; hyde = hypothetical answer passage"), | ||
| query: z.string().describe("The query text. For lex: use keywords, \"quoted phrases\", and -negation. " + | ||
| "For vec: natural language question. For hyde: 50-100 word answer passage."), | ||
| }); | ||
| server.registerTool("query", { | ||
| title: "Query", | ||
| description: `Search the knowledge base using a query document — one or more typed sub-queries combined for best recall. | ||
| ## Query Types | ||
| **lex** — BM25 keyword search. Fast, exact, no LLM needed. | ||
| Full lex syntax: | ||
| - \`term\` — prefix match ("perf" matches "performance") | ||
| - \`"exact phrase"\` — phrase must appear verbatim | ||
| - \`-term\` or \`-"phrase"\` — exclude documents containing this | ||
| Good lex examples: | ||
| - \`"connection pool" timeout -redis\` | ||
| - \`"machine learning" -sports -athlete\` | ||
| - \`handleError async typescript\` | ||
| **vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words. | ||
| - \`how does the rate limiter handle burst traffic?\` | ||
| - \`what is the tradeoff between consistency and availability?\` | ||
| **hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics. | ||
| - \`The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.\` | ||
| ## Strategy | ||
| Combine types for best results. First sub-query gets 2× weight — put your strongest signal first. | ||
| | Goal | Approach | | ||
| |------|----------| | ||
| | Know exact term/name | \`lex\` only | | ||
| | Concept search | \`vec\` only | | ||
| | Best recall | \`lex\` + \`vec\` | | ||
| | Complex/nuanced | \`lex\` + \`vec\` + \`hyde\` | | ||
| | Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it | | ||
| ## Examples | ||
| Simple lookup: | ||
| \`\`\`json | ||
| [{ "type": "lex", "query": "CAP theorem" }] | ||
| \`\`\` | ||
| Best recall on a technical topic: | ||
| \`\`\`json | ||
| [ | ||
| { "type": "lex", "query": "\\"connection pool\\" timeout -redis" }, | ||
| { "type": "vec", "query": "why do database connections time out under load" }, | ||
| { "type": "hyde", "query": "Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected." } | ||
| ] | ||
| \`\`\` | ||
| Intent-aware lex (C++ performance, not sports): | ||
| \`\`\`json | ||
| [ | ||
| { "type": "lex", "query": "\\"C++ performance\\" optimization -sports -athlete" }, | ||
| { "type": "vec", "query": "how to optimize C++ program performance" } | ||
| ] | ||
| \`\`\``, | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: { | ||
| searches: z.array(subSearchSchema).min(1).max(10).describe("Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight."), | ||
| limit: z.number().optional().default(10).describe("Max results (default: 10)"), | ||
| minScore: z.number().optional().default(0).describe("Min relevance 0-1 (default: 0)"), | ||
| candidateLimit: z.number().optional().describe("Maximum candidates to rerank (default: 40, lower = faster but may miss results)"), | ||
| collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"), | ||
| intent: z.string().optional().describe("Background context to disambiguate the query. Example: query='performance', intent='web page load times and Core Web Vitals'. Does not search on its own."), | ||
| }, | ||
| }, async ({ searches, limit, minScore, candidateLimit, collections, intent }) => { | ||
| // Map to internal format | ||
| const queries = searches.map(s => ({ | ||
| type: s.type, | ||
| query: s.query, | ||
| })); | ||
| // Use default collections if none specified | ||
| const effectiveCollections = collections ?? defaultCollectionNames; | ||
| const results = await store.search({ | ||
| queries, | ||
| collections: effectiveCollections.length > 0 ? effectiveCollections : undefined, | ||
| limit, | ||
| minScore, | ||
| intent, | ||
| }); | ||
| // Use first lex or vec query for snippet extraction | ||
| const primaryQuery = searches.find(s => s.type === 'lex')?.query | ||
| || searches.find(s => s.type === 'vec')?.query | ||
| || searches[0]?.query || ""; | ||
| const filtered = results.map(r => { | ||
| const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300, undefined, undefined, intent); | ||
| return { | ||
| docid: `#${r.docid}`, | ||
| file: r.displayPath, | ||
| title: r.title, | ||
| score: Math.round(r.score * 100) / 100, | ||
| context: r.context, | ||
| snippet: addLineNumbers(snippet, line), | ||
| }; | ||
| }); | ||
| return { | ||
| content: [{ type: "text", text: formatSearchSummary(filtered, primaryQuery) }], | ||
| structuredContent: { results: filtered }, | ||
| }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: qmd_get (Retrieve document) | ||
| // --------------------------------------------------------------------------- | ||
| server.registerTool("get", { | ||
| title: "Get Document", | ||
| description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.", | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: { | ||
| file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"), | ||
| fromLine: z.number().optional().describe("Start from this line number (1-indexed)"), | ||
| maxLines: z.number().optional().describe("Maximum number of lines to return"), | ||
| lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"), | ||
| }, | ||
| }, async ({ file, fromLine, maxLines, lineNumbers }) => { | ||
| // Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided | ||
| let parsedFromLine = fromLine; | ||
| let lookup = file; | ||
| const colonMatch = lookup.match(/:(\d+)$/); | ||
| if (colonMatch && colonMatch[1] && parsedFromLine === undefined) { | ||
| parsedFromLine = parseInt(colonMatch[1], 10); | ||
| lookup = lookup.slice(0, -colonMatch[0].length); | ||
| } | ||
| const result = await store.get(lookup, { includeBody: false }); | ||
| if ("error" in result) { | ||
| let msg = `Document not found: ${file}`; | ||
| if (result.similarFiles.length > 0) { | ||
| msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`; | ||
| } | ||
| return { | ||
| content: [{ type: "text", text: msg }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines }) ?? ""; | ||
| let text = body; | ||
| if (lineNumbers) { | ||
| const startLine = parsedFromLine || 1; | ||
| text = addLineNumbers(text, startLine); | ||
| } | ||
| if (result.context) { | ||
| text = `<!-- Context: ${result.context} -->\n\n` + text; | ||
| } | ||
| return { | ||
| content: [{ | ||
| type: "resource", | ||
| resource: { | ||
| uri: `qmd://${encodeQmdPath(result.displayPath)}`, | ||
| name: result.displayPath, | ||
| title: result.title, | ||
| mimeType: "text/markdown", | ||
| text, | ||
| }, | ||
| }], | ||
| }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: qmd_multi_get (Retrieve multiple documents) | ||
| // --------------------------------------------------------------------------- | ||
| server.registerTool("multi_get", { | ||
| title: "Multi-Get Documents", | ||
| description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.", | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: { | ||
| pattern: z.string().describe("Glob pattern or comma-separated list of file paths"), | ||
| maxLines: z.number().optional().describe("Maximum lines per file"), | ||
| maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"), | ||
| lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"), | ||
| }, | ||
| }, async ({ pattern, maxLines, maxBytes, lineNumbers }) => { | ||
| const { docs, errors } = await store.multiGet(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES }); | ||
| if (docs.length === 0 && errors.length === 0) { | ||
| return { | ||
| content: [{ type: "text", text: `No files matched pattern: ${pattern}` }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const content = []; | ||
| if (errors.length > 0) { | ||
| content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` }); | ||
| } | ||
| for (const result of docs) { | ||
| if (result.skipped) { | ||
| content.push({ | ||
| type: "text", | ||
| text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`, | ||
| }); | ||
| continue; | ||
| } | ||
| let text = result.doc.body || ""; | ||
| if (maxLines !== undefined) { | ||
| const lines = text.split("\n"); | ||
| text = lines.slice(0, maxLines).join("\n"); | ||
| if (lines.length > maxLines) { | ||
| text += `\n\n[... truncated ${lines.length - maxLines} more lines]`; | ||
| } | ||
| } | ||
| if (lineNumbers) { | ||
| text = addLineNumbers(text); | ||
| } | ||
| if (result.doc.context) { | ||
| text = `<!-- Context: ${result.doc.context} -->\n\n` + text; | ||
| } | ||
| content.push({ | ||
| type: "resource", | ||
| resource: { | ||
| uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`, | ||
| name: result.doc.displayPath, | ||
| title: result.doc.title, | ||
| mimeType: "text/markdown", | ||
| text, | ||
| }, | ||
| }); | ||
| } | ||
| return { content }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: qmd_status (Index status) | ||
| // --------------------------------------------------------------------------- | ||
| server.registerTool("status", { | ||
| title: "Index Status", | ||
| description: "Show the status of the QMD index: collections, document counts, and health information.", | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: {}, | ||
| }, async () => { | ||
| const status = await store.getStatus(); | ||
| const summary = [ | ||
| `QMD Index Status:`, | ||
| ` Total documents: ${status.totalDocuments}`, | ||
| ` Needs embedding: ${status.needsEmbedding}`, | ||
| ` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`, | ||
| ` Collections: ${status.collections.length}`, | ||
| ]; | ||
| for (const col of status.collections) { | ||
| summary.push(` - ${col.path} (${col.documents} docs)`); | ||
| } | ||
| return { | ||
| content: [{ type: "text", text: summary.join('\n') }], | ||
| structuredContent: status, | ||
| }; | ||
| }); | ||
| return server; | ||
| } | ||
| // ============================================================================= | ||
| // Transport: stdio (default) | ||
| // ============================================================================= | ||
| export async function startMcpServer() { | ||
| const store = await createStore({ dbPath: getDefaultDbPath() }); | ||
| const server = await createMcpServer(store); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } | ||
| /** | ||
| * Start MCP server over Streamable HTTP (JSON responses, no SSE). | ||
| * Binds to localhost only. Returns a handle for shutdown and port discovery. | ||
| */ | ||
| export async function startMcpHttpServer(port, options) { | ||
| 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. | ||
| const sessions = new Map(); | ||
| async function createSession() { | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ | ||
| sessionIdGenerator: () => randomUUID(), | ||
| enableJsonResponse: true, | ||
| onsessioninitialized: (sessionId) => { | ||
| sessions.set(sessionId, transport); | ||
| log(`${ts()} New session ${sessionId} (${sessions.size} active)`); | ||
| }, | ||
| }); | ||
| const server = await createMcpServer(store); | ||
| await server.connect(transport); | ||
| transport.onclose = () => { | ||
| if (transport.sessionId) { | ||
| sessions.delete(transport.sessionId); | ||
| } | ||
| }; | ||
| return transport; | ||
| } | ||
| const startTime = Date.now(); | ||
| const quiet = options?.quiet ?? false; | ||
| /** Format timestamp for request logging */ | ||
| function ts() { | ||
| return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS | ||
| } | ||
| /** Extract a human-readable label from a JSON-RPC body */ | ||
| function describeRequest(body) { | ||
| const method = body?.method ?? "unknown"; | ||
| if (method === "tools/call") { | ||
| const tool = body.params?.name ?? "?"; | ||
| const args = body.params?.arguments; | ||
| // Show query string if present, truncated | ||
| if (args?.query) { | ||
| const q = String(args.query).slice(0, 80); | ||
| return `tools/call ${tool} "${q}"`; | ||
| } | ||
| if (args?.path) | ||
| return `tools/call ${tool} ${args.path}`; | ||
| if (args?.pattern) | ||
| return `tools/call ${tool} ${args.pattern}`; | ||
| return `tools/call ${tool}`; | ||
| } | ||
| return method; | ||
| } | ||
| function log(msg) { | ||
| if (!quiet) | ||
| console.error(msg); | ||
| } | ||
| // Helper to collect request body | ||
| async function collectBody(req) { | ||
| const chunks = []; | ||
| for await (const chunk of req) | ||
| chunks.push(chunk); | ||
| return Buffer.concat(chunks).toString(); | ||
| } | ||
| const httpServer = createServer(async (nodeReq, nodeRes) => { | ||
| const reqStart = Date.now(); | ||
| const pathname = nodeReq.url || "/"; | ||
| try { | ||
| if (pathname === "/health" && nodeReq.method === "GET") { | ||
| const body = JSON.stringify({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000) }); | ||
| nodeRes.writeHead(200, { "Content-Type": "application/json" }); | ||
| nodeRes.end(body); | ||
| log(`${ts()} GET /health (${Date.now() - reqStart}ms)`); | ||
| return; | ||
| } | ||
| // REST endpoint: POST /search — structured search without MCP protocol | ||
| // REST endpoint: POST /query (alias: /search) — structured search without MCP protocol | ||
| 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" }); | ||
| nodeRes.end(JSON.stringify({ error: "Missing required field: searches (array)" })); | ||
| return; | ||
| } | ||
| // Map to internal format | ||
| const queries = params.searches.map((s) => ({ | ||
| type: s.type, | ||
| query: String(s.query || ""), | ||
| })); | ||
| // Use default collections if none specified | ||
| const effectiveCollections = params.collections ?? defaultCollectionNames; | ||
| const results = await store.search({ | ||
| queries, | ||
| collections: effectiveCollections.length > 0 ? effectiveCollections : undefined, | ||
| limit: params.limit ?? 10, | ||
| minScore: params.minScore ?? 0, | ||
| intent: params.intent, | ||
| }); | ||
| // Use first lex or vec query for snippet extraction | ||
| const primaryQuery = params.searches.find((s) => s.type === 'lex')?.query | ||
| || params.searches.find((s) => s.type === 'vec')?.query | ||
| || params.searches[0]?.query || ""; | ||
| const formatted = results.map(r => { | ||
| const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300); | ||
| return { | ||
| docid: `#${r.docid}`, | ||
| file: r.displayPath, | ||
| title: r.title, | ||
| score: Math.round(r.score * 100) / 100, | ||
| context: r.context, | ||
| snippet: addLineNumbers(snippet, line), | ||
| }; | ||
| }); | ||
| nodeRes.writeHead(200, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ results: formatted })); | ||
| log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`); | ||
| return; | ||
| } | ||
| if (pathname === "/mcp" && nodeReq.method === "POST") { | ||
| const rawBody = await collectBody(nodeReq); | ||
| const body = JSON.parse(rawBody); | ||
| const label = describeRequest(body); | ||
| const url = `http://localhost:${port}${pathname}`; | ||
| const headers = {}; | ||
| for (const [k, v] of Object.entries(nodeReq.headers)) { | ||
| if (typeof v === "string") | ||
| headers[k] = v; | ||
| } | ||
| // Route to existing session or create new one on initialize | ||
| const sessionId = headers["mcp-session-id"]; | ||
| let transport; | ||
| if (sessionId) { | ||
| const existing = sessions.get(sessionId); | ||
| if (!existing) { | ||
| nodeRes.writeHead(404, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32001, message: "Session not found" }, | ||
| id: body?.id ?? null, | ||
| })); | ||
| return; | ||
| } | ||
| transport = existing; | ||
| } | ||
| else if (isInitializeRequest(body)) { | ||
| transport = await createSession(); | ||
| } | ||
| else { | ||
| nodeRes.writeHead(400, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32000, message: "Bad Request: Missing session ID" }, | ||
| id: body?.id ?? null, | ||
| })); | ||
| return; | ||
| } | ||
| const request = new Request(url, { method: "POST", headers, body: rawBody }); | ||
| const response = await transport.handleRequest(request, { parsedBody: body }); | ||
| nodeRes.writeHead(response.status, Object.fromEntries(response.headers)); | ||
| nodeRes.end(Buffer.from(await response.arrayBuffer())); | ||
| log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`); | ||
| return; | ||
| } | ||
| if (pathname === "/mcp") { | ||
| const headers = {}; | ||
| for (const [k, v] of Object.entries(nodeReq.headers)) { | ||
| if (typeof v === "string") | ||
| headers[k] = v; | ||
| } | ||
| // GET/DELETE must have a valid session | ||
| const sessionId = headers["mcp-session-id"]; | ||
| if (!sessionId) { | ||
| nodeRes.writeHead(400, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32000, message: "Bad Request: Missing session ID" }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| const transport = sessions.get(sessionId); | ||
| if (!transport) { | ||
| nodeRes.writeHead(404, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32001, message: "Session not found" }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| const url = `http://localhost:${port}${pathname}`; | ||
| const rawBody = nodeReq.method !== "GET" && nodeReq.method !== "HEAD" ? await collectBody(nodeReq) : undefined; | ||
| const request = new Request(url, { method: nodeReq.method || "GET", headers, ...(rawBody ? { body: rawBody } : {}) }); | ||
| const response = await transport.handleRequest(request); | ||
| nodeRes.writeHead(response.status, Object.fromEntries(response.headers)); | ||
| nodeRes.end(Buffer.from(await response.arrayBuffer())); | ||
| return; | ||
| } | ||
| nodeRes.writeHead(404); | ||
| nodeRes.end("Not Found"); | ||
| } | ||
| catch (err) { | ||
| console.error("HTTP handler error:", err); | ||
| nodeRes.writeHead(500); | ||
| nodeRes.end("Internal Server Error"); | ||
| } | ||
| }); | ||
| await new Promise((resolve, reject) => { | ||
| httpServer.on("error", reject); | ||
| httpServer.listen(port, "localhost", () => resolve()); | ||
| }); | ||
| const actualPort = httpServer.address().port; | ||
| let stopping = false; | ||
| const stop = async () => { | ||
| if (stopping) | ||
| return; | ||
| stopping = true; | ||
| for (const transport of sessions.values()) { | ||
| await transport.close(); | ||
| } | ||
| sessions.clear(); | ||
| httpServer.close(); | ||
| await store.close(); | ||
| }; | ||
| process.on("SIGTERM", async () => { | ||
| console.error("Shutting down (SIGTERM)..."); | ||
| await stop(); | ||
| process.exit(0); | ||
| }); | ||
| process.on("SIGINT", async () => { | ||
| console.error("Shutting down (SIGINT)..."); | ||
| await stop(); | ||
| process.exit(0); | ||
| }); | ||
| log(`QMD MCP server listening on http://localhost:${actualPort}/mcp`); | ||
| return { httpServer, port: actualPort, stop }; | ||
| } | ||
| // Run if this is the main module | ||
| if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("/server.js")) { | ||
| startMcpServer().catch(console.error); | ||
| } |
+25
-0
@@ -5,2 +5,27 @@ # Changelog | ||
| ## [2.0.0] - 2026-03-10 | ||
| QMD 2.0 declares a stable library API. The SDK is now the primary interface — | ||
| the MCP server is a clean consumer of it, and the source is organized into | ||
| `src/cli/` and `src/mcp/`. Also: Node 25 support and a runtime-aware bin wrapper | ||
| for bun installs. | ||
| ### Changes | ||
| - Stable SDK API with `QMDStore` interface — search, retrieval, collection/context | ||
| management, indexing, lifecycle | ||
| - Unified `search()`: pass `query` for auto-expansion or `queries` for | ||
| pre-expanded lex/vec/hyde — replaces the old query/search/structuredSearch split | ||
| - New `getDocumentBody()`, `getDefaultCollectionNames()`, `Maintenance` class | ||
| - MCP server rewritten as a clean SDK consumer — zero internal store access | ||
| - CLI and MCP organized into `src/cli/` and `src/mcp/` subdirectories | ||
| - Runtime-aware `bin/qmd` wrapper detects bun vs node to avoid ABI mismatches. | ||
| Closes #319 | ||
| - `better-sqlite3` bumped to ^12.4.5 for Node 25 support. Closes #257 | ||
| - Utility exports: `extractSnippet`, `addLineNumbers`, `DEFAULT_MULTI_GET_MAX_BYTES` | ||
| ### Fixes | ||
| - Remove unused `import { resolve }` in store.ts that shadowed local export | ||
| ## [1.1.6] - 2026-03-09 | ||
@@ -7,0 +32,0 @@ |
+129
-38
@@ -7,3 +7,3 @@ /** | ||
| * | ||
| * const store = createStore({ | ||
| * const store = await createStore({ | ||
| * dbPath: './my-index.sqlite', | ||
@@ -17,11 +17,81 @@ * config: { | ||
| * | ||
| * const results = await store.query("how does auth work?") | ||
| * store.close() | ||
| * const results = await store.search({ query: "how does auth work?" }) | ||
| * await store.close() | ||
| */ | ||
| import { type Store as InternalStore, type DocumentResult, type DocumentNotFound, type SearchResult, type HybridQueryResult, type HybridQueryOptions, type HybridQueryExplain, type StructuredSubSearch, type StructuredSearchOptions, type MultiGetResult, type IndexStatus, type IndexHealthInfo, type ExpandedQuery, type SearchHooks } from "./store.js"; | ||
| import { extractSnippet, addLineNumbers, DEFAULT_MULTI_GET_MAX_BYTES, type Store as InternalStore, type DocumentResult, type DocumentNotFound, type SearchResult, type HybridQueryResult, type HybridQueryOptions, type HybridQueryExplain, type ExpandedQuery, type StructuredSearchOptions, type MultiGetResult, type IndexStatus, type IndexHealthInfo, type SearchHooks, type ReindexProgress, type ReindexResult, type EmbedProgress, type EmbedResult } from "./store.js"; | ||
| import { type Collection, type CollectionConfig, type NamedCollection, type ContextMap } from "./collections.js"; | ||
| export type { DocumentResult, DocumentNotFound, SearchResult, HybridQueryResult, HybridQueryOptions, HybridQueryExplain, StructuredSubSearch, StructuredSearchOptions, MultiGetResult, IndexStatus, IndexHealthInfo, ExpandedQuery, SearchHooks, Collection, CollectionConfig, NamedCollection, ContextMap, }; | ||
| export type { DocumentResult, DocumentNotFound, SearchResult, HybridQueryResult, HybridQueryOptions, HybridQueryExplain, ExpandedQuery, StructuredSearchOptions, MultiGetResult, IndexStatus, IndexHealthInfo, SearchHooks, ReindexProgress, ReindexResult, EmbedProgress, EmbedResult, Collection, CollectionConfig, NamedCollection, ContextMap, }; | ||
| export type { InternalStore }; | ||
| export { extractSnippet, addLineNumbers, DEFAULT_MULTI_GET_MAX_BYTES }; | ||
| export { getDefaultDbPath } from "./store.js"; | ||
| export { Maintenance } from "./maintenance.js"; | ||
| /** | ||
| * Progress info emitted during update() for each file processed. | ||
| */ | ||
| export type UpdateProgress = { | ||
| collection: string; | ||
| file: string; | ||
| current: number; | ||
| total: number; | ||
| }; | ||
| /** | ||
| * Aggregated result from update() across all collections. | ||
| */ | ||
| export type UpdateResult = { | ||
| collections: number; | ||
| indexed: number; | ||
| updated: number; | ||
| unchanged: number; | ||
| removed: number; | ||
| needsEmbedding: number; | ||
| }; | ||
| /** | ||
| * Options for the unified search() method. | ||
| */ | ||
| export interface SearchOptions { | ||
| /** Simple query string — will be auto-expanded via LLM */ | ||
| query?: string; | ||
| /** Pre-expanded queries (from expandQuery) — skips auto-expansion */ | ||
| queries?: ExpandedQuery[]; | ||
| /** Domain intent hint — steers expansion and reranking */ | ||
| intent?: string; | ||
| /** Rerank results using LLM (default: true) */ | ||
| rerank?: boolean; | ||
| /** Filter to a specific collection */ | ||
| collection?: string; | ||
| /** Filter to specific collections */ | ||
| collections?: string[]; | ||
| /** Max results (default: 10) */ | ||
| limit?: number; | ||
| /** Minimum score threshold */ | ||
| minScore?: number; | ||
| /** Include explain traces */ | ||
| explain?: boolean; | ||
| } | ||
| /** | ||
| * Options for searchLex() — BM25 keyword search. | ||
| */ | ||
| export interface LexSearchOptions { | ||
| limit?: number; | ||
| collection?: string; | ||
| } | ||
| /** | ||
| * Options for searchVector() — vector similarity search. | ||
| */ | ||
| export interface VectorSearchOptions { | ||
| limit?: number; | ||
| collection?: string; | ||
| } | ||
| /** | ||
| * Options for expandQuery() — manual query expansion. | ||
| */ | ||
| export interface ExpandQueryOptions { | ||
| intent?: string; | ||
| } | ||
| /** | ||
| * Options for creating a QMD store. | ||
| * You must provide `dbPath` and either `configPath` (YAML file) or `config` (inline). | ||
| * | ||
| * Provide `dbPath` and optionally `configPath` (YAML file) or `config` (inline). | ||
| * If neither configPath nor config is provided, the store reads from existing | ||
| * DB state (useful for reopening a previously-configured store). | ||
| */ | ||
@@ -39,2 +109,5 @@ export interface StoreOptions { | ||
| * context management, and indexing operations. | ||
| * | ||
| * All methods are async. The store manages its own LlamaCpp instance | ||
| * (lazy-loaded, auto-unloaded after inactivity) — no global singletons. | ||
| */ | ||
@@ -46,15 +119,19 @@ export interface QMDStore { | ||
| readonly dbPath: string; | ||
| /** Hybrid search: BM25 + vector + query expansion + LLM reranking */ | ||
| query(query: string, options?: HybridQueryOptions): Promise<HybridQueryResult[]>; | ||
| /** BM25 full-text keyword search (fast, no LLM) */ | ||
| search(query: string, options?: { | ||
| limit?: number; | ||
| collection?: string; | ||
| }): SearchResult[]; | ||
| /** Structured search with pre-expanded queries (for LLM callers) */ | ||
| structuredSearch(searches: StructuredSubSearch[], options?: StructuredSearchOptions): Promise<HybridQueryResult[]>; | ||
| /** Full search: query expansion + multi-signal retrieval + LLM reranking */ | ||
| search(options: SearchOptions): Promise<HybridQueryResult[]>; | ||
| /** BM25 keyword search (fast, no LLM) */ | ||
| searchLex(query: string, options?: LexSearchOptions): Promise<SearchResult[]>; | ||
| /** Vector similarity search (embedding model, no reranking) */ | ||
| searchVector(query: string, options?: VectorSearchOptions): Promise<SearchResult[]>; | ||
| /** Expand a query into typed sub-searches (lex/vec/hyde) for manual control */ | ||
| expandQuery(query: string, options?: ExpandQueryOptions): Promise<ExpandedQuery[]>; | ||
| /** Get a single document by path or docid */ | ||
| get(pathOrDocid: string, options?: { | ||
| includeBody?: boolean; | ||
| }): DocumentResult | DocumentNotFound; | ||
| }): 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 */ | ||
@@ -64,6 +141,6 @@ multiGet(pattern: string, options?: { | ||
| maxBytes?: number; | ||
| }): { | ||
| }): Promise<{ | ||
| docs: MultiGetResult[]; | ||
| errors: string[]; | ||
| }; | ||
| }>; | ||
| /** Add or update a collection */ | ||
@@ -74,9 +151,9 @@ addCollection(name: string, opts: { | ||
| ignore?: string[]; | ||
| }): void; | ||
| }): Promise<void>; | ||
| /** Remove a collection */ | ||
| removeCollection(name: string): boolean; | ||
| removeCollection(name: string): Promise<boolean>; | ||
| /** Rename a collection */ | ||
| renameCollection(oldName: string, newName: string): boolean; | ||
| renameCollection(oldName: string, newName: string): Promise<boolean>; | ||
| /** List all collections with document stats */ | ||
| listCollections(): { | ||
| listCollections(): Promise<{ | ||
| name: string; | ||
@@ -88,23 +165,37 @@ pwd: string; | ||
| last_modified: string | null; | ||
| }[]; | ||
| includeByDefault: boolean; | ||
| }[]>; | ||
| /** Get names of collections included by default in queries */ | ||
| getDefaultCollectionNames(): Promise<string[]>; | ||
| /** Add context for a path within a collection */ | ||
| addContext(collectionName: string, pathPrefix: string, contextText: string): boolean; | ||
| addContext(collectionName: string, pathPrefix: string, contextText: string): Promise<boolean>; | ||
| /** Remove context from a collection path */ | ||
| removeContext(collectionName: string, pathPrefix: string): boolean; | ||
| removeContext(collectionName: string, pathPrefix: string): Promise<boolean>; | ||
| /** Set global context (applies to all collections) */ | ||
| setGlobalContext(context: string | undefined): void; | ||
| setGlobalContext(context: string | undefined): Promise<void>; | ||
| /** Get global context */ | ||
| getGlobalContext(): string | undefined; | ||
| getGlobalContext(): Promise<string | undefined>; | ||
| /** List all contexts across all collections */ | ||
| listContexts(): Array<{ | ||
| listContexts(): Promise<Array<{ | ||
| collection: string; | ||
| path: string; | ||
| context: string; | ||
| }>; | ||
| }>>; | ||
| /** Re-index collections by scanning the filesystem */ | ||
| update(options?: { | ||
| collections?: string[]; | ||
| onProgress?: (info: UpdateProgress) => void; | ||
| }): Promise<UpdateResult>; | ||
| /** Generate vector embeddings for documents that need them */ | ||
| embed(options?: { | ||
| force?: boolean; | ||
| model?: string; | ||
| onProgress?: (info: EmbedProgress) => void; | ||
| }): Promise<EmbedResult>; | ||
| /** Get index status (document counts, collections, embedding state) */ | ||
| getStatus(): IndexStatus; | ||
| getStatus(): Promise<IndexStatus>; | ||
| /** Get index health info (stale embeddings, etc.) */ | ||
| getIndexHealth(): IndexHealthInfo; | ||
| /** Close the database connection */ | ||
| close(): void; | ||
| getIndexHealth(): Promise<IndexHealthInfo>; | ||
| /** Close the store and release all resources (LLM models, DB connection) */ | ||
| close(): Promise<void>; | ||
| } | ||
@@ -117,3 +208,3 @@ /** | ||
| * // With a YAML config file | ||
| * const store = createStore({ | ||
| * const store = await createStore({ | ||
| * dbPath: './index.sqlite', | ||
@@ -124,3 +215,3 @@ * configPath: './qmd.yml', | ||
| * // With inline config (no files needed besides the DB) | ||
| * const store = createStore({ | ||
| * const store = await createStore({ | ||
| * dbPath: './index.sqlite', | ||
@@ -134,6 +225,6 @@ * config: { | ||
| * | ||
| * const results = await store.query("authentication flow") | ||
| * store.close() | ||
| * const results = await store.search({ query: "authentication flow" }) | ||
| * await store.close() | ||
| * ``` | ||
| */ | ||
| export declare function createStore(options: StoreOptions): QMDStore; | ||
| export declare function createStore(options: StoreOptions): Promise<QMDStore>; |
+175
-41
@@ -7,3 +7,3 @@ /** | ||
| * | ||
| * const store = createStore({ | ||
| * const store = await createStore({ | ||
| * dbPath: './my-index.sqlite', | ||
@@ -17,7 +17,14 @@ * config: { | ||
| * | ||
| * const results = await store.query("how does auth work?") | ||
| * store.close() | ||
| * const results = await store.search({ query: "how does auth work?" }) | ||
| * await store.close() | ||
| */ | ||
| import { createStore as createStoreInternal, hybridQuery, structuredSearch, listCollections as storeListCollections, } from "./store.js"; | ||
| import { setConfigSource, loadConfig, addCollection as collectionsAddCollection, removeCollection as collectionsRemoveCollection, renameCollection as collectionsRenameCollection, listCollections as collectionsListCollections, addContext as collectionsAddContext, removeContext as collectionsRemoveContext, setGlobalContext as collectionsSetGlobalContext, getGlobalContext as collectionsGetGlobalContext, listAllContexts as collectionsListAllContexts, } from "./collections.js"; | ||
| import { createStore as createStoreInternal, hybridQuery, structuredSearch, extractSnippet, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_MULTI_GET_MAX_BYTES, reindexCollection, generateEmbeddings, listCollections as storeListCollections, syncConfigToDb, getStoreCollections, getStoreCollection, getStoreGlobalContext, getStoreContexts, upsertStoreCollection, deleteStoreCollection, renameStoreCollection, updateStoreContext, removeStoreContext, setStoreGlobalContext, vacuumDatabase, cleanupOrphanedContent, cleanupOrphanedVectors, deleteLLMCache, deleteInactiveDocuments, clearAllEmbeddings, } from "./store.js"; | ||
| import { LlamaCpp, } from "./llm.js"; | ||
| import { setConfigSource, loadConfig, addCollection as collectionsAddCollection, removeCollection as collectionsRemoveCollection, renameCollection as collectionsRenameCollection, addContext as collectionsAddContext, removeContext as collectionsRemoveContext, setGlobalContext as collectionsSetGlobalContext, } from "./collections.js"; | ||
| // 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"; | ||
| /** | ||
@@ -29,3 +36,3 @@ * Create a QMD store for programmatic access to search and indexing. | ||
| * // With a YAML config file | ||
| * const store = createStore({ | ||
| * const store = await createStore({ | ||
| * dbPath: './index.sqlite', | ||
@@ -36,3 +43,3 @@ * configPath: './qmd.yml', | ||
| * // With inline config (no files needed besides the DB) | ||
| * const store = createStore({ | ||
| * const store = await createStore({ | ||
| * dbPath: './index.sqlite', | ||
@@ -46,52 +53,179 @@ * config: { | ||
| * | ||
| * const results = await store.query("authentication flow") | ||
| * store.close() | ||
| * const results = await store.search({ query: "authentication flow" }) | ||
| * await store.close() | ||
| * ``` | ||
| */ | ||
| export function createStore(options) { | ||
| export async function createStore(options) { | ||
| if (!options.dbPath) { | ||
| throw new Error("dbPath is required"); | ||
| } | ||
| if (!options.configPath && !options.config) { | ||
| throw new Error("Either configPath or config is required"); | ||
| } | ||
| if (options.configPath && options.config) { | ||
| throw new Error("Provide either configPath or config, not both"); | ||
| } | ||
| // Inject config source into collections module | ||
| setConfigSource({ | ||
| configPath: options.configPath, | ||
| config: options.config, | ||
| // Create the internal store (opens DB, creates tables) | ||
| const internal = createStoreInternal(options.dbPath); | ||
| const db = internal.db; | ||
| // Track whether we have a YAML config path for write-through | ||
| const hasYamlConfig = !!options.configPath; | ||
| // Sync config into SQLite store_collections | ||
| if (options.configPath) { | ||
| // YAML mode: inject config source for write-through, sync to DB | ||
| setConfigSource({ configPath: options.configPath }); | ||
| const config = loadConfig(); | ||
| syncConfigToDb(db, config); | ||
| } | ||
| else if (options.config) { | ||
| // Inline config mode: inject config source for mutations, sync to DB | ||
| setConfigSource({ config: options.config }); | ||
| syncConfigToDb(db, options.config); | ||
| } | ||
| // else: DB-only mode — no external config, use existing store_collections | ||
| // Create a per-store LlamaCpp instance — lazy-loads models on first use, | ||
| // auto-unloads after 5 min inactivity to free VRAM. | ||
| const llm = new LlamaCpp({ | ||
| inactivityTimeoutMs: 5 * 60 * 1000, | ||
| disposeModelsOnInactivity: true, | ||
| }); | ||
| // Create the internal store | ||
| const internal = createStoreInternal(options.dbPath); | ||
| internal.llm = llm; | ||
| const store = { | ||
| internal, | ||
| dbPath: internal.dbPath, | ||
| // Search & Retrieval | ||
| query: (q, opts) => hybridQuery(internal, q, opts), | ||
| search: (q, opts) => internal.searchFTS(q, opts?.limit, opts?.collection), | ||
| structuredSearch: (searches, opts) => structuredSearch(internal, searches, opts), | ||
| get: (pathOrDocid, opts) => internal.findDocument(pathOrDocid, opts), | ||
| multiGet: (pattern, opts) => internal.findDocuments(pattern, opts), | ||
| // Collection Management | ||
| addCollection: (name, opts) => { | ||
| collectionsAddCollection(name, opts.path, opts.pattern); | ||
| // Search | ||
| search: async (opts) => { | ||
| if (!opts.query && !opts.queries) { | ||
| throw new Error("search() requires either 'query' or 'queries'"); | ||
| } | ||
| // Normalize collection/collections | ||
| const collections = [ | ||
| ...(opts.collection ? [opts.collection] : []), | ||
| ...(opts.collections ?? []), | ||
| ]; | ||
| const skipRerank = opts.rerank === false; | ||
| if (opts.queries) { | ||
| // Pre-expanded queries — use structuredSearch | ||
| return structuredSearch(internal, opts.queries, { | ||
| collections: collections.length > 0 ? collections : undefined, | ||
| limit: opts.limit, | ||
| minScore: opts.minScore, | ||
| explain: opts.explain, | ||
| intent: opts.intent, | ||
| skipRerank, | ||
| }); | ||
| } | ||
| // Simple query string — use hybridQuery (expand + search + rerank) | ||
| return hybridQuery(internal, opts.query, { | ||
| collection: collections[0], | ||
| limit: opts.limit, | ||
| minScore: opts.minScore, | ||
| explain: opts.explain, | ||
| intent: opts.intent, | ||
| skipRerank, | ||
| }); | ||
| }, | ||
| removeCollection: (name) => collectionsRemoveCollection(name), | ||
| renameCollection: (oldName, newName) => collectionsRenameCollection(oldName, newName), | ||
| listCollections: () => storeListCollections(internal.db), | ||
| // Context Management | ||
| addContext: (collectionName, pathPrefix, contextText) => collectionsAddContext(collectionName, pathPrefix, contextText), | ||
| removeContext: (collectionName, pathPrefix) => collectionsRemoveContext(collectionName, pathPrefix), | ||
| setGlobalContext: (context) => collectionsSetGlobalContext(context), | ||
| getGlobalContext: () => collectionsGetGlobalContext(), | ||
| listContexts: () => collectionsListAllContexts(), | ||
| searchLex: async (q, opts) => internal.searchFTS(q, 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), | ||
| 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 | ||
| addCollection: async (name, opts) => { | ||
| upsertStoreCollection(db, name, { path: opts.path, pattern: opts.pattern, ignore: opts.ignore }); | ||
| if (hasYamlConfig || options.config) { | ||
| collectionsAddCollection(name, opts.path, opts.pattern); | ||
| } | ||
| }, | ||
| removeCollection: async (name) => { | ||
| const result = deleteStoreCollection(db, name); | ||
| if (hasYamlConfig || options.config) { | ||
| collectionsRemoveCollection(name); | ||
| } | ||
| return result; | ||
| }, | ||
| renameCollection: async (oldName, newName) => { | ||
| const result = renameStoreCollection(db, oldName, newName); | ||
| if (hasYamlConfig || options.config) { | ||
| collectionsRenameCollection(oldName, newName); | ||
| } | ||
| 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) => { | ||
| const result = updateStoreContext(db, collectionName, pathPrefix, contextText); | ||
| if (hasYamlConfig || options.config) { | ||
| collectionsAddContext(collectionName, pathPrefix, contextText); | ||
| } | ||
| return result; | ||
| }, | ||
| removeContext: async (collectionName, pathPrefix) => { | ||
| const result = removeStoreContext(db, collectionName, pathPrefix); | ||
| if (hasYamlConfig || options.config) { | ||
| collectionsRemoveContext(collectionName, pathPrefix); | ||
| } | ||
| return result; | ||
| }, | ||
| setGlobalContext: async (context) => { | ||
| setStoreGlobalContext(db, context); | ||
| if (hasYamlConfig || options.config) { | ||
| collectionsSetGlobalContext(context); | ||
| } | ||
| }, | ||
| getGlobalContext: async () => getStoreGlobalContext(db), | ||
| listContexts: async () => getStoreContexts(db), | ||
| // Indexing — reads collections from SQLite | ||
| update: async (updateOpts) => { | ||
| const collections = getStoreCollections(db); | ||
| const filtered = updateOpts?.collections | ||
| ? collections.filter(c => updateOpts.collections.includes(c.name)) | ||
| : collections; | ||
| internal.clearCache(); | ||
| let totalIndexed = 0, totalUpdated = 0, totalUnchanged = 0, totalRemoved = 0; | ||
| for (const col of filtered) { | ||
| const result = await reindexCollection(internal, col.path, col.pattern || "**/*.md", col.name, { | ||
| ignorePatterns: col.ignore, | ||
| onProgress: updateOpts?.onProgress | ||
| ? (info) => updateOpts.onProgress({ collection: col.name, ...info }) | ||
| : undefined, | ||
| }); | ||
| totalIndexed += result.indexed; | ||
| totalUpdated += result.updated; | ||
| totalUnchanged += result.unchanged; | ||
| totalRemoved += result.removed; | ||
| } | ||
| return { | ||
| collections: filtered.length, | ||
| indexed: totalIndexed, | ||
| updated: totalUpdated, | ||
| unchanged: totalUnchanged, | ||
| removed: totalRemoved, | ||
| needsEmbedding: internal.getHashesNeedingEmbedding(), | ||
| }; | ||
| }, | ||
| embed: async (embedOpts) => { | ||
| return generateEmbeddings(internal, { | ||
| force: embedOpts?.force, | ||
| model: embedOpts?.model, | ||
| onProgress: embedOpts?.onProgress, | ||
| }); | ||
| }, | ||
| // Index Health | ||
| getStatus: () => internal.getStatus(), | ||
| getIndexHealth: () => internal.getIndexHealth(), | ||
| getStatus: async () => internal.getStatus(), | ||
| getIndexHealth: async () => internal.getIndexHealth(), | ||
| // Lifecycle | ||
| close: () => { | ||
| close: async () => { | ||
| await llm.dispose(); | ||
| internal.close(); | ||
| setConfigSource(undefined); // Reset config source | ||
| if (hasYamlConfig || options.config) { | ||
| setConfigSource(undefined); // Reset config source | ||
| } | ||
| }, | ||
@@ -98,0 +232,0 @@ }; |
+6
-0
@@ -215,2 +215,3 @@ /** | ||
| export declare class LlamaCpp implements LLM { | ||
| private readonly _ciMode; | ||
| private llama; | ||
@@ -378,2 +379,7 @@ private embedModel; | ||
| /** | ||
| * Execute a function with a scoped LLM session using a specific LlamaCpp instance. | ||
| * Unlike withLLMSession, this does not use the global singleton. | ||
| */ | ||
| export declare function withLLMSessionForLlm<T>(llm: LlamaCpp, fn: (session: ILLMSession) => Promise<T>, options?: LLMSessionOptions): Promise<T>; | ||
| /** | ||
| * Check if idle unload is safe (no active sessions or operations). | ||
@@ -380,0 +386,0 @@ * Used internally by LlamaCpp idle timer. |
+23
-0
@@ -167,2 +167,3 @@ /** | ||
| export class LlamaCpp { | ||
| _ciMode = !!process.env.CI; | ||
| llama = null; | ||
@@ -579,2 +580,4 @@ embedModel = null; | ||
| async embedBatch(texts) { | ||
| if (this._ciMode) | ||
| throw new Error("LLM operations are disabled in CI (set CI=true)"); | ||
| // Ping activity at start to keep models alive during this operation | ||
@@ -631,2 +634,4 @@ this.touchActivity(); | ||
| async generate(prompt, options = {}) { | ||
| if (this._ciMode) | ||
| throw new Error("LLM operations are disabled in CI (set CI=true)"); | ||
| // Ping activity at start to keep models alive during this operation | ||
@@ -683,2 +688,4 @@ this.touchActivity(); | ||
| async expandQuery(query, options = {}) { | ||
| if (this._ciMode) | ||
| throw new Error("LLM operations are disabled in CI (set CI=true)"); | ||
| // Ping activity at start to keep models alive during this operation | ||
@@ -771,2 +778,4 @@ this.touchActivity(); | ||
| async rerank(query, documents, options = {}) { | ||
| if (this._ciMode) | ||
| throw new Error("LLM operations are disabled in CI (set CI=true)"); | ||
| // Ping activity at start to keep models alive during this operation | ||
@@ -1074,2 +1083,16 @@ this.touchActivity(); | ||
| /** | ||
| * 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, fn, options) { | ||
| 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). | ||
@@ -1076,0 +1099,0 @@ * Used internally by LlamaCpp idle timer. |
+83
-19
@@ -14,3 +14,4 @@ /** | ||
| import type { Database } from "./db.js"; | ||
| import { formatQueryForEmbedding, formatDocForEmbedding, type ILLMSession } from "./llm.js"; | ||
| import { LlamaCpp, formatQueryForEmbedding, formatDocForEmbedding, type ILLMSession } from "./llm.js"; | ||
| import type { NamedCollection, Collection, CollectionConfig } from "./collections.js"; | ||
| export declare const DEFAULT_EMBED_MODEL = "embeddinggemma"; | ||
@@ -92,3 +93,5 @@ export declare const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0"; | ||
| type: 'lex' | 'vec' | 'hyde'; | ||
| text: string; | ||
| query: string; | ||
| /** Optional line number for error reporting (CLI parser) */ | ||
| line?: number; | ||
| }; | ||
@@ -169,2 +172,23 @@ export declare function homedir(): string; | ||
| export declare function verifySqliteVecLoaded(db: Database): void; | ||
| export declare function getStoreCollections(db: Database): NamedCollection[]; | ||
| export declare function getStoreCollection(db: Database, name: string): NamedCollection | null; | ||
| export declare function getStoreGlobalContext(db: Database): string | undefined; | ||
| export declare function getStoreContexts(db: Database): Array<{ | ||
| collection: string; | ||
| path: string; | ||
| context: string; | ||
| }>; | ||
| export declare function upsertStoreCollection(db: Database, name: string, collection: Omit<Collection, 'pattern'> & { | ||
| pattern?: string; | ||
| }): void; | ||
| export declare function deleteStoreCollection(db: Database, name: string): boolean; | ||
| export declare function renameStoreCollection(db: Database, oldName: string, newName: string): boolean; | ||
| export declare function updateStoreContext(db: Database, collectionName: string, path: string, text: string): boolean; | ||
| export declare function removeStoreContext(db: Database, collectionName: string, path: string): boolean; | ||
| export declare function setStoreGlobalContext(db: Database, value: string | undefined): void; | ||
| /** | ||
| * Sync external config (YAML/inline) into SQLite store_collections. | ||
| * External config always wins. Skips sync if config hash hasn't changed. | ||
| */ | ||
| export declare function syncConfigToDb(db: Database, config: CollectionConfig): void; | ||
| export declare function isSqliteVecAvailable(): boolean; | ||
@@ -174,2 +198,4 @@ export type Store = { | ||
| dbPath: string; | ||
| /** Optional LlamaCpp instance for this store (overrides the global singleton) */ | ||
| llm?: LlamaCpp; | ||
| close: () => void; | ||
@@ -259,3 +285,46 @@ ensureVecTable: (dimensions: number) => void; | ||
| }; | ||
| export type ReindexProgress = { | ||
| file: string; | ||
| current: number; | ||
| total: number; | ||
| }; | ||
| export type ReindexResult = { | ||
| indexed: number; | ||
| updated: number; | ||
| unchanged: number; | ||
| removed: number; | ||
| orphanedCleaned: number; | ||
| }; | ||
| /** | ||
| * Re-index a single collection by scanning the filesystem and updating the database. | ||
| * Pure function — no console output, no db lifecycle management. | ||
| */ | ||
| export declare function reindexCollection(store: Store, collectionPath: string, globPattern: string, collectionName: string, options?: { | ||
| ignorePatterns?: string[]; | ||
| onProgress?: (info: ReindexProgress) => void; | ||
| }): Promise<ReindexResult>; | ||
| export type EmbedProgress = { | ||
| chunksEmbedded: number; | ||
| totalChunks: number; | ||
| bytesProcessed: number; | ||
| totalBytes: number; | ||
| errors: number; | ||
| }; | ||
| export type EmbedResult = { | ||
| docsProcessed: number; | ||
| chunksEmbedded: number; | ||
| errors: number; | ||
| durationMs: number; | ||
| }; | ||
| /** | ||
| * Generate vector embeddings for documents that need them. | ||
| * Pure function — no console output, no db lifecycle management. | ||
| * Uses the store's LlamaCpp instance if set, otherwise the global singleton. | ||
| */ | ||
| export declare function generateEmbeddings(store: Store, options?: { | ||
| force?: boolean; | ||
| model?: string; | ||
| onProgress?: (info: EmbedProgress) => void; | ||
| }): Promise<EmbedResult>; | ||
| /** | ||
| * Create a new store instance with the given database path. | ||
@@ -360,4 +429,4 @@ * If no path is provided, uses the default path (~/.cache/qmd/index.sqlite). | ||
| name: string; | ||
| path: string; | ||
| pattern: string; | ||
| path: string | null; | ||
| pattern: string | null; | ||
| documents: number; | ||
@@ -500,8 +569,7 @@ lastUpdated: string; | ||
| * Get context for a file path (virtual or filesystem). | ||
| * Resolves the collection and relative path using the YAML collections config. | ||
| * Resolves the collection and relative path from the DB store_collections table. | ||
| */ | ||
| export declare function getContextForFile(db: Database, filepath: string): string | null; | ||
| /** | ||
| * Get collection by name from YAML config. | ||
| * Returns collection metadata from ~/.config/qmd/index.yml | ||
| * Get collection by name from DB store_collections table. | ||
| */ | ||
@@ -515,3 +583,3 @@ export declare function getCollectionByName(db: Database, name: string): { | ||
| * List all collections with document counts from database. | ||
| * Merges YAML config with database statistics. | ||
| * Merges store_collections config with database statistics. | ||
| */ | ||
@@ -525,2 +593,3 @@ export declare function listCollections(db: Database): { | ||
| last_modified: string | null; | ||
| includeByDefault: boolean; | ||
| }[]; | ||
@@ -610,7 +679,7 @@ /** | ||
| export declare function insertEmbedding(db: Database, hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string): void; | ||
| export declare function expandQuery(query: string, model: string | undefined, db: Database, intent?: string): Promise<ExpandedQuery[]>; | ||
| export declare function expandQuery(query: string, model: string | undefined, db: Database, intent?: string, llmOverride?: LlamaCpp): Promise<ExpandedQuery[]>; | ||
| export declare function rerank(query: string, documents: { | ||
| file: string; | ||
| text: string; | ||
| }[], model: string | undefined, db: Database, intent?: string): Promise<{ | ||
| }[], model: string | undefined, db: Database, intent?: string, llmOverride?: LlamaCpp): Promise<{ | ||
| file: string; | ||
@@ -706,2 +775,3 @@ score: number; | ||
| intent?: string; | ||
| skipRerank?: boolean; | ||
| hooks?: SearchHooks; | ||
@@ -770,10 +840,2 @@ } | ||
| */ | ||
| export interface StructuredSubSearch { | ||
| /** Search type: 'lex' for BM25, 'vec' for semantic, 'hyde' for hypothetical */ | ||
| type: 'lex' | 'vec' | 'hyde'; | ||
| /** The search query text */ | ||
| query: string; | ||
| /** Optional line number for error reporting (CLI parser) */ | ||
| line?: number; | ||
| } | ||
| export interface StructuredSearchOptions { | ||
@@ -787,2 +849,4 @@ collections?: string[]; | ||
| intent?: string; | ||
| /** Skip LLM reranking, use only RRF scores */ | ||
| skipRerank?: boolean; | ||
| hooks?: SearchHooks; | ||
@@ -808,2 +872,2 @@ } | ||
| */ | ||
| export declare function structuredSearch(store: Store, searches: StructuredSubSearch[], options?: StructuredSearchOptions): Promise<HybridQueryResult[]>; | ||
| export declare function structuredSearch(store: Store, searches: ExpandedQuery[], options?: StructuredSearchOptions): Promise<HybridQueryResult[]>; |
+12
-11
| { | ||
| "name": "@tobilu/qmd", | ||
| "version": "1.1.6", | ||
| "version": "2.0.0", | ||
| "description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking", | ||
@@ -15,5 +15,6 @@ "type": "module", | ||
| "bin": { | ||
| "qmd": "dist/qmd.js" | ||
| "qmd": "bin/qmd" | ||
| }, | ||
| "files": [ | ||
| "bin/", | ||
| "dist/", | ||
@@ -25,11 +26,11 @@ "LICENSE", | ||
| "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" | ||
@@ -50,3 +51,3 @@ }, | ||
| "@modelcontextprotocol/sdk": "^1.25.1", | ||
| "better-sqlite3": "^11.0.0", | ||
| "better-sqlite3": "^12.4.5", | ||
| "fast-glob": "^3.3.0", | ||
@@ -53,0 +54,0 @@ "node-llama-cpp": "^3.17.1", |
+197
-38
@@ -77,8 +77,6 @@ # QMD - Query Markup Documents | ||
| **Tools exposed:** | ||
| - `qmd_search` - Fast BM25 keyword search (supports collection filter) | ||
| - `qmd_vector_search` - Semantic vector search (supports collection filter) | ||
| - `qmd_deep_search` - Deep search with query expansion and reranking (supports collection filter) | ||
| - `qmd_get` - Retrieve document by path or docid (with fuzzy matching suggestions) | ||
| - `qmd_multi_get` - Retrieve multiple documents by glob pattern, list, or docids | ||
| - `qmd_status` - Index health and collection info | ||
| - `query` — Search with typed sub-queries (`lex`/`vec`/`hyde`), combined via RRF + reranking | ||
| - `get` — Retrieve a document by path or docid (with fuzzy matching suggestions) | ||
| - `multi_get` — Batch retrieve by glob pattern, comma-separated list, or docids | ||
| - `status` — Index health and collection info | ||
@@ -143,4 +141,6 @@ **Claude Desktop configuration** (`~/Library/Application Support/Claude/claude_desktop_config.json`): | ||
| Use QMD as a library in your own Node.js or Bun applications: | ||
| Use QMD as a library in your own Node.js or Bun applications. | ||
| #### Installation | ||
| ```sh | ||
@@ -150,7 +150,8 @@ npm install @tobilu/qmd | ||
| #### Quick Start | ||
| ```typescript | ||
| import { createStore } from '@tobilu/qmd' | ||
| // Create a store with inline config (no config file needed) | ||
| const store = createStore({ | ||
| const store = await createStore({ | ||
| dbPath: './my-index.sqlite', | ||
@@ -160,3 +161,2 @@ config: { | ||
| docs: { path: '/path/to/docs', pattern: '**/*.md' }, | ||
| notes: { path: '/path/to/notes', pattern: '**/*.md' }, | ||
| }, | ||
@@ -166,55 +166,214 @@ }, | ||
| // Or reference a YAML config file | ||
| const store2 = createStore({ | ||
| dbPath: './my-index.sqlite', | ||
| const results = await store.search({ query: "authentication flow" }) | ||
| console.log(results.map(r => `${r.title} (${Math.round(r.score * 100)}%)`)) | ||
| await store.close() | ||
| ``` | ||
| #### Store Creation | ||
| `createStore()` accepts three modes: | ||
| ```typescript | ||
| import { createStore } from '@tobilu/qmd' | ||
| // 1. Inline config — no files needed besides the DB | ||
| const store = await createStore({ | ||
| dbPath: './index.sqlite', | ||
| config: { | ||
| collections: { | ||
| docs: { path: '/path/to/docs', pattern: '**/*.md' }, | ||
| notes: { path: '/path/to/notes' }, | ||
| }, | ||
| }, | ||
| }) | ||
| // 2. YAML config file — collections defined in a file | ||
| const store2 = await createStore({ | ||
| dbPath: './index.sqlite', | ||
| configPath: './qmd.yml', | ||
| }) | ||
| // 3. DB-only — reopen a previously configured store | ||
| const store3 = await createStore({ dbPath: './index.sqlite' }) | ||
| ``` | ||
| **Search & retrieval:** | ||
| #### Search | ||
| The unified `search()` method handles both simple queries and pre-expanded structured queries: | ||
| ```typescript | ||
| // Hybrid search: BM25 + vector + query expansion + LLM reranking (best quality) | ||
| const results = await store.query("authentication flow", { limit: 5 }) | ||
| // Simple query — auto-expanded via LLM, then BM25 + vector + reranking | ||
| const results = await store.search({ query: "authentication flow" }) | ||
| // Fast BM25 keyword search (no LLM, synchronous) | ||
| const keywords = store.search("auth middleware", { limit: 10 }) | ||
| // With options | ||
| const results2 = await store.search({ | ||
| query: "rate limiting", | ||
| intent: "API throttling and abuse prevention", | ||
| collection: "docs", | ||
| limit: 5, | ||
| minScore: 0.3, | ||
| explain: true, | ||
| }) | ||
| // Structured search with pre-expanded queries (for LLM callers) | ||
| const structured = await store.structuredSearch([ | ||
| { type: 'lex', query: 'authentication' }, | ||
| { type: 'vec', query: 'how users log in' }, | ||
| ], { limit: 5 }) | ||
| // Pre-expanded queries — skip auto-expansion, control each sub-query | ||
| const results3 = await store.search({ | ||
| queries: [ | ||
| { type: 'lex', query: '"connection pool" timeout -redis' }, | ||
| { type: 'vec', query: 'why do database connections time out under load' }, | ||
| ], | ||
| collections: ["docs", "notes"], | ||
| }) | ||
| // Skip reranking for faster results | ||
| const fast = await store.search({ query: "auth", rerank: false }) | ||
| ``` | ||
| For direct backend access: | ||
| ```typescript | ||
| // BM25 keyword search (fast, no LLM) | ||
| const lexResults = await store.searchLex("auth middleware", { limit: 10 }) | ||
| // Vector similarity search (embedding model, no reranking) | ||
| const vecResults = await store.searchVector("how users log in", { limit: 10 }) | ||
| // Manual query expansion for full control | ||
| const expanded = await store.expandQuery("auth flow", { intent: "user login" }) | ||
| const results4 = await store.search({ queries: expanded }) | ||
| ``` | ||
| #### Retrieval | ||
| ```typescript | ||
| // Get a document by path or docid | ||
| const doc = store.get("docs/readme.md") | ||
| const byId = store.get("#abc123") | ||
| const doc = await store.get("docs/readme.md") | ||
| const byId = await store.get("#abc123") | ||
| // Get multiple documents by glob | ||
| const { docs, errors } = store.multiGet("docs/**/*.md") | ||
| if (!("error" in doc)) { | ||
| console.log(doc.title, doc.displayPath, doc.context) | ||
| } | ||
| // Get document body with line range | ||
| const body = await store.getDocumentBody("docs/readme.md", { | ||
| fromLine: 50, | ||
| maxLines: 100, | ||
| }) | ||
| // Batch retrieve by glob or comma-separated list | ||
| const { docs, errors } = await store.multiGet("docs/**/*.md", { | ||
| maxBytes: 20480, | ||
| }) | ||
| ``` | ||
| **Collection & context management:** | ||
| #### Collections | ||
| ```typescript | ||
| // Add a collection | ||
| store.addCollection("myapp", { path: "/src/myapp", pattern: "**/*.ts" }) | ||
| await store.addCollection("myapp", { | ||
| path: "/src/myapp", | ||
| pattern: "**/*.ts", | ||
| ignore: ["node_modules/**", "*.test.ts"], | ||
| }) | ||
| // Add context (improves search relevance) | ||
| store.addContext("myapp", "/auth", "Authentication and session management") | ||
| store.setGlobalContext("Internal engineering documentation") | ||
| // List collections with document stats | ||
| const collections = await store.listCollections() | ||
| // => [{ name, pwd, glob_pattern, doc_count, active_count, last_modified, includeByDefault }] | ||
| // List everything | ||
| store.listCollections() | ||
| store.listContexts() | ||
| // Get names of collections included in queries by default | ||
| const defaults = await store.getDefaultCollectionNames() | ||
| // Remove / rename | ||
| await store.removeCollection("myapp") | ||
| await store.renameCollection("old-name", "new-name") | ||
| ``` | ||
| **Lifecycle:** | ||
| #### Context | ||
| Context adds descriptive metadata that improves search relevance and is returned alongside results: | ||
| ```typescript | ||
| store.close() | ||
| // Add context for a path within a collection | ||
| await store.addContext("docs", "/api", "REST API reference documentation") | ||
| // Set global context (applies to all collections) | ||
| await store.setGlobalContext("Internal engineering documentation") | ||
| // List all contexts | ||
| const contexts = await store.listContexts() | ||
| // => [{ collection, path, context }] | ||
| // Remove context | ||
| await store.removeContext("docs", "/api") | ||
| await store.setGlobalContext(undefined) // clear global | ||
| ``` | ||
| The SDK requires explicit `dbPath` and config — no defaults are assumed. This makes it safe to embed in any application without side effects. | ||
| #### Indexing | ||
| ```typescript | ||
| // Re-index collections by scanning the filesystem | ||
| const result = await store.update({ | ||
| collections: ["docs"], // optional — defaults to all | ||
| onProgress: ({ collection, file, current, total }) => { | ||
| console.log(`[${collection}] ${current}/${total} ${file}`) | ||
| }, | ||
| }) | ||
| // => { collections, indexed, updated, unchanged, removed, needsEmbedding } | ||
| // Generate vector embeddings | ||
| const embedResult = await store.embed({ | ||
| force: false, // true to re-embed everything | ||
| onProgress: ({ current, total, collection }) => { | ||
| console.log(`Embedding ${current}/${total}`) | ||
| }, | ||
| }) | ||
| ``` | ||
| #### Types | ||
| Key types exported for SDK consumers: | ||
| ```typescript | ||
| import type { | ||
| QMDStore, // The store interface | ||
| SearchOptions, // Options for search() | ||
| LexSearchOptions, // Options for searchLex() | ||
| VectorSearchOptions, // Options for searchVector() | ||
| HybridQueryResult, // Search result with score, snippet, context | ||
| SearchResult, // Result from searchLex/searchVector | ||
| ExpandedQuery, // Typed sub-query { type: 'lex'|'vec'|'hyde', query } | ||
| DocumentResult, // Document metadata + body | ||
| DocumentNotFound, // Error with similarFiles suggestions | ||
| MultiGetResult, // Batch retrieval result | ||
| UpdateProgress, // Progress callback info for update() | ||
| UpdateResult, // Aggregated update result | ||
| EmbedProgress, // Progress callback info for embed() | ||
| EmbedResult, // Embedding result | ||
| StoreOptions, // createStore() options | ||
| CollectionConfig, // Inline config shape | ||
| IndexStatus, // From getStatus() | ||
| IndexHealthInfo, // From getIndexHealth() | ||
| } from '@tobilu/qmd' | ||
| ``` | ||
| Utility exports: | ||
| ```typescript | ||
| import { | ||
| extractSnippet, // Extract a relevant snippet from text | ||
| addLineNumbers, // Add line numbers to text | ||
| DEFAULT_MULTI_GET_MAX_BYTES, // Default max file size for multiGet (10KB) | ||
| Maintenance, // Database maintenance operations | ||
| } from '@tobilu/qmd' | ||
| ``` | ||
| #### Lifecycle | ||
| ```typescript | ||
| // Close the store — disposes LLM models and DB connection | ||
| await store.close() | ||
| ``` | ||
| The SDK requires explicit `dbPath` — no defaults are assumed. This makes it safe to embed in any application without side effects. | ||
| ## Architecture | ||
@@ -221,0 +380,0 @@ |
| /** | ||
| * formatter.ts - Output formatting utilities for QMD | ||
| * | ||
| * Provides methods to format search results and documents into various output formats: | ||
| * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output). | ||
| */ | ||
| import type { SearchResult, MultiGetResult, DocumentResult } from "./store.js"; | ||
| export type { SearchResult, MultiGetResult, DocumentResult }; | ||
| export type MultiGetFile = { | ||
| filepath: string; | ||
| displayPath: string; | ||
| title: string; | ||
| body: string; | ||
| context?: string | null; | ||
| skipped: false; | ||
| } | { | ||
| filepath: string; | ||
| displayPath: string; | ||
| title: string; | ||
| body: string; | ||
| context?: string | null; | ||
| skipped: true; | ||
| skipReason: string; | ||
| }; | ||
| export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json"; | ||
| export type FormatOptions = { | ||
| full?: boolean; | ||
| query?: string; | ||
| useColor?: boolean; | ||
| lineNumbers?: boolean; | ||
| intent?: string; | ||
| }; | ||
| /** | ||
| * Add line numbers to text content. | ||
| * Each line becomes: "{lineNum}: {content}" | ||
| * @param text The text to add line numbers to | ||
| * @param startLine Optional starting line number (default: 1) | ||
| */ | ||
| export declare function addLineNumbers(text: string, startLine?: number): string; | ||
| /** | ||
| * Extract short docid from a full hash (first 6 characters). | ||
| */ | ||
| export declare function getDocid(hash: string): string; | ||
| export declare function escapeCSV(value: string | null | number): string; | ||
| export declare function escapeXml(str: string): string; | ||
| /** | ||
| * Format search results as JSON | ||
| */ | ||
| export declare function searchResultsToJson(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results as CSV | ||
| */ | ||
| export declare function searchResultsToCsv(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results as simple files list (docid,score,filepath,context) | ||
| */ | ||
| export declare function searchResultsToFiles(results: SearchResult[]): string; | ||
| /** | ||
| * Format search results as Markdown | ||
| */ | ||
| export declare function searchResultsToMarkdown(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results as XML | ||
| */ | ||
| export declare function searchResultsToXml(results: SearchResult[], opts?: FormatOptions): string; | ||
| /** | ||
| * Format search results for MCP (simpler CSV format with pre-extracted snippets) | ||
| */ | ||
| export declare function searchResultsToMcpCsv(results: { | ||
| docid: string; | ||
| file: string; | ||
| title: string; | ||
| score: number; | ||
| context: string | null; | ||
| snippet: string; | ||
| }[]): string; | ||
| /** | ||
| * Format documents as JSON | ||
| */ | ||
| export declare function documentsToJson(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as CSV | ||
| */ | ||
| export declare function documentsToCsv(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as files list | ||
| */ | ||
| export declare function documentsToFiles(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as Markdown | ||
| */ | ||
| export declare function documentsToMarkdown(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format documents as XML | ||
| */ | ||
| export declare function documentsToXml(results: MultiGetFile[]): string; | ||
| /** | ||
| * Format a single DocumentResult as JSON | ||
| */ | ||
| export declare function documentToJson(doc: DocumentResult): string; | ||
| /** | ||
| * Format a single DocumentResult as Markdown | ||
| */ | ||
| export declare function documentToMarkdown(doc: DocumentResult): string; | ||
| /** | ||
| * Format a single DocumentResult as XML | ||
| */ | ||
| export declare function documentToXml(doc: DocumentResult): string; | ||
| /** | ||
| * Format a single document to the specified format | ||
| */ | ||
| export declare function formatDocument(doc: DocumentResult, format: OutputFormat): string; | ||
| /** | ||
| * Format search results to the specified output format | ||
| */ | ||
| export declare function formatSearchResults(results: SearchResult[], format: OutputFormat, opts?: FormatOptions): string; | ||
| /** | ||
| * Format documents to the specified output format | ||
| */ | ||
| export declare function formatDocuments(results: MultiGetFile[], format: OutputFormat): string; |
| /** | ||
| * formatter.ts - Output formatting utilities for QMD | ||
| * | ||
| * Provides methods to format search results and documents into various output formats: | ||
| * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output). | ||
| */ | ||
| import { extractSnippet } from "./store.js"; | ||
| // ============================================================================= | ||
| // Helper Functions | ||
| // ============================================================================= | ||
| /** | ||
| * Add line numbers to text content. | ||
| * Each line becomes: "{lineNum}: {content}" | ||
| * @param text The text to add line numbers to | ||
| * @param startLine Optional starting line number (default: 1) | ||
| */ | ||
| export function addLineNumbers(text, startLine = 1) { | ||
| const lines = text.split('\n'); | ||
| return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n'); | ||
| } | ||
| /** | ||
| * Extract short docid from a full hash (first 6 characters). | ||
| */ | ||
| export function getDocid(hash) { | ||
| return hash.slice(0, 6); | ||
| } | ||
| // ============================================================================= | ||
| // Escape Helpers | ||
| // ============================================================================= | ||
| export function escapeCSV(value) { | ||
| if (value === null || value === undefined) | ||
| return ""; | ||
| const str = String(value); | ||
| if (str.includes(",") || str.includes('"') || str.includes("\n")) { | ||
| return `"${str.replace(/"/g, '""')}"`; | ||
| } | ||
| return str; | ||
| } | ||
| export function escapeXml(str) { | ||
| return str | ||
| .replace(/&/g, "&") | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, """) | ||
| .replace(/'/g, "'"); | ||
| } | ||
| // ============================================================================= | ||
| // Search Results Formatters | ||
| // ============================================================================= | ||
| /** | ||
| * Format search results as JSON | ||
| */ | ||
| export function searchResultsToJson(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| const output = results.map(row => { | ||
| const bodyStr = row.body || ""; | ||
| let body = opts.full ? bodyStr : undefined; | ||
| let snippet = !opts.full ? extractSnippet(bodyStr, query, 300, row.chunkPos, undefined, opts.intent).snippet : undefined; | ||
| if (opts.lineNumbers) { | ||
| if (body) | ||
| body = addLineNumbers(body); | ||
| if (snippet) | ||
| snippet = addLineNumbers(snippet); | ||
| } | ||
| return { | ||
| docid: `#${row.docid}`, | ||
| score: Math.round(row.score * 100) / 100, | ||
| file: row.displayPath, | ||
| title: row.title, | ||
| ...(row.context && { context: row.context }), | ||
| ...(body && { body }), | ||
| ...(snippet && { snippet }), | ||
| }; | ||
| }); | ||
| return JSON.stringify(output, null, 2); | ||
| } | ||
| /** | ||
| * Format search results as CSV | ||
| */ | ||
| export function searchResultsToCsv(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| const header = "docid,score,file,title,context,line,snippet"; | ||
| const rows = results.map(row => { | ||
| const bodyStr = row.body || ""; | ||
| const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent); | ||
| let content = opts.full ? bodyStr : snippet; | ||
| if (opts.lineNumbers && content) { | ||
| content = addLineNumbers(content); | ||
| } | ||
| return [ | ||
| `#${row.docid}`, | ||
| row.score.toFixed(4), | ||
| escapeCSV(row.displayPath), | ||
| escapeCSV(row.title), | ||
| escapeCSV(row.context || ""), | ||
| line, | ||
| escapeCSV(content), | ||
| ].join(","); | ||
| }); | ||
| return [header, ...rows].join("\n"); | ||
| } | ||
| /** | ||
| * Format search results as simple files list (docid,score,filepath,context) | ||
| */ | ||
| export function searchResultsToFiles(results) { | ||
| return results.map(row => { | ||
| const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : ""; | ||
| return `#${row.docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format search results as Markdown | ||
| */ | ||
| export function searchResultsToMarkdown(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| return results.map(row => { | ||
| const heading = row.title || row.displayPath; | ||
| const bodyStr = row.body || ""; | ||
| let content; | ||
| if (opts.full) { | ||
| content = bodyStr; | ||
| } | ||
| else { | ||
| content = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet; | ||
| } | ||
| if (opts.lineNumbers) { | ||
| content = addLineNumbers(content); | ||
| } | ||
| const contextLine = row.context ? `**context:** ${row.context}\n` : ""; | ||
| return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n${contextLine}\n${content}\n`; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format search results as XML | ||
| */ | ||
| export function searchResultsToXml(results, opts = {}) { | ||
| const query = opts.query || ""; | ||
| const items = results.map(row => { | ||
| const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : ""; | ||
| const bodyStr = row.body || ""; | ||
| let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet; | ||
| if (opts.lineNumbers) { | ||
| content = addLineNumbers(content); | ||
| } | ||
| const contextAttr = row.context ? ` context="${escapeXml(row.context)}"` : ""; | ||
| return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}${contextAttr}>\n${escapeXml(content)}\n</file>`; | ||
| }); | ||
| return items.join("\n\n"); | ||
| } | ||
| /** | ||
| * Format search results for MCP (simpler CSV format with pre-extracted snippets) | ||
| */ | ||
| export function searchResultsToMcpCsv(results) { | ||
| const header = "docid,file,title,score,context,snippet"; | ||
| const rows = results.map(r => [`#${r.docid}`, r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",")); | ||
| return [header, ...rows].join("\n"); | ||
| } | ||
| // ============================================================================= | ||
| // Document Formatters (for multi-get using MultiGetFile from store) | ||
| // ============================================================================= | ||
| /** | ||
| * Format documents as JSON | ||
| */ | ||
| export function documentsToJson(results) { | ||
| const output = results.map(r => ({ | ||
| file: r.displayPath, | ||
| title: r.title, | ||
| ...(r.context && { context: r.context }), | ||
| ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }), | ||
| })); | ||
| return JSON.stringify(output, null, 2); | ||
| } | ||
| /** | ||
| * Format documents as CSV | ||
| */ | ||
| export function documentsToCsv(results) { | ||
| const header = "file,title,context,skipped,body"; | ||
| const rows = results.map(r => [ | ||
| r.displayPath, | ||
| r.title, | ||
| r.context || "", | ||
| r.skipped ? "true" : "false", | ||
| r.skipped ? (r.skipReason || "") : r.body | ||
| ].map(escapeCSV).join(",")); | ||
| return [header, ...rows].join("\n"); | ||
| } | ||
| /** | ||
| * Format documents as files list | ||
| */ | ||
| export function documentsToFiles(results) { | ||
| return results.map(r => { | ||
| const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : ""; | ||
| const status = r.skipped ? ",[SKIPPED]" : ""; | ||
| return `${r.displayPath}${ctx}${status}`; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format documents as Markdown | ||
| */ | ||
| export function documentsToMarkdown(results) { | ||
| return results.map(r => { | ||
| let md = `## ${r.displayPath}\n\n`; | ||
| if (r.title && r.title !== r.displayPath) | ||
| md += `**Title:** ${r.title}\n\n`; | ||
| if (r.context) | ||
| md += `**Context:** ${r.context}\n\n`; | ||
| if (r.skipped) { | ||
| md += `> ${r.skipReason}\n`; | ||
| } | ||
| else { | ||
| md += "```\n" + r.body + "\n```\n"; | ||
| } | ||
| return md; | ||
| }).join("\n"); | ||
| } | ||
| /** | ||
| * Format documents as XML | ||
| */ | ||
| export function documentsToXml(results) { | ||
| const items = results.map(r => { | ||
| let xml = " <document>\n"; | ||
| xml += ` <file>${escapeXml(r.displayPath)}</file>\n`; | ||
| xml += ` <title>${escapeXml(r.title)}</title>\n`; | ||
| if (r.context) | ||
| xml += ` <context>${escapeXml(r.context)}</context>\n`; | ||
| if (r.skipped) { | ||
| xml += ` <skipped>true</skipped>\n`; | ||
| xml += ` <reason>${escapeXml(r.skipReason || "")}</reason>\n`; | ||
| } | ||
| else { | ||
| xml += ` <body>${escapeXml(r.body)}</body>\n`; | ||
| } | ||
| xml += " </document>"; | ||
| return xml; | ||
| }); | ||
| return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`; | ||
| } | ||
| // ============================================================================= | ||
| // Single Document Formatters | ||
| // ============================================================================= | ||
| /** | ||
| * Format a single DocumentResult as JSON | ||
| */ | ||
| export function documentToJson(doc) { | ||
| return JSON.stringify({ | ||
| file: doc.displayPath, | ||
| title: doc.title, | ||
| ...(doc.context && { context: doc.context }), | ||
| hash: doc.hash, | ||
| modifiedAt: doc.modifiedAt, | ||
| bodyLength: doc.bodyLength, | ||
| ...(doc.body !== undefined && { body: doc.body }), | ||
| }, null, 2); | ||
| } | ||
| /** | ||
| * Format a single DocumentResult as Markdown | ||
| */ | ||
| export function documentToMarkdown(doc) { | ||
| let md = `# ${doc.title || doc.displayPath}\n\n`; | ||
| if (doc.context) | ||
| md += `**Context:** ${doc.context}\n\n`; | ||
| md += `**File:** ${doc.displayPath}\n`; | ||
| md += `**Modified:** ${doc.modifiedAt}\n\n`; | ||
| if (doc.body !== undefined) { | ||
| md += "---\n\n" + doc.body + "\n"; | ||
| } | ||
| return md; | ||
| } | ||
| /** | ||
| * Format a single DocumentResult as XML | ||
| */ | ||
| export function documentToXml(doc) { | ||
| let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`; | ||
| xml += ` <file>${escapeXml(doc.displayPath)}</file>\n`; | ||
| xml += ` <title>${escapeXml(doc.title)}</title>\n`; | ||
| if (doc.context) | ||
| xml += ` <context>${escapeXml(doc.context)}</context>\n`; | ||
| xml += ` <hash>${escapeXml(doc.hash)}</hash>\n`; | ||
| xml += ` <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`; | ||
| xml += ` <bodyLength>${doc.bodyLength}</bodyLength>\n`; | ||
| if (doc.body !== undefined) { | ||
| xml += ` <body>${escapeXml(doc.body)}</body>\n`; | ||
| } | ||
| xml += `</document>`; | ||
| return xml; | ||
| } | ||
| /** | ||
| * Format a single document to the specified format | ||
| */ | ||
| export function formatDocument(doc, format) { | ||
| switch (format) { | ||
| case "json": | ||
| return documentToJson(doc); | ||
| case "md": | ||
| return documentToMarkdown(doc); | ||
| case "xml": | ||
| return documentToXml(doc); | ||
| default: | ||
| // Default to markdown for CLI and other formats | ||
| return documentToMarkdown(doc); | ||
| } | ||
| } | ||
| // ============================================================================= | ||
| // Universal Format Function | ||
| // ============================================================================= | ||
| /** | ||
| * Format search results to the specified output format | ||
| */ | ||
| export function formatSearchResults(results, format, opts = {}) { | ||
| switch (format) { | ||
| case "json": | ||
| return searchResultsToJson(results, opts); | ||
| case "csv": | ||
| return searchResultsToCsv(results, opts); | ||
| case "files": | ||
| return searchResultsToFiles(results); | ||
| case "md": | ||
| return searchResultsToMarkdown(results, opts); | ||
| case "xml": | ||
| return searchResultsToXml(results, opts); | ||
| case "cli": | ||
| // CLI format should be handled separately with colors | ||
| // Return a simple text version as fallback | ||
| return searchResultsToMarkdown(results, opts); | ||
| default: | ||
| return searchResultsToJson(results, opts); | ||
| } | ||
| } | ||
| /** | ||
| * Format documents to the specified output format | ||
| */ | ||
| export function formatDocuments(results, format) { | ||
| switch (format) { | ||
| case "json": | ||
| return documentsToJson(results); | ||
| case "csv": | ||
| return documentsToCsv(results); | ||
| case "files": | ||
| return documentsToFiles(results); | ||
| case "md": | ||
| return documentsToMarkdown(results); | ||
| case "xml": | ||
| return documentsToXml(results); | ||
| case "cli": | ||
| // CLI format should be handled separately with colors | ||
| return documentsToMarkdown(results); | ||
| default: | ||
| return documentsToJson(results); | ||
| } | ||
| } |
| /** | ||
| * QMD MCP Server - Model Context Protocol server for QMD | ||
| * | ||
| * Exposes QMD search and document retrieval as MCP tools and resources. | ||
| * Documents are accessible via qmd:// URIs. | ||
| * | ||
| * Follows MCP spec 2025-06-18 for proper response types. | ||
| */ | ||
| export declare function startMcpServer(): Promise<void>; | ||
| export type HttpServerHandle = { | ||
| httpServer: import("http").Server; | ||
| port: number; | ||
| stop: () => Promise<void>; | ||
| }; | ||
| /** | ||
| * Start MCP server over Streamable HTTP (JSON responses, no SSE). | ||
| * Binds to localhost only. Returns a handle for shutdown and port discovery. | ||
| */ | ||
| export declare function startMcpHttpServer(port: number, options?: { | ||
| quiet?: boolean; | ||
| }): Promise<HttpServerHandle>; |
-671
| /** | ||
| * QMD MCP Server - Model Context Protocol server for QMD | ||
| * | ||
| * Exposes QMD search and document retrieval as MCP tools and resources. | ||
| * Documents are accessible via qmd:// URIs. | ||
| * | ||
| * Follows MCP spec 2025-06-18 for proper response types. | ||
| */ | ||
| import { createServer } from "node:http"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { fileURLToPath } from "url"; | ||
| import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | ||
| import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { z } from "zod"; | ||
| import { createStore, extractSnippet, addLineNumbers, structuredSearch, DEFAULT_MULTI_GET_MAX_BYTES, } from "./store.js"; | ||
| import { getCollection, getGlobalContext, getDefaultCollectionNames } from "./collections.js"; | ||
| import { disposeDefaultLlamaCpp } from "./llm.js"; | ||
| // ============================================================================= | ||
| // Helper functions | ||
| // ============================================================================= | ||
| /** | ||
| * Encode a path for use in qmd:// URIs. | ||
| * Encodes special characters but preserves forward slashes for readability. | ||
| */ | ||
| function encodeQmdPath(path) { | ||
| // Encode each path segment separately to preserve slashes | ||
| return path.split('/').map(segment => encodeURIComponent(segment)).join('/'); | ||
| } | ||
| /** | ||
| * Format search results as human-readable text summary | ||
| */ | ||
| function formatSearchSummary(results, query) { | ||
| if (results.length === 0) { | ||
| return `No results found for "${query}"`; | ||
| } | ||
| const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`]; | ||
| for (const r of results) { | ||
| lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // MCP Server | ||
| // ============================================================================= | ||
| /** | ||
| * Build dynamic server instructions from actual index state. | ||
| * 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) { | ||
| const status = store.getStatus(); | ||
| const lines = []; | ||
| // --- 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}`); | ||
| // --- What's searchable? --- | ||
| if (status.collections.length > 0) { | ||
| 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}` : ""; | ||
| lines.push(` - "${col.name}" (${col.documents} docs)${desc}`); | ||
| } | ||
| } | ||
| // --- Capability gaps --- | ||
| if (!status.hasVectorIndex) { | ||
| lines.push(""); | ||
| lines.push("Note: No vector embeddings yet. Run `qmd embed` to enable semantic search (vec/hyde)."); | ||
| } | ||
| else if (status.needsEmbedding > 0) { | ||
| lines.push(""); | ||
| lines.push(`Note: ${status.needsEmbedding} documents need embedding. Run \`qmd embed\` to update.`); | ||
| } | ||
| // --- Search tool --- | ||
| lines.push(""); | ||
| lines.push("Search: Use `query` with sub-queries (lex/vec/hyde):"); | ||
| lines.push(" - type:'lex' — BM25 keyword search (exact terms, fast)"); | ||
| lines.push(" - type:'vec' — semantic vector search (meaning-based)"); | ||
| lines.push(" - type:'hyde' — hypothetical document (write what the answer looks like)"); | ||
| lines.push(""); | ||
| lines.push(" Always provide `intent` on every search call to disambiguate and improve snippets."); | ||
| lines.push(""); | ||
| lines.push("Examples:"); | ||
| lines.push(" Quick keyword lookup: [{type:'lex', query:'error handling'}]"); | ||
| lines.push(" Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]"); | ||
| lines.push(" Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]"); | ||
| lines.push(" With intent: searches=[{type:'lex', query:'performance'}], intent='web page load times'"); | ||
| // --- Retrieval workflow --- | ||
| lines.push(""); | ||
| lines.push("Retrieval:"); | ||
| lines.push(" - `get` — single document by path or docid (#abc123). Supports line offset (`file.md:100`)."); | ||
| lines.push(" - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`) or comma-separated list."); | ||
| // --- Non-obvious things that prevent mistakes --- | ||
| lines.push(""); | ||
| lines.push("Tips:"); | ||
| lines.push(" - File paths in results are relative to their collection."); | ||
| lines.push(" - Use `minScore: 0.5` to filter low-confidence results."); | ||
| lines.push(" - Results include a `context` field describing the content type."); | ||
| return lines.join("\n"); | ||
| } | ||
| /** | ||
| * Create an MCP server with all QMD tools, resources, and prompts registered. | ||
| * Shared by both stdio and HTTP transports. | ||
| */ | ||
| function createMcpServer(store) { | ||
| const server = new McpServer({ name: "qmd", version: "0.9.9" }, { instructions: buildInstructions(store) }); | ||
| // --------------------------------------------------------------------------- | ||
| // Resource: qmd://{path} - read-only access to documents by path | ||
| // Note: No list() - documents are discovered via search tools | ||
| // --------------------------------------------------------------------------- | ||
| server.registerResource("document", new ResourceTemplate("qmd://{+path}", { list: undefined }), { | ||
| title: "QMD Document", | ||
| description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.", | ||
| mimeType: "text/markdown", | ||
| }, async (uri, { path }) => { | ||
| // Decode URL-encoded path (MCP clients send encoded URIs) | ||
| 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('/'); | ||
| // 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); | ||
| // 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}`); | ||
| } | ||
| if (!doc) { | ||
| 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; | ||
| } | ||
| const displayName = `${doc.collection}/${doc.path}`; | ||
| return { | ||
| contents: [{ | ||
| uri: uri.href, | ||
| name: displayName, | ||
| title: doc.title || doc.path, | ||
| mimeType: "text/markdown", | ||
| text, | ||
| }], | ||
| }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: query (Primary search tool) | ||
| // --------------------------------------------------------------------------- | ||
| const subSearchSchema = z.object({ | ||
| type: z.enum(['lex', 'vec', 'hyde']).describe("lex = BM25 keywords (supports \"phrase\" and -negation); " + | ||
| "vec = semantic question; hyde = hypothetical answer passage"), | ||
| query: z.string().describe("The query text. For lex: use keywords, \"quoted phrases\", and -negation. " + | ||
| "For vec: natural language question. For hyde: 50-100 word answer passage."), | ||
| }); | ||
| server.registerTool("query", { | ||
| title: "Query", | ||
| description: `Search the knowledge base using a query document — one or more typed sub-queries combined for best recall. | ||
| ## Query Types | ||
| **lex** — BM25 keyword search. Fast, exact, no LLM needed. | ||
| Full lex syntax: | ||
| - \`term\` — prefix match ("perf" matches "performance") | ||
| - \`"exact phrase"\` — phrase must appear verbatim | ||
| - \`-term\` or \`-"phrase"\` — exclude documents containing this | ||
| Good lex examples: | ||
| - \`"connection pool" timeout -redis\` | ||
| - \`"machine learning" -sports -athlete\` | ||
| - \`handleError async typescript\` | ||
| **vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words. | ||
| - \`how does the rate limiter handle burst traffic?\` | ||
| - \`what is the tradeoff between consistency and availability?\` | ||
| **hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics. | ||
| - \`The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.\` | ||
| ## Strategy | ||
| Combine types for best results. First sub-query gets 2× weight — put your strongest signal first. | ||
| | Goal | Approach | | ||
| |------|----------| | ||
| | Know exact term/name | \`lex\` only | | ||
| | Concept search | \`vec\` only | | ||
| | Best recall | \`lex\` + \`vec\` | | ||
| | Complex/nuanced | \`lex\` + \`vec\` + \`hyde\` | | ||
| | Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it | | ||
| ## Examples | ||
| Simple lookup: | ||
| \`\`\`json | ||
| [{ "type": "lex", "query": "CAP theorem" }] | ||
| \`\`\` | ||
| Best recall on a technical topic: | ||
| \`\`\`json | ||
| [ | ||
| { "type": "lex", "query": "\\"connection pool\\" timeout -redis" }, | ||
| { "type": "vec", "query": "why do database connections time out under load" }, | ||
| { "type": "hyde", "query": "Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected." } | ||
| ] | ||
| \`\`\` | ||
| Intent-aware lex (C++ performance, not sports): | ||
| \`\`\`json | ||
| [ | ||
| { "type": "lex", "query": "\\"C++ performance\\" optimization -sports -athlete" }, | ||
| { "type": "vec", "query": "how to optimize C++ program performance" } | ||
| ] | ||
| \`\`\``, | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: { | ||
| searches: z.array(subSearchSchema).min(1).max(10).describe("Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight."), | ||
| limit: z.number().optional().default(10).describe("Max results (default: 10)"), | ||
| minScore: z.number().optional().default(0).describe("Min relevance 0-1 (default: 0)"), | ||
| candidateLimit: z.number().optional().describe("Maximum candidates to rerank (default: 40, lower = faster but may miss results)"), | ||
| collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"), | ||
| intent: z.string().optional().describe("Background context to disambiguate the query. Example: query='performance', intent='web page load times and Core Web Vitals'. Does not search on its own."), | ||
| }, | ||
| }, async ({ searches, limit, minScore, candidateLimit, collections, intent }) => { | ||
| // Map to internal format | ||
| const subSearches = searches.map(s => ({ | ||
| type: s.type, | ||
| query: s.query, | ||
| })); | ||
| // Use default collections if none specified | ||
| const effectiveCollections = collections ?? getDefaultCollectionNames(); | ||
| const results = await structuredSearch(store, subSearches, { | ||
| collections: effectiveCollections.length > 0 ? effectiveCollections : undefined, | ||
| limit, | ||
| minScore, | ||
| candidateLimit, | ||
| intent, | ||
| }); | ||
| // Use first lex or vec query for snippet extraction | ||
| const primaryQuery = searches.find(s => s.type === 'lex')?.query | ||
| || searches.find(s => s.type === 'vec')?.query | ||
| || searches[0]?.query || ""; | ||
| const filtered = results.map(r => { | ||
| const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300, undefined, undefined, intent); | ||
| return { | ||
| docid: `#${r.docid}`, | ||
| file: r.displayPath, | ||
| title: r.title, | ||
| score: Math.round(r.score * 100) / 100, | ||
| context: r.context, | ||
| snippet: addLineNumbers(snippet, line), | ||
| }; | ||
| }); | ||
| return { | ||
| content: [{ type: "text", text: formatSearchSummary(filtered, primaryQuery) }], | ||
| structuredContent: { results: filtered }, | ||
| }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: qmd_get (Retrieve document) | ||
| // --------------------------------------------------------------------------- | ||
| server.registerTool("get", { | ||
| title: "Get Document", | ||
| description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.", | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: { | ||
| file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"), | ||
| fromLine: z.number().optional().describe("Start from this line number (1-indexed)"), | ||
| maxLines: z.number().optional().describe("Maximum number of lines to return"), | ||
| lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"), | ||
| }, | ||
| }, async ({ file, fromLine, maxLines, lineNumbers }) => { | ||
| // Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided | ||
| let parsedFromLine = fromLine; | ||
| let lookup = file; | ||
| const colonMatch = lookup.match(/:(\d+)$/); | ||
| if (colonMatch && colonMatch[1] && parsedFromLine === undefined) { | ||
| parsedFromLine = parseInt(colonMatch[1], 10); | ||
| lookup = lookup.slice(0, -colonMatch[0].length); | ||
| } | ||
| const result = store.findDocument(lookup, { includeBody: false }); | ||
| if ("error" in result) { | ||
| let msg = `Document not found: ${file}`; | ||
| if (result.similarFiles.length > 0) { | ||
| msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`; | ||
| } | ||
| return { | ||
| content: [{ type: "text", text: msg }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? ""; | ||
| let text = body; | ||
| if (lineNumbers) { | ||
| const startLine = parsedFromLine || 1; | ||
| text = addLineNumbers(text, startLine); | ||
| } | ||
| if (result.context) { | ||
| text = `<!-- Context: ${result.context} -->\n\n` + text; | ||
| } | ||
| return { | ||
| content: [{ | ||
| type: "resource", | ||
| resource: { | ||
| uri: `qmd://${encodeQmdPath(result.displayPath)}`, | ||
| name: result.displayPath, | ||
| title: result.title, | ||
| mimeType: "text/markdown", | ||
| text, | ||
| }, | ||
| }], | ||
| }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: qmd_multi_get (Retrieve multiple documents) | ||
| // --------------------------------------------------------------------------- | ||
| server.registerTool("multi_get", { | ||
| title: "Multi-Get Documents", | ||
| description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.", | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: { | ||
| pattern: z.string().describe("Glob pattern or comma-separated list of file paths"), | ||
| maxLines: z.number().optional().describe("Maximum lines per file"), | ||
| maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"), | ||
| lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"), | ||
| }, | ||
| }, async ({ pattern, maxLines, maxBytes, lineNumbers }) => { | ||
| const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES }); | ||
| if (docs.length === 0 && errors.length === 0) { | ||
| return { | ||
| content: [{ type: "text", text: `No files matched pattern: ${pattern}` }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const content = []; | ||
| if (errors.length > 0) { | ||
| content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` }); | ||
| } | ||
| for (const result of docs) { | ||
| if (result.skipped) { | ||
| content.push({ | ||
| type: "text", | ||
| text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`, | ||
| }); | ||
| continue; | ||
| } | ||
| let text = result.doc.body || ""; | ||
| if (maxLines !== undefined) { | ||
| const lines = text.split("\n"); | ||
| text = lines.slice(0, maxLines).join("\n"); | ||
| if (lines.length > maxLines) { | ||
| text += `\n\n[... truncated ${lines.length - maxLines} more lines]`; | ||
| } | ||
| } | ||
| if (lineNumbers) { | ||
| text = addLineNumbers(text); | ||
| } | ||
| if (result.doc.context) { | ||
| text = `<!-- Context: ${result.doc.context} -->\n\n` + text; | ||
| } | ||
| content.push({ | ||
| type: "resource", | ||
| resource: { | ||
| uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`, | ||
| name: result.doc.displayPath, | ||
| title: result.doc.title, | ||
| mimeType: "text/markdown", | ||
| text, | ||
| }, | ||
| }); | ||
| } | ||
| return { content }; | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Tool: qmd_status (Index status) | ||
| // --------------------------------------------------------------------------- | ||
| server.registerTool("status", { | ||
| title: "Index Status", | ||
| description: "Show the status of the QMD index: collections, document counts, and health information.", | ||
| annotations: { readOnlyHint: true, openWorldHint: false }, | ||
| inputSchema: {}, | ||
| }, async () => { | ||
| const status = store.getStatus(); | ||
| const summary = [ | ||
| `QMD Index Status:`, | ||
| ` Total documents: ${status.totalDocuments}`, | ||
| ` Needs embedding: ${status.needsEmbedding}`, | ||
| ` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`, | ||
| ` Collections: ${status.collections.length}`, | ||
| ]; | ||
| for (const col of status.collections) { | ||
| summary.push(` - ${col.path} (${col.documents} docs)`); | ||
| } | ||
| return { | ||
| content: [{ type: "text", text: summary.join('\n') }], | ||
| structuredContent: status, | ||
| }; | ||
| }); | ||
| return server; | ||
| } | ||
| // ============================================================================= | ||
| // Transport: stdio (default) | ||
| // ============================================================================= | ||
| export async function startMcpServer() { | ||
| const store = createStore(); | ||
| const server = createMcpServer(store); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } | ||
| /** | ||
| * Start MCP server over Streamable HTTP (JSON responses, no SSE). | ||
| * Binds to localhost only. Returns a handle for shutdown and port discovery. | ||
| */ | ||
| export async function startMcpHttpServer(port, options) { | ||
| const store = createStore(); | ||
| // 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. | ||
| const sessions = new Map(); | ||
| async function createSession() { | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ | ||
| sessionIdGenerator: () => randomUUID(), | ||
| enableJsonResponse: true, | ||
| onsessioninitialized: (sessionId) => { | ||
| sessions.set(sessionId, transport); | ||
| log(`${ts()} New session ${sessionId} (${sessions.size} active)`); | ||
| }, | ||
| }); | ||
| const server = createMcpServer(store); | ||
| await server.connect(transport); | ||
| transport.onclose = () => { | ||
| if (transport.sessionId) { | ||
| sessions.delete(transport.sessionId); | ||
| } | ||
| }; | ||
| return transport; | ||
| } | ||
| const startTime = Date.now(); | ||
| const quiet = options?.quiet ?? false; | ||
| /** Format timestamp for request logging */ | ||
| function ts() { | ||
| return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS | ||
| } | ||
| /** Extract a human-readable label from a JSON-RPC body */ | ||
| function describeRequest(body) { | ||
| const method = body?.method ?? "unknown"; | ||
| if (method === "tools/call") { | ||
| const tool = body.params?.name ?? "?"; | ||
| const args = body.params?.arguments; | ||
| // Show query string if present, truncated | ||
| if (args?.query) { | ||
| const q = String(args.query).slice(0, 80); | ||
| return `tools/call ${tool} "${q}"`; | ||
| } | ||
| if (args?.path) | ||
| return `tools/call ${tool} ${args.path}`; | ||
| if (args?.pattern) | ||
| return `tools/call ${tool} ${args.pattern}`; | ||
| return `tools/call ${tool}`; | ||
| } | ||
| return method; | ||
| } | ||
| function log(msg) { | ||
| if (!quiet) | ||
| console.error(msg); | ||
| } | ||
| // Helper to collect request body | ||
| async function collectBody(req) { | ||
| const chunks = []; | ||
| for await (const chunk of req) | ||
| chunks.push(chunk); | ||
| return Buffer.concat(chunks).toString(); | ||
| } | ||
| const httpServer = createServer(async (nodeReq, nodeRes) => { | ||
| const reqStart = Date.now(); | ||
| const pathname = nodeReq.url || "/"; | ||
| try { | ||
| if (pathname === "/health" && nodeReq.method === "GET") { | ||
| const body = JSON.stringify({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000) }); | ||
| nodeRes.writeHead(200, { "Content-Type": "application/json" }); | ||
| nodeRes.end(body); | ||
| log(`${ts()} GET /health (${Date.now() - reqStart}ms)`); | ||
| return; | ||
| } | ||
| // REST endpoint: POST /search — structured search without MCP protocol | ||
| // REST endpoint: POST /query (alias: /search) — structured search without MCP protocol | ||
| 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" }); | ||
| nodeRes.end(JSON.stringify({ error: "Missing required field: searches (array)" })); | ||
| return; | ||
| } | ||
| // Map to internal format | ||
| const subSearches = params.searches.map((s) => ({ | ||
| type: s.type, | ||
| query: String(s.query || ""), | ||
| })); | ||
| // Use default collections if none specified | ||
| const effectiveCollections = params.collections ?? getDefaultCollectionNames(); | ||
| const results = await structuredSearch(store, subSearches, { | ||
| collections: effectiveCollections.length > 0 ? effectiveCollections : undefined, | ||
| limit: params.limit ?? 10, | ||
| minScore: params.minScore ?? 0, | ||
| candidateLimit: params.candidateLimit, | ||
| }); | ||
| // Use first lex or vec query for snippet extraction | ||
| const primaryQuery = params.searches.find((s) => s.type === 'lex')?.query | ||
| || params.searches.find((s) => s.type === 'vec')?.query | ||
| || params.searches[0]?.query || ""; | ||
| const formatted = results.map(r => { | ||
| const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300); | ||
| return { | ||
| docid: `#${r.docid}`, | ||
| file: r.displayPath, | ||
| title: r.title, | ||
| score: Math.round(r.score * 100) / 100, | ||
| context: r.context, | ||
| snippet: addLineNumbers(snippet, line), | ||
| }; | ||
| }); | ||
| nodeRes.writeHead(200, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ results: formatted })); | ||
| log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`); | ||
| return; | ||
| } | ||
| if (pathname === "/mcp" && nodeReq.method === "POST") { | ||
| const rawBody = await collectBody(nodeReq); | ||
| const body = JSON.parse(rawBody); | ||
| const label = describeRequest(body); | ||
| const url = `http://localhost:${port}${pathname}`; | ||
| const headers = {}; | ||
| for (const [k, v] of Object.entries(nodeReq.headers)) { | ||
| if (typeof v === "string") | ||
| headers[k] = v; | ||
| } | ||
| // Route to existing session or create new one on initialize | ||
| const sessionId = headers["mcp-session-id"]; | ||
| let transport; | ||
| if (sessionId) { | ||
| const existing = sessions.get(sessionId); | ||
| if (!existing) { | ||
| nodeRes.writeHead(404, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32001, message: "Session not found" }, | ||
| id: body?.id ?? null, | ||
| })); | ||
| return; | ||
| } | ||
| transport = existing; | ||
| } | ||
| else if (isInitializeRequest(body)) { | ||
| transport = await createSession(); | ||
| } | ||
| else { | ||
| nodeRes.writeHead(400, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32000, message: "Bad Request: Missing session ID" }, | ||
| id: body?.id ?? null, | ||
| })); | ||
| return; | ||
| } | ||
| const request = new Request(url, { method: "POST", headers, body: rawBody }); | ||
| const response = await transport.handleRequest(request, { parsedBody: body }); | ||
| nodeRes.writeHead(response.status, Object.fromEntries(response.headers)); | ||
| nodeRes.end(Buffer.from(await response.arrayBuffer())); | ||
| log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`); | ||
| return; | ||
| } | ||
| if (pathname === "/mcp") { | ||
| const headers = {}; | ||
| for (const [k, v] of Object.entries(nodeReq.headers)) { | ||
| if (typeof v === "string") | ||
| headers[k] = v; | ||
| } | ||
| // GET/DELETE must have a valid session | ||
| const sessionId = headers["mcp-session-id"]; | ||
| if (!sessionId) { | ||
| nodeRes.writeHead(400, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32000, message: "Bad Request: Missing session ID" }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| const transport = sessions.get(sessionId); | ||
| if (!transport) { | ||
| nodeRes.writeHead(404, { "Content-Type": "application/json" }); | ||
| nodeRes.end(JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| error: { code: -32001, message: "Session not found" }, | ||
| id: null, | ||
| })); | ||
| return; | ||
| } | ||
| const url = `http://localhost:${port}${pathname}`; | ||
| const rawBody = nodeReq.method !== "GET" && nodeReq.method !== "HEAD" ? await collectBody(nodeReq) : undefined; | ||
| const request = new Request(url, { method: nodeReq.method || "GET", headers, ...(rawBody ? { body: rawBody } : {}) }); | ||
| const response = await transport.handleRequest(request); | ||
| nodeRes.writeHead(response.status, Object.fromEntries(response.headers)); | ||
| nodeRes.end(Buffer.from(await response.arrayBuffer())); | ||
| return; | ||
| } | ||
| nodeRes.writeHead(404); | ||
| nodeRes.end("Not Found"); | ||
| } | ||
| catch (err) { | ||
| console.error("HTTP handler error:", err); | ||
| nodeRes.writeHead(500); | ||
| nodeRes.end("Internal Server Error"); | ||
| } | ||
| }); | ||
| await new Promise((resolve, reject) => { | ||
| httpServer.on("error", reject); | ||
| httpServer.listen(port, "localhost", () => resolve()); | ||
| }); | ||
| const actualPort = httpServer.address().port; | ||
| let stopping = false; | ||
| const stop = async () => { | ||
| if (stopping) | ||
| return; | ||
| stopping = true; | ||
| for (const transport of sessions.values()) { | ||
| await transport.close(); | ||
| } | ||
| sessions.clear(); | ||
| httpServer.close(); | ||
| store.close(); | ||
| await disposeDefaultLlamaCpp(); | ||
| }; | ||
| process.on("SIGTERM", async () => { | ||
| console.error("Shutting down (SIGTERM)..."); | ||
| await stop(); | ||
| process.exit(0); | ||
| }); | ||
| process.on("SIGINT", async () => { | ||
| console.error("Shutting down (SIGINT)..."); | ||
| await stop(); | ||
| process.exit(0); | ||
| }); | ||
| log(`QMD MCP server listening on http://localhost:${actualPort}/mcp`); | ||
| return { httpServer, port: actualPort, stop }; | ||
| } | ||
| // 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")) { | ||
| startMcpServer().catch(console.error); | ||
| } |
| export {}; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
488659
8.58%23
15%10538
8.15%885
21.9%26
4%+ Added
- Removed
Updated