Selaa lähdekoodia

Add path normalization, output format tests, and fix test isolation

- Add support for collection/path.md format in get command (checks if
  first component is a known collection before treating as filesystem path)
- Add comprehensive output format tests verifying qmd:// URIs, docid,
  and context in JSON, CSV, MD, XML, files, and CLI formats
- Add path normalization tests for various input formats:
  qmd://, //, qmd:////, collection/path, and path:line suffix
- Add isolated test environments (createIsolatedTestEnv) to prevent
  YAML config conflicts between test suites
- Add test fixture files test1.md and test2.md for path tests
- Update runQmd helper to accept custom configDir parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tobi Lutke 5 kuukautta sitten
vanhempi
commit
a3703c069a
5 muutettua tiedostoa jossa 662 lisäystä ja 118 poistoa
  1. 89 38
      README.md
  2. 265 20
      src/cli.test.ts
  3. 104 57
      src/qmd.ts
  4. 151 0
      src/store.test.ts
  5. 53 3
      src/store.ts

+ 89 - 38
README.md

@@ -10,15 +10,15 @@ QMD combines BM25 full-text search, vector semantic search, and LLM re-ranking
 # Install globally
 bun install -g https://github.com/tobi/qmd
 
-# Index your notes, docs, and meeting transcripts
-cd ~/notes && qmd add .
-cd ~/Documents/meetings && qmd add .
-cd ~/work/docs && qmd add .
+# Create collections for your notes, docs, and meeting transcripts
+qmd collection add ~/notes --name notes
+qmd collection add ~/Documents/meetings --name meetings
+qmd collection add ~/work/docs --name docs
 
 # Add context to help with search results
-qmd add-context ~/notes "Personal notes and ideas"
-qmd add-context ~/Documents/meetings "Meeting transcripts and notes"
-qmd add-context ~/work/docs "Work documentation"
+qmd context add qmd://notes "Personal notes and ideas"
+qmd context add qmd://meetings "Meeting transcripts and notes"
+qmd context add qmd://docs "Work documentation"
 
 # Generate embeddings for semantic search
 qmd embed
@@ -31,6 +31,9 @@ qmd query "quarterly planning process"  # Hybrid + reranking (best quality)
 # Get a specific document
 qmd get "meetings/2024-01-15.md"
 
+# Get a document by docid (shown in search results)
+qmd get "#abc123"
+
 # Get multiple documents by glob pattern
 qmd multi-get "journals/2025-05*.md"
 
@@ -64,8 +67,8 @@ Although the tool works perfectly fine when you just tell your agent to use it o
 - `qmd_search` - Fast BM25 keyword search (supports collection filter)
 - `qmd_vsearch` - Semantic vector search (supports collection filter)
 - `qmd_query` - Hybrid search with reranking (supports collection filter)
-- `qmd_get` - Retrieve document content (with fuzzy matching suggestions)
-- `qmd_multi_get` - Retrieve multiple documents by glob pattern or list
+- `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
 
 **Claude Desktop configuration** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
@@ -228,17 +231,27 @@ bun install
 
 ## Usage
 
-### Index Markdown Files
+### Collection Management
 
 ```sh
-# Index all .md files in current directory
-qmd add .
+# Create a collection from current directory
+qmd collection add . --name myproject
+
+# Create a collection with explicit path and custom glob mask
+qmd collection add ~/Documents/notes --name notes --mask "**/*.md"
+
+# List all collections
+qmd collection list
+
+# Remove a collection
+qmd collection remove myproject
 
-# Index with custom glob pattern
-qmd add "docs/**/*.md"
+# Rename a collection
+qmd collection rename myproject my-project
 
-# Drop and re-add a collection
-qmd add --drop .
+# List files in a collection
+qmd ls notes
+qmd ls notes/subfolder
 ```
 
 ### Generate Vector Embeddings
@@ -251,12 +264,27 @@ qmd embed
 qmd embed -f
 ```
 
-### Add Context
+### Context Management
+
+Context adds descriptive metadata to collections and paths, helping search understand your content.
 
 ```sh
-# Add context description for files in a path
-qmd add-context . "Project documentation and guides"
-qmd add-context ./meetings "Internal meeting transcripts"
+# Add context to a collection (using qmd:// virtual paths)
+qmd context add qmd://notes "Personal notes and ideas"
+qmd context add qmd://docs/api "API documentation"
+
+# Add context from within a collection directory
+cd ~/notes && qmd context add "Personal notes and ideas"
+cd ~/notes/work && qmd context add "Work-related notes"
+
+# Add global context (applies to all collections)
+qmd context add / "Knowledge base for my projects"
+
+# List all contexts
+qmd context list
+
+# Remove context
+qmd context rm qmd://notes/old
 ```
 
 ### Search Commands
@@ -291,15 +319,21 @@ qmd query "user authentication"
 --all              # Return all matches (use with --min-score to filter)
 --min-score <num>  # Minimum score threshold (default: 0)
 --full             # Show full document content
+--line-numbers     # Add line numbers to output
 --index <name>     # Use named index
 
 # Output formats (for search and multi-get)
---files            # Output: score,filepath,context (search) or filepath,context (multi-get)
---json             # JSON output
+--files            # Output: docid,score,filepath,context
+--json             # JSON output with snippets
 --csv              # CSV output
 --md               # Markdown output
 --xml              # XML output
 
+# Get options
+qmd get <file>[:line]  # Get document, optionally starting at line
+-l <num>               # Maximum lines to return
+--from <num>           # Start from line number
+
 # Multi-get options
 -l <num>           # Maximum lines per file
 --max-bytes <num>  # Skip files larger than N bytes (default: 10KB)
@@ -310,7 +344,7 @@ qmd query "user authentication"
 Default output is colorized CLI format (respects `NO_COLOR` env):
 
 ```
-docs/guide.md:42
+docs/guide.md:42 #a1b2c3
 Title: Software Craftsmanship
 Context: Work documentation
 Score: 93%
@@ -320,7 +354,7 @@ quality software with attention to detail.
 See also: engineering principles
 
 
-notes/meeting.md:15
+notes/meeting.md:15 #d4e5f6
 Title: Q4 Planning
 Context: Personal notes and ideas
 Score: 67%
@@ -329,9 +363,10 @@ Discussion about code quality and craftsmanship
 in the development process.
 ```
 
-- **Path**: Collection-relative, includes parent folder (e.g., `docs/guide.md`)
+- **Path**: Collection-relative path (e.g., `docs/guide.md`)
+- **Docid**: Short hash identifier (e.g., `#a1b2c3`) - use with `qmd get #a1b2c3`
 - **Title**: Extracted from document (first heading or filename)
-- **Context**: Folder context if configured via `add-context`
+- **Context**: Path context if configured via `qmd context add`
 - **Score**: Color-coded (green >70%, yellow >40%, dim otherwise)
 - **Snippet**: Context around match with query terms highlighted
 
@@ -351,23 +386,32 @@ qmd query --json "quarterly reports"
 qmd --index work search "quarterly reports"
 ```
 
-### Manage Collections
+### Index Maintenance
 
 ```sh
 # Show index status and collections with contexts
 qmd status
 
 # Re-index all collections
-qmd update-all
+qmd update
+
+# Re-index with git pull first (for remote repos)
+qmd update --pull
+
+# Get document by filepath (with fuzzy matching suggestions)
+qmd get notes/meeting.md
+
+# Get document by docid (from search results)
+qmd get "#abc123"
 
-# Get document body by filepath (with fuzzy matching)
-qmd get ~/notes/meeting.md
+# Get document starting at line 50, max 100 lines
+qmd get notes/meeting.md:50 -l 100
 
 # Get multiple documents by glob pattern
 qmd multi-get "journals/2025-05*.md"
 
-# Get multiple documents by comma-separated list
-qmd multi-get "doc1.md, doc2.md, doc3.md"
+# Get multiple documents by comma-separated list (supports docids)
+qmd multi-get "doc1.md, doc2.md, #abc123"
 
 # Limit multi-get to files under 20KB
 qmd multi-get "docs/*.md" --max-bytes 20480
@@ -386,9 +430,9 @@ Index stored in: `~/.cache/qmd/index.sqlite`
 ### Schema
 
 ```sql
-collections     -- Indexed directories and glob patterns
-path_contexts   -- Context descriptions by path prefix
-documents       -- Markdown content with metadata
+collections     -- Indexed directories with name and glob patterns
+path_contexts   -- Context descriptions by virtual path (qmd://...)
+documents       -- Markdown content with metadata and docid (6-char hash)
 documents_fts   -- FTS5 full-text index
 content_vectors -- Embedding chunks (hash, seq, pos)
 vectors_vec     -- sqlite-vec vector index (hash_seq key)
@@ -407,9 +451,16 @@ ollama_cache    -- Cached API responses
 ### Indexing Flow
 
 ```
-Markdown Files ──► Parse Title ──► Hash Content ──► Store in SQLite
-                      │                                    │
-                      └──────────► FTS5 Index ◄────────────┘
+Collection ──► Glob Pattern ──► Markdown Files ──► Parse Title ──► Hash Content
+    │                                                   │              │
+    │                                                   │              ▼
+    │                                                   │         Generate docid
+    │                                                   │         (6-char hash)
+    │                                                   │              │
+    └──────────────────────────────────────────────────►└──► Store in SQLite
+                                                                       │
+                                                                       ▼
+                                                                  FTS5 Index
 ```
 
 ### Embedding Flow

+ 265 - 20
src/cli.test.ts

@@ -24,16 +24,17 @@ const qmdScript = join(qmdDir, "qmd.ts");
 // Helper to run qmd command with test database
 async function runQmd(
   args: string[],
-  options: { cwd?: string; env?: Record<string, string>; dbPath?: string } = {}
+  options: { cwd?: string; env?: Record<string, string>; dbPath?: string; configDir?: string } = {}
 ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
   const workingDir = options.cwd || fixturesDir;
   const dbPath = options.dbPath || testDbPath;
+  const configDir = options.configDir || testConfigDir;
   const proc = Bun.spawn(["bun", qmdScript, ...args], {
     cwd: workingDir,
     env: {
       ...process.env,
       INDEX_PATH: dbPath,
-      QMD_CONFIG_DIR: testConfigDir, // Use test config directory
+      QMD_CONFIG_DIR: configDir, // Use test config directory
       PWD: workingDir, // Must explicitly set PWD since getPwd() checks this
       ...options.env,
     },
@@ -54,6 +55,16 @@ function getFreshDbPath(): string {
   return join(testDir, `test-${testCounter}.sqlite`);
 }
 
+// Create an isolated test environment (db + config dir)
+async function createIsolatedTestEnv(prefix: string): Promise<{ dbPath: string; configDir: string }> {
+  testCounter++;
+  const dbPath = join(testDir, `${prefix}-${testCounter}.sqlite`);
+  const configDir = join(testDir, `${prefix}-config-${testCounter}`);
+  await mkdir(configDir, { recursive: true });
+  await writeFile(join(configDir, "index.yml"), "collections: {}\n");
+  return { dbPath, configDir };
+}
+
 // Setup test fixtures
 beforeAll(async () => {
   // Create temp directory structure
@@ -145,6 +156,27 @@ Retrieve a specific document.
 
 ### POST /index
 Index new documents.
+`
+  );
+
+  // Create test files for path normalization tests
+  await writeFile(
+    join(fixturesDir, "test1.md"),
+    `# Test Document 1
+
+This is the first test document.
+
+It has multiple lines for testing line numbers.
+Line 6 is here.
+Line 7 is here.
+`
+  );
+
+  await writeFile(
+    join(fixturesDir, "test2.md"),
+    `# Test Document 2
+
+This is the second test document.
 `
   );
 });
@@ -339,31 +371,38 @@ describe("CLI Update Command", () => {
 });
 
 describe("CLI Add-Context Command", () => {
-  beforeEach(async () => {
-    // Ensure we have indexed files
-    await runQmd(["collection", "add", "."]);
+  let localDbPath: string;
+  let localConfigDir: string;
+  const collName = "fixtures";
+
+  beforeAll(async () => {
+    const env = await createIsolatedTestEnv("context-cmd");
+    localDbPath = env.dbPath;
+    localConfigDir = env.configDir;
+
+    // Add collection with known name
+    const { exitCode, stderr } = await runQmd(
+      ["collection", "add", fixturesDir, "--name", collName],
+      { dbPath: localDbPath, configDir: localConfigDir }
+    );
+    if (exitCode !== 0) console.error("collection add failed:", stderr);
+    expect(exitCode).toBe(0);
   });
 
   test("adds context to a path", async () => {
-    // First add a collection to get its name
-    const listResult = await runQmd(["collection", "list"]);
-    // Parse collection name - it's on the 3rd line (after header and blank line)
-    const lines = listResult.stdout.split('\n').filter(l => l.trim());
-    const collectionName = lines[1]; // "fixtures" is on line 2
-
     // Add context to the collection root using virtual path
     const { stdout, exitCode } = await runQmd([
       "context",
       "add",
-      `qmd://${collectionName}/`,
+      `qmd://${collName}/`,
       "Personal notes and meeting logs",
-    ]);
+    ], { dbPath: localDbPath, configDir: localConfigDir });
     expect(exitCode).toBe(0);
     expect(stdout).toContain("✓ Added context");
   });
 
   test("requires path and text arguments", async () => {
-    const { stderr, exitCode } = await runQmd(["add-context"]);
+    const { stderr, exitCode } = await runQmd(["add-context"], { dbPath: localDbPath, configDir: localConfigDir });
     expect(exitCode).toBe(1);
     // Error message goes to stderr
     expect(stderr).toContain("Usage:");
@@ -621,7 +660,7 @@ describe("CLI Collection Commands", () => {
     expect(exitCode).toBe(0);
     expect(stdout).toContain("Collections");
     expect(stdout).toContain("fixtures");
-    expect(stdout).toContain("Path:");
+    expect(stdout).toContain("qmd://fixtures/");
     expect(stdout).toContain("Pattern:");
     expect(stdout).toContain("Files:");
   });
@@ -663,7 +702,7 @@ describe("CLI Collection Commands", () => {
   test("renames a collection", async () => {
     // First verify the collection exists
     const { stdout: listBefore } = await runQmd(["collection", "list"], { dbPath: localDbPath });
-    expect(listBefore).toMatch(/^fixtures$/m); // Collection name on its own line
+    expect(listBefore).toContain("qmd://fixtures/");
 
     // Rename it
     const { stdout, exitCode } = await runQmd(["collection", "rename", "fixtures", "my-fixtures"], { dbPath: localDbPath });
@@ -674,8 +713,8 @@ describe("CLI Collection Commands", () => {
 
     // Verify the new name exists and old name is gone
     const { stdout: listAfter } = await runQmd(["collection", "list"], { dbPath: localDbPath });
-    expect(listAfter).toMatch(/^my-fixtures$/m); // Collection name on its own line
-    expect(listAfter).not.toMatch(/^fixtures$/m); // Old name should not appear as collection name
+    expect(listAfter).toContain("qmd://my-fixtures/");
+    expect(listAfter).not.toContain("qmd://fixtures/"); // Old collection should not appear
   });
 
   test("handles renaming non-existent collection", async () => {
@@ -697,8 +736,8 @@ describe("CLI Collection Commands", () => {
 
     // Verify both collections exist
     const { stdout: listBoth } = await runQmd(["collection", "list"], { dbPath: localDbPath });
-    expect(listBoth).toMatch(/^fixtures$/m);
-    expect(listBoth).toMatch(/^second$/m);
+    expect(listBoth).toContain("qmd://fixtures/");
+    expect(listBoth).toContain("qmd://second/");
 
     // Try to rename fixtures to second (which already exists)
     const { stderr, exitCode } = await runQmd(["collection", "rename", "fixtures", "second"], { dbPath: localDbPath });
@@ -716,3 +755,209 @@ describe("CLI Collection Commands", () => {
     expect(stderr2).toContain("Usage:");
   });
 });
+
+// =============================================================================
+// Output Format Tests - qmd:// URIs, context, and docid
+// =============================================================================
+
+describe("search output formats", () => {
+  let localDbPath: string;
+  let localConfigDir: string;
+  const collName = "fixtures";
+
+  beforeAll(async () => {
+    const env = await createIsolatedTestEnv("output-format");
+    localDbPath = env.dbPath;
+    localConfigDir = env.configDir;
+
+    // Add collection
+    const { exitCode, stderr } = await runQmd(
+      ["collection", "add", fixturesDir, "--name", collName],
+      { dbPath: localDbPath, configDir: localConfigDir }
+    );
+    if (exitCode !== 0) console.error("collection add failed:", stderr);
+    expect(exitCode).toBe(0);
+
+    // Add context
+    await runQmd(["context", "add", `qmd://${collName}/`, "Test fixtures for QMD"], { dbPath: localDbPath, configDir: localConfigDir });
+  });
+
+  test("search --json includes qmd:// path, docid, and context", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "test", "--json", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    const results = JSON.parse(stdout);
+    expect(results.length).toBeGreaterThan(0);
+
+    const result = results[0];
+    expect(result.file).toMatch(new RegExp(`^qmd://${collName}/`));
+    expect(result.docid).toMatch(/^#[a-f0-9]{6}$/);
+    expect(result.context).toBe("Test fixtures for QMD");
+    // Ensure no full filesystem paths
+    expect(result.file).not.toMatch(/^\/Users\//);
+    expect(result.file).not.toMatch(/^\/home\//);
+  });
+
+  test("search --files includes qmd:// path, docid, and context", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "test", "--files", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    // Format: #docid,score,qmd://collection/path,"context"
+    expect(stdout).toMatch(new RegExp(`^#[a-f0-9]{6},[\\d.]+,qmd://${collName}/`, "m"));
+    expect(stdout).toContain("Test fixtures for QMD");
+    // Ensure no full filesystem paths
+    expect(stdout).not.toMatch(/\/Users\//);
+    expect(stdout).not.toMatch(/\/home\//);
+  });
+
+  test("search --csv includes qmd:// path, docid, and context", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "test", "--csv", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    // Header should include context
+    expect(stdout).toMatch(/^docid,score,file,title,context,line,snippet$/m);
+    // Data rows should have qmd:// paths and context
+    expect(stdout).toMatch(new RegExp(`#[a-f0-9]{6},[\\d.]+,qmd://${collName}/`));
+    expect(stdout).toContain("Test fixtures for QMD");
+    // Ensure no full filesystem paths
+    expect(stdout).not.toMatch(/\/Users\//);
+    expect(stdout).not.toMatch(/\/home\//);
+  });
+
+  test("search --md includes docid and context", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "test", "--md", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    expect(stdout).toMatch(/\*\*docid:\*\* `#[a-f0-9]{6}`/);
+    expect(stdout).toContain("**context:** Test fixtures for QMD");
+  });
+
+  test("search --xml includes qmd:// path, docid, and context", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "test", "--xml", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    expect(stdout).toMatch(new RegExp(`<file docid="#[a-f0-9]{6}" name="qmd://${collName}/`));
+    expect(stdout).toContain('context="Test fixtures for QMD"');
+    // Ensure no full filesystem paths
+    expect(stdout).not.toMatch(/\/Users\//);
+    expect(stdout).not.toMatch(/\/home\//);
+  });
+
+  test("search default CLI format includes qmd:// path, docid, and context", async () => {
+    const { stdout, exitCode } = await runQmd(["search", "test", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    // First line should have qmd:// path and docid
+    expect(stdout).toMatch(new RegExp(`^qmd://${collName}/.*#[a-f0-9]{6}`, "m"));
+    expect(stdout).toContain("Context: Test fixtures for QMD");
+    // Ensure no full filesystem paths
+    expect(stdout).not.toMatch(/\/Users\//);
+    expect(stdout).not.toMatch(/\/home\//);
+  });
+});
+
+// =============================================================================
+// Get Command Path Normalization Tests
+// =============================================================================
+
+describe("get command path normalization", () => {
+  let localDbPath: string;
+  let localConfigDir: string;
+  const collName = "fixtures";
+
+  beforeAll(async () => {
+    const env = await createIsolatedTestEnv("get-paths");
+    localDbPath = env.dbPath;
+    localConfigDir = env.configDir;
+
+    const { exitCode, stderr } = await runQmd(
+      ["collection", "add", fixturesDir, "--name", collName],
+      { dbPath: localDbPath, configDir: localConfigDir }
+    );
+    if (exitCode !== 0) console.error("collection add failed:", stderr);
+    expect(exitCode).toBe(0);
+  });
+
+  test("get with qmd://collection/path format", async () => {
+    const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Test Document 1");
+  });
+
+  test("get with collection/path format (no scheme)", async () => {
+    const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Test Document 1");
+  });
+
+  test("get with //collection/path format", async () => {
+    const { stdout, exitCode } = await runQmd(["get", `//${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Test Document 1");
+  });
+
+  test("get with qmd:////collection/path format (extra slashes)", async () => {
+    const { stdout, exitCode } = await runQmd(["get", `qmd:////${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+    expect(stdout).toContain("Test Document 1");
+  });
+
+  test("get with path:line format", async () => {
+    const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:3`, "-l", "2"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+    // Should start from line 3, not line 1
+    expect(stdout).not.toMatch(/^# Test Document 1$/m);
+  });
+
+  test("get with qmd://path:line format", async () => {
+    const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md:3`, "-l", "2"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+    // Should start from line 3, not line 1
+    expect(stdout).not.toMatch(/^# Test Document 1$/m);
+  });
+});
+
+// =============================================================================
+// Status and Collection List - No Full Paths
+// =============================================================================
+
+describe("status and collection list hide filesystem paths", () => {
+  let localDbPath: string;
+  let localConfigDir: string;
+  const collName = "fixtures";
+
+  beforeAll(async () => {
+    const env = await createIsolatedTestEnv("status-paths");
+    localDbPath = env.dbPath;
+    localConfigDir = env.configDir;
+
+    const { exitCode, stderr } = await runQmd(
+      ["collection", "add", fixturesDir, "--name", collName],
+      { dbPath: localDbPath, configDir: localConfigDir }
+    );
+    if (exitCode !== 0) console.error("collection add failed:", stderr);
+    expect(exitCode).toBe(0);
+  });
+
+  test("status does not show full filesystem paths", async () => {
+    const { stdout, exitCode } = await runQmd(["status"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    // Should show qmd:// URIs
+    expect(stdout).toContain(`qmd://${collName}/`);
+    // Should NOT show full filesystem paths (except for the index location which is ok)
+    const lines = stdout.split('\n').filter(l => !l.includes('Index:'));
+    const pathLines = lines.filter(l => l.includes('/Users/') || l.includes('/home/') || l.includes('/tmp/'));
+    expect(pathLines.length).toBe(0);
+  });
+
+  test("collection list does not show full filesystem paths", async () => {
+    const { stdout, exitCode } = await runQmd(["collection", "list"], { dbPath: localDbPath, configDir: localConfigDir });
+    expect(exitCode).toBe(0);
+
+    // Should show qmd:// URIs
+    expect(stdout).toContain(`qmd://${collName}/`);
+    // Should NOT show Path: lines with filesystem paths
+    expect(stdout).not.toMatch(/Path:\s+\//);
+  });
+});

+ 104 - 57
src/qmd.ts

@@ -453,7 +453,6 @@ function showStatus(): void {
       const contexts = contextsByCollection.get(col.name) || [];
 
       console.log(`  ${c.cyan}${col.name}${c.reset} ${c.dim}(qmd://${col.name}/)${c.reset}`);
-      console.log(`    ${c.dim}Path:${c.reset}     ${col.pwd}`);
       console.log(`    ${c.dim}Pattern:${c.reset}  ${col.glob_pattern}`);
       console.log(`    ${c.dim}Files:${c.reset}    ${col.active_count} (updated ${lastMod})`);
 
@@ -511,9 +510,7 @@ async function updateCollections(): Promise<void> {
 
   for (let i = 0; i < collections.length; i++) {
     const col = collections[i];
-    console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.name}${c.reset}`);
-    console.log(`${c.dim}    Path: ${col.pwd}${c.reset}`);
-    console.log(`${c.dim}    Pattern: ${col.glob_pattern}${c.reset}`);
+    console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.name}${c.reset} ${c.dim}(${col.glob_pattern})${c.reset}`);
 
     // Execute custom update command if specified in YAML
     const yamlCol = getCollectionFromYaml(col.name);
@@ -761,9 +758,7 @@ function contextCheck(): void {
     console.log(`\n${c.yellow}Collections without any context:${c.reset}\n`);
 
     for (const coll of collectionsWithoutContext) {
-      console.log(`${c.cyan}${coll.name}${c.reset}`);
-      console.log(`  ${c.dim}Path: ${coll.pwd}${c.reset}`);
-      console.log(`  ${c.dim}Documents: ${coll.doc_count}${c.reset}`);
+      console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(${coll.doc_count} documents)${c.reset}`);
       console.log(`  ${c.dim}Suggestion: qmd context add qmd://${coll.name}/ "Description of ${coll.name}"${c.reset}\n`);
     }
   }
@@ -845,47 +840,90 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin
 
     virtualPath = inputPath;
   } else {
-    // Handle filesystem paths
-    let fsPath = inputPath;
-
-    // Expand ~ to home directory
-    if (fsPath.startsWith('~/')) {
-      fsPath = homedir() + fsPath.slice(1);
-    } else if (!fsPath.startsWith('/')) {
-      // Relative path - resolve from current directory
-      fsPath = resolve(getPwd(), fsPath);
+    // Try to interpret as collection/path format first (before filesystem path)
+    // If path is relative (no / or ~ prefix), check if first component is a collection name
+    if (!inputPath.startsWith('/') && !inputPath.startsWith('~')) {
+      const parts = inputPath.split('/');
+      if (parts.length >= 2) {
+        const possibleCollection = parts[0];
+        const possiblePath = parts.slice(1).join('/');
+
+        // Check if this collection exists
+        const collExists = db.prepare(`
+          SELECT 1 FROM documents WHERE collection = ? AND active = 1 LIMIT 1
+        `).get(possibleCollection);
+
+        if (collExists) {
+          // Try exact match on collection + path
+          doc = db.prepare(`
+            SELECT d.collection as collectionName, d.path, content.doc as body
+            FROM documents d
+            JOIN content ON content.hash = d.hash
+            WHERE d.collection = ? AND d.path = ? AND d.active = 1
+          `).get(possibleCollection, possiblePath) as typeof doc;
+
+          if (!doc) {
+            // Try fuzzy match by path ending
+            doc = db.prepare(`
+              SELECT d.collection as collectionName, d.path, content.doc as body
+              FROM documents d
+              JOIN content ON content.hash = d.hash
+              WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
+              LIMIT 1
+            `).get(possibleCollection, `%${possiblePath}`) as typeof doc;
+          }
+
+          if (doc) {
+            virtualPath = buildVirtualPath(doc.collectionName, doc.path);
+            // Skip the filesystem path handling below
+          }
+        }
+      }
     }
-    fsPath = getRealPath(fsPath);
 
-    // Try to detect which collection contains this path
-    const detected = detectCollectionFromPath(db, fsPath);
+    // If not found as collection/path, handle as filesystem paths
+    if (!doc) {
+      let fsPath = inputPath;
+
+      // Expand ~ to home directory
+      if (fsPath.startsWith('~/')) {
+        fsPath = homedir() + fsPath.slice(1);
+      } else if (!fsPath.startsWith('/')) {
+        // Relative path - resolve from current directory
+        fsPath = resolve(getPwd(), fsPath);
+      }
+      fsPath = getRealPath(fsPath);
 
-    if (detected) {
-      // Found collection - query by collection name + relative path
-      doc = db.prepare(`
-        SELECT d.collection as collectionName, d.path, content.doc as body
-        FROM documents d
-        JOIN content ON content.hash = d.hash
-        WHERE d.collection = ? AND d.path = ? AND d.active = 1
-      `).get(detected.collectionName, detected.relativePath) as typeof doc;
-    }
+      // Try to detect which collection contains this path
+      const detected = detectCollectionFromPath(db, fsPath);
 
-    // Fuzzy match by filename (last component of path)
-    if (!doc) {
-      const filename = inputPath.split('/').pop() || inputPath;
-      doc = db.prepare(`
-        SELECT d.collection as collectionName, d.path, content.doc as body
-        FROM documents d
-        JOIN content ON content.hash = d.hash
-        WHERE d.path LIKE ? AND d.active = 1
-        LIMIT 1
-      `).get(`%${filename}`) as typeof doc;
-    }
+      if (detected) {
+        // Found collection - query by collection name + relative path
+        doc = db.prepare(`
+          SELECT d.collection as collectionName, d.path, content.doc as body
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.collection = ? AND d.path = ? AND d.active = 1
+        `).get(detected.collectionName, detected.relativePath) as typeof doc;
+      }
 
-    if (doc) {
-      virtualPath = buildVirtualPath(doc.collectionName, doc.path);
-    } else {
-      virtualPath = inputPath;
+      // Fuzzy match by filename (last component of path)
+      if (!doc) {
+        const filename = inputPath.split('/').pop() || inputPath;
+        doc = db.prepare(`
+          SELECT d.collection as collectionName, d.path, content.doc as body
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.path LIKE ? AND d.active = 1
+          LIMIT 1
+        `).get(`%${filename}`) as typeof doc;
+      }
+
+      if (doc) {
+        virtualPath = buildVirtualPath(doc.collectionName, doc.path);
+      } else {
+        virtualPath = inputPath;
+      }
     }
   }
 
@@ -1315,8 +1353,7 @@ function collectionList(): void {
     const updatedAt = new Date(coll.updated_at);
     const timeAgo = formatTimeAgo(updatedAt);
 
-    console.log(`${c.cyan}${coll.name}${c.reset}`);
-    console.log(`  ${c.dim}Path:${c.reset}     ${coll.pwd}`);
+    console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(qmd://${coll.name}/)${c.reset}`);
     console.log(`  ${c.dim}Pattern:${c.reset}  ${coll.glob_pattern}`);
     console.log(`  ${c.dim}Files:${c.reset}    ${coll.active_count}`);
     console.log(`  ${c.dim}Updated:${c.reset}  ${timeAgo}`);
@@ -1347,8 +1384,7 @@ async function collectionAdd(pwd: string, globPattern: string, name?: string): P
 
   if (existingPwdGlob) {
     console.error(`${c.yellow}A collection already exists for this path and pattern:${c.reset}`);
-    console.error(`  Name: ${existingPwdGlob.name}`);
-    console.error(`  Path: ${pwd}`);
+    console.error(`  Name: ${existingPwdGlob.name} (qmd://${existingPwdGlob.name}/)`);
     console.error(`  Pattern: ${globPattern}`);
     console.error(`\nUse 'qmd update' to re-index it, or remove it first with 'qmd collection remove ${existingPwdGlob.name}'`);
     process.exit(1);
@@ -1846,6 +1882,9 @@ function outputResults(results: { file: string; displayPath: string; title: stri
     return;
   }
 
+  // Helper to create qmd:// URI from displayPath
+  const toQmdPath = (displayPath: string) => `qmd://${displayPath}`;
+
   if (opts.format === "json") {
     // JSON output for LLM consumption
     const output = filtered.map(row => {
@@ -1859,7 +1898,7 @@ function outputResults(results: { file: string; displayPath: string; title: stri
       return {
         ...(docid && { docid: `#${docid}` }),
         score: Math.round(row.score * 100) / 100,
-        file: row.displayPath,
+        file: toQmdPath(row.displayPath),
         title: row.title,
         ...(row.context && { context: row.context }),
         ...(body && { body }),
@@ -1872,7 +1911,7 @@ function outputResults(results: { file: string; displayPath: string; title: stri
     for (const row of filtered) {
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
       const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
-      console.log(`#${docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`);
+      console.log(`#${docid},${row.score.toFixed(2)},${toQmdPath(row.displayPath)}${ctx}`);
     }
   } else if (opts.format === "cli") {
     for (let i = 0; i < filtered.length; i++) {
@@ -1881,7 +1920,7 @@ function outputResults(results: { file: string; displayPath: string; title: stri
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
 
       // Line 1: filepath with docid
-      const path = row.displayPath;
+      const path = toQmdPath(row.displayPath);
       const lineInfo = hasMatch ? `:${line}` : "";
       const docidStr = docid ? ` ${c.dim}#${docid}${c.reset}` : "";
       console.log(`${c.cyan}${path}${c.dim}${lineInfo}${c.reset}${docidStr}`);
@@ -1917,22 +1956,24 @@ function outputResults(results: { file: string; displayPath: string; title: stri
       if (opts.lineNumbers) {
         content = addLineNumbers(content);
       }
-      const docidLine = docid ? `\n**docid:** \`#${docid}\`\n` : "";
-      console.log(`---\n# ${heading}${docidLine}\n${content}\n`);
+      const docidLine = docid ? `**docid:** \`#${docid}\`\n` : "";
+      const contextLine = row.context ? `**context:** ${row.context}\n` : "";
+      console.log(`---\n# ${heading}\n${docidLine}${contextLine}\n${content}\n`);
     }
   } else if (opts.format === "xml") {
     for (const row of filtered) {
       const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
+      const contextAttr = row.context ? ` context="${row.context.replace(/"/g, '&quot;')}"` : "";
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
       let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet;
       if (opts.lineNumbers) {
         content = addLineNumbers(content);
       }
-      console.log(`<file docid="#${docid}" name="${row.displayPath}"${titleAttr}>\n${content}\n</file>\n`);
+      console.log(`<file docid="#${docid}" name="${toQmdPath(row.displayPath)}"${titleAttr}${contextAttr}>\n${content}\n</file>\n`);
     }
   } else {
     // CSV format
-    console.log("docid,score,file,title,line,snippet");
+    console.log("docid,score,file,title,context,line,snippet");
     for (const row of filtered) {
       const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
       let content = opts.full ? row.body : snippet;
@@ -1940,7 +1981,7 @@ function outputResults(results: { file: string; displayPath: string; title: stri
         content = addLineNumbers(content, line);
       }
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
-      console.log(`#${docid},${row.score.toFixed(4)},${escapeCSV(row.displayPath)},${escapeCSV(row.title)},${line},${escapeCSV(content)}`);
+      console.log(`#${docid},${row.score.toFixed(4)},${escapeCSV(toQmdPath(row.displayPath))},${escapeCSV(row.title)},${escapeCSV(row.context || "")},${line},${escapeCSV(content)}`);
     }
   }
 }
@@ -2012,7 +2053,7 @@ async function vectorSearch(query: string, opts: OutputOptions, model: string =
   // Collect results from all query variations
   // For --all, fetch more results per query
   const perQueryLimit = opts.all ? 500 : 20;
-  const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number }>();
+  const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number; hash: string }>();
 
   for (const q of queries) {
     // searchVec accepts collection name as number parameter for legacy reasons (will be fixed in store.ts)
@@ -2020,7 +2061,7 @@ async function vectorSearch(query: string, opts: OutputOptions, model: string =
     for (const r of vecResults) {
       const existing = allResults.get(r.filepath);
       if (!existing || r.score > existing.score) {
-        allResults.set(r.filepath, { file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score });
+        allResults.set(r.filepath, { file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score, hash: r.hash });
       }
     }
   }
@@ -2133,11 +2174,15 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin
   const rankedLists: RankedResult[][] = [];
   const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
 
+  // Map to store hash by filepath for final results
+  const hashMap = new Map<string, string>();
+
   for (const q of queries) {
     // FTS search - get ranked results
     // searchFTS accepts collection name as number parameter for legacy reasons (will be fixed in store.ts)
     const ftsResults = searchFTS(db, q, 20, collectionName as any);
     if (ftsResults.length > 0) {
+      for (const r of ftsResults) hashMap.set(r.filepath, r.hash);
       rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
     }
 
@@ -2146,6 +2191,7 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin
       // searchVec accepts collection name as number parameter for legacy reasons (will be fixed in store.ts)
       const vecResults = await searchVec(db, q, embedModel, 20, collectionName as any);
       if (vecResults.length > 0) {
+        for (const r of vecResults) hashMap.set(r.filepath, r.hash);
         rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
       }
     }
@@ -2200,6 +2246,7 @@ async function querySearch(query: string, opts: OutputOptions, embedModel: strin
       body: candidate?.body || "",
       score: blendedScore,
       context: getContextForFile(db, r.file),
+      hash: hashMap.get(r.file) || "",
     };
   }).sort((a, b) => b.score - a.score);
 

+ 151 - 0
src/store.test.ts

@@ -28,6 +28,9 @@ import {
   extractSnippet,
   getCacheKey,
   handelize,
+  normalizeVirtualPath,
+  isVirtualPath,
+  parseVirtualPath,
   OLLAMA_URL,
   type Store,
   type DocumentResult,
@@ -2223,3 +2226,151 @@ describe("Content-Addressable Storage", () => {
     await cleanupTestDb(store);
   });
 });
+
+// =============================================================================
+// Virtual Path Normalization Tests
+// =============================================================================
+
+describe("normalizeVirtualPath", () => {
+  test("already normalized qmd:// path passes through", () => {
+    expect(normalizeVirtualPath("qmd://collection/path.md")).toBe("qmd://collection/path.md");
+    expect(normalizeVirtualPath("qmd://journals/2025-01-01.md")).toBe("qmd://journals/2025-01-01.md");
+  });
+
+  test("handles //collection/path format (missing qmd: prefix)", () => {
+    expect(normalizeVirtualPath("//collection/path.md")).toBe("qmd://collection/path.md");
+    expect(normalizeVirtualPath("//journals/2025-01-01.md")).toBe("qmd://journals/2025-01-01.md");
+  });
+
+  test("handles qmd:// with extra slashes", () => {
+    expect(normalizeVirtualPath("qmd:////collection/path.md")).toBe("qmd://collection/path.md");
+    expect(normalizeVirtualPath("qmd:///journals/2025-01-01.md")).toBe("qmd://journals/2025-01-01.md");
+    expect(normalizeVirtualPath("qmd:///////archive/file.md")).toBe("qmd://archive/file.md");
+  });
+
+  test("handles collection root paths", () => {
+    expect(normalizeVirtualPath("qmd://collection/")).toBe("qmd://collection/");
+    expect(normalizeVirtualPath("qmd://collection")).toBe("qmd://collection");
+    expect(normalizeVirtualPath("//collection/")).toBe("qmd://collection/");
+  });
+
+  test("preserves bare collection/path format (not auto-converted)", () => {
+    // Bare paths without qmd:// or // prefix are NOT converted
+    // (could be relative filesystem paths)
+    expect(normalizeVirtualPath("collection/path.md")).toBe("collection/path.md");
+    expect(normalizeVirtualPath("journals/2025-01-01.md")).toBe("journals/2025-01-01.md");
+  });
+
+  test("preserves absolute filesystem paths", () => {
+    expect(normalizeVirtualPath("/Users/test/file.md")).toBe("/Users/test/file.md");
+    expect(normalizeVirtualPath("/absolute/path/file.md")).toBe("/absolute/path/file.md");
+  });
+
+  test("preserves home-relative paths", () => {
+    expect(normalizeVirtualPath("~/Documents/file.md")).toBe("~/Documents/file.md");
+  });
+
+  test("preserves docid format", () => {
+    expect(normalizeVirtualPath("#abc123")).toBe("#abc123");
+    expect(normalizeVirtualPath("#def456")).toBe("#def456");
+  });
+
+  test("handles whitespace trimming", () => {
+    expect(normalizeVirtualPath("  qmd://collection/path.md  ")).toBe("qmd://collection/path.md");
+    expect(normalizeVirtualPath("  //collection/path.md  ")).toBe("qmd://collection/path.md");
+  });
+});
+
+describe("isVirtualPath", () => {
+  test("recognizes qmd:// paths", () => {
+    expect(isVirtualPath("qmd://collection/path.md")).toBe(true);
+    expect(isVirtualPath("qmd://journals/2025-01-01.md")).toBe(true);
+    expect(isVirtualPath("qmd://collection")).toBe(true);
+  });
+
+  test("recognizes //collection/path format", () => {
+    expect(isVirtualPath("//collection/path.md")).toBe(true);
+    expect(isVirtualPath("//journals/2025-01-01.md")).toBe(true);
+  });
+
+  test("does not auto-recognize bare collection/path format", () => {
+    // Bare paths could be relative filesystem paths, so not auto-detected as virtual
+    expect(isVirtualPath("collection/path.md")).toBe(false);
+    expect(isVirtualPath("journals/2025-01-01.md")).toBe(false);
+    expect(isVirtualPath("archive/subfolder/file.md")).toBe(false);
+  });
+
+  test("rejects docid format", () => {
+    expect(isVirtualPath("#abc123")).toBe(false);
+    expect(isVirtualPath("#def456")).toBe(false);
+  });
+
+  test("rejects absolute filesystem paths", () => {
+    expect(isVirtualPath("/Users/test/file.md")).toBe(false);
+    expect(isVirtualPath("/absolute/path/file.md")).toBe(false);
+  });
+
+  test("rejects home-relative paths", () => {
+    expect(isVirtualPath("~/Documents/file.md")).toBe(false);
+    expect(isVirtualPath("~/notes/journal.md")).toBe(false);
+  });
+
+  test("rejects paths without slashes", () => {
+    expect(isVirtualPath("file.md")).toBe(false);
+    expect(isVirtualPath("document")).toBe(false);
+  });
+});
+
+describe("parseVirtualPath", () => {
+  test("parses standard qmd:// paths", () => {
+    expect(parseVirtualPath("qmd://collection/path.md")).toEqual({
+      collectionName: "collection",
+      path: "path.md",
+    });
+    expect(parseVirtualPath("qmd://journals/2025-01-01.md")).toEqual({
+      collectionName: "journals",
+      path: "2025-01-01.md",
+    });
+  });
+
+  test("parses paths with nested directories", () => {
+    expect(parseVirtualPath("qmd://archive/subfolder/file.md")).toEqual({
+      collectionName: "archive",
+      path: "subfolder/file.md",
+    });
+  });
+
+  test("parses collection root paths", () => {
+    expect(parseVirtualPath("qmd://collection/")).toEqual({
+      collectionName: "collection",
+      path: "",
+    });
+    expect(parseVirtualPath("qmd://collection")).toEqual({
+      collectionName: "collection",
+      path: "",
+    });
+  });
+
+  test("parses //collection/path format (normalizes first)", () => {
+    expect(parseVirtualPath("//collection/path.md")).toEqual({
+      collectionName: "collection",
+      path: "path.md",
+    });
+  });
+
+  test("parses qmd:// with extra slashes (normalizes first)", () => {
+    expect(parseVirtualPath("qmd:////collection/path.md")).toEqual({
+      collectionName: "collection",
+      path: "path.md",
+    });
+  });
+
+  test("returns null for non-virtual paths", () => {
+    expect(parseVirtualPath("/absolute/path.md")).toBe(null);
+    expect(parseVirtualPath("~/home/path.md")).toBe(null);
+    expect(parseVirtualPath("#docid")).toBe(null);
+    expect(parseVirtualPath("file.md")).toBe(null);
+    // Bare collection/path is not recognized as virtual
+    expect(parseVirtualPath("collection/path.md")).toBe(null);
+  });
+});

+ 53 - 3
src/store.ts

@@ -113,15 +113,51 @@ export type VirtualPath = {
   path: string;  // relative path within collection
 };
 
+/**
+ * Normalize explicit virtual path formats to standard qmd:// format.
+ * Only handles paths that are already explicitly virtual:
+ * - qmd://collection/path.md (already normalized)
+ * - qmd:////collection/path.md (extra slashes - normalize)
+ * - //collection/path.md (missing qmd: prefix - add it)
+ *
+ * Does NOT handle:
+ * - collection/path.md (bare paths - could be filesystem relative)
+ * - :linenum suffix (should be parsed separately before calling this)
+ */
+export function normalizeVirtualPath(input: string): string {
+  let path = input.trim();
+
+  // Handle qmd:// with extra slashes: qmd:////collection/path -> qmd://collection/path
+  if (path.startsWith('qmd:')) {
+    // Remove qmd: prefix and normalize slashes
+    path = path.slice(4);
+    // Remove leading slashes and re-add exactly two
+    path = path.replace(/^\/+/, '');
+    return `qmd://${path}`;
+  }
+
+  // Handle //collection/path (missing qmd: prefix)
+  if (path.startsWith('//')) {
+    path = path.replace(/^\/+/, '');
+    return `qmd://${path}`;
+  }
+
+  // Return as-is for other cases (filesystem paths, docids, bare collection/path, etc.)
+  return path;
+}
+
 /**
  * Parse a virtual path like "qmd://collection-name/path/to/file.md"
  * into its components.
  * Also supports collection root: "qmd://collection-name/" or "qmd://collection-name"
  */
 export function parseVirtualPath(virtualPath: string): VirtualPath | null {
+  // Normalize the path first
+  const normalized = normalizeVirtualPath(virtualPath);
+
   // Match: qmd://collection-name[/optional-path]
   // Allows: qmd://name, qmd://name/, qmd://name/path
-  const match = virtualPath.match(/^qmd:\/\/([^\/]+)\/?(.*)$/);
+  const match = normalized.match(/^qmd:\/\/([^\/]+)\/?(.*)$/);
   if (!match) return null;
   return {
     collectionName: match[1],
@@ -137,10 +173,24 @@ export function buildVirtualPath(collectionName: string, path: string): string {
 }
 
 /**
- * Check if a path is a virtual path (starts with qmd://).
+ * Check if a path is explicitly a virtual path.
+ * Only recognizes explicit virtual path formats:
+ * - qmd://collection/path.md
+ * - //collection/path.md
+ *
+ * Does NOT consider bare collection/path.md as virtual - that should be
+ * handled separately by checking if the first component is a collection name.
  */
 export function isVirtualPath(path: string): boolean {
-  return path.startsWith('qmd://');
+  const trimmed = path.trim();
+
+  // Explicit qmd:// prefix (with any number of slashes)
+  if (trimmed.startsWith('qmd:')) return true;
+
+  // //collection/path format (missing qmd: prefix)
+  if (trimmed.startsWith('//')) return true;
+
+  return false;
 }
 
 /**