UNPKG

@taazkareem/clickup-mcp-server

Version:

ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol

979 lines (978 loc) 47.5 kB
/** * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com> * SPDX-License-Identifier: MIT * * ClickUp Task Service - Search Module * * Handles search and lookup operations for tasks in ClickUp, including: * - Finding tasks by name * - Global workspace task lookup * - Task summaries and detailed task data */ import { TaskServiceCore } from './task-core.js'; import { isNameMatch } from '../../../utils/resolver-utils.js'; import { findListIDByName } from '../../../tools/list.js'; import { estimateTokensFromObject, wouldExceedTokenLimit } from '../../../utils/token-utils.js'; /** * Search functionality for the TaskService */ export class TaskServiceSearch extends TaskServiceCore { /** * Find a task by name within a specific list * @param listId The ID of the list to search in * @param taskName The name of the task to find * @returns The task if found, otherwise null */ async findTaskByName(listId, taskName) { this.logOperation('findTaskByName', { listId, taskName }); try { const tasks = await this.getTasks(listId); return this.findTaskInArray(tasks, taskName); } catch (error) { throw this.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`); } } /** * Find a task by name from an array of tasks * @param taskArray Array of tasks to search in * @param name Name of the task to search for * @param includeDetails Whether to add list context to task * @returns The task that best matches the name, or null if no match */ findTaskInArray(taskArray, name, includeDetails = false) { if (!taskArray || !Array.isArray(taskArray) || taskArray.length === 0 || !name) { return null; } // Get match scores for each task const taskMatchScores = taskArray .map(task => { const matchResult = isNameMatch(task.name, name); return { task, matchResult, // Parse the date_updated field as a number for sorting updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0 }; }) .filter(result => result.matchResult.isMatch); if (taskMatchScores.length === 0) { return null; } // First, try to find exact matches const exactMatches = taskMatchScores .filter(result => result.matchResult.exactMatch) .sort((a, b) => { // For exact matches with the same score, sort by most recently updated if (b.matchResult.score === a.matchResult.score) { return b.updatedAt - a.updatedAt; } return b.matchResult.score - a.matchResult.score; }); // Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatchScores.sort((a, b) => { // First sort by match score (highest first) if (b.matchResult.score !== a.matchResult.score) { return b.matchResult.score - a.matchResult.score; } // Then sort by most recently updated return b.updatedAt - a.updatedAt; }); // Get the best match const bestMatch = bestMatches[0].task; // If we need to include more details if (includeDetails) { // Include any additional details needed } return bestMatch; } /** * Formats a task into a lightweight summary format * @param task The task to format * @returns A TaskSummary object */ formatTaskSummary(task) { return { id: task.id, name: task.name, status: task.status.status, list: { id: task.list.id, name: task.list.name }, due_date: task.due_date, url: task.url, priority: this.extractPriorityValue(task), tags: task.tags.map(tag => ({ name: tag.name, tag_bg: tag.tag_bg, tag_fg: tag.tag_fg })) }; } /** * Estimates token count for a task in JSON format * @param task ClickUp task * @returns Estimated token count */ estimateTaskTokens(task) { return estimateTokensFromObject(task); } /** * Get filtered tasks across the entire team/workspace using tags and other filters * @param filters Task filters to apply including tags, list/folder/space filtering * @returns Either a DetailedTaskResponse or WorkspaceTasksResponse depending on detail_level */ async getWorkspaceTasks(filters = {}) { try { this.logOperation('getWorkspaceTasks', { filters }); const params = this.buildTaskFilterParams(filters); const response = await this.makeRequest(async () => { return await this.client.get(`/team/${this.teamId}/task`, { params }); }); const tasks = response.data.tasks; const totalCount = tasks.length; // Note: This is just the current page count const hasMore = totalCount === 100; // ClickUp returns max 100 tasks per page const nextPage = (filters.page || 0) + 1; // If the estimated token count exceeds 50,000 or detail_level is 'summary', // return summary format for efficiency and to avoid hitting token limits const TOKEN_LIMIT = 50000; // Estimate tokens for the full response let tokensExceedLimit = false; if (filters.detail_level !== 'summary' && tasks.length > 0) { // We only need to check token count if detailed was requested // For summary requests, we always return summary format // First check with a sample task - if one task exceeds the limit, we definitely need summary const sampleTask = tasks[0]; // Check if all tasks would exceed the token limit const estimatedTokensPerTask = this.estimateTaskTokens(sampleTask); const estimatedTotalTokens = estimatedTokensPerTask * tasks.length; // Add 10% overhead for the response wrapper tokensExceedLimit = estimatedTotalTokens * 1.1 > TOKEN_LIMIT; // Double-check with more precise estimation if we're close to the limit if (!tokensExceedLimit && estimatedTotalTokens * 1.1 > TOKEN_LIMIT * 0.8) { // More precise check - build a representative sample and extrapolate tokensExceedLimit = wouldExceedTokenLimit({ tasks, total_count: totalCount, has_more: hasMore, next_page: nextPage }, TOKEN_LIMIT); } } // Determine if we should return summary or detailed based on request and token limit const shouldUseSummary = filters.detail_level === 'summary' || tokensExceedLimit; this.logOperation('getWorkspaceTasks', { totalTasks: tasks.length, estimatedTokens: tasks.reduce((count, task) => count + this.estimateTaskTokens(task), 0), usingDetailedFormat: !shouldUseSummary, requestedFormat: filters.detail_level || 'auto' }); if (shouldUseSummary) { return { summaries: tasks.map(task => this.formatTaskSummary(task)), total_count: totalCount, has_more: hasMore, next_page: nextPage }; } return { tasks, total_count: totalCount, has_more: hasMore, next_page: nextPage }; } catch (error) { this.logOperation('getWorkspaceTasks', { error: error.message, status: error.response?.status }); throw this.handleError(error, 'Failed to get workspace tasks'); } } /** * Get task summaries for lightweight retrieval * @param filters Task filters to apply * @returns WorkspaceTasksResponse with task summaries */ async getTaskSummaries(filters = {}) { return this.getWorkspaceTasks({ ...filters, detail_level: 'summary' }); } /** * Get all views for a given list and identify the default "List" view ID * @param listId The ID of the list to get views for * @returns The ID of the default list view, or null if not found */ async getListViews(listId) { try { this.logOperation('getListViews', { listId }); const response = await this.makeRequest(async () => { return await this.client.get(`/list/${listId}/view`); }); // First try to get the default list view from required_views.list if (response.data.required_views?.list?.id) { this.logOperation('getListViews', { listId, foundDefaultView: response.data.required_views.list.id, source: 'required_views.list' }); return response.data.required_views.list.id; } // Fallback: look for a view with type "list" in the views array const listView = response.data.views?.find(view => view.type?.toLowerCase() === 'list' || view.name?.toLowerCase().includes('list')); if (listView?.id) { this.logOperation('getListViews', { listId, foundDefaultView: listView.id, source: 'views_array_fallback', viewName: listView.name }); return listView.id; } // If no specific list view found, use the first available view if (response.data.views?.length > 0) { const firstView = response.data.views[0]; this.logOperation('getListViews', { listId, foundDefaultView: firstView.id, source: 'first_available_view', viewName: firstView.name, warning: 'No specific list view found, using first available view' }); return firstView.id; } this.logOperation('getListViews', { listId, error: 'No views found for list', responseData: response.data }); return null; } catch (error) { this.logOperation('getListViews', { listId, error: error.message, status: error.response?.status }); throw this.handleError(error, `Failed to get views for list ${listId}`); } } /** * Retrieve tasks from a specific view, applying supported filters * @param viewId The ID of the view to get tasks from * @param filters Task filters to apply (only supported filters will be used) * @returns Array of ClickUpTask objects from the view */ async getTasksFromView(viewId, filters = {}) { try { this.logOperation('getTasksFromView', { viewId, filters }); // Build query parameters for supported filters const params = {}; // Map supported filters to query parameters if (filters.subtasks !== undefined) params.subtasks = filters.subtasks; if (filters.include_closed !== undefined) params.include_closed = filters.include_closed; if (filters.archived !== undefined) params.archived = filters.archived; if (filters.page !== undefined) params.page = filters.page; if (filters.order_by) params.order_by = filters.order_by; if (filters.reverse !== undefined) params.reverse = filters.reverse; // Status filtering if (filters.statuses && filters.statuses.length > 0) { params.statuses = filters.statuses; } // Assignee filtering if (filters.assignees && filters.assignees.length > 0) { params.assignees = filters.assignees; } // Date filters if (filters.date_created_gt) params.date_created_gt = filters.date_created_gt; if (filters.date_created_lt) params.date_created_lt = filters.date_created_lt; if (filters.date_updated_gt) params.date_updated_gt = filters.date_updated_gt; if (filters.date_updated_lt) params.date_updated_lt = filters.date_updated_lt; if (filters.due_date_gt) params.due_date_gt = filters.due_date_gt; if (filters.due_date_lt) params.due_date_lt = filters.due_date_lt; // Custom fields if (filters.custom_fields) { params.custom_fields = filters.custom_fields; } let allTasks = []; let currentPage = filters.page || 0; let hasMore = true; const maxPages = 50; // Safety limit to prevent infinite loops let pageCount = 0; while (hasMore && pageCount < maxPages) { const pageParams = { ...params, page: currentPage }; const response = await this.makeRequest(async () => { return await this.client.get(`/view/${viewId}/task`, { params: pageParams }); }); const tasks = response.data.tasks || []; allTasks = allTasks.concat(tasks); // Check if there are more pages hasMore = response.data.has_more === true && tasks.length > 0; currentPage++; pageCount++; this.logOperation('getTasksFromView', { viewId, page: currentPage - 1, tasksInPage: tasks.length, totalTasksSoFar: allTasks.length, hasMore }); // If we're not paginating (original request had no page specified), // only get the first page if (filters.page === undefined && currentPage === 1) { break; } } if (pageCount >= maxPages) { this.logOperation('getTasksFromView', { viewId, warning: `Reached maximum page limit (${maxPages}) while fetching tasks`, totalTasks: allTasks.length }); } this.logOperation('getTasksFromView', { viewId, totalTasks: allTasks.length, totalPages: pageCount }); return allTasks; } catch (error) { this.logOperation('getTasksFromView', { viewId, error: error.message, status: error.response?.status }); throw this.handleError(error, `Failed to get tasks from view ${viewId}`); } } /** * Get detailed task data * @param filters Task filters to apply * @returns DetailedTaskResponse with full task data */ async getTaskDetails(filters = {}) { return this.getWorkspaceTasks({ ...filters, detail_level: 'detailed' }); } /** * Unified method for finding tasks by ID or name with consistent handling of global lookup * * This method provides a single entry point for all task lookup operations: * - Direct lookup by task ID (highest priority) * - Lookup by task name within a specific list * - Global lookup by task name across the entire workspace * * @param options Lookup options with the following parameters: * - taskId: Optional task ID for direct lookup * - customTaskId: Optional custom task ID for direct lookup * - taskName: Optional task name to search for * - listId: Optional list ID to scope the search * - listName: Optional list name to scope the search * - allowMultipleMatches: Whether to return all matches instead of throwing an error * - useSmartDisambiguation: Whether to automatically select the most recently updated task * - includeFullDetails: Whether to include full task details (true) or just task summaries (false) * - includeListContext: Whether to include list/folder/space context with results * - requireExactMatch: Whether to only consider exact name matches (true) or allow fuzzy matches (false) * @returns Either a single task or an array of tasks depending on options * @throws Error if task cannot be found or if multiple matches are found when not allowed */ async findTasks({ taskId, customTaskId, taskName, listId, listName, allowMultipleMatches = false, useSmartDisambiguation = true, includeFullDetails = true, includeListContext = false, requireExactMatch = false }) { try { this.logOperation('findTasks', { taskId, customTaskId, taskName, listId, listName, allowMultipleMatches, useSmartDisambiguation, requireExactMatch }); // Check name-to-ID cache first if we have a task name if (taskName && !taskId && !customTaskId) { // Resolve list ID if we have a list name let resolvedListId = listId; if (listName && !listId) { const listInfo = await findListIDByName(this.workspaceService, listName); if (listInfo) { resolvedListId = listInfo.id; } } // Try to get cached task ID const cachedTaskId = this.getCachedTaskId(taskName, resolvedListId); if (cachedTaskId) { this.logOperation('findTasks', { message: 'Using cached task ID for name lookup', taskName, cachedTaskId }); taskId = cachedTaskId; } } // Case 1: Direct task ID lookup (highest priority) if (taskId) { // Check if it looks like a custom ID if (taskId.includes('-') && /^[A-Z]+\-\d+$/.test(taskId)) { this.logOperation('findTasks', { detectedCustomId: taskId }); try { // Try to get it as a custom ID first let resolvedListId; if (listId) { resolvedListId = listId; } else if (listName) { const listInfo = await findListIDByName(this.workspaceService, listName); if (listInfo) { resolvedListId = listInfo.id; } } const foundTask = await this.getTaskByCustomId(taskId, resolvedListId); return foundTask; } catch (error) { // If it fails as a custom ID, try as a regular ID this.logOperation('findTasks', { message: `Failed to find task with custom ID "${taskId}", falling back to regular ID`, error: error.message }); return await this.getTask(taskId); } } // Regular task ID return await this.getTask(taskId); } // Case 2: Explicit custom task ID lookup if (customTaskId) { let resolvedListId; if (listId) { resolvedListId = listId; } else if (listName) { const listInfo = await findListIDByName(this.workspaceService, listName); if (listInfo) { resolvedListId = listInfo.id; } } return await this.getTaskByCustomId(customTaskId, resolvedListId); } // Case 3: Task name lookup (requires either list context or global lookup) if (taskName) { // Case 3a: Task name + list context - search in specific list if (listId || listName) { let resolvedListId; if (listId) { resolvedListId = listId; } else { const listInfo = await findListIDByName(this.workspaceService, listName); if (!listInfo) { throw new Error(`List "${listName}" not found`); } resolvedListId = listInfo.id; } const foundTask = this.findTaskInArray(await this.getTasks(resolvedListId), taskName, includeListContext); if (!foundTask) { throw new Error(`Task "${taskName}" not found in list`); } // Cache the task name to ID mapping with list context this.cacheTaskNameToId(taskName, foundTask.id, resolvedListId); // If includeFullDetails is true and we need context not already in the task, // get full details, otherwise return what we already have if (includeFullDetails && (!foundTask.list || !foundTask.list.name || !foundTask.status)) { return await this.getTask(foundTask.id); } return foundTask; } // Case 3b: Task name without list context - global lookup across workspace // Get lightweight task summaries for efficient first-pass filtering this.logOperation('findTasks', { message: `Starting global task search for "${taskName}"`, includeFullDetails, useSmartDisambiguation, requireExactMatch }); // Use statuses parameter to get both open and closed tasks // Include additional filters to ensure we get as many tasks as possible const response = await this.getTaskSummaries({ include_closed: true, include_archived_lists: true, include_closed_lists: true, subtasks: true }); if (!this.workspaceService) { throw new Error("Workspace service required for global task lookup"); } // Create an index to efficiently look up list context information const hierarchy = await this.workspaceService.getWorkspaceHierarchy(); const listContextMap = new Map(); // Function to recursively build list context map function buildListContextMap(nodes, spaceId, spaceName, folderId, folderName) { for (const node of nodes) { if (node.type === 'space') { // Process space children if (node.children) { buildListContextMap(node.children, node.id, node.name); } } else if (node.type === 'folder') { // Process folder children if (node.children) { buildListContextMap(node.children, spaceId, spaceName, node.id, node.name); } } else if (node.type === 'list') { // Add list context to map listContextMap.set(node.id, { listId: node.id, listName: node.name, spaceId: spaceId, spaceName: spaceName, folderId, folderName }); } } } // Build the context map buildListContextMap(hierarchy.root.children); // Find tasks that match the provided name with scored match results const initialMatches = []; // Process task summaries to find initial matches let taskCount = 0; let matchesFound = 0; // Add additional logging to debug task matching this.logOperation('findTasks', { total_tasks_in_response: response.summaries.length, search_term: taskName, requireExactMatch }); for (const taskSummary of response.summaries) { taskCount++; // Use isNameMatch for consistent matching behavior with scoring const matchResult = isNameMatch(taskSummary.name, taskName); const isMatch = matchResult.isMatch; // For debugging, log every 20th task or any task with a similar name if (taskCount % 20 === 0 || taskSummary.name.toLowerCase().includes(taskName.toLowerCase()) || taskName.toLowerCase().includes(taskSummary.name.toLowerCase())) { this.logOperation('findTasks:matching', { task_name: taskSummary.name, search_term: taskName, list_name: taskSummary.list?.name || 'Unknown list', is_match: isMatch, match_score: matchResult.score, match_reason: matchResult.reason || 'no-match' }); } if (isMatch) { matchesFound++; // Get list context information const listContext = listContextMap.get(taskSummary.list.id); if (listContext) { // Store task summary and context with match score initialMatches.push({ id: taskSummary.id, task: taskSummary, listContext, matchScore: matchResult.score, matchReason: matchResult.reason || 'unknown' }); } } } this.logOperation('findTasks', { globalSearch: true, searchTerm: taskName, tasksSearched: taskCount, matchesFound: matchesFound, validMatchesWithContext: initialMatches.length }); // Handle the no matches case if (initialMatches.length === 0) { throw new Error(`Task "${taskName}" not found in any list across your workspace. Please check the task name and try again.`); } // Sort matches by match score first (higher is better), then by update time initialMatches.sort((a, b) => { // First sort by match score (highest first) if (b.matchScore !== a.matchScore) { return b.matchScore - a.matchScore; } // Try to get the date_updated from the task const aDate = a.task.date_updated ? parseInt(a.task.date_updated, 10) : 0; const bDate = b.task.date_updated ? parseInt(b.task.date_updated, 10) : 0; // For equal scores, sort by most recently updated return bDate - aDate; }); // Handle the single match case - we can return early if we don't need full details if (initialMatches.length === 1 && !useSmartDisambiguation && !includeFullDetails) { const match = initialMatches[0]; if (includeListContext) { return { ...match.task, list: { id: match.listContext.listId, name: match.listContext.listName }, folder: match.listContext.folderId ? { id: match.listContext.folderId, name: match.listContext.folderName } : undefined, space: { id: match.listContext.spaceId, name: match.listContext.spaceName } }; } return match.task; } // Handle the exact match case - if there's an exact or very good match, prefer it over others // This is our key improvement to prefer exact matches over update time const bestMatchScore = initialMatches[0].matchScore; if (bestMatchScore >= 80) { // 80+ is an exact match or case-insensitive exact match // If there's a single best match with score 80+, use it directly const exactMatches = initialMatches.filter(m => m.matchScore >= 80); if (exactMatches.length === 1 && !allowMultipleMatches) { this.logOperation('findTasks', { message: `Found single exact match with score ${exactMatches[0].matchScore}, prioritizing over other matches`, matchReason: exactMatches[0].matchReason }); // If we don't need details, return early if (!includeFullDetails) { const match = exactMatches[0]; if (includeListContext) { return { ...match.task, list: { id: match.listContext.listId, name: match.listContext.listName }, folder: match.listContext.folderId ? { id: match.listContext.folderId, name: match.listContext.folderName } : undefined, space: { id: match.listContext.spaceId, name: match.listContext.spaceName } }; } return match.task; } // Otherwise, get the full details const fullTask = await this.getTask(exactMatches[0].id); if (includeListContext) { const match = exactMatches[0]; // Enhance task with context information fullTask.list = { ...fullTask.list, name: match.listContext.listName }; if (match.listContext.folderId) { fullTask.folder = { id: match.listContext.folderId, name: match.listContext.folderName }; } fullTask.space = { id: match.listContext.spaceId, name: match.listContext.spaceName }; } return fullTask; } } // For multiple matches or when we need details, fetch full task info const fullMatches = []; const matchScoreMap = new Map(); // To preserve match scores try { // Process in sequence for better reliability for (const match of initialMatches) { const fullTask = await this.getTask(match.id); matchScoreMap.set(fullTask.id, match.matchScore); if (includeListContext) { // Enhance task with context information fullTask.list = { ...fullTask.list, name: match.listContext.listName }; if (match.listContext.folderId) { fullTask.folder = { id: match.listContext.folderId, name: match.listContext.folderName }; } fullTask.space = { id: match.listContext.spaceId, name: match.listContext.spaceName }; } fullMatches.push(fullTask); } // Sort matches - first by match score, then by update time if (fullMatches.length > 1) { fullMatches.sort((a, b) => { // First sort by match score (highest first) const aScore = matchScoreMap.get(a.id) || 0; const bScore = matchScoreMap.get(b.id) || 0; if (aScore !== bScore) { return bScore - aScore; } // For equal scores, sort by update time const aDate = parseInt(a.date_updated || '0', 10); const bDate = parseInt(b.date_updated || '0', 10); return bDate - aDate; // Most recent first }); } } catch (error) { this.logOperation('findTasks', { error: error.message, message: "Failed to get detailed task information" }); // If detailed fetch fails, use the summaries with context info // This fallback ensures we still return something useful if (allowMultipleMatches) { return initialMatches.map(match => ({ ...match.task, list: { id: match.listContext.listId, name: match.listContext.listName }, folder: match.listContext.folderId ? { id: match.listContext.folderId, name: match.listContext.folderName } : undefined, space: { id: match.listContext.spaceId, name: match.listContext.spaceName } })); } else { // For single result, return the first match (best match score) const match = initialMatches[0]; return { ...match.task, list: { id: match.listContext.listId, name: match.listContext.listName }, folder: match.listContext.folderId ? { id: match.listContext.folderId, name: match.listContext.folderName } : undefined, space: { id: match.listContext.spaceId, name: match.listContext.spaceName } }; } } // After finding the task in global search, cache the mapping if (initialMatches.length === 1 || useSmartDisambiguation) { const bestMatch = fullMatches[0]; this.cacheTaskNameToId(taskName, bestMatch.id, bestMatch.list?.id); return bestMatch; } // Return results based on options if (fullMatches.length === 1 || useSmartDisambiguation) { return fullMatches[0]; // Return best match (sorted by score then update time) } else if (allowMultipleMatches) { return fullMatches; // Return all matches } else { // Format error message for multiple matches const matchesInfo = fullMatches.map(task => { const listName = task.list?.name || "Unknown list"; const folderName = task.folder?.name; const spaceName = task.space?.name || "Unknown space"; const updateTime = task.date_updated ? new Date(parseInt(task.date_updated, 10)).toLocaleString() : "Unknown date"; const matchScore = matchScoreMap.get(task.id) || 0; const matchQuality = matchScore >= 100 ? "Exact match" : matchScore >= 80 ? "Case-insensitive exact match" : matchScore >= 70 ? "Text match ignoring emojis" : matchScore >= 50 ? "Contains search term" : "Partial match"; const location = `list "${listName}"${folderName ? ` (folder: "${folderName}")` : ''} (space: "${spaceName}")`; return `- "${task.name}" in ${location} - Updated ${updateTime} - Match quality: ${matchQuality} (${matchScore}/100)`; }).join('\n'); throw new Error(`Multiple tasks found with name "${taskName}":\n${matchesInfo}\n\nPlease provide list context to disambiguate, use the exact task name with requireExactMatch=true, or set allowMultipleMatches to true.`); } } // No valid lookup parameters provided throw new Error("At least one of taskId, customTaskId, or taskName must be provided"); } catch (error) { if (error.message?.includes('Task "') && error.message?.includes('not found')) { throw error; } if (error.message?.includes('Multiple tasks found')) { throw error; } // Unexpected errors throw this.handleError(error, `Error finding task: ${error.message}`); } } /** * Update a task by name within a specific list * @param listId The ID of the list containing the task * @param taskName The name of the task to update * @param updateData The data to update the task with * @returns The updated task */ async updateTaskByName(listId, taskName, updateData) { this.logOperation('updateTaskByName', { listId, taskName, ...updateData }); try { const task = await this.findTaskByName(listId, taskName); if (!task) { throw new Error(`Task "${taskName}" not found in list ${listId}`); } return await this.updateTask(task.id, updateData); } catch (error) { throw this.handleError(error, `Failed to update task by name: ${error instanceof Error ? error.message : String(error)}`); } } /** * Global task search by name across all lists * This is a specialized method that uses getWorkspaceTasks to search all lists at once * which is more efficient than searching list by list * * @param taskName The name to search for * @returns The best matching task or null if no match found */ async findTaskByNameGlobally(taskName) { this.logOperation('findTaskByNameGlobally', { taskName }); // Use a static cache for task data to avoid redundant API calls // This dramatically reduces API usage across multiple task lookups if (!this.constructor.hasOwnProperty('_taskCache')) { Object.defineProperty(this.constructor, '_taskCache', { value: { tasks: [], lastFetch: 0, cacheTTL: 60000, // 1 minute cache TTL }, writable: true }); } const cache = this.constructor._taskCache; const now = Date.now(); try { // Use cached tasks if available and not expired let tasks = []; if (cache.tasks.length > 0 && (now - cache.lastFetch) < cache.cacheTTL) { this.logOperation('findTaskByNameGlobally', { usedCache: true, cacheAge: now - cache.lastFetch, taskCount: cache.tasks.length }); tasks = cache.tasks; } else { // Get tasks using a single efficient workspace-wide API call const response = await this.getWorkspaceTasks({ include_closed: true, detail_level: 'detailed' }); tasks = 'tasks' in response ? response.tasks : []; // Update cache cache.tasks = tasks; cache.lastFetch = now; this.logOperation('findTaskByNameGlobally', { usedCache: false, fetchedTaskCount: tasks.length }); } // Map tasks to include match scores and updated time for sorting const taskMatches = tasks.map(task => { const matchResult = isNameMatch(task.name, taskName); return { task, matchResult, updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0 }; }).filter(result => result.matchResult.isMatch); this.logOperation('findTaskByNameGlobally', { taskCount: tasks.length, matchCount: taskMatches.length, taskName }); if (taskMatches.length === 0) { return null; } // First try exact matches const exactMatches = taskMatches .filter(result => result.matchResult.exactMatch) .sort((a, b) => { // For exact matches with the same score, sort by most recently updated if (b.matchResult.score === a.matchResult.score) { return b.updatedAt - a.updatedAt; } return b.matchResult.score - a.matchResult.score; }); // Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatches.sort((a, b) => { // First sort by match score (highest first) if (b.matchResult.score !== a.matchResult.score) { return b.matchResult.score - a.matchResult.score; } // Then sort by most recently updated return b.updatedAt - a.updatedAt; }); // Log the top matches for debugging const topMatches = bestMatches.slice(0, 3).map(match => ({ taskName: match.task.name, score: match.matchResult.score, reason: match.matchResult.reason, updatedAt: match.updatedAt, list: match.task.list?.name || 'Unknown list' })); this.logOperation('findTaskByNameGlobally', { topMatches }); // Return the best match return bestMatches[0].task; } catch (error) { this.logOperation('findTaskByNameGlobally', { error: error.message }); // If there's an error (like rate limit), try to use cached data even if expired if (cache.tasks.length > 0) { this.logOperation('findTaskByNameGlobally', { message: 'Using expired cache due to API error', cacheAge: now - cache.lastFetch }); // Perform the same matching logic with cached data const taskMatches = cache.tasks .map(task => { const matchResult = isNameMatch(task.name, taskName); return { task, matchResult, updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0 }; }) .filter(result => result.matchResult.isMatch) .sort((a, b) => { if (b.matchResult.score !== a.matchResult.score) { return b.matchResult.score - a.matchResult.score; } return b.updatedAt - a.updatedAt; }); if (taskMatches.length > 0) { return taskMatches[0].task; } } return null; } } }