Quellcode durchsuchen

Drop collections and path_contexts tables from schema

- Remove collections and path_contexts table creation from initializeDatabase()
- Update all queries to work without these tables
- Remove JOINs with collections table from document retrieval functions
- Compute absolute file paths from YAML collections in application code
- Update deleteContext() to use collection names instead of IDs
- Update qmd.ts status command to use listCollections() from YAML
- Deprecate cleanupDuplicateCollections() (returns 0)

All collection and context metadata is now managed exclusively in YAML
at ~/.config/qmd/index.yml. Database contains only documents and content
(content-addressable storage).

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Tobi Lutke vor 5 Monaten
Ursprung
Commit
f109193e62
3 geänderte Dateien mit 86 neuen und 101 gelöschten Zeilen
  1. 2 2
      .beads/issues.jsonl
  2. 4 13
      src/qmd.ts
  3. 80 86
      src/store.ts

+ 2 - 2
.beads/issues.jsonl

@@ -8,7 +8,7 @@
 {"id":"qmd-6s5","title":"Export current database to index.yml","description":"Write a script to export current collections and path_contexts from SQLite to ~/.config/qmd/index.yml format. Include all collection metadata and contexts.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-13T09:54:52.707844-05:00","updated_at":"2025-12-13T09:57:36.650437-05:00","closed_at":"2025-12-13T09:57:36.650437-05:00","dependencies":[{"issue_id":"qmd-6s5","depends_on_id":"qmd-3z9","type":"blocks","created_at":"2025-12-13T09:55:07.606834-05:00","created_by":"daemon"}]}
 {"id":"qmd-7ss","title":"remove all the symlinks and stuff in the git repo, clean up the root directory","description":"","status":"closed","priority":4,"issue_type":"task","created_at":"2025-12-12T16:40:00.744982-05:00","updated_at":"2025-12-12T17:11:18.034215-05:00","closed_at":"2025-12-12T17:11:18.034215-05:00"}
 {"id":"qmd-8eu","title":"Update documents table schema for collection names","description":"Change documents.collection_id (integer FK) to documents.collection (text). Update all queries and indices. Keep backwards compatibility during transition.","design":"Schema change:\n- Add `collection TEXT` column\n- Migrate data: UPDATE documents SET collection = (SELECT name FROM collections WHERE id = collection_id)\n- Drop collection_id column\n- Update FTS5 trigger\n- Update all queries in store.ts","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-13T09:54:52.830305-05:00","updated_at":"2025-12-13T10:08:24.88716-05:00","closed_at":"2025-12-13T10:08:24.88716-05:00","dependencies":[{"issue_id":"qmd-8eu","depends_on_id":"qmd-6s5","type":"blocks","created_at":"2025-12-13T09:55:07.662048-05:00","created_by":"daemon"}]}
-{"id":"qmd-9ua","title":"Update all qmd commands for YAML-based collections","description":"Update qmd.ts commands: collection add/list/remove/rename, status, update, ls. All should use collections.ts instead of store.ts collection functions.","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-13T09:54:53.14644-05:00","updated_at":"2025-12-13T09:54:53.14644-05:00","dependencies":[{"issue_id":"qmd-9ua","depends_on_id":"qmd-u84","type":"blocks","created_at":"2025-12-13T09:55:07.893268-05:00","created_by":"daemon"},{"issue_id":"qmd-9ua","depends_on_id":"qmd-oxy","type":"blocks","created_at":"2025-12-13T09:55:07.942221-05:00","created_by":"daemon"}]}
+{"id":"qmd-9ua","title":"Update all qmd commands for YAML-based collections","description":"Update qmd.ts commands: collection add/list/remove/rename, status, update, ls. All should use collections.ts instead of store.ts collection functions.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-13T09:54:53.14644-05:00","updated_at":"2025-12-13T10:17:39.67707-05:00","closed_at":"2025-12-13T10:17:39.67707-05:00","dependencies":[{"issue_id":"qmd-9ua","depends_on_id":"qmd-u84","type":"blocks","created_at":"2025-12-13T09:55:07.893268-05:00","created_by":"daemon"},{"issue_id":"qmd-9ua","depends_on_id":"qmd-oxy","type":"blocks","created_at":"2025-12-13T09:55:07.942221-05:00","created_by":"daemon"}]}
 {"id":"qmd-afe","title":"implement qmd collection rename, which changes the global path prefix for the collection","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T15:55:54.779325-05:00","updated_at":"2025-12-12T16:29:24.153196-05:00","closed_at":"2025-12-12T16:29:24.153196-05:00"}
 {"id":"qmd-ama","title":"Refactor database system","description":"All documents should be stored as content addressable hash, e.g. hash, doc, created_at,\n┃ updated_at. documents should be a file system layer on top e.g. collection, path, hash,\n┃ created_at, updated_at. (collection,path)\n┃\n┃\n\n┃ All documents should be stored as content addressable hash, e.g. hash, doc, created_at,\n┃ updated_at. documents should be a file system layer on top e.g. collection_id, path, hash,\n┃ created_at, updated_at. (collection,path) is unique. There is also collection which stores PWD\n┃ + glob pattern, name (\\w+). Every document is treated as path qmd://collection.name/","notes":"## Completed\n- ✅ Implemented content-addressable storage (content table with hash→doc mapping)\n- ✅ Refactored documents table as file system layer (collection_id, path, hash)\n- ✅ Added collection names (e.g., \"pages\", \"journals\", \"archive\")\n- ✅ Implemented virtual paths (qmd://collection-name/path/to/file.md)\n- ✅ Added hierarchical context support (collection-scoped)\n- ✅ Successfully migrated existing database\n- ✅ Updated search functions to work with new schema\n- ✅ Updated indexing logic to use content-addressable storage\n- ✅ Orphaned content hash cleanup\n\n## Still TODO\n- Fix migration SQL to properly extract basename (currently needs manual fix)\n- Implement `qmd collection add . --name \u003cname\u003e --mask '**/*.md'`\n- Implement `qmd ls [path]` for exploring virtual file tree","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-10T10:57:35.497489-05:00","updated_at":"2025-12-12T15:39:48.879143-05:00","closed_at":"2025-12-12T15:39:48.879143-05:00"}
 {"id":"qmd-bs8","title":"Update documentation for YAML configuration","description":"Update CLAUDE.md, README.md with new YAML configuration approach. Document index.yml format and manual editing instructions.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-13T09:54:53.449584-05:00","updated_at":"2025-12-13T09:54:53.449584-05:00","dependencies":[{"issue_id":"qmd-bs8","depends_on_id":"qmd-1xd","type":"blocks","created_at":"2025-12-13T09:55:08.264615-05:00","created_by":"daemon"}]}
@@ -28,7 +28,7 @@
 {"id":"qmd-rck","title":"move the source files to src/*, clean up teh directory","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T16:40:19.198119-05:00","updated_at":"2025-12-12T17:12:22.502746-05:00","closed_at":"2025-12-12T17:12:22.502746-05:00"}
 {"id":"qmd-rhd","title":"Fix 'qmd status' output for new schema","description":"Update status to show collections by name, cleaner context display, virtual path examples.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T15:29:54.020596-05:00","updated_at":"2025-12-12T16:13:28.08389-05:00","closed_at":"2025-12-12T16:13:28.08389-05:00","dependencies":[{"issue_id":"qmd-rhd","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:54.021095-05:00","created_by":"daemon"}]}
 {"id":"qmd-s1y","title":"Update 'qmd add-context' for collection scoping","description":"Update add-context to work with collection-scoped contexts using new path_contexts schema.","notes":"Refactoring to:\n- qmd context add [path] \"text\" (defaults to current collection if in one)\n- qmd context list\n- qmd context rm \u003cpath\u003e\n- Support \"/\" for global/system context\n- Auto-detect collection from pwd","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T15:29:54.076582-05:00","updated_at":"2025-12-12T15:37:47.683263-05:00","closed_at":"2025-12-12T15:37:47.683263-05:00"}
-{"id":"qmd-thw","title":"Drop collections and path_contexts tables","description":"Remove collections and path_contexts tables from schema. Update initDb() to not create these tables. Only keep documents, content, and search indices.","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-13T09:54:53.247136-05:00","updated_at":"2025-12-13T09:54:53.247136-05:00","dependencies":[{"issue_id":"qmd-thw","depends_on_id":"qmd-9ua","type":"blocks","created_at":"2025-12-13T09:55:08.027101-05:00","created_by":"daemon"}]}
+{"id":"qmd-thw","title":"Drop collections and path_contexts tables","description":"Remove collections and path_contexts tables from schema. Update initDb() to not create these tables. Only keep documents, content, and search indices.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-12-13T09:54:53.247136-05:00","updated_at":"2025-12-13T10:20:15.923202-05:00","dependencies":[{"issue_id":"qmd-thw","depends_on_id":"qmd-9ua","type":"blocks","created_at":"2025-12-13T09:55:08.027101-05:00","created_by":"daemon"}]}
 {"id":"qmd-u84","title":"Refactor store.ts to use collections.ts","description":"Replace all collection DB queries with collections.ts calls. Remove getCollectionById, getCollectionByName, listCollections DB functions. Use YAML config instead.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-13T09:54:52.936782-05:00","updated_at":"2025-12-13T10:16:07.681047-05:00","closed_at":"2025-12-13T10:16:07.681047-05:00","dependencies":[{"issue_id":"qmd-u84","depends_on_id":"qmd-3z9","type":"blocks","created_at":"2025-12-13T09:55:07.720439-05:00","created_by":"daemon"},{"issue_id":"qmd-u84","depends_on_id":"qmd-8eu","type":"blocks","created_at":"2025-12-13T09:55:07.782051-05:00","created_by":"daemon"}]}
 {"id":"qmd-vro","title":"Update 'qmd get' to support virtual paths","description":"Allow qmd get to accept both virtual paths (qmd://journals/...) and filesystem paths, plus fuzzy matching by filename.","status":"closed","priority":0,"issue_type":"task","created_at":"2025-12-12T15:29:53.963113-05:00","updated_at":"2025-12-12T15:47:29.178955-05:00","closed_at":"2025-12-12T15:47:29.178955-05:00","dependencies":[{"issue_id":"qmd-vro","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:53.963641-05:00","created_by":"daemon"}]}
 {"id":"qmd-x19","title":"Update 'qmd add-context' for collection-scoped contexts","description":"Update add-context to work with collections:\n- qmd add-context \u003ccollection\u003e/\u003cpath\u003e \"context description\"\n- Support both virtual and filesystem paths\n- Update to use new path_contexts schema","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T15:29:38.142575-05:00","updated_at":"2025-12-12T15:53:00.525001-05:00","closed_at":"2025-12-12T15:53:00.525001-05:00"}

+ 4 - 13
src/qmd.ts

@@ -435,17 +435,8 @@ function showStatus(): void {
     indexSize = stat;
   } catch {}
 
-  // Collections info
-  const collections = db.prepare(`
-    SELECT c.id, c.name, c.pwd, c.glob_pattern, c.created_at,
-           COUNT(d.id) as doc_count,
-           SUM(CASE WHEN d.active = 1 THEN 1 ELSE 0 END) as active_count,
-           MAX(d.modified_at) as last_modified
-    FROM collections c
-    LEFT JOIN documents d ON d.collection = c.name
-    GROUP BY c.id
-    ORDER BY c.name
-  `).all() as { id: number; name: string; pwd: string; glob_pattern: string; created_at: string; doc_count: number; active_count: number; last_modified: string | null }[];
+  // Collections info (from YAML + database stats)
+  const collections = listCollections(db);
 
   // Overall stats
   const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get() as { count: number };
@@ -768,7 +759,7 @@ function contextRemove(pathArg: string): void {
       process.exit(1);
     }
 
-    const changes = deleteContext(db, coll.id, parsed.path);
+    const changes = deleteContext(db, coll.name, parsed.path);
 
     if (changes === 0) {
       console.error(`${c.yellow}No context found for: ${pathArg}${c.reset}`);
@@ -796,7 +787,7 @@ function contextRemove(pathArg: string): void {
     process.exit(1);
   }
 
-  const changes = deleteContext(db, detected.collectionId, detected.relativePath);
+  const changes = deleteContext(db, detected.collectionName, detected.relativePath);
 
   if (changes === 0) {
     console.error(`${c.yellow}No context found for: qmd://${detected.collectionName}/${detected.relativePath}${c.reset}`);

+ 80 - 86
src/store.ts

@@ -217,6 +217,10 @@ function initializeDatabase(db: Database): void {
     return; // Migration will call initializeDatabase again
   }
 
+  // Drop legacy tables that are now managed in YAML
+  db.exec(`DROP TABLE IF EXISTS path_contexts`);
+  db.exec(`DROP TABLE IF EXISTS collections`);
+
   // Content-addressable storage - the source of truth for document content
   db.exec(`
     CREATE TABLE IF NOT EXISTS content (
@@ -226,20 +230,8 @@ function initializeDatabase(db: Database): void {
     )
   `);
 
-  // Collections table with name field
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS collections (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      name TEXT NOT NULL UNIQUE,
-      pwd TEXT NOT NULL,
-      glob_pattern TEXT NOT NULL,
-      created_at TEXT NOT NULL,
-      updated_at TEXT NOT NULL,
-      UNIQUE(pwd, glob_pattern)
-    )
-  `);
-
   // Documents table - file system layer mapping virtual paths to content hashes
+  // Collections are now managed in ~/.config/qmd/index.yml
   db.exec(`
     CREATE TABLE IF NOT EXISTS documents (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -259,20 +251,6 @@ function initializeDatabase(db: Database): void {
   db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`);
   db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(path, active)`);
 
-  // Path-based context (collection-scoped, hierarchical)
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS path_contexts (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      collection_id INTEGER NOT NULL,
-      path_prefix TEXT NOT NULL,
-      context TEXT NOT NULL,
-      created_at TEXT NOT NULL,
-      FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE,
-      UNIQUE(collection_id, path_prefix)
-    )
-  `);
-  db.exec(`CREATE INDEX IF NOT EXISTS idx_path_contexts_collection ON path_contexts(collection_id, path_prefix)`);
-
   // Cache table for Ollama API calls
   db.exec(`
     CREATE TABLE IF NOT EXISTS ollama_cache (
@@ -1026,25 +1004,12 @@ export function cleanupOrphanedVectors(db: Database): number {
 
 /**
  * Remove duplicate collections, keeping the oldest one per (pwd, glob_pattern).
- * Also removes bogus "." glob pattern entries.
- * Returns the number of duplicate collections removed.
+ * NOTE: This function is deprecated since collections are now managed in YAML.
+ * Kept for backwards compatibility but returns 0.
  */
 export function cleanupDuplicateCollections(db: Database): number {
-  // Count duplicates before removal
-  const beforeCount = (db.prepare(`SELECT COUNT(*) as c FROM collections`).get() as { c: number }).c;
-
-  // Remove duplicates keeping the oldest one
-  db.exec(`
-    DELETE FROM collections WHERE id NOT IN (
-      SELECT MIN(id) FROM collections GROUP BY pwd, glob_pattern
-    )
-  `);
-
-  // Remove bogus "." glob pattern entries (from earlier bug)
-  db.exec(`DELETE FROM collections WHERE glob_pattern = '.'`);
-
-  const afterCount = (db.prepare(`SELECT COUNT(*) as c FROM collections`).get() as { c: number }).c;
-  return beforeCount - afterCount;
+  // Collections are now managed in YAML, no cleanup needed
+  return 0;
 }
 
 /**
@@ -1447,15 +1412,9 @@ export function insertContext(db: Database, collectionId: number, pathPrefix: st
  * Delete a context for a specific collection and path prefix.
  * Returns the number of contexts deleted.
  */
-export function deleteContext(db: Database, collectionId: number, pathPrefix: string): number {
-  // Get collection name from ID
-  const coll = db.prepare(`SELECT name FROM collections WHERE id = ?`).get(collectionId) as { name: string } | null;
-  if (!coll) {
-    return 0;
-  }
-
+export function deleteContext(db: Database, collectionName: string, pathPrefix: string): number {
   // Use collections.ts to remove context
-  const success = collectionsRemoveContext(coll.name, pathPrefix);
+  const success = collectionsRemoveContext(collectionName, pathPrefix);
   return success ? 1 : 0;
 }
 
@@ -1901,11 +1860,11 @@ export function reciprocalRankFusion(
 // =============================================================================
 
 type DbDocRow = {
-  filepath: string;
   display_path: string;
   title: string;
   hash: string;
   collection: string;
+  path: string;
   modified_at: string;
   body_length: number;
   body?: string;
@@ -1928,46 +1887,54 @@ export function findDocument(db: Database, filename: string, options: { includeB
 
   const bodyCol = options.includeBody ? `, content.doc as body` : ``;
 
-  // Build computed columns for filepath and display_path
+  // Build computed columns for display_path
+  // Note: filepath is computed from YAML collections after query
   const selectCols = `
-    c.pwd || '/' || d.path as filepath,
     'qmd://' || d.collection || '/' || d.path as display_path,
     d.title,
     d.hash,
     d.collection,
+    d.path,
     d.modified_at,
     LENGTH(content.doc) as body_length
     ${bodyCol}
   `;
 
-  // Try various match strategies - always join content for body_length
+  // Try to match by virtual path first
   let doc = db.prepare(`
     SELECT ${selectCols}
     FROM documents d
-    JOIN collections c ON c.name = d.collection
     JOIN content ON content.hash = d.hash
-    WHERE c.pwd || '/' || d.path = ? AND d.active = 1
+    WHERE 'qmd://' || d.collection || '/' || d.path = ? AND d.active = 1
   `).get(filepath) as DbDocRow | null;
 
+  // Try fuzzy match by virtual path
   if (!doc) {
     doc = db.prepare(`
       SELECT ${selectCols}
       FROM documents d
-      JOIN collections c ON c.name = d.collection
       JOIN content ON content.hash = d.hash
-      WHERE 'qmd://' || d.collection || '/' || d.path = ? AND d.active = 1
-    `).get(filepath) as DbDocRow | null;
+      WHERE 'qmd://' || d.collection || '/' || d.path LIKE ? AND d.active = 1
+      LIMIT 1
+    `).get(`%${filepath}`) as DbDocRow | null;
   }
 
-  if (!doc) {
-    doc = db.prepare(`
-      SELECT ${selectCols}
-      FROM documents d
-      JOIN collections c ON c.name = d.collection
-      JOIN content ON content.hash = d.hash
-      WHERE (c.pwd || '/' || d.path LIKE ? OR 'qmd://' || d.collection || '/' || d.path LIKE ?) AND d.active = 1
-      LIMIT 1
-    `).get(`%${filepath}`, `%${filepath}`) as DbDocRow | null;
+  // Try to match by absolute path (requires looking up collection paths from YAML)
+  if (!doc && !filepath.startsWith('qmd://')) {
+    const collections = collectionsListCollections();
+    for (const coll of collections) {
+      const absPath = `${coll.path}/${filepath}`;
+      const relativePath = absPath.startsWith(coll.path + '/') ? absPath.slice(coll.path.length + 1) : null;
+      if (relativePath) {
+        doc = db.prepare(`
+          SELECT ${selectCols}
+          FROM documents d
+          JOIN content ON content.hash = d.hash
+          WHERE d.collection = ? AND d.path = ? AND d.active = 1
+        `).get(coll.name, relativePath) as DbDocRow | null;
+        if (doc) break;
+      }
+    }
   }
 
   if (!doc) {
@@ -1975,10 +1942,13 @@ export function findDocument(db: Database, filename: string, options: { includeB
     return { error: "not_found", query: filename, similarFiles: similar };
   }
 
-  const context = getContextForFile(db, doc.filepath);
+  // Compute absolute filepath from collection (in YAML) and relative path
+  const coll = getCollection(doc.collection);
+  const absoluteFilepath = coll ? `${coll.path}/${doc.path}` : doc.path;
+  const context = getContextForFile(db, absoluteFilepath);
 
   return {
-    filepath: doc.filepath,
+    filepath: absoluteFilepath,
     displayPath: doc.display_path,
     title: doc.title,
     context,
@@ -1996,13 +1966,37 @@ export function findDocument(db: Database, filename: string, options: { includeB
  */
 export function getDocumentBody(db: Database, doc: DocumentResult | { filepath: string }, fromLine?: number, maxLines?: number): string | null {
   const filepath = 'filepath' in doc ? doc.filepath : doc.filepath;
-  const row = db.prepare(`
-    SELECT content.doc as body
-    FROM documents d
-    JOIN collections c ON c.name = d.collection
-    JOIN content ON content.hash = d.hash
-    WHERE c.pwd || '/' || d.path = ? AND d.active = 1
-  `).get(filepath) as { body: string } | null;
+
+  // Try to resolve document by filepath (absolute or virtual)
+  let row: { body: string } | null = null;
+
+  // Try virtual path first
+  if (filepath.startsWith('qmd://')) {
+    row = db.prepare(`
+      SELECT content.doc as body
+      FROM documents d
+      JOIN content ON content.hash = d.hash
+      WHERE 'qmd://' || d.collection || '/' || d.path = ? AND d.active = 1
+    `).get(filepath) as { body: string } | null;
+  }
+
+  // Try absolute path by looking up in YAML collections
+  if (!row) {
+    const collections = collectionsListCollections();
+    for (const coll of collections) {
+      if (filepath.startsWith(coll.path + '/')) {
+        const relativePath = filepath.slice(coll.path.length + 1);
+        row = db.prepare(`
+          SELECT 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(coll.name, relativePath) as { body: string } | null;
+        if (row) break;
+      }
+    }
+  }
+
   if (!row) return null;
 
   let body = row.body;
@@ -2059,11 +2053,11 @@ export function findDocuments(
 
   const bodyCol = options.includeBody ? `, content.doc as body` : ``;
   const selectCols = `
-    c.pwd || '/' || d.path as filepath,
     'qmd://' || d.collection || '/' || d.path as display_path,
     d.title,
     d.hash,
     d.collection,
+    d.path,
     d.modified_at,
     LENGTH(content.doc) as body_length
     ${bodyCol}
@@ -2078,7 +2072,6 @@ export function findDocuments(
       let doc = db.prepare(`
         SELECT ${selectCols}
         FROM documents d
-        JOIN collections c ON c.name = d.collection
         JOIN content ON content.hash = d.hash
         WHERE 'qmd://' || d.collection || '/' || d.path = ? AND d.active = 1
       `).get(name) as DbDocRow | null;
@@ -2086,7 +2079,6 @@ export function findDocuments(
         doc = db.prepare(`
           SELECT ${selectCols}
           FROM documents d
-          JOIN collections c ON c.name = d.collection
           JOIN content ON content.hash = d.hash
           WHERE 'qmd://' || d.collection || '/' || d.path LIKE ? AND d.active = 1
           LIMIT 1
@@ -2115,7 +2107,6 @@ export function findDocuments(
     fileRows = db.prepare(`
       SELECT ${selectCols}
       FROM documents d
-      JOIN collections c ON c.name = d.collection
       JOIN content ON content.hash = d.hash
       WHERE 'qmd://' || d.collection || '/' || d.path IN (${placeholders}) AND d.active = 1
     `).all(...virtualPaths) as DbDocRow[];
@@ -2124,11 +2115,14 @@ export function findDocuments(
   const results: MultiGetResult[] = [];
 
   for (const row of fileRows) {
-    const context = getContextForFile(db, row.filepath);
+    // Compute absolute filepath from collection
+    const coll = getCollection(row.collection);
+    const absoluteFilepath = coll ? `${coll.path}/${row.path}` : row.path;
+    const context = getContextForFile(db, absoluteFilepath);
 
     if (row.body_length > maxBytes) {
       results.push({
-        doc: { filepath: row.filepath, displayPath: row.display_path },
+        doc: { filepath: absoluteFilepath, displayPath: row.display_path },
         skipped: true,
         skipReason: `File too large (${Math.round(row.body_length / 1024)}KB > ${Math.round(maxBytes / 1024)}KB)`,
       });
@@ -2137,7 +2131,7 @@ export function findDocuments(
 
     results.push({
       doc: {
-        filepath: row.filepath,
+        filepath: absoluteFilepath,
         displayPath: row.display_path,
         title: row.title || row.display_path.split('/').pop() || row.display_path,
         context,