Files
msd-core/gsd-memory/dist/qmd.js
Lex Christopherson af7a057294 feat: add GSD Memory cross-project knowledge system
Implements MCP server for semantic search across GSD projects:

MCP Tools:
- gsd_memory_search: Search across all registered projects
- gsd_memory_decisions: Find decisions from SUMMARY/PROJECT.md
- gsd_memory_patterns: Find patterns from SUMMARY.md
- gsd_memory_pitfalls: Find pitfalls from RESEARCH.md
- gsd_memory_stack: Find tech stack entries
- gsd_memory_register: Register project with memory
- gsd_memory_index: Trigger indexing/update
- gsd_memory_status: Show system status

Architecture:
- QMD wrapper with grep fallback when QMD unavailable
- YAML frontmatter extractors for GSD document types
- Project registry at ~/.gsd/projects.json
- Auto-install via bin/install.js for Claude Code

Workflow integrations:
- new-project: Registers project with memory
- plan-phase: Indexes after planning
- execute-phase: Indexes after execution
- complete-milestone: Indexes milestone completion
- Researcher agents query memory before Context7

Tests: 117 passing (66 unit + 51 integration)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 13:13:22 -06:00

199 lines
6.5 KiB
JavaScript

import { exec } from 'child_process';
import { promisify } from 'util';
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
const execAsync = promisify(exec);
/**
* QMD wrapper - provides semantic search when QMD is available,
* falls back to grep-based search otherwise
*/
export const qmd = {
/**
* Check if QMD is installed and available
*/
async isAvailable() {
try {
await execAsync('which qmd');
return true;
}
catch {
return false;
}
},
/**
* Search for content using QMD or grep fallback
*/
async search(options) {
const available = await this.isAvailable();
if (available && options.collection) {
return this.qmdSearch(options);
}
return this.grepFallback(options);
},
/**
* Search using QMD (when available)
*/
async qmdSearch(options) {
try {
const limit = options.limit || 10;
const { stdout } = await execAsync(`qmd query "${options.query}" --collection "${options.collection}" --limit ${limit} --json`);
const parsed = JSON.parse(stdout);
return (parsed.results || []).map((r) => ({
content: r.content,
source: r.source,
relevance: r.score || 0.5,
context: 'qmd'
}));
}
catch {
// Fall back to grep if QMD search fails
return this.grepFallback(options);
}
},
/**
* Grep-based fallback search
*/
async grepFallback(options) {
const results = [];
const limit = options.limit || 20;
for (const searchPath of options.paths) {
try {
const files = this.findMarkdownFiles(searchPath);
for (const file of files) {
try {
const content = readFileSync(file, 'utf-8');
const query = options.query.toLowerCase();
// Simple relevance: count matches
const matches = (content.toLowerCase().match(new RegExp(query, 'g')) || []).length;
if (matches > 0) {
// Extract context around first match
const lowerContent = content.toLowerCase();
const matchIndex = lowerContent.indexOf(query);
const start = Math.max(0, matchIndex - 100);
const end = Math.min(content.length, matchIndex + query.length + 100);
const snippet = content.slice(start, end);
results.push({
content: snippet,
source: file,
relevance: Math.min(1, matches * 0.1),
context: 'grep'
});
}
}
catch {
// Skip files that can't be read
}
}
}
catch {
// Skip paths that can't be accessed
}
}
// Sort by relevance and limit
return results
.sort((a, b) => b.relevance - a.relevance)
.slice(0, limit);
},
/**
* Find all markdown files in a directory recursively
*/
findMarkdownFiles(dir, files = []) {
try {
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
try {
const stat = statSync(fullPath);
if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') {
this.findMarkdownFiles(fullPath, files);
}
else if (stat.isFile() && entry.endsWith('.md')) {
files.push(fullPath);
}
}
catch {
// Skip entries that can't be accessed
}
}
}
catch {
// Return empty if directory can't be read
}
return files;
},
/**
* Create a QMD collection for a project
*/
async createCollection(options) {
const available = await this.isAvailable();
if (!available) {
return {
success: false,
reason: 'qmd_not_available'
};
}
try {
const paths = options.paths.join(' ');
const contextArg = options.context ? `--context "${options.context}"` : '';
await execAsync(`qmd add "${options.name}" ${paths} ${contextArg}`);
return { success: true };
}
catch (error) {
return {
success: false,
reason: 'create_failed',
data: error instanceof Error ? error.message : String(error)
};
}
},
/**
* Trigger reindexing of a collection
*/
async index(collectionName) {
const available = await this.isAvailable();
if (!available) {
return {
success: false,
reason: 'qmd_not_available'
};
}
try {
await execAsync(`qmd update "${collectionName}"`);
return { success: true };
}
catch (error) {
return {
success: false,
reason: 'index_failed',
data: error instanceof Error ? error.message : String(error)
};
}
},
/**
* Get status of a collection
*/
async status(collectionName) {
const available = await this.isAvailable();
if (!available) {
return { available: false };
}
try {
const { stdout } = await execAsync(`qmd status "${collectionName}" --json`);
const parsed = JSON.parse(stdout);
return {
available: true,
collection: collectionName,
documentCount: parsed.documentCount || 0,
lastIndexed: parsed.lastIndexed
};
}
catch {
return {
available: true,
collection: collectionName,
documentCount: 0
};
}
}
};
//# sourceMappingURL=qmd.js.map