Skip to main content
Glama
query-service.ts16.8 kB
import type { FileStorage } from '../../infrastructure/file-storage.js'; import type { PlanService } from './plan-service.js'; import type { LinkingService } from './linking-service.js'; import type { Entity, EntityType, Requirement, Solution, Decision, Phase, Artifact, Link, Tag, } from '../entities/types.js'; // Input types export interface SearchEntitiesInput { planId: string; query: string; entityTypes?: EntityType[]; filters?: { tags?: Tag[]; status?: string; }; limit?: number; offset?: number; } export interface TraceRequirementInput { planId: string; requirementId: string; } export interface ValidatePlanInput { planId: string; checks?: string[]; } export interface ExportPlanInput { planId: string; format: 'markdown' | 'json'; sections?: string[]; includeVersionHistory?: boolean; } // Output types export interface SearchResult { entityId: string; entityType: EntityType; entity: Entity; relevanceScore: number; matchedFields: string[]; } export interface SearchEntitiesResult { results: SearchResult[]; total: number; hasMore: boolean; } export interface TraceRequirementResult { requirement: Requirement; trace: { proposedSolutions: Solution[]; selectedSolution: Solution | null; alternativeSolutions: Solution[]; decisions: Decision[]; implementingPhases: Phase[]; completionStatus: { isAddressed: boolean; isImplemented: boolean; completionPercentage: number; }; }; } export interface ValidationIssue { severity: 'error' | 'warning' | 'info'; type: string; entityId?: string; entityType?: EntityType; message: string; suggestion?: string; } export interface ValidatePlanResult { isValid: boolean; issues: ValidationIssue[]; summary: { totalIssues: number; errors: number; warnings: number; infos: number; }; checksPerformed: string[]; } export interface ExportPlanResult { format: string; content: string; filePath?: string; sizeBytes: number; } export class QueryService { constructor( private storage: FileStorage, private planService: PlanService, private linkingService: LinkingService ) {} async searchEntities(input: SearchEntitiesInput): Promise<SearchEntitiesResult> { const planData = await this.planService.getPlan({ planId: input.planId, includeEntities: true, }); const entities = planData.plan.entities!; const allEntities: Entity[] = [ ...entities.requirements, ...entities.solutions, ...entities.decisions, ...entities.phases, ...entities.artifacts, ]; const query = input.query.toLowerCase(); let results: SearchResult[] = []; for (const entity of allEntities) { // Filter by entity type if (input.entityTypes && !input.entityTypes.includes(entity.type)) { continue; } // Search in entity fields const matchedFields: string[] = []; let score = 0; const searchableText = this.getSearchableText(entity); for (const [field, text] of Object.entries(searchableText)) { if (text.toLowerCase().includes(query)) { matchedFields.push(field); score += 1; } } if (matchedFields.length > 0) { results.push({ entityId: entity.id, entityType: entity.type, entity, relevanceScore: score / Object.keys(searchableText).length, matchedFields, }); } } // Sort by relevance results.sort((a, b) => b.relevanceScore - a.relevanceScore); // Pagination const total = results.length; const offset = input.offset || 0; const limit = input.limit || 50; const paginated = results.slice(offset, offset + limit); return { results: paginated, total, hasMore: offset + limit < total, }; } async traceRequirement(input: TraceRequirementInput): Promise<TraceRequirementResult> { const planData = await this.planService.getPlan({ planId: input.planId, includeEntities: true, }); const entities = planData.plan.entities!; const links = planData.plan.links!; const requirement = entities.requirements.find((r) => r.id === input.requirementId); if (!requirement) { throw new Error('Requirement not found'); } // Find solutions that implement this requirement const implementsLinks = links.filter( (l) => l.targetId === input.requirementId && l.relationType === 'implements' ); const solutionIds = implementsLinks.map((l) => l.sourceId); const allSolutions = entities.solutions.filter((s) => solutionIds.includes(s.id)); const selectedSolution = allSolutions.find((s) => s.status === 'selected') || null; const alternativeSolutions = allSolutions.filter((s) => s.status !== 'selected'); // Find phases that address this requirement const addressesLinks = links.filter( (l) => l.targetId === input.requirementId && l.relationType === 'addresses' ); const phaseIds = addressesLinks.map((l) => l.sourceId); const implementingPhases = entities.phases.filter((p) => phaseIds.includes(p.id)); // Find related decisions const decisionLinks = links.filter( (l) => (l.sourceId === input.requirementId || l.targetId === input.requirementId) && (entities.decisions.some((d) => d.id === l.sourceId) || entities.decisions.some((d) => d.id === l.targetId)) ); const decisionIds = new Set( decisionLinks.flatMap((l) => [l.sourceId, l.targetId]) ); const decisions = entities.decisions.filter((d) => decisionIds.has(d.id)); // Calculate completion const isAddressed = allSolutions.length > 0; const isImplemented = implementingPhases.some((p) => p.status === 'completed'); const completionPercentage = implementingPhases.length > 0 ? Math.round( implementingPhases.reduce((sum, p) => sum + p.progress, 0) / implementingPhases.length ) : 0; return { requirement, trace: { proposedSolutions: allSolutions, selectedSolution, alternativeSolutions, decisions, implementingPhases, completionStatus: { isAddressed, isImplemented, completionPercentage, }, }, }; } async validatePlan(input: ValidatePlanInput): Promise<ValidatePlanResult> { const planData = await this.planService.getPlan({ planId: input.planId, includeEntities: true, }); const entities = planData.plan.entities!; const links = planData.plan.links!; const issues: ValidationIssue[] = []; const checksPerformed: string[] = []; // Check: Uncovered requirements checksPerformed.push('uncovered_requirements'); for (const req of entities.requirements) { const hasImplementation = links.some( (l) => l.targetId === req.id && l.relationType === 'implements' ); if (!hasImplementation) { issues.push({ severity: 'error', type: 'uncovered_requirement', entityId: req.id, entityType: 'requirement', message: `Requirement '${req.title}' has no proposed solutions`, suggestion: 'Use propose_solution to address this requirement', }); } } // Check: Orphan solutions checksPerformed.push('orphan_solutions'); for (const sol of entities.solutions) { const hasLinks = links.some( (l) => l.sourceId === sol.id && l.relationType === 'implements' ); if (!hasLinks && sol.addressing.length === 0) { issues.push({ severity: 'warning', type: 'orphan_solution', entityId: sol.id, entityType: 'solution', message: `Solution '${sol.title}' is not linked to any requirement`, suggestion: 'Link to requirement or delete if not needed', }); } } // Check: Missing decisions checksPerformed.push('missing_decisions'); const reqsWithMultipleSolutions = new Map<string, Solution[]>(); for (const sol of entities.solutions) { for (const reqId of sol.addressing) { if (!reqsWithMultipleSolutions.has(reqId)) { reqsWithMultipleSolutions.set(reqId, []); } reqsWithMultipleSolutions.get(reqId)!.push(sol); } } for (const [reqId, solutions] of reqsWithMultipleSolutions) { if (solutions.length > 1) { const hasSelected = solutions.some((s) => s.status === 'selected'); if (!hasSelected) { issues.push({ severity: 'info', type: 'no_decision_recorded', entityId: reqId, entityType: 'requirement', message: `Multiple solutions for requirement but none selected`, suggestion: 'Use select_solution to choose one', }); } } } // Check: Broken links checksPerformed.push('broken_links'); const allIds = new Set([ ...entities.requirements.map((r) => r.id), ...entities.solutions.map((s) => s.id), ...entities.decisions.map((d) => d.id), ...entities.phases.map((p) => p.id), ...entities.artifacts.map((a) => a.id), ]); for (const link of links) { if (!allIds.has(link.sourceId)) { issues.push({ severity: 'error', type: 'broken_link', message: `Link references non-existent source: ${link.sourceId}`, }); } if (!allIds.has(link.targetId)) { issues.push({ severity: 'error', type: 'broken_link', message: `Link references non-existent target: ${link.targetId}`, }); } } const errors = issues.filter((i) => i.severity === 'error').length; const warnings = issues.filter((i) => i.severity === 'warning').length; const infos = issues.filter((i) => i.severity === 'info').length; return { isValid: errors === 0, issues, summary: { totalIssues: issues.length, errors, warnings, infos, }, checksPerformed, }; } async exportPlan(input: ExportPlanInput): Promise<ExportPlanResult> { const planData = await this.planService.getPlan({ planId: input.planId, includeEntities: true, }); let content: string; if (input.format === 'json') { content = JSON.stringify(planData.plan, null, 2); } else { content = this.generateMarkdown(planData.plan.manifest, planData.plan.entities!); } const sizeBytes = Buffer.byteLength(content, 'utf-8'); // Save export file const filename = input.format === 'json' ? 'plan-export.json' : 'plan-export.md'; const filePath = await this.storage.saveExport(input.planId, filename, content); return { format: input.format, content, filePath, sizeBytes, }; } private getSearchableText(entity: Entity): Record<string, string> { const base: Record<string, string> = { id: entity.id, tags: entity.metadata.tags.map((t) => `${t.key}:${t.value}`).join(' '), }; switch (entity.type) { case 'requirement': { const req = entity as Requirement; return { ...base, title: req.title, description: req.description, rationale: req.rationale || '', acceptanceCriteria: req.acceptanceCriteria.join(' '), }; } case 'solution': { const sol = entity as Solution; return { ...base, title: sol.title, description: sol.description, approach: sol.approach, }; } case 'decision': { const dec = entity as Decision; return { ...base, title: dec.title, question: dec.question, decision: dec.decision, context: dec.context, }; } case 'phase': { const phase = entity as Phase; return { ...base, title: phase.title, description: phase.description, objectives: phase.objectives.join(' '), deliverables: phase.deliverables.join(' '), }; } case 'artifact': { const art = entity as Artifact; return { ...base, title: art.title, description: art.description, content: art.content.sourceCode || '', filename: art.content.filename || '', }; } default: return base; } } private generateMarkdown( manifest: any, entities: { requirements: Requirement[]; solutions: Solution[]; decisions: Decision[]; phases: Phase[]; artifacts: Artifact[]; } ): string { const lines: string[] = []; lines.push(`# ${manifest.name}`); lines.push(''); lines.push(manifest.description); lines.push(''); lines.push(`**Status**: ${manifest.status}`); lines.push(`**Progress**: ${manifest.statistics.completionPercentage}%`); lines.push(''); // Requirements if (entities.requirements.length > 0) { lines.push('## Requirements'); lines.push(''); for (const req of entities.requirements) { lines.push(`### ${req.title}`); lines.push(''); lines.push(req.description); lines.push(''); lines.push(`**Priority**: ${req.priority} | **Category**: ${req.category}`); if (req.acceptanceCriteria.length > 0) { lines.push(''); lines.push('**Acceptance Criteria**:'); for (const ac of req.acceptanceCriteria) { lines.push(`- ${ac}`); } } lines.push(''); } } // Solutions if (entities.solutions.length > 0) { lines.push('## Solutions'); lines.push(''); for (const sol of entities.solutions) { const badge = sol.status === 'selected' ? ' [SELECTED]' : ''; lines.push(`### ${sol.title}${badge}`); lines.push(''); lines.push(sol.description); lines.push(''); const tradeoffs = sol.tradeoffs || []; if (tradeoffs.length > 0) { lines.push('**Trade-offs**:'); for (const t of tradeoffs) { lines.push(`- **${t.aspect}**: +${t.pros.join(', ')} / -${t.cons.join(', ')}`); } } lines.push(''); } } // Phases if (entities.phases.length > 0) { lines.push('## Phases'); lines.push(''); const sortedPhases = [...entities.phases].sort((a, b) => a.path.localeCompare(b.path) ); for (const phase of sortedPhases) { const indent = ' '.repeat(phase.depth); const status = phase.status === 'completed' ? ' [DONE]' : ''; lines.push(`${indent}- **${phase.path}. ${phase.title}**${status}`); if (phase.progress > 0 && phase.progress < 100) { lines.push(`${indent} Progress: ${phase.progress}%`); } } lines.push(''); } // Artifacts if (entities.artifacts.length > 0) { lines.push('## Artifacts'); lines.push(''); for (const artifact of entities.artifacts) { const statusBadge = artifact.status !== 'draft' ? ` [${artifact.status.toUpperCase()}]` : ''; lines.push(`### ${artifact.title}${statusBadge}`); lines.push(''); lines.push(artifact.description); lines.push(''); lines.push(`**Type**: ${artifact.artifactType}`); if (artifact.content.language) { lines.push(` | **Language**: ${artifact.content.language}`); } if (artifact.content.filename) { lines.push(` | **File**: ${artifact.content.filename}`); } lines.push(''); // File table if (artifact.fileTable && artifact.fileTable.length > 0) { lines.push('**Files**:'); for (const file of artifact.fileTable) { const desc = file.description ? ` - ${file.description}` : ''; lines.push(`- \`${file.path}\` [${file.action}]${desc}`); } lines.push(''); } // Source code (truncated if too long) if (artifact.content.sourceCode) { const maxLength = 500; const code = artifact.content.sourceCode.length > maxLength ? artifact.content.sourceCode.substring(0, maxLength) + '\n... (truncated)' : artifact.content.sourceCode; lines.push('```' + (artifact.content.language || '')); lines.push(code); lines.push('```'); lines.push(''); } } } return lines.join('\n'); } } export default QueryService;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server