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)'
}
}
}
}
];
}
}