Skip to main content
Glama

COA Goldfish MCP

by anortham
search.tsโ€ข17.7 kB
/** * Search tools - Fuse.js powered fuzzy search and timeline */ import { SearchEngine } from '../core/search.js'; import { Storage } from '../core/storage.js'; import { SessionManager } from '../core/session-manager.js'; import { SearchHistoryResponse, RecallResponse, TimelineResponse } from '../types/responses.js'; import { getLocalDateKey, formatDateName } from '../utils/date-utils.js'; import { GoldfishDisplayHandler } from '../vscode-bridge/display-handler.js'; import { buildToolContent, OutputMode } from '../core/output-utils.js'; export class SearchTools { private searchEngine: SearchEngine; private storage: Storage; private sessionManager: SessionManager; private displayHandler?: GoldfishDisplayHandler; constructor(storage: Storage, sessionManager: SessionManager, displayHandler?: GoldfishDisplayHandler) { this.storage = storage; this.sessionManager = sessionManager; this.searchEngine = new SearchEngine(storage); this.displayHandler = displayHandler; } /** * Search work history with fuzzy matching */ async searchHistory(args: { query: string; since?: string; workspace?: string; scope?: 'current' | 'all'; limit?: number; format?: OutputMode; }) { const { query, since, workspace, scope = 'current', limit = 20, format } = args; try { const results = await this.searchEngine.searchWithHighlights(query, { since, workspace, scope, limit, type: 'checkpoint' // Focus on checkpoints for history }); if (results.length === 0) { const formatted = `๐Ÿ” No results found for "${query}"\n\nTry:\nโ€ข Different keywords\nโ€ข Broader time range (e.g., since: "7d")\nโ€ข Cross-workspace search (scope: "all")`; const data = { query, since, workspace, scope, resultsFound: 0 } as const; return buildToolContent('search-history', formatted, data as any, format); } const output = [`๐Ÿ” Found ${results.length} results for "${query}"\n`]; for (const result of results.slice(0, 10)) { const { memory, score, matches } = result; const age = this.formatAge(memory.timestamp); const workspace = memory.workspace === this.storage.getCurrentWorkspace() ? '' : ` [${memory.workspace}]`; output.push(`๐Ÿ’พ ${age}${workspace} - Score: ${(1 - score).toFixed(2)}`); if (typeof memory.content === 'object' && memory.content && 'description' in memory.content) { const contentObj = memory.content as { description?: string; highlights?: string[] }; output.push(` ${contentObj.description}`); if (contentObj.highlights && Array.isArray(contentObj.highlights) && contentObj.highlights.length > 0) { output.push(` โœจ ${contentObj.highlights.slice(0, 2).join(', ')}`); } } else { output.push(` ${memory.content}`); } // Show match context (skip if it looks like raw JSON) if (matches.length > 0) { const bestMatch = matches[0]; if (bestMatch && bestMatch.value && !bestMatch.value.startsWith('{')) { const snippet = this.getMatchSnippet(bestMatch.value, bestMatch.indices); output.push(` ๐ŸŽฏ "${snippet}"`); } } output.push(''); } if (results.length > 10) { output.push(`... and ${results.length - 10} more results`); } const formatted = output.join('\n'); const data = { query, resultsFound: results.length, matches: results.slice(0, 10).map(result => ({ memory: result.memory as Record<string, unknown>, score: 1 - result.score, snippet: result.matches.length > 0 && result.matches[0] ? this.getMatchSnippet(result.matches[0].value, result.matches[0].indices) : undefined })) } as const; return buildToolContent('search-history', formatted, data as any, format); } catch (error) { return { content: [ { type: 'text', text: `โŒ Search failed: ${error instanceof Error ? error.message : String(error)}` } ] }; } } /** * Show timeline of recent work sessions */ async timeline(args: { since?: string; workspace?: string; scope?: 'current' | 'all'; format?: OutputMode; }) { const { since = '7d', workspace, scope = 'current', format } = args; try { const memories = await this.searchEngine.searchMemories({ since, workspace: scope === 'all' ? undefined : workspace, scope, type: 'checkpoint', limit: 200 }); if (memories.length === 0) { const formatted = `๐Ÿ“… No work sessions found in the last ${since}\n\nTry extending the time range or checking other workspaces.`; const data = { scope, since, workspace, totalItems: 0, workspacesFound: 0, checkpointsFound: 0, byDate: {}, byWorkspace: {} } as const; return buildToolContent('timeline', formatted, data as any, format); } // Group by date and workspace const timelineMap = new Map<string, Map<string, { count: number; highlights: string[] }>>(); for (const memory of memories) { // Extract local date key for user-intuitive timeline grouping const date = getLocalDateKey(memory.timestamp); const ws = memory.workspace || 'unknown'; if (!timelineMap.has(date)) { timelineMap.set(date, new Map()); } const dayMap = timelineMap.get(date); if (!dayMap) continue; if (!dayMap.has(ws)) { dayMap.set(ws, { count: 0, highlights: [] }); } const wsData = dayMap.get(ws)!; wsData.count++; // Extract highlights if (typeof memory.content === 'object' && memory.content && 'highlights' in memory.content) { const contentObj = memory.content as { highlights?: string[] }; if (Array.isArray(contentObj.highlights)) { wsData.highlights.push(...contentObj.highlights); } } } // Build formatted output const output = [`๐Ÿ“… Work Timeline (${since})`]; const sortedDates = Array.from(timelineMap.keys()).sort().reverse(); for (const date of sortedDates) { const dayData = timelineMap.get(date)!; // Use centralized date formatting utility for consistent Today/Yesterday logic const dayName = formatDateName(date); output.push(`\n**${dayName}** (${date})`); for (const [ws, data] of dayData.entries()) { const wsDisplay = ws; // Always show actual workspace name output.push(` ๐Ÿ“ ${wsDisplay}: ${data.count} checkpoints`); // Show unique highlights const uniqueHighlights = [...new Set(data.highlights)]; if (uniqueHighlights.length > 0) { uniqueHighlights.slice(0, 3).forEach(highlight => { output.push(` โœจ ${highlight}`); }); if (uniqueHighlights.length > 3) { output.push(` ... and ${uniqueHighlights.length - 3} more`); } } } } // Send to VS Code if available if (this.displayHandler?.isAvailable) { try { await this.displayHandler.displayTimeline(memories, `Work Timeline (${since})`); console.error('๐Ÿ“Š Timeline sent to VS Code'); } catch (error) { console.error('โš ๏ธ Failed to send timeline to VS Code:', error); } } const formatted = output.join('\n'); const data = { scope, since, workspace, totalItems: memories.length, workspacesFound: new Set(memories.map(m => m.workspace || 'unknown')).size, checkpointsFound: memories.filter(m => m.type === 'checkpoint').length, byDate: Object.fromEntries(Array.from(timelineMap.entries()).map(([date, wsMap]) => [ date, Object.fromEntries(wsMap.entries()) ])), byWorkspace: {} } as const; return buildToolContent('timeline', formatted, data as any, format); } catch (error) { return { content: [ { type: 'text', text: `โŒ Timeline failed: ${error instanceof Error ? error.message : String(error)}` } ] }; } } /** * Enhanced recall with Fuse.js search */ async recall(args: { query?: string; since?: string; workspace?: string; scope?: 'current' | 'all'; type?: string; tags?: string[]; limit?: number; format?: OutputMode; }) { const { query, since = '7d', workspace, scope = 'current', type, tags, limit = 10, format } = args; try { let memories; if (query) { // Use fuzzy search memories = await this.searchEngine.searchMemories({ query, since, workspace, scope, type, tags, limit }); } else { // Return recent memories memories = await this.searchEngine.searchMemories({ since, workspace, scope, type, tags, limit }); } if (memories.length === 0) { const searchInfo = query ? ` matching "${query}"` : ''; const formatted = `๐Ÿง  No memories found${searchInfo} in the last ${since}`; const data = { query, since, scope, workspace, memoriesFound: 0 } as const; return buildToolContent('recall', formatted, data as any, format); } // Build formatted output const output = ['๐Ÿง  Recent Memories:']; for (const memory of memories) { const age = this.formatAge(memory.timestamp); const typeIcon = this.getTypeIcon(memory.type); const workspaceInfo = memory.workspace === this.storage.getCurrentWorkspace() ? '' : ` [${memory.workspace}]`; output.push(`${typeIcon} [${memory.id.slice(-6)}] ${age}${workspaceInfo}`); if (typeof memory.content === 'object' && memory.content && 'description' in memory.content) { const contentObj = memory.content as { description?: string }; output.push(` ${contentObj.description}`); } else { const contentStr = typeof memory.content === 'string' ? memory.content : JSON.stringify(memory.content); output.push(` ${contentStr.slice(0, 200)}${contentStr.length > 200 ? '...' : ''}`); } if (memory.tags && memory.tags.length > 0) { output.push(` Tags: ${memory.tags.join(', ')}`); } output.push(''); } const formatted = output.join('\n'); const data = { memoriesFound: memories.length, timeRange: since, memories: memories.map(m => ({ id: m.id, type: m.type, age: this.formatAge(m.timestamp), content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content), workspace: m.workspace, tags: m.tags })) } as const; return buildToolContent('recall', formatted, data as any, format); } catch (error) { return { content: [ { type: 'text', text: `โŒ Recall failed: ${error instanceof Error ? error.message : String(error)}` } ] }; } } /** * Helper methods */ private formatAge(timestamp: Date): string { const now = new Date(); const diffMs = now.getTime() - timestamp.getTime(); const diffHours = diffMs / (1000 * 60 * 60); const diffDays = diffMs / (1000 * 60 * 60 * 24); if (diffHours < 1) { const diffMinutes = Math.floor(diffMs / (1000 * 60)); return `${diffMinutes}m ago`; } else if (diffHours < 24) { return `${Math.floor(diffHours)}h ago`; } else if (diffDays < 7) { return `${Math.floor(diffDays)}d ago`; } else { return timestamp.toLocaleDateString(); } } private getTypeIcon(type: string): string { const icons = { checkpoint: '๐Ÿ’พ', // Deprecated: general, todo, context (now handled by TodoLists) }; return icons[type as keyof typeof icons] || '๐Ÿ“„'; } private getMatchSnippet(text: string, indices: readonly [number, number][]): string { if (indices.length === 0) return text.slice(0, 100); const firstIndex = indices[0]; if (!firstIndex) return text.slice(0, 100); const [start, end] = firstIndex; const contextStart = Math.max(0, start - 20); const contextEnd = Math.min(text.length, end + 20); let snippet = text.slice(contextStart, contextEnd); if (contextStart > 0) snippet = '...' + snippet; if (contextEnd < text.length) snippet = snippet + '...'; return snippet; } /** * Get tool schemas for MCP */ static getToolSchemas() { return [ { name: 'search_history', description: 'Find past work and solutions. Use when user asks about previous implementations or mentions earlier tasks. Searches all work history.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query (e.g., "auth bug fix", "database migration")' }, since: { type: 'string', description: 'Time range (e.g., "3d", "1w", "yesterday", "2025-01-15")' }, workspace: { type: 'string', description: 'Workspace name or path (e.g., "coa-goldfish-mcp" or "C:\\source\\COA Goldfish MCP"). Will be normalized automatically.' }, scope: { type: 'string', enum: ['current', 'all'], description: 'Search scope: current workspace or all workspaces', default: 'current' }, limit: { type: 'number', description: 'Maximum results to return (default: 20)', default: 20 }, format: { type: 'string', enum: ['plain', 'emoji', 'json', 'dual'], description: 'Output format override (defaults to env GOLDFISH_OUTPUT_MODE or dual)' } }, required: ['query'] } }, { name: 'timeline', description: 'Review work progress chronologically. Use when user asks "what did I do" or needs timeline view for reporting.', inputSchema: { type: 'object', properties: { since: { type: 'string', description: 'Time range to show (default: "7d")', default: '7d' }, workspace: { type: 'string', description: 'Workspace name or path (e.g., "coa-goldfish-mcp" or "C:\\source\\COA Goldfish MCP"). Will be normalized automatically.' }, scope: { type: 'string', enum: ['current', 'all'], description: 'Timeline scope: current workspace or all workspaces', default: 'current' }, format: { type: 'string', enum: ['plain', 'emoji', 'json', 'dual'], description: 'Output format override (defaults to env GOLDFISH_OUTPUT_MODE or dual)' } } } }, { name: 'recall', description: 'Restore working context after breaks or /clear. Shows recent activity without query. Use when resuming work sessions.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query (optional - if not provided, shows recent memories)' }, since: { type: 'string', description: 'Time range (default: "7d")', default: '7d' }, workspace: { type: 'string', description: 'Workspace name or path (e.g., "coa-goldfish-mcp" or "C:\\source\\COA Goldfish MCP"). Will be normalized automatically.' }, scope: { type: 'string', enum: ['current', 'all'], description: 'Search scope (default: "current")', default: 'current' }, type: { type: 'string', enum: ['checkpoint'], description: 'Content type filter - only checkpoints available (Memory objects deprecated)' }, tags: { type: 'array', items: { type: 'string' }, description: 'Filter by exact tags (all tags must match)' }, limit: { type: 'number', description: 'Maximum results (default: 10)', default: 10 }, format: { type: 'string', enum: ['plain', 'emoji', 'json', 'dual'], description: 'Output format override (defaults to env GOLDFISH_OUTPUT_MODE or dual)' } } } } ]; } }

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/anortham/coa-goldfish-mcp'

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