Skip to main content
Glama
search.ts10.3 kB
/** * Task Search Tool * * Full-text search across tasks with fuzzy matching support. * Uses Levenshtein distance for typo tolerance. */ import * as path from 'path'; import { z } from 'zod'; import { parseTasksFile, type Task } from '../speckit/taskParser.js'; // Zod schema for tasks_search tool export const TasksSearchSchema = z.object({ workspacePath: z.string().optional().describe('Path to workspace directory containing .dincoder folder'), tasksPath: z.string().optional().describe('Direct path to tasks.md file (overrides workspacePath)'), query: z.string().describe('Search query (plain text or regex pattern)'), searchFields: z.array(z.enum(['description', 'phase', 'type', 'tags', 'all'])).default(['description']).describe('Fields to search in'), caseSensitive: z.boolean().default(false).describe('Case-sensitive search'), fuzzy: z.boolean().default(false).describe('Enable fuzzy matching for typo tolerance'), fuzzyThreshold: z.number().min(0).max(100).default(70).describe('Fuzzy match similarity threshold (0-100%)'), useRegex: z.boolean().default(false).describe('Treat query as regex pattern'), limit: z.number().optional().describe('Limit number of results (default: 10, max: 100)'), }); export type TasksSearchParams = z.infer<typeof TasksSearchSchema>; interface SearchResult { task: Task; matches: { field: string; matchedText: string; context: string; }[]; relevanceScore: number; } /** * Search tasks by keyword */ export async function tasksSearch(params: TasksSearchParams): Promise<string> { // Resolve tasks.md path const tasksPath = params.tasksPath || path.join( params.workspacePath || process.cwd(), '.dincoder', 'tasks.md' ); // Parse tasks const tasks = parseTasksFile(tasksPath); // Perform search const results = searchTasks(tasks, params); // Apply limit const limit = params.limit ? Math.min(params.limit, 100) : 10; const limitedResults = results.slice(0, limit); // Format results return formatSearchResults(limitedResults, params, tasks.length); } /** * Search tasks and return ranked results */ function searchTasks(tasks: Task[], params: TasksSearchParams): SearchResult[] { const results: SearchResult[] = []; for (const task of tasks) { const searchResult = searchTask(task, params); if (searchResult) { results.push(searchResult); } } // Sort by relevance score (highest first) return results.sort((a, b) => b.relevanceScore - a.relevanceScore); } /** * Search a single task */ function searchTask(task: Task, params: TasksSearchParams): SearchResult | null { const matches: SearchResult['matches'] = []; let maxRelevance = 0; // Determine which fields to search const fieldsToSearch = params.searchFields.includes('all') ? ['description', 'phase', 'type', 'tags'] : params.searchFields; for (const field of fieldsToSearch) { let fieldValue = ''; switch (field) { case 'description': fieldValue = task.description; break; case 'phase': fieldValue = task.metadata.phase || ''; break; case 'type': fieldValue = task.metadata.type || ''; break; case 'tags': fieldValue = (task.metadata.tags || []).join(' '); break; } if (!fieldValue) { continue; } // Perform search const match = performSearch(fieldValue, params.query, params); if (match) { matches.push({ field, matchedText: match.matchedText, context: match.context, }); maxRelevance = Math.max(maxRelevance, match.relevance); } } if (matches.length === 0) { return null; } return { task, matches, relevanceScore: maxRelevance, }; } /** * Perform search on a field value */ function performSearch( value: string, query: string, params: TasksSearchParams ): { matchedText: string; context: string; relevance: number } | null { const searchValue = params.caseSensitive ? value : value.toLowerCase(); const searchQuery = params.caseSensitive ? query : query.toLowerCase(); if (params.useRegex) { // Regex search try { const flags = params.caseSensitive ? 'g' : 'gi'; const regex = new RegExp(searchQuery, flags); const match = searchValue.match(regex); if (match) { return { matchedText: match[0], context: getContext(value, match.index || 0, match[0].length), relevance: calculateRelevance(value, match[0], 'regex'), }; } } catch { // Invalid regex, fall through to exact search } } // Exact search const exactIndex = searchValue.indexOf(searchQuery); if (exactIndex !== -1) { return { matchedText: value.substring(exactIndex, exactIndex + query.length), context: getContext(value, exactIndex, query.length), relevance: calculateRelevance(value, query, exactIndex === 0 ? 'startsWith' : 'contains'), }; } // Fuzzy search if (params.fuzzy) { const fuzzyMatch = fuzzySearch(searchValue, searchQuery, params.fuzzyThreshold); if (fuzzyMatch) { return { matchedText: fuzzyMatch.match, context: getContext(value, fuzzyMatch.index, fuzzyMatch.match.length), relevance: fuzzyMatch.similarity, }; } } return null; } /** * Calculate relevance score (0-100) */ function calculateRelevance(value: string, match: string, matchType: 'exact' | 'startsWith' | 'contains' | 'regex'): number { switch (matchType) { case 'exact': return 100; case 'startsWith': return 90; case 'contains': return 70; case 'regex': return 80; default: return 50; } } /** * Get context around match (20 chars before/after) */ function getContext(text: string, index: number, length: number): string { const start = Math.max(0, index - 20); const end = Math.min(text.length, index + length + 20); let context = text.substring(start, end); if (start > 0) { context = '...' + context; } if (end < text.length) { context = context + '...'; } // Highlight the match const matchStart = index - start + (start > 0 ? 3 : 0); const matchEnd = matchStart + length; context = context.substring(0, matchStart) + '**' + context.substring(matchStart, matchEnd) + '**' + context.substring(matchEnd); return context; } /** * Fuzzy search using Levenshtein distance */ function fuzzySearch(text: string, query: string, threshold: number): { match: string; index: number; similarity: number } | null { const words = text.split(/\s+/); let bestMatch: { match: string; index: number; similarity: number } | null = null; let currentIndex = 0; for (const word of words) { const similarity = calculateSimilarity(word, query); if (similarity >= threshold) { if (!bestMatch || similarity > bestMatch.similarity) { bestMatch = { match: word, index: currentIndex, similarity, }; } } currentIndex += word.length + 1; // +1 for space } return bestMatch; } /** * Calculate similarity percentage using Levenshtein distance */ function calculateSimilarity(str1: string, str2: string): number { const distance = levenshteinDistance(str1, str2); const maxLength = Math.max(str1.length, str2.length); if (maxLength === 0) { return 100; } return Math.round(((maxLength - distance) / maxLength) * 100); } /** * Levenshtein distance algorithm * Returns the minimum number of edits needed to transform str1 into str2 */ function levenshteinDistance(str1: string, str2: string): number { const len1 = str1.length; const len2 = str2.length; // Create a 2D array for dynamic programming const matrix: number[][] = []; // Initialize first row and column for (let i = 0; i <= len1; i++) { matrix[i] = [i]; } for (let j = 0; j <= len2; j++) { matrix[0][j] = j; } // Fill in the rest of the matrix for (let i = 1; i <= len1; i++) { for (let j = 1; j <= len2; j++) { const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; matrix[i][j] = Math.min( matrix[i - 1][j] + 1, // deletion matrix[i][j - 1] + 1, // insertion matrix[i - 1][j - 1] + cost // substitution ); } } return matrix[len1][len2]; } /** * Format search results */ function formatSearchResults(results: SearchResult[], params: TasksSearchParams, totalTasks: number): string { let output = '# Task Search Results\n\n'; output += `**Query:** "${params.query}"\n`; output += `**Search fields:** ${params.searchFields.join(', ')}\n`; output += `**Options:** `; const options: string[] = []; if (params.fuzzy) { options.push(`fuzzy (${params.fuzzyThreshold}% threshold)`); } if (params.useRegex) { options.push('regex'); } if (params.caseSensitive) { options.push('case-sensitive'); } output += options.length > 0 ? options.join(', ') : 'exact match'; output += '\n\n'; output += `**Results:** ${results.length} task${results.length !== 1 ? 's' : ''} found (out of ${totalTasks} total)\n\n`; if (results.length === 0) { output += '*No tasks match your search query.*\n\n'; if (!params.fuzzy) { output += '*Tip: Try enabling fuzzy matching for typo tolerance.*\n'; } return output; } output += '---\n\n'; for (const result of results) { const task = result.task; const statusIcon = task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '~' : '○'; output += `## ${statusIcon} ${task.id}: ${task.description}\n\n`; output += `**Relevance:** ${result.relevanceScore}%\n\n`; // Add metadata const metadata: string[] = []; if (task.metadata.phase) { metadata.push(`phase: ${task.metadata.phase}`); } if (task.metadata.type) { metadata.push(`type: ${task.metadata.type}`); } if (task.metadata.priority) { metadata.push(`priority: ${task.metadata.priority}`); } if (metadata.length > 0) { output += `*${metadata.join(' | ')}*\n\n`; } // Show matches output += `**Matches:**\n`; for (const match of result.matches) { output += `- **${match.field}:** ${match.context}\n`; } output += '\n'; } return output; }

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/flight505/MCP_DinCoder'

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