diff --git a/src/cli.test.ts b/src/cli.test.ts index 75bd5c6..e87bcf8 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -289,13 +289,21 @@ describe("CLI Get Command", () => { }); describe("CLI Multi-Get Command", () => { + let localDbPath: string; + beforeEach(async () => { + // Use fresh database for each test + localDbPath = getFreshDbPath(); // Ensure we have indexed files - await runQmd(["collection", "add", "."]); + const addResult = await runQmd(["collection", "add", ".", "--name", "fixtures"], { dbPath: localDbPath }); + if (addResult.exitCode !== 0) { + throw new Error(`Failed to add collection: ${addResult.stderr}`); + } }); test("retrieves multiple documents by pattern", async () => { - const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md"]); + // Test glob pattern matching + const { stdout, stderr, exitCode } = await runQmd(["multi-get", "notes/*.md"], { dbPath: localDbPath }); expect(exitCode).toBe(0); // Should contain content from both notes files expect(stdout).toContain("Meeting"); @@ -306,7 +314,7 @@ describe("CLI Multi-Get Command", () => { const { stdout, exitCode } = await runQmd([ "multi-get", "README.md,notes/meeting.md", - ]); + ], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("Test Project"); expect(stdout).toContain("Team Meeting"); diff --git a/src/mcp.test.ts b/src/mcp.test.ts index 522b714..4ce350e 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -11,6 +11,11 @@ import * as sqliteVec from "sqlite-vec"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { setDefaultOllama, Ollama } from "./llm"; +import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import YAML from "yaml"; +import type { CollectionConfig } from "./collections"; // ============================================================================= // Mock Ollama @@ -28,12 +33,13 @@ const mockOllamaResponses: Record Response> = { }); }, "/api/generate": (body: unknown) => { - const reqBody = body as { prompt?: string }; + const reqBody = body as { prompt?: string; logprobs?: boolean }; if (reqBody.prompt?.includes("Judge") || reqBody.prompt?.includes("Document")) { + // Return format matching Ollama API return new Response(JSON.stringify({ response: "yes", done: true, - logprobs: { tokens: ["yes"], token_logprobs: [-0.1] }, + logprobs: reqBody.logprobs ? { tokens: ["yes"], token_logprobs: [-0.1] } : undefined }), { status: 200, headers: { "Content-Type": "application/json" } }); } else { return new Response(JSON.stringify({ @@ -72,6 +78,7 @@ function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - beforeAll(() => { + beforeAll(async () => { globalThis.fetch = mockFetch as typeof fetch; setDefaultOllama(new Ollama({ baseUrl: OLLAMA_URL })); + // Set up test config directory + const configPrefix = join(tmpdir(), `qmd-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); + testConfigDir = await mkdtemp(configPrefix); + process.env.QMD_CONFIG_DIR = testConfigDir; + + // Create YAML config with test collection + const testConfig: CollectionConfig = { + collections: { + docs: { + path: "/test/docs", + pattern: "**/*.md", + context: { + "/meetings": "Meeting notes and transcripts" + } + } + } + }; + await writeFile(join(testConfigDir, "index.yml"), YAML.stringify(testConfig)); + testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`; testDb = new Database(testDbPath); initTestDatabase(testDb); seedTestData(testDb); }); - afterAll(() => { + afterAll(async () => { globalThis.fetch = originalFetch; setDefaultOllama(null); testDb.close(); try { require("fs").unlinkSync(testDbPath); } catch {} + + // Clean up test config directory + try { + const files = await readdir(testConfigDir); + for (const file of files) { + await unlink(join(testConfigDir, file)); + } + await rmdir(testConfigDir); + } catch {} + + delete process.env.QMD_CONFIG_DIR; }); // =========================================================================== @@ -377,10 +414,10 @@ describe("MCP Server", () => { const ftsResults = searchFTS(testDb, q, 20); if (ftsResults.length > 0) { rankedLists.push(ftsResults.map(r => ({ - file: r.file, + file: r.filepath, displayPath: r.displayPath, title: r.title, - body: r.body, + body: r.body || "", score: r.score, }))); } diff --git a/src/qmd.ts b/src/qmd.ts index 69aa067..6c3268c 100755 --- a/src/qmd.ts +++ b/src/qmd.ts @@ -1012,7 +1012,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT let path = file.path; if (!collection || !path) { - const parsed = parseVirtualPath(file.displayPath); + const parsed = parseVirtualPath(file.filepath); if (parsed) { collection = parsed.collectionName; path = parsed.path; @@ -1036,16 +1036,15 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT continue; } - // Fetch document content - use virtual path to query - const parsed = parseVirtualPath(file.displayPath); - if (!parsed) continue; + // Fetch document content using collection and path + if (!collection || !path) continue; const doc = db.prepare(` SELECT content.doc as body, d.title FROM documents d JOIN content ON content.hash = d.hash WHERE d.collection = ? AND d.path = ? AND d.active = 1 - `).get(parsed.collectionName, parsed.path) as { body: string; title: string } | null; + `).get(collection, path) as { body: string; title: string } | null; if (!doc) continue; diff --git a/src/store.test.ts b/src/store.test.ts index 692ef42..3d537b2 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -827,7 +827,8 @@ describe("Document Retrieval", () => { expect("error" in result).toBe(false); if (!("error" in result)) { expect(result.title).toBe("My Document"); - expect(result.displayPath).toBe(`qmd://${collectionName}/mydoc.md`); + expect(result.displayPath).toBe("mydoc.md"); + expect(result.filepath).toBe(`qmd://${collectionName}/mydoc.md`); expect(result.body).toBeUndefined(); // body not included by default } @@ -917,8 +918,8 @@ describe("Document Retrieval", () => { test("findDocument expands ~ to home directory", async () => { const store = await createTestStore(); - const collectionName = await createTestCollection(); const home = homedir(); + const collectionName = await createTestCollection({ pwd: home, name: "home" }); await insertTestDocument(store.db, collectionName, { name: "mydoc", filepath: `${home}/docs/mydoc.md`, @@ -948,6 +949,41 @@ describe("Document Retrieval", () => { await cleanupTestDb(store); }); + + test("findDocument includes hierarchical contexts (global + collection + path)", async () => { + const store = await createTestStore(); + const collectionName = await createTestCollection({ pwd: "/archive", name: "archive" }); + + // Add global context + await addGlobalContext("Global context for all documents"); + + // Add collection root context + await addPathContext(collectionName, "/", "Archive collection context"); + + // Add path-specific contexts at different levels + await addPathContext(collectionName, "/podcasts", "Podcast episodes"); + await addPathContext(collectionName, "/podcasts/external", "External podcast interviews"); + + // Insert document in nested path + await insertTestDocument(store.db, collectionName, { + name: "interview", + displayPath: "podcasts/external/2024-jan-interview.md", + }); + + const result = store.findDocument("/archive/podcasts/external/2024-jan-interview.md"); + expect("error" in result).toBe(false); + if (!("error" in result)) { + // Should have all contexts joined with double newlines + expect(result.context).toBe( + "Global context for all documents\n\n" + + "Archive collection context\n\n" + + "Podcast episodes\n\n" + + "External podcast interviews" + ); + } + + await cleanupTestDb(store); + }); }); describe("getDocumentBody", () => { diff --git a/src/store.ts b/src/store.ts index 5666b55..5432c04 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1909,11 +1909,11 @@ export function findDocuments( const bodyCol = options.includeBody ? `, content.doc as body` : ``; const selectCols = ` - 'qmd://' || d.collection || '/' || d.path as display_path, + 'qmd://' || d.collection || '/' || d.path as virtual_path, + d.path as display_path, d.title, d.hash, d.collection, - d.path, d.modified_at, LENGTH(content.doc) as body_length ${bodyCol} @@ -1971,14 +1971,13 @@ export function findDocuments( const results: MultiGetResult[] = []; for (const row of fileRows) { - // Compute absolute filepath from collection - const coll = getCollection(row.collection); - const absoluteFilepath = coll ? `${coll.path}/${row.path}` : row.path; - const context = getContextForFile(db, absoluteFilepath); + // Get context using virtual path + const virtualPath = row.virtual_path || `qmd://${row.collection}/${row.display_path}`; + const context = getContextForFile(db, virtualPath); if (row.body_length > maxBytes) { results.push({ - doc: { filepath: absoluteFilepath, displayPath: row.display_path }, + doc: { filepath: virtualPath, displayPath: row.display_path }, skipped: true, skipReason: `File too large (${Math.round(row.body_length / 1024)}KB > ${Math.round(maxBytes / 1024)}KB)`, }); @@ -1987,7 +1986,7 @@ export function findDocuments( results.push({ doc: { - filepath: absoluteFilepath, + filepath: virtualPath, displayPath: row.display_path, title: row.title || row.display_path.split('/').pop() || row.display_path, context,