import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; import { parseISO, subDays, subWeeks, subMonths, subYears, startOfToday, startOfYesterday, isValid, format, } from 'date-fns'; interface HistoryToolParams { text?: string; startTime?: string; endTime?: string; maxResults?: number; excludeCurrentTabs?: boolean; } interface HistoryItem { id: string; url?: string; title?: string; lastVisitTime?: number; // Timestamp in milliseconds visitCount?: number; typedCount?: number; } interface HistoryResult { items: HistoryItem[]; totalCount: number; timeRange: { startTime: number; endTime: number; startTimeFormatted: string; endTimeFormatted: string; }; query?: string; } class HistoryTool extends BaseBrowserToolExecutor { name = TOOL_NAMES.BROWSER.HISTORY; private static readonly ONE_DAY_MS = 24 * 60 * 60 * 1000; /** * Parse a date string into milliseconds since epoch. * Returns null if the date string is invalid. * Supports: * - ISO date strings (e.g., "2023-10-31", "2023-10-31T14:30:00.000Z") * - Relative times: "1 day ago", "2 weeks ago", "3 months ago", "1 year ago" * - Special keywords: "now", "today", "yesterday" */ private parseDateString(dateStr: string | undefined | null): number | null { if (!dateStr) { // If an empty or null string is passed, it might mean "no specific date", // depending on how you want to treat it. Returning null is safer. return null; } const now = new Date(); const lowerDateStr = dateStr.toLowerCase().trim(); if (lowerDateStr === 'now') return now.getTime(); if (lowerDateStr === 'today') return startOfToday().getTime(); if (lowerDateStr === 'yesterday') return startOfYesterday().getTime(); const relativeMatch = lowerDateStr.match( /^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/, ); if (relativeMatch) { const amount = parseInt(relativeMatch[1], 10); const unit = relativeMatch[2]; let resultDate: Date; if (unit.startsWith('day')) resultDate = subDays(now, amount); else if (unit.startsWith('week')) resultDate = subWeeks(now, amount); else if (unit.startsWith('month')) resultDate = subMonths(now, amount); else if (unit.startsWith('year')) resultDate = subYears(now, amount); else return null; // Should not happen with the regex return resultDate.getTime(); } // Try parsing as ISO or other common date string formats // Native Date constructor can be unreliable for non-standard formats. // date-fns' parseISO is good for ISO 8601. // For other formats, date-fns' parse function is more flexible. let parsedDate = parseISO(dateStr); // Handles "2023-10-31" or "2023-10-31T10:00:00" if (isValid(parsedDate)) { return parsedDate.getTime(); } // Fallback to new Date() for other potential formats, but with caution parsedDate = new Date(dateStr); if (isValid(parsedDate) && dateStr.includes(parsedDate.getFullYear().toString())) { return parsedDate.getTime(); } console.warn(`Could not parse date string: ${dateStr}`); return null; } /** * Format a timestamp as a human-readable date string */ private formatDate(timestamp: number): string { // Using date-fns for consistent and potentially localized formatting return format(timestamp, 'yyyy-MM-dd HH:mm:ss'); } async execute(args: HistoryToolParams): Promise { try { console.log('Executing HistoryTool with args:', args); const { text = '', maxResults = 100, // Default to 100 results excludeCurrentTabs = false, } = args; const now = Date.now(); let startTimeMs: number; let endTimeMs: number; // Parse startTime if (args.startTime) { const parsedStart = this.parseDateString(args.startTime); if (parsedStart === null) { return createErrorResponse( `Invalid format for start time: "${args.startTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`, ); } startTimeMs = parsedStart; } else { // Default to 24 hours ago if startTime is not provided startTimeMs = now - HistoryTool.ONE_DAY_MS; } // Parse endTime if (args.endTime) { const parsedEnd = this.parseDateString(args.endTime); if (parsedEnd === null) { return createErrorResponse( `Invalid format for end time: "${args.endTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`, ); } endTimeMs = parsedEnd; } else { // Default to current time if endTime is not provided endTimeMs = now; } // Validate time range if (startTimeMs > endTimeMs) { return createErrorResponse('Start time cannot be after end time.'); } console.log( `Searching history from ${this.formatDate(startTimeMs)} to ${this.formatDate(endTimeMs)} for query "${text}"`, ); const historyItems = await chrome.history.search({ text, startTime: startTimeMs, endTime: endTimeMs, maxResults, }); console.log(`Found ${historyItems.length} history items before filtering current tabs.`); let filteredItems = historyItems; if (excludeCurrentTabs && historyItems.length > 0) { const currentTabs = await chrome.tabs.query({}); const openUrls = new Set(); currentTabs.forEach((tab) => { if (tab.url) { openUrls.add(tab.url); } }); if (openUrls.size > 0) { filteredItems = historyItems.filter((item) => !(item.url && openUrls.has(item.url))); console.log( `Filtered out ${historyItems.length - filteredItems.length} items that are currently open. ${filteredItems.length} items remaining.`, ); } } const result: HistoryResult = { items: filteredItems.map((item) => ({ id: item.id, url: item.url, title: item.title, lastVisitTime: item.lastVisitTime, visitCount: item.visitCount, typedCount: item.typedCount, })), totalCount: filteredItems.length, timeRange: { startTime: startTimeMs, endTime: endTimeMs, startTimeFormatted: this.formatDate(startTimeMs), endTimeFormatted: this.formatDate(endTimeMs), }, }; if (text) { result.query = text; } return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], isError: false, }; } catch (error) { console.error('Error in HistoryTool.execute:', error); return createErrorResponse( `Error retrieving browsing history: ${error instanceof Error ? error.message : String(error)}`, ); } } } export const historyTool = new HistoryTool();