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>
199 lines
6.5 KiB
JavaScript
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
|