@alanse/clickup-multi-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/Dockerfile +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +470 -0
  4. package/build/config.js +237 -0
  5. package/build/index.js +87 -0
  6. package/build/logger.js +163 -0
  7. package/build/middleware/security.js +231 -0
  8. package/build/server.js +288 -0
  9. package/build/services/clickup/base.js +432 -0
  10. package/build/services/clickup/bulk.js +180 -0
  11. package/build/services/clickup/document.js +159 -0
  12. package/build/services/clickup/folder.js +136 -0
  13. package/build/services/clickup/index.js +76 -0
  14. package/build/services/clickup/list.js +191 -0
  15. package/build/services/clickup/tag.js +239 -0
  16. package/build/services/clickup/task/index.js +32 -0
  17. package/build/services/clickup/task/task-attachments.js +105 -0
  18. package/build/services/clickup/task/task-comments.js +114 -0
  19. package/build/services/clickup/task/task-core.js +604 -0
  20. package/build/services/clickup/task/task-custom-fields.js +107 -0
  21. package/build/services/clickup/task/task-search.js +986 -0
  22. package/build/services/clickup/task/task-service.js +104 -0
  23. package/build/services/clickup/task/task-tags.js +113 -0
  24. package/build/services/clickup/time.js +244 -0
  25. package/build/services/clickup/types.js +33 -0
  26. package/build/services/clickup/workspace.js +397 -0
  27. package/build/services/shared.js +61 -0
  28. package/build/sse_server.js +277 -0
  29. package/build/tools/documents.js +489 -0
  30. package/build/tools/folder.js +331 -0
  31. package/build/tools/index.js +16 -0
  32. package/build/tools/list.js +428 -0
  33. package/build/tools/member.js +106 -0
  34. package/build/tools/tag.js +833 -0
  35. package/build/tools/task/attachments.js +357 -0
  36. package/build/tools/task/attachments.types.js +9 -0
  37. package/build/tools/task/bulk-operations.js +338 -0
  38. package/build/tools/task/handlers.js +919 -0
  39. package/build/tools/task/index.js +30 -0
  40. package/build/tools/task/main.js +233 -0
  41. package/build/tools/task/single-operations.js +469 -0
  42. package/build/tools/task/time-tracking.js +575 -0
  43. package/build/tools/task/utilities.js +310 -0
  44. package/build/tools/task/workspace-operations.js +258 -0
  45. package/build/tools/tool-enhancer.js +37 -0
  46. package/build/tools/utils.js +12 -0
  47. package/build/tools/workspace-helper.js +44 -0
  48. package/build/tools/workspace.js +73 -0
  49. package/build/utils/color-processor.js +183 -0
  50. package/build/utils/concurrency-utils.js +248 -0
  51. package/build/utils/date-utils.js +542 -0
  52. package/build/utils/resolver-utils.js +135 -0
  53. package/build/utils/sponsor-service.js +93 -0
  54. package/build/utils/token-utils.js +49 -0
  55. package/package.json +77 -0
  56. package/smithery.yaml +23 -0
@@ -0,0 +1,986 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service - Search Module
6
+ *
7
+ * Handles search and lookup operations for tasks in ClickUp, including:
8
+ * - Finding tasks by name
9
+ * - Global workspace task lookup
10
+ * - Task summaries and detailed task data
11
+ */
12
+ import { isNameMatch } from '../../../utils/resolver-utils.js';
13
+ import { findListIDByName } from '../../../tools/list.js';
14
+ import { estimateTokensFromObject, wouldExceedTokenLimit } from '../../../utils/token-utils.js';
15
+ /**
16
+ * Search functionality for the TaskService
17
+ *
18
+ * This service handles all search and lookup operations for ClickUp tasks.
19
+ * It uses composition to access core functionality instead of inheritance.
20
+ *
21
+ * REFACTORED: Now uses composition instead of inheritance.
22
+ * Only depends on TaskServiceCore for base functionality.
23
+ */
24
+ export class TaskServiceSearch {
25
+ constructor(core) {
26
+ this.core = core;
27
+ }
28
+ /**
29
+ * Find a task by name within a specific list
30
+ * @param listId The ID of the list to search in
31
+ * @param taskName The name of the task to find
32
+ * @returns The task if found, otherwise null
33
+ */
34
+ async findTaskByName(listId, taskName) {
35
+ this.core.logOperation('findTaskByName', { listId, taskName });
36
+ try {
37
+ const tasks = await this.core.getTasks(listId);
38
+ return this.findTaskInArray(tasks, taskName);
39
+ }
40
+ catch (error) {
41
+ throw this.core.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ }
44
+ /**
45
+ * Find a task by name from an array of tasks
46
+ * @param taskArray Array of tasks to search in
47
+ * @param name Name of the task to search for
48
+ * @param includeDetails Whether to add list context to task
49
+ * @returns The task that best matches the name, or null if no match
50
+ */
51
+ findTaskInArray(taskArray, name, includeDetails = false) {
52
+ if (!taskArray || !Array.isArray(taskArray) || taskArray.length === 0 || !name) {
53
+ return null;
54
+ }
55
+ // Get match scores for each task
56
+ const taskMatchScores = taskArray
57
+ .map(task => {
58
+ const matchResult = isNameMatch(task.name, name);
59
+ return {
60
+ task,
61
+ matchResult,
62
+ // Parse the date_updated field as a number for sorting
63
+ updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
64
+ };
65
+ })
66
+ .filter(result => result.matchResult.isMatch);
67
+ if (taskMatchScores.length === 0) {
68
+ return null;
69
+ }
70
+ // First, try to find exact matches
71
+ const exactMatches = taskMatchScores
72
+ .filter(result => result.matchResult.exactMatch)
73
+ .sort((a, b) => {
74
+ // For exact matches with the same score, sort by most recently updated
75
+ if (b.matchResult.score === a.matchResult.score) {
76
+ return b.updatedAt - a.updatedAt;
77
+ }
78
+ return b.matchResult.score - a.matchResult.score;
79
+ });
80
+ // Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches
81
+ const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatchScores.sort((a, b) => {
82
+ // First sort by match score (highest first)
83
+ if (b.matchResult.score !== a.matchResult.score) {
84
+ return b.matchResult.score - a.matchResult.score;
85
+ }
86
+ // Then sort by most recently updated
87
+ return b.updatedAt - a.updatedAt;
88
+ });
89
+ // Get the best match
90
+ const bestMatch = bestMatches[0].task;
91
+ // If we need to include more details
92
+ if (includeDetails) {
93
+ // Include any additional details needed
94
+ }
95
+ return bestMatch;
96
+ }
97
+ /**
98
+ * Formats a task into a lightweight summary format
99
+ * @param task The task to format
100
+ * @returns A TaskSummary object
101
+ */
102
+ formatTaskSummary(task) {
103
+ return {
104
+ id: task.id,
105
+ name: task.name,
106
+ status: task.status.status,
107
+ list: {
108
+ id: task.list.id,
109
+ name: task.list.name
110
+ },
111
+ due_date: task.due_date,
112
+ url: task.url,
113
+ priority: this.core.extractPriorityValue(task),
114
+ tags: task.tags.map(tag => ({
115
+ name: tag.name,
116
+ tag_bg: tag.tag_bg,
117
+ tag_fg: tag.tag_fg
118
+ }))
119
+ };
120
+ }
121
+ /**
122
+ * Estimates token count for a task in JSON format
123
+ * @param task ClickUp task
124
+ * @returns Estimated token count
125
+ */
126
+ estimateTaskTokens(task) {
127
+ return estimateTokensFromObject(task);
128
+ }
129
+ /**
130
+ * Get filtered tasks across the entire team/workspace using tags and other filters
131
+ * @param filters Task filters to apply including tags, list/folder/space filtering
132
+ * @returns Either a DetailedTaskResponse or WorkspaceTasksResponse depending on detail_level
133
+ */
134
+ async getWorkspaceTasks(filters = {}) {
135
+ try {
136
+ this.core.logOperation('getWorkspaceTasks', { filters });
137
+ const params = this.core.buildTaskFilterParams(filters);
138
+ const response = await this.core.makeRequest(async () => {
139
+ return await this.core.client.get(`/team/${this.core.teamId}/task`, {
140
+ params
141
+ });
142
+ });
143
+ const tasks = response.data.tasks;
144
+ const totalCount = tasks.length; // Note: This is just the current page count
145
+ const hasMore = totalCount === 100; // ClickUp returns max 100 tasks per page
146
+ const nextPage = (filters.page || 0) + 1;
147
+ // If the estimated token count exceeds 50,000 or detail_level is 'summary',
148
+ // return summary format for efficiency and to avoid hitting token limits
149
+ const TOKEN_LIMIT = 50000;
150
+ // Estimate tokens for the full response
151
+ let tokensExceedLimit = false;
152
+ if (filters.detail_level !== 'summary' && tasks.length > 0) {
153
+ // We only need to check token count if detailed was requested
154
+ // For summary requests, we always return summary format
155
+ // First check with a sample task - if one task exceeds the limit, we definitely need summary
156
+ const sampleTask = tasks[0];
157
+ // Check if all tasks would exceed the token limit
158
+ const estimatedTokensPerTask = this.core.estimateTaskTokens(sampleTask);
159
+ const estimatedTotalTokens = estimatedTokensPerTask * tasks.length;
160
+ // Add 10% overhead for the response wrapper
161
+ tokensExceedLimit = estimatedTotalTokens * 1.1 > TOKEN_LIMIT;
162
+ // Double-check with more precise estimation if we're close to the limit
163
+ if (!tokensExceedLimit && estimatedTotalTokens * 1.1 > TOKEN_LIMIT * 0.8) {
164
+ // More precise check - build a representative sample and extrapolate
165
+ tokensExceedLimit = wouldExceedTokenLimit({ tasks, total_count: totalCount, has_more: hasMore, next_page: nextPage }, TOKEN_LIMIT);
166
+ }
167
+ }
168
+ // Determine if we should return summary or detailed based on request and token limit
169
+ const shouldUseSummary = filters.detail_level === 'summary' || tokensExceedLimit;
170
+ this.core.logOperation('getWorkspaceTasks', {
171
+ totalTasks: tasks.length,
172
+ estimatedTokens: tasks.reduce((count, task) => count + this.core.estimateTaskTokens(task), 0),
173
+ usingDetailedFormat: !shouldUseSummary,
174
+ requestedFormat: filters.detail_level || 'auto'
175
+ });
176
+ if (shouldUseSummary) {
177
+ return {
178
+ summaries: tasks.map(task => this.core.formatTaskSummary(task)),
179
+ total_count: totalCount,
180
+ has_more: hasMore,
181
+ next_page: nextPage
182
+ };
183
+ }
184
+ return {
185
+ tasks,
186
+ total_count: totalCount,
187
+ has_more: hasMore,
188
+ next_page: nextPage
189
+ };
190
+ }
191
+ catch (error) {
192
+ this.core.logOperation('getWorkspaceTasks', { error: error.message, status: error.response?.status });
193
+ throw this.core.handleError(error, 'Failed to get workspace tasks');
194
+ }
195
+ }
196
+ /**
197
+ * Get task summaries for lightweight retrieval
198
+ * @param filters Task filters to apply
199
+ * @returns WorkspaceTasksResponse with task summaries
200
+ */
201
+ async getTaskSummaries(filters = {}) {
202
+ return this.getWorkspaceTasks({ ...filters, detail_level: 'summary' });
203
+ }
204
+ /**
205
+ * Get all views for a given list and identify the default "List" view ID
206
+ * @param listId The ID of the list to get views for
207
+ * @returns The ID of the default list view, or null if not found
208
+ */
209
+ async getListViews(listId) {
210
+ try {
211
+ this.core.logOperation('getListViews', { listId });
212
+ const response = await this.core.makeRequest(async () => {
213
+ return await this.core.client.get(`/list/${listId}/view`);
214
+ });
215
+ // First try to get the default list view from required_views.list
216
+ if (response.data.required_views?.list?.id) {
217
+ this.core.logOperation('getListViews', {
218
+ listId,
219
+ foundDefaultView: response.data.required_views.list.id,
220
+ source: 'required_views.list'
221
+ });
222
+ return response.data.required_views.list.id;
223
+ }
224
+ // Fallback: look for a view with type "list" in the views array
225
+ const listView = response.data.views?.find(view => view.type?.toLowerCase() === 'list' ||
226
+ view.name?.toLowerCase().includes('list'));
227
+ if (listView?.id) {
228
+ this.core.logOperation('getListViews', {
229
+ listId,
230
+ foundDefaultView: listView.id,
231
+ source: 'views_array_fallback',
232
+ viewName: listView.name
233
+ });
234
+ return listView.id;
235
+ }
236
+ // If no specific list view found, use the first available view
237
+ if (response.data.views?.length > 0) {
238
+ const firstView = response.data.views[0];
239
+ this.core.logOperation('getListViews', {
240
+ listId,
241
+ foundDefaultView: firstView.id,
242
+ source: 'first_available_view',
243
+ viewName: firstView.name,
244
+ warning: 'No specific list view found, using first available view'
245
+ });
246
+ return firstView.id;
247
+ }
248
+ this.core.logOperation('getListViews', {
249
+ listId,
250
+ error: 'No views found for list',
251
+ responseData: response.data
252
+ });
253
+ return null;
254
+ }
255
+ catch (error) {
256
+ this.core.logOperation('getListViews', {
257
+ listId,
258
+ error: error.message,
259
+ status: error.response?.status
260
+ });
261
+ throw this.core.handleError(error, `Failed to get views for list ${listId}`);
262
+ }
263
+ }
264
+ /**
265
+ * Retrieve tasks from a specific view, applying supported filters
266
+ * @param viewId The ID of the view to get tasks from
267
+ * @param filters Task filters to apply (only supported filters will be used)
268
+ * @returns Array of ClickUpTask objects from the view
269
+ */
270
+ async getTasksFromView(viewId, filters = {}) {
271
+ try {
272
+ this.core.logOperation('getTasksFromView', { viewId, filters });
273
+ // Build query parameters for supported filters
274
+ const params = {};
275
+ // Map supported filters to query parameters
276
+ if (filters.subtasks !== undefined)
277
+ params.subtasks = filters.subtasks;
278
+ if (filters.include_closed !== undefined)
279
+ params.include_closed = filters.include_closed;
280
+ if (filters.archived !== undefined)
281
+ params.archived = filters.archived;
282
+ if (filters.page !== undefined)
283
+ params.page = filters.page;
284
+ if (filters.order_by)
285
+ params.order_by = filters.order_by;
286
+ if (filters.reverse !== undefined)
287
+ params.reverse = filters.reverse;
288
+ // Status filtering
289
+ if (filters.statuses && filters.statuses.length > 0) {
290
+ params.statuses = filters.statuses;
291
+ }
292
+ // Assignee filtering
293
+ if (filters.assignees && filters.assignees.length > 0) {
294
+ params.assignees = filters.assignees;
295
+ }
296
+ // Date filters
297
+ if (filters.date_created_gt)
298
+ params.date_created_gt = filters.date_created_gt;
299
+ if (filters.date_created_lt)
300
+ params.date_created_lt = filters.date_created_lt;
301
+ if (filters.date_updated_gt)
302
+ params.date_updated_gt = filters.date_updated_gt;
303
+ if (filters.date_updated_lt)
304
+ params.date_updated_lt = filters.date_updated_lt;
305
+ if (filters.due_date_gt)
306
+ params.due_date_gt = filters.due_date_gt;
307
+ if (filters.due_date_lt)
308
+ params.due_date_lt = filters.due_date_lt;
309
+ // Custom fields
310
+ if (filters.custom_fields) {
311
+ params.custom_fields = filters.custom_fields;
312
+ }
313
+ let allTasks = [];
314
+ let currentPage = filters.page || 0;
315
+ let hasMore = true;
316
+ const maxPages = 50; // Safety limit to prevent infinite loops
317
+ let pageCount = 0;
318
+ while (hasMore && pageCount < maxPages) {
319
+ const pageParams = { ...params, page: currentPage };
320
+ const response = await this.core.makeRequest(async () => {
321
+ return await this.core.client.get(`/view/${viewId}/task`, {
322
+ params: pageParams
323
+ });
324
+ });
325
+ const tasks = response.data.tasks || [];
326
+ allTasks = allTasks.concat(tasks);
327
+ // Check if there are more pages
328
+ hasMore = response.data.has_more === true && tasks.length > 0;
329
+ currentPage++;
330
+ pageCount++;
331
+ this.core.logOperation('getTasksFromView', {
332
+ viewId,
333
+ page: currentPage - 1,
334
+ tasksInPage: tasks.length,
335
+ totalTasksSoFar: allTasks.length,
336
+ hasMore
337
+ });
338
+ // If we're not paginating (original request had no page specified),
339
+ // only get the first page
340
+ if (filters.page === undefined && currentPage === 1) {
341
+ break;
342
+ }
343
+ }
344
+ if (pageCount >= maxPages) {
345
+ this.core.logOperation('getTasksFromView', {
346
+ viewId,
347
+ warning: `Reached maximum page limit (${maxPages}) while fetching tasks`,
348
+ totalTasks: allTasks.length
349
+ });
350
+ }
351
+ this.core.logOperation('getTasksFromView', {
352
+ viewId,
353
+ totalTasks: allTasks.length,
354
+ totalPages: pageCount
355
+ });
356
+ return allTasks;
357
+ }
358
+ catch (error) {
359
+ this.core.logOperation('getTasksFromView', {
360
+ viewId,
361
+ error: error.message,
362
+ status: error.response?.status
363
+ });
364
+ throw this.core.handleError(error, `Failed to get tasks from view ${viewId}`);
365
+ }
366
+ }
367
+ /**
368
+ * Get detailed task data
369
+ * @param filters Task filters to apply
370
+ * @returns DetailedTaskResponse with full task data
371
+ */
372
+ async getTaskDetails(filters = {}) {
373
+ return this.getWorkspaceTasks({ ...filters, detail_level: 'detailed' });
374
+ }
375
+ /**
376
+ * Unified method for finding tasks by ID or name with consistent handling of global lookup
377
+ *
378
+ * This method provides a single entry point for all task lookup operations:
379
+ * - Direct lookup by task ID (highest priority)
380
+ * - Lookup by task name within a specific list
381
+ * - Global lookup by task name across the entire workspace
382
+ *
383
+ * @param options Lookup options with the following parameters:
384
+ * - taskId: Optional task ID for direct lookup
385
+ * - customTaskId: Optional custom task ID for direct lookup
386
+ * - taskName: Optional task name to search for
387
+ * - listId: Optional list ID to scope the search
388
+ * - listName: Optional list name to scope the search
389
+ * - allowMultipleMatches: Whether to return all matches instead of throwing an error
390
+ * - useSmartDisambiguation: Whether to automatically select the most recently updated task
391
+ * - includeFullDetails: Whether to include full task details (true) or just task summaries (false)
392
+ * - includeListContext: Whether to include list/folder/space context with results
393
+ * - requireExactMatch: Whether to only consider exact name matches (true) or allow fuzzy matches (false)
394
+ * @returns Either a single task or an array of tasks depending on options
395
+ * @throws Error if task cannot be found or if multiple matches are found when not allowed
396
+ */
397
+ async findTasks({ taskId, customTaskId, taskName, listId, listName, allowMultipleMatches = false, useSmartDisambiguation = true, includeFullDetails = true, includeListContext = false, requireExactMatch = false }) {
398
+ try {
399
+ this.core.logOperation('findTasks', {
400
+ taskId,
401
+ customTaskId,
402
+ taskName,
403
+ listId,
404
+ listName,
405
+ allowMultipleMatches,
406
+ useSmartDisambiguation,
407
+ requireExactMatch
408
+ });
409
+ // Check name-to-ID cache first if we have a task name
410
+ if (taskName && !taskId && !customTaskId) {
411
+ // Resolve list ID if we have a list name
412
+ let resolvedListId = listId;
413
+ if (listName && !listId) {
414
+ const listInfo = await findListIDByName(this.core.workspaceService, listName);
415
+ if (listInfo) {
416
+ resolvedListId = listInfo.id;
417
+ }
418
+ }
419
+ // Try to get cached task ID
420
+ const cachedTaskId = this.core.getCachedTaskId(taskName, resolvedListId);
421
+ if (cachedTaskId) {
422
+ this.core.logOperation('findTasks', {
423
+ message: 'Using cached task ID for name lookup',
424
+ taskName,
425
+ cachedTaskId
426
+ });
427
+ taskId = cachedTaskId;
428
+ }
429
+ }
430
+ // Case 1: Direct task ID lookup (highest priority)
431
+ if (taskId) {
432
+ // Check if it looks like a custom ID
433
+ if (taskId.includes('-') && /^[A-Z]+\-\d+$/.test(taskId)) {
434
+ this.core.logOperation('findTasks', { detectedCustomId: taskId });
435
+ try {
436
+ // Try to get it as a custom ID first
437
+ let resolvedListId;
438
+ if (listId) {
439
+ resolvedListId = listId;
440
+ }
441
+ else if (listName) {
442
+ const listInfo = await findListIDByName(this.core.workspaceService, listName);
443
+ if (listInfo) {
444
+ resolvedListId = listInfo.id;
445
+ }
446
+ }
447
+ const foundTask = await this.core.getTaskByCustomId(taskId, resolvedListId);
448
+ return foundTask;
449
+ }
450
+ catch (error) {
451
+ // If it fails as a custom ID, try as a regular ID
452
+ this.core.logOperation('findTasks', {
453
+ message: `Failed to find task with custom ID "${taskId}", falling back to regular ID`,
454
+ error: error.message
455
+ });
456
+ return await this.core.getTask(taskId);
457
+ }
458
+ }
459
+ // Regular task ID
460
+ return await this.core.getTask(taskId);
461
+ }
462
+ // Case 2: Explicit custom task ID lookup
463
+ if (customTaskId) {
464
+ let resolvedListId;
465
+ if (listId) {
466
+ resolvedListId = listId;
467
+ }
468
+ else if (listName) {
469
+ const listInfo = await findListIDByName(this.core.workspaceService, listName);
470
+ if (listInfo) {
471
+ resolvedListId = listInfo.id;
472
+ }
473
+ }
474
+ return await this.core.getTaskByCustomId(customTaskId, resolvedListId);
475
+ }
476
+ // Case 3: Task name lookup (requires either list context or global lookup)
477
+ if (taskName) {
478
+ // Case 3a: Task name + list context - search in specific list
479
+ if (listId || listName) {
480
+ let resolvedListId;
481
+ if (listId) {
482
+ resolvedListId = listId;
483
+ }
484
+ else {
485
+ const listInfo = await findListIDByName(this.core.workspaceService, listName);
486
+ if (!listInfo) {
487
+ throw new Error(`List "${listName}" not found`);
488
+ }
489
+ resolvedListId = listInfo.id;
490
+ }
491
+ const foundTask = this.core.findTaskInArray(await this.core.getTasks(resolvedListId), taskName, includeListContext);
492
+ if (!foundTask) {
493
+ throw new Error(`Task "${taskName}" not found in list`);
494
+ }
495
+ // Cache the task name to ID mapping with list context
496
+ this.core.cacheTaskNameToId(taskName, foundTask.id, resolvedListId);
497
+ // If includeFullDetails is true and we need context not already in the task,
498
+ // get full details, otherwise return what we already have
499
+ if (includeFullDetails && (!foundTask.list || !foundTask.list.name || !foundTask.status)) {
500
+ return await this.core.getTask(foundTask.id);
501
+ }
502
+ return foundTask;
503
+ }
504
+ // Case 3b: Task name without list context - global lookup across workspace
505
+ // Get lightweight task summaries for efficient first-pass filtering
506
+ this.core.logOperation('findTasks', {
507
+ message: `Starting global task search for "${taskName}"`,
508
+ includeFullDetails,
509
+ useSmartDisambiguation,
510
+ requireExactMatch
511
+ });
512
+ // Use statuses parameter to get both open and closed tasks
513
+ // Include additional filters to ensure we get as many tasks as possible
514
+ const response = await this.getTaskSummaries({
515
+ include_closed: true,
516
+ include_archived_lists: true,
517
+ include_closed_lists: true,
518
+ subtasks: true
519
+ });
520
+ if (!this.core.workspaceService) {
521
+ throw new Error("Workspace service required for global task lookup");
522
+ }
523
+ // Create an index to efficiently look up list context information
524
+ const hierarchy = await this.core.workspaceService.getWorkspaceHierarchy();
525
+ const listContextMap = new Map();
526
+ // Function to recursively build list context map
527
+ function buildListContextMap(nodes, spaceId, spaceName, folderId, folderName) {
528
+ for (const node of nodes) {
529
+ if (node.type === 'space') {
530
+ // Process space children
531
+ if (node.children) {
532
+ buildListContextMap(node.children, node.id, node.name);
533
+ }
534
+ }
535
+ else if (node.type === 'folder') {
536
+ // Process folder children
537
+ if (node.children) {
538
+ buildListContextMap(node.children, spaceId, spaceName, node.id, node.name);
539
+ }
540
+ }
541
+ else if (node.type === 'list') {
542
+ // Add list context to map
543
+ listContextMap.set(node.id, {
544
+ listId: node.id,
545
+ listName: node.name,
546
+ spaceId: spaceId,
547
+ spaceName: spaceName,
548
+ folderId,
549
+ folderName
550
+ });
551
+ }
552
+ }
553
+ }
554
+ // Build the context map
555
+ buildListContextMap(hierarchy.root.children);
556
+ // Find tasks that match the provided name with scored match results
557
+ const initialMatches = [];
558
+ // Process task summaries to find initial matches
559
+ let taskCount = 0;
560
+ let matchesFound = 0;
561
+ // Add additional logging to debug task matching
562
+ this.core.logOperation('findTasks', {
563
+ total_tasks_in_response: response.summaries.length,
564
+ search_term: taskName,
565
+ requireExactMatch
566
+ });
567
+ for (const taskSummary of response.summaries) {
568
+ taskCount++;
569
+ // Use isNameMatch for consistent matching behavior with scoring
570
+ const matchResult = isNameMatch(taskSummary.name, taskName);
571
+ const isMatch = matchResult.isMatch;
572
+ // For debugging, log every 20th task or any task with a similar name
573
+ if (taskCount % 20 === 0 || taskSummary.name.toLowerCase().includes(taskName.toLowerCase()) ||
574
+ taskName.toLowerCase().includes(taskSummary.name.toLowerCase())) {
575
+ this.core.logOperation('findTasks:matching', {
576
+ task_name: taskSummary.name,
577
+ search_term: taskName,
578
+ list_name: taskSummary.list?.name || 'Unknown list',
579
+ is_match: isMatch,
580
+ match_score: matchResult.score,
581
+ match_reason: matchResult.reason || 'no-match'
582
+ });
583
+ }
584
+ if (isMatch) {
585
+ matchesFound++;
586
+ // Get list context information
587
+ const listContext = listContextMap.get(taskSummary.list.id);
588
+ if (listContext) {
589
+ // Store task summary and context with match score
590
+ initialMatches.push({
591
+ id: taskSummary.id,
592
+ task: taskSummary,
593
+ listContext,
594
+ matchScore: matchResult.score,
595
+ matchReason: matchResult.reason || 'unknown'
596
+ });
597
+ }
598
+ }
599
+ }
600
+ this.core.logOperation('findTasks', {
601
+ globalSearch: true,
602
+ searchTerm: taskName,
603
+ tasksSearched: taskCount,
604
+ matchesFound: matchesFound,
605
+ validMatchesWithContext: initialMatches.length
606
+ });
607
+ // Handle the no matches case
608
+ if (initialMatches.length === 0) {
609
+ throw new Error(`Task "${taskName}" not found in any list across your workspace. Please check the task name and try again.`);
610
+ }
611
+ // Sort matches by match score first (higher is better), then by update time
612
+ initialMatches.sort((a, b) => {
613
+ // First sort by match score (highest first)
614
+ if (b.matchScore !== a.matchScore) {
615
+ return b.matchScore - a.matchScore;
616
+ }
617
+ // Try to get the date_updated from the task
618
+ const aDate = a.task.date_updated ? parseInt(a.task.date_updated, 10) : 0;
619
+ const bDate = b.task.date_updated ? parseInt(b.task.date_updated, 10) : 0;
620
+ // For equal scores, sort by most recently updated
621
+ return bDate - aDate;
622
+ });
623
+ // Handle the single match case - we can return early if we don't need full details
624
+ if (initialMatches.length === 1 && !useSmartDisambiguation && !includeFullDetails) {
625
+ const match = initialMatches[0];
626
+ if (includeListContext) {
627
+ return {
628
+ ...match.task,
629
+ list: {
630
+ id: match.listContext.listId,
631
+ name: match.listContext.listName
632
+ },
633
+ folder: match.listContext.folderId ? {
634
+ id: match.listContext.folderId,
635
+ name: match.listContext.folderName
636
+ } : undefined,
637
+ space: {
638
+ id: match.listContext.spaceId,
639
+ name: match.listContext.spaceName
640
+ }
641
+ };
642
+ }
643
+ return match.task;
644
+ }
645
+ // Handle the exact match case - if there's an exact or very good match, prefer it over others
646
+ // This is our key improvement to prefer exact matches over update time
647
+ const bestMatchScore = initialMatches[0].matchScore;
648
+ if (bestMatchScore >= 80) { // 80+ is an exact match or case-insensitive exact match
649
+ // If there's a single best match with score 80+, use it directly
650
+ const exactMatches = initialMatches.filter(m => m.matchScore >= 80);
651
+ if (exactMatches.length === 1 && !allowMultipleMatches) {
652
+ this.core.logOperation('findTasks', {
653
+ message: `Found single exact match with score ${exactMatches[0].matchScore}, prioritizing over other matches`,
654
+ matchReason: exactMatches[0].matchReason
655
+ });
656
+ // If we don't need details, return early
657
+ if (!includeFullDetails) {
658
+ const match = exactMatches[0];
659
+ if (includeListContext) {
660
+ return {
661
+ ...match.task,
662
+ list: {
663
+ id: match.listContext.listId,
664
+ name: match.listContext.listName
665
+ },
666
+ folder: match.listContext.folderId ? {
667
+ id: match.listContext.folderId,
668
+ name: match.listContext.folderName
669
+ } : undefined,
670
+ space: {
671
+ id: match.listContext.spaceId,
672
+ name: match.listContext.spaceName
673
+ }
674
+ };
675
+ }
676
+ return match.task;
677
+ }
678
+ // Otherwise, get the full details
679
+ const fullTask = await this.core.getTask(exactMatches[0].id);
680
+ if (includeListContext) {
681
+ const match = exactMatches[0];
682
+ // Enhance task with context information
683
+ fullTask.list = {
684
+ ...fullTask.list,
685
+ name: match.listContext.listName
686
+ };
687
+ if (match.listContext.folderId) {
688
+ fullTask.folder = {
689
+ id: match.listContext.folderId,
690
+ name: match.listContext.folderName
691
+ };
692
+ }
693
+ fullTask.space = {
694
+ id: match.listContext.spaceId,
695
+ name: match.listContext.spaceName
696
+ };
697
+ }
698
+ return fullTask;
699
+ }
700
+ }
701
+ // For multiple matches or when we need details, fetch full task info
702
+ const fullMatches = [];
703
+ const matchScoreMap = new Map(); // To preserve match scores
704
+ try {
705
+ // Process in sequence for better reliability
706
+ for (const match of initialMatches) {
707
+ const fullTask = await this.core.getTask(match.id);
708
+ matchScoreMap.set(fullTask.id, match.matchScore);
709
+ if (includeListContext) {
710
+ // Enhance task with context information
711
+ fullTask.list = {
712
+ ...fullTask.list,
713
+ name: match.listContext.listName
714
+ };
715
+ if (match.listContext.folderId) {
716
+ fullTask.folder = {
717
+ id: match.listContext.folderId,
718
+ name: match.listContext.folderName
719
+ };
720
+ }
721
+ fullTask.space = {
722
+ id: match.listContext.spaceId,
723
+ name: match.listContext.spaceName
724
+ };
725
+ }
726
+ fullMatches.push(fullTask);
727
+ }
728
+ // Sort matches - first by match score, then by update time
729
+ if (fullMatches.length > 1) {
730
+ fullMatches.sort((a, b) => {
731
+ // First sort by match score (highest first)
732
+ const aScore = matchScoreMap.get(a.id) || 0;
733
+ const bScore = matchScoreMap.get(b.id) || 0;
734
+ if (aScore !== bScore) {
735
+ return bScore - aScore;
736
+ }
737
+ // For equal scores, sort by update time
738
+ const aDate = parseInt(a.date_updated || '0', 10);
739
+ const bDate = parseInt(b.date_updated || '0', 10);
740
+ return bDate - aDate; // Most recent first
741
+ });
742
+ }
743
+ }
744
+ catch (error) {
745
+ this.core.logOperation('findTasks', {
746
+ error: error.message,
747
+ message: "Failed to get detailed task information"
748
+ });
749
+ // If detailed fetch fails, use the summaries with context info
750
+ // This fallback ensures we still return something useful
751
+ if (allowMultipleMatches) {
752
+ return initialMatches.map(match => ({
753
+ ...match.task,
754
+ list: {
755
+ id: match.listContext.listId,
756
+ name: match.listContext.listName
757
+ },
758
+ folder: match.listContext.folderId ? {
759
+ id: match.listContext.folderId,
760
+ name: match.listContext.folderName
761
+ } : undefined,
762
+ space: {
763
+ id: match.listContext.spaceId,
764
+ name: match.listContext.spaceName
765
+ }
766
+ }));
767
+ }
768
+ else {
769
+ // For single result, return the first match (best match score)
770
+ const match = initialMatches[0];
771
+ return {
772
+ ...match.task,
773
+ list: {
774
+ id: match.listContext.listId,
775
+ name: match.listContext.listName
776
+ },
777
+ folder: match.listContext.folderId ? {
778
+ id: match.listContext.folderId,
779
+ name: match.listContext.folderName
780
+ } : undefined,
781
+ space: {
782
+ id: match.listContext.spaceId,
783
+ name: match.listContext.spaceName
784
+ }
785
+ };
786
+ }
787
+ }
788
+ // After finding the task in global search, cache the mapping
789
+ if (initialMatches.length === 1 || useSmartDisambiguation) {
790
+ const bestMatch = fullMatches[0];
791
+ this.core.cacheTaskNameToId(taskName, bestMatch.id, bestMatch.list?.id);
792
+ return bestMatch;
793
+ }
794
+ // Return results based on options
795
+ if (fullMatches.length === 1 || useSmartDisambiguation) {
796
+ return fullMatches[0]; // Return best match (sorted by score then update time)
797
+ }
798
+ else if (allowMultipleMatches) {
799
+ return fullMatches; // Return all matches
800
+ }
801
+ else {
802
+ // Format error message for multiple matches
803
+ const matchesInfo = fullMatches.map(task => {
804
+ const listName = task.list?.name || "Unknown list";
805
+ const folderName = task.folder?.name;
806
+ const spaceName = task.space?.name || "Unknown space";
807
+ const updateTime = task.date_updated
808
+ ? new Date(parseInt(task.date_updated, 10)).toLocaleString()
809
+ : "Unknown date";
810
+ const matchScore = matchScoreMap.get(task.id) || 0;
811
+ const matchQuality = matchScore >= 100 ? "Exact match" :
812
+ matchScore >= 80 ? "Case-insensitive exact match" :
813
+ matchScore >= 70 ? "Text match ignoring emojis" :
814
+ matchScore >= 50 ? "Contains search term" :
815
+ "Partial match";
816
+ const location = `list "${listName}"${folderName ? ` (folder: "${folderName}")` : ''} (space: "${spaceName}")`;
817
+ return `- "${task.name}" in ${location} - Updated ${updateTime} - Match quality: ${matchQuality} (${matchScore}/100)`;
818
+ }).join('\n');
819
+ 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.`);
820
+ }
821
+ }
822
+ // No valid lookup parameters provided
823
+ throw new Error("At least one of taskId, customTaskId, or taskName must be provided");
824
+ }
825
+ catch (error) {
826
+ if (error.message?.includes('Task "') && error.message?.includes('not found')) {
827
+ throw error;
828
+ }
829
+ if (error.message?.includes('Multiple tasks found')) {
830
+ throw error;
831
+ }
832
+ // Unexpected errors
833
+ throw this.core.handleError(error, `Error finding task: ${error.message}`);
834
+ }
835
+ }
836
+ /**
837
+ * Update a task by name within a specific list
838
+ * @param listId The ID of the list containing the task
839
+ * @param taskName The name of the task to update
840
+ * @param updateData The data to update the task with
841
+ * @returns The updated task
842
+ */
843
+ async updateTaskByName(listId, taskName, updateData) {
844
+ this.core.logOperation('updateTaskByName', { listId, taskName, ...updateData });
845
+ try {
846
+ const task = await this.findTaskByName(listId, taskName);
847
+ if (!task) {
848
+ throw new Error(`Task "${taskName}" not found in list ${listId}`);
849
+ }
850
+ return await this.core.updateTask(task.id, updateData);
851
+ }
852
+ catch (error) {
853
+ throw this.core.handleError(error, `Failed to update task by name: ${error instanceof Error ? error.message : String(error)}`);
854
+ }
855
+ }
856
+ /**
857
+ * Global task search by name across all lists
858
+ * This is a specialized method that uses getWorkspaceTasks to search all lists at once
859
+ * which is more efficient than searching list by list
860
+ *
861
+ * @param taskName The name to search for
862
+ * @returns The best matching task or null if no match found
863
+ */
864
+ async findTaskByNameGlobally(taskName) {
865
+ this.core.logOperation('findTaskByNameGlobally', { taskName });
866
+ // Use a static cache for task data to avoid redundant API calls
867
+ // This dramatically reduces API usage across multiple task lookups
868
+ if (!this.constructor.hasOwnProperty('_taskCache')) {
869
+ Object.defineProperty(this.constructor, '_taskCache', {
870
+ value: {
871
+ tasks: [],
872
+ lastFetch: 0,
873
+ cacheTTL: 60000, // 1 minute cache TTL
874
+ },
875
+ writable: true
876
+ });
877
+ }
878
+ const cache = this.constructor._taskCache;
879
+ const now = Date.now();
880
+ try {
881
+ // Use cached tasks if available and not expired
882
+ let tasks = [];
883
+ if (cache.tasks.length > 0 && (now - cache.lastFetch) < cache.cacheTTL) {
884
+ this.core.logOperation('findTaskByNameGlobally', {
885
+ usedCache: true,
886
+ cacheAge: now - cache.lastFetch,
887
+ taskCount: cache.tasks.length
888
+ });
889
+ tasks = cache.tasks;
890
+ }
891
+ else {
892
+ // Get tasks using a single efficient workspace-wide API call
893
+ const response = await this.getWorkspaceTasks({
894
+ include_closed: true,
895
+ detail_level: 'detailed'
896
+ });
897
+ tasks = 'tasks' in response ? response.tasks : [];
898
+ // Update cache
899
+ cache.tasks = tasks;
900
+ cache.lastFetch = now;
901
+ this.core.logOperation('findTaskByNameGlobally', {
902
+ usedCache: false,
903
+ fetchedTaskCount: tasks.length
904
+ });
905
+ }
906
+ // Map tasks to include match scores and updated time for sorting
907
+ const taskMatches = tasks.map(task => {
908
+ const matchResult = isNameMatch(task.name, taskName);
909
+ return {
910
+ task,
911
+ matchResult,
912
+ updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
913
+ };
914
+ }).filter(result => result.matchResult.isMatch);
915
+ this.core.logOperation('findTaskByNameGlobally', {
916
+ taskCount: tasks.length,
917
+ matchCount: taskMatches.length,
918
+ taskName
919
+ });
920
+ if (taskMatches.length === 0) {
921
+ return null;
922
+ }
923
+ // First try exact matches
924
+ const exactMatches = taskMatches
925
+ .filter(result => result.matchResult.exactMatch)
926
+ .sort((a, b) => {
927
+ // For exact matches with the same score, sort by most recently updated
928
+ if (b.matchResult.score === a.matchResult.score) {
929
+ return b.updatedAt - a.updatedAt;
930
+ }
931
+ return b.matchResult.score - a.matchResult.score;
932
+ });
933
+ // Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches
934
+ const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatches.sort((a, b) => {
935
+ // First sort by match score (highest first)
936
+ if (b.matchResult.score !== a.matchResult.score) {
937
+ return b.matchResult.score - a.matchResult.score;
938
+ }
939
+ // Then sort by most recently updated
940
+ return b.updatedAt - a.updatedAt;
941
+ });
942
+ // Log the top matches for debugging
943
+ const topMatches = bestMatches.slice(0, 3).map(match => ({
944
+ taskName: match.task.name,
945
+ score: match.matchResult.score,
946
+ reason: match.matchResult.reason,
947
+ updatedAt: match.updatedAt,
948
+ list: match.task.list?.name || 'Unknown list'
949
+ }));
950
+ this.core.logOperation('findTaskByNameGlobally', { topMatches });
951
+ // Return the best match
952
+ return bestMatches[0].task;
953
+ }
954
+ catch (error) {
955
+ this.core.logOperation('findTaskByNameGlobally', { error: error.message });
956
+ // If there's an error (like rate limit), try to use cached data even if expired
957
+ if (cache.tasks.length > 0) {
958
+ this.core.logOperation('findTaskByNameGlobally', {
959
+ message: 'Using expired cache due to API error',
960
+ cacheAge: now - cache.lastFetch
961
+ });
962
+ // Perform the same matching logic with cached data
963
+ const taskMatches = cache.tasks
964
+ .map(task => {
965
+ const matchResult = isNameMatch(task.name, taskName);
966
+ return {
967
+ task,
968
+ matchResult,
969
+ updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
970
+ };
971
+ })
972
+ .filter(result => result.matchResult.isMatch)
973
+ .sort((a, b) => {
974
+ if (b.matchResult.score !== a.matchResult.score) {
975
+ return b.matchResult.score - a.matchResult.score;
976
+ }
977
+ return b.updatedAt - a.updatedAt;
978
+ });
979
+ if (taskMatches.length > 0) {
980
+ return taskMatches[0].task;
981
+ }
982
+ }
983
+ return null;
984
+ }
985
+ }
986
+ }