@taazkareem/clickup-mcp-server 0.6.8 → 0.6.9

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.
@@ -0,0 +1,976 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service
6
+ *
7
+ * Handles all operations related to tasks in ClickUp, including:
8
+ * - Creating tasks (single and bulk)
9
+ * - Retrieving tasks (single or multiple)
10
+ * - Updating tasks
11
+ * - Deleting tasks
12
+ * - Finding tasks by name
13
+ */
14
+ import { BaseClickUpService, ErrorCode, ClickUpServiceError } from './base.js';
15
+ import { ListService } from './list.js';
16
+ import { estimateTokensFromObject, wouldExceedTokenLimit } from '../../utils/token-utils.js';
17
+ import { isNameMatch } from '../../utils/resolver-utils.js';
18
+ import { findListIDByName } from '../../tools/list.js';
19
+ export class TaskService extends BaseClickUpService {
20
+ constructor(apiKey, teamId, baseUrl, workspaceService) {
21
+ super(apiKey, teamId, baseUrl);
22
+ this.workspaceService = null;
23
+ if (workspaceService) {
24
+ this.workspaceService = workspaceService;
25
+ this.logOperation('constructor', { usingSharedWorkspaceService: true });
26
+ }
27
+ // Initialize list service for list lookups
28
+ this.listService = new ListService(apiKey, teamId, baseUrl, this.workspaceService);
29
+ this.logOperation('constructor', { initialized: true });
30
+ }
31
+ /**
32
+ * Helper method to handle errors consistently
33
+ * @param error The error that occurred
34
+ * @param message Optional custom error message
35
+ * @returns A ClickUpServiceError
36
+ */
37
+ handleError(error, message) {
38
+ if (error instanceof ClickUpServiceError) {
39
+ return error;
40
+ }
41
+ return new ClickUpServiceError(message || `Task service error: ${error.message}`, ErrorCode.UNKNOWN, error);
42
+ }
43
+ /**
44
+ * Build URL parameters from task filters
45
+ * @param filters Task filters to convert to URL parameters
46
+ * @returns URLSearchParams object
47
+ */
48
+ buildTaskFilterParams(filters) {
49
+ const params = new URLSearchParams();
50
+ // Add all filters to the query parameters
51
+ if (filters.include_closed)
52
+ params.append('include_closed', String(filters.include_closed));
53
+ if (filters.subtasks)
54
+ params.append('subtasks', String(filters.subtasks));
55
+ if (filters.page)
56
+ params.append('page', String(filters.page));
57
+ if (filters.order_by)
58
+ params.append('order_by', filters.order_by);
59
+ if (filters.reverse)
60
+ params.append('reverse', String(filters.reverse));
61
+ // Array parameters
62
+ if (filters.statuses && filters.statuses.length > 0) {
63
+ filters.statuses.forEach(status => params.append('statuses[]', status));
64
+ }
65
+ if (filters.assignees && filters.assignees.length > 0) {
66
+ filters.assignees.forEach(assignee => params.append('assignees[]', assignee));
67
+ }
68
+ // Team tasks endpoint specific parameters
69
+ if (filters.tags && filters.tags.length > 0) {
70
+ filters.tags.forEach(tag => params.append('tags[]', tag));
71
+ }
72
+ if (filters.list_ids && filters.list_ids.length > 0) {
73
+ filters.list_ids.forEach(id => params.append('list_ids[]', id));
74
+ }
75
+ if (filters.folder_ids && filters.folder_ids.length > 0) {
76
+ filters.folder_ids.forEach(id => params.append('folder_ids[]', id));
77
+ }
78
+ if (filters.space_ids && filters.space_ids.length > 0) {
79
+ filters.space_ids.forEach(id => params.append('space_ids[]', id));
80
+ }
81
+ if (filters.archived !== undefined)
82
+ params.append('archived', String(filters.archived));
83
+ if (filters.include_closed_lists !== undefined)
84
+ params.append('include_closed_lists', String(filters.include_closed_lists));
85
+ if (filters.include_archived_lists !== undefined)
86
+ params.append('include_archived_lists', String(filters.include_archived_lists));
87
+ if (filters.include_compact_time_entries !== undefined)
88
+ params.append('include_compact_time_entries', String(filters.include_compact_time_entries));
89
+ // Date filters
90
+ if (filters.due_date_gt)
91
+ params.append('due_date_gt', String(filters.due_date_gt));
92
+ if (filters.due_date_lt)
93
+ params.append('due_date_lt', String(filters.due_date_lt));
94
+ if (filters.date_created_gt)
95
+ params.append('date_created_gt', String(filters.date_created_gt));
96
+ if (filters.date_created_lt)
97
+ params.append('date_created_lt', String(filters.date_created_lt));
98
+ if (filters.date_updated_gt)
99
+ params.append('date_updated_gt', String(filters.date_updated_gt));
100
+ if (filters.date_updated_lt)
101
+ params.append('date_updated_lt', String(filters.date_updated_lt));
102
+ // Handle custom fields if present
103
+ if (filters.custom_fields) {
104
+ Object.entries(filters.custom_fields).forEach(([key, value]) => {
105
+ params.append(`custom_fields[${key}]`, String(value));
106
+ });
107
+ }
108
+ return params;
109
+ }
110
+ /**
111
+ * Extract priority value from a task
112
+ * @param task The task to extract priority from
113
+ * @returns TaskPriority or null
114
+ */
115
+ extractPriorityValue(task) {
116
+ if (!task.priority || !task.priority.id) {
117
+ return null;
118
+ }
119
+ const priorityValue = parseInt(task.priority.id);
120
+ // Ensure it's in the valid range 1-4
121
+ if (isNaN(priorityValue) || priorityValue < 1 || priorityValue > 4) {
122
+ return null;
123
+ }
124
+ return priorityValue;
125
+ }
126
+ /**
127
+ * Extract task data for creation/duplication
128
+ * @param task The source task
129
+ * @param nameOverride Optional override for the task name
130
+ * @returns CreateTaskData object
131
+ */
132
+ extractTaskData(task, nameOverride) {
133
+ return {
134
+ name: nameOverride || task.name,
135
+ description: task.description || '',
136
+ status: task.status?.status,
137
+ priority: this.extractPriorityValue(task),
138
+ due_date: task.due_date ? Number(task.due_date) : undefined,
139
+ assignees: task.assignees?.map(a => a.id) || []
140
+ };
141
+ }
142
+ /**
143
+ * Create a new task in the specified list
144
+ * @param listId The ID of the list to create the task in
145
+ * @param taskData The data for the new task
146
+ * @returns The created task
147
+ */
148
+ async createTask(listId, taskData) {
149
+ this.logOperation('createTask', { listId, ...taskData });
150
+ try {
151
+ return await this.makeRequest(async () => {
152
+ const response = await this.client.post(`/list/${listId}/task`, taskData);
153
+ return response.data;
154
+ });
155
+ }
156
+ catch (error) {
157
+ throw this.handleError(error, `Failed to create task in list ${listId}`);
158
+ }
159
+ }
160
+ /**
161
+ * Get all tasks in a list with optional filtering
162
+ * @param listId The ID of the list to get tasks from
163
+ * @param filters Optional filters to apply
164
+ * @returns List of tasks matching the filters
165
+ */
166
+ async getTasks(listId, filters = {}) {
167
+ this.logOperation('getTasks', { listId, filters });
168
+ try {
169
+ return await this.makeRequest(async () => {
170
+ const params = this.buildTaskFilterParams(filters);
171
+ const response = await this.client.get(`/list/${listId}/task?${params.toString()}`);
172
+ return response.data.tasks;
173
+ });
174
+ }
175
+ catch (error) {
176
+ throw this.handleError(error, `Failed to get tasks from list ${listId}`);
177
+ }
178
+ }
179
+ /**
180
+ * Get a specific task by ID
181
+ * @param taskId The ID of the task to retrieve
182
+ * @returns The task details
183
+ */
184
+ async getTask(taskId) {
185
+ this.logOperation('getTask', { taskId });
186
+ try {
187
+ return await this.makeRequest(async () => {
188
+ const response = await this.client.get(`/task/${taskId}`);
189
+ return response.data;
190
+ });
191
+ }
192
+ catch (error) {
193
+ throw this.handleError(error, `Failed to get task ${taskId}`);
194
+ }
195
+ }
196
+ /**
197
+ * Get subtasks of a specific task
198
+ * @param taskId The ID of the parent task
199
+ * @returns Array of subtask details
200
+ */
201
+ async getSubtasks(taskId) {
202
+ this.logOperation('getSubtasks', { taskId });
203
+ try {
204
+ return await this.makeRequest(async () => {
205
+ // First, get the task to get its list ID
206
+ const task = await this.getTask(taskId);
207
+ const listId = task.list.id;
208
+ // Then get all tasks from the list
209
+ const allTasks = await this.getTasks(listId, { subtasks: true });
210
+ // Filter tasks that have the specified task as parent
211
+ return allTasks.filter(t => t.parent === taskId || t.top_level_parent === taskId);
212
+ });
213
+ }
214
+ catch (error) {
215
+ throw this.handleError(error, `Failed to get subtasks of task ${taskId}`);
216
+ }
217
+ }
218
+ /**
219
+ * Get a specific task by its custom ID
220
+ * @param customTaskId The custom ID of the task (e.g., "DEV-1234")
221
+ * @param listId Optional list ID to search within for better performance
222
+ * @returns The task details
223
+ */
224
+ async getTaskByCustomId(customTaskId, listId) {
225
+ this.logOperation('getTaskByCustomId', { customTaskId, listId });
226
+ try {
227
+ return await this.makeRequest(async () => {
228
+ // Build query with custom_task_ids=true and team_id
229
+ const params = new URLSearchParams({
230
+ custom_task_ids: 'true',
231
+ team_id: this.teamId
232
+ });
233
+ // Use the ClickUp API endpoint for retrieving tasks by ID
234
+ // With custom_task_ids=true parameter, it will treat the ID as a custom ID
235
+ const response = await this.client.get(`/task/${customTaskId}?${params.toString()}`);
236
+ return response.data;
237
+ });
238
+ }
239
+ catch (error) {
240
+ // Enhance error message for custom ID lookups
241
+ if (error?.response?.status === 404) {
242
+ throw this.handleError(error, `Task with custom ID ${customTaskId} not found`);
243
+ }
244
+ throw this.handleError(error, `Failed to get task with custom ID ${customTaskId}`);
245
+ }
246
+ }
247
+ /**
248
+ * Update an existing task
249
+ * @param taskId ID of the task to update
250
+ * @param updateData Data to update on the task
251
+ * @returns The updated task
252
+ */
253
+ async updateTask(taskId, updateData) {
254
+ this.logOperation('updateTask', { taskId, ...updateData });
255
+ try {
256
+ return await this.makeRequest(async () => {
257
+ const response = await this.client.put(`/task/${taskId}`, updateData);
258
+ return response.data;
259
+ });
260
+ }
261
+ catch (error) {
262
+ throw this.handleError(error, `Failed to update task ${taskId}`);
263
+ }
264
+ }
265
+ /**
266
+ * Delete a task
267
+ * @param taskId The ID of the task to delete
268
+ * @returns Success indicator
269
+ */
270
+ async deleteTask(taskId) {
271
+ this.logOperation('deleteTask', { taskId });
272
+ try {
273
+ await this.makeRequest(async () => {
274
+ await this.client.delete(`/task/${taskId}`);
275
+ });
276
+ return {
277
+ success: true
278
+ };
279
+ }
280
+ catch (error) {
281
+ throw this.handleError(error, `Failed to delete task ${taskId}`);
282
+ }
283
+ }
284
+ /**
285
+ * Find a task by name within a list
286
+ * @param listId The list ID to search within
287
+ * @param taskName The name of the task to find
288
+ * @returns The task if found, otherwise null
289
+ */
290
+ async findTaskByName(listId, taskName) {
291
+ this.logOperation('findTaskByName', { listId, taskName });
292
+ try {
293
+ const tasks = await this.getTasks(listId);
294
+ // Find task by exact match first, then case-insensitive, then substring
295
+ // Exact match
296
+ let match = tasks.find(task => task.name === taskName);
297
+ if (match)
298
+ return match;
299
+ // Case-insensitive match
300
+ match = tasks.find(task => task.name.toLowerCase() === taskName.toLowerCase());
301
+ if (match)
302
+ return match;
303
+ // Substring match
304
+ match = tasks.find(task => task.name.toLowerCase().includes(taskName.toLowerCase()));
305
+ return match || null;
306
+ }
307
+ catch (error) {
308
+ throw this.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`);
309
+ }
310
+ }
311
+ /**
312
+ * Move a task to a different list
313
+ * @param taskId The ID of the task to move
314
+ * @param destinationListId The ID of the list to move the task to
315
+ * @returns The updated task
316
+ */
317
+ async moveTask(taskId, destinationListId) {
318
+ this.logOperation('moveTask', { taskId, destinationListId });
319
+ try {
320
+ // First, get both the task and list info in parallel to save time
321
+ const [originalTask, destinationList] = await Promise.all([
322
+ this.getTask(taskId),
323
+ this.listService.getList(destinationListId)
324
+ ]);
325
+ const currentStatus = originalTask.status?.status;
326
+ const availableStatuses = destinationList.statuses?.map(s => s.status) || [];
327
+ // Determine the appropriate status for the destination list
328
+ let newStatus = availableStatuses.includes(currentStatus || '')
329
+ ? currentStatus // Keep the same status if available in destination list
330
+ : destinationList.statuses?.[0]?.status; // Otherwise use the default (first) status
331
+ // Prepare the task data for the new list
332
+ const taskData = this.extractTaskData(originalTask);
333
+ taskData.status = newStatus;
334
+ // Create new task and delete old one in a single makeRequest call
335
+ return await this.makeRequest(async () => {
336
+ // First create the new task
337
+ const response = await this.client.post(`/list/${destinationListId}/task`, taskData);
338
+ // Then delete the original task
339
+ await this.client.delete(`/task/${taskId}`);
340
+ // Add a property to indicate the task was moved
341
+ const newTask = {
342
+ ...response.data,
343
+ moved: true,
344
+ originalId: taskId
345
+ };
346
+ return newTask;
347
+ });
348
+ }
349
+ catch (error) {
350
+ throw this.handleError(error, 'Failed to move task');
351
+ }
352
+ }
353
+ /**
354
+ * Create a duplicate of an existing task
355
+ * @param taskId The ID of the task to duplicate
356
+ * @param listId Optional destination list ID (defaults to the same list)
357
+ * @returns The newly created duplicate task
358
+ */
359
+ async duplicateTask(taskId, listId) {
360
+ this.logOperation('duplicateTask', { taskId, listId });
361
+ try {
362
+ // Get the original task to duplicate
363
+ const originalTask = await this.getTask(taskId);
364
+ // Create a copy of the task data with "(copy)" appended to the name
365
+ const newTaskData = this.extractTaskData(originalTask, `${originalTask.name} (copy)`);
366
+ // Create the new task in the specified list or original list
367
+ const targetListId = listId || originalTask.list.id;
368
+ return await this.createTask(targetListId, newTaskData);
369
+ }
370
+ catch (error) {
371
+ throw this.handleError(error, 'Failed to duplicate task');
372
+ }
373
+ }
374
+ /**
375
+ * Get all comments for a task
376
+ *
377
+ * @param taskId ID of the task to get comments for
378
+ * @param start Optional pagination start
379
+ * @param startId Optional comment ID to start from
380
+ * @returns Array of task comments
381
+ */
382
+ async getTaskComments(taskId, start, startId) {
383
+ this.logOperation('getTaskComments', { taskId, start, startId });
384
+ try {
385
+ // Build query parameters for pagination
386
+ const queryParams = new URLSearchParams();
387
+ if (start !== undefined) {
388
+ queryParams.append('start', start.toString());
389
+ }
390
+ if (startId) {
391
+ queryParams.append('start_id', startId);
392
+ }
393
+ const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
394
+ const response = await this.client.get(`/task/${taskId}/comment${queryString}`);
395
+ return response.data.comments || [];
396
+ }
397
+ catch (error) {
398
+ throw this.handleError(error, 'Failed to get task comments');
399
+ }
400
+ }
401
+ /**
402
+ * Create a comment on a task
403
+ *
404
+ * @param taskId ID of the task to comment on
405
+ * @param commentText Text content of the comment
406
+ * @param notifyAll Whether to notify all assignees
407
+ * @param assignee Optional user ID to assign the comment to
408
+ * @returns The created comment
409
+ */
410
+ async createTaskComment(taskId, commentText, notifyAll = false, assignee) {
411
+ this.logOperation('createTaskComment', { taskId, commentText, notifyAll, assignee });
412
+ try {
413
+ const payload = {
414
+ comment_text: commentText,
415
+ notify_all: notifyAll
416
+ };
417
+ if (assignee) {
418
+ payload.assignee = assignee;
419
+ }
420
+ // Make the request directly without using makeRequest for better error handling
421
+ const response = await this.client.post(`/task/${taskId}/comment`, payload);
422
+ // Handle different response formats from ClickUp API
423
+ if (response.data) {
424
+ if (response.data.comment) {
425
+ // Standard format: { comment: ClickUpComment }
426
+ return response.data.comment;
427
+ }
428
+ else if (response.data.id && (response.data.comment_text || response.data.comment)) {
429
+ // Direct format: the comment object itself
430
+ return response.data;
431
+ }
432
+ else {
433
+ // Fallback: construct a minimal valid comment object
434
+ return {
435
+ id: response.data.id || `custom-${Date.now()}`,
436
+ comment: response.data.comment || commentText,
437
+ comment_text: response.data.comment_text || commentText,
438
+ user: response.data.user || { id: 0, username: 'Unknown', email: '', color: '' },
439
+ date: response.data.date || new Date().toISOString(),
440
+ resolved: false
441
+ };
442
+ }
443
+ }
444
+ throw new Error('Invalid response from ClickUp API');
445
+ }
446
+ catch (error) {
447
+ // Check if comment might have been created despite error
448
+ if (error.response?.status === 200 || error.response?.status === 201) {
449
+ // Try to construct a comment object from what we know
450
+ return {
451
+ id: `fallback-${Date.now()}`,
452
+ comment: commentText,
453
+ comment_text: commentText,
454
+ user: { id: 0, username: 'Unknown', email: '', color: '' },
455
+ date: new Date().toISOString(),
456
+ resolved: false
457
+ };
458
+ }
459
+ throw this.handleError(error, 'Failed to create task comment');
460
+ }
461
+ }
462
+ /**
463
+ * Validate that a list exists
464
+ *
465
+ * @param listId ID of the list to validate
466
+ * @throws ClickUpServiceError if the list doesn't exist
467
+ */
468
+ async validateListExists(listId) {
469
+ this.logOperation('validateListExists', { listId });
470
+ try {
471
+ const list = await this.listService.getList(listId);
472
+ if (!list) {
473
+ throw new ClickUpServiceError(`List not found: ${listId}`, ErrorCode.NOT_FOUND, { listId });
474
+ }
475
+ }
476
+ catch (error) {
477
+ if (error instanceof ClickUpServiceError) {
478
+ throw error;
479
+ }
480
+ throw new ClickUpServiceError(`Failed to validate list existence: ${error instanceof Error ? error.message : String(error)}`, ErrorCode.UNKNOWN, { listId });
481
+ }
482
+ }
483
+ /**
484
+ * Upload a file attachment to a ClickUp task
485
+ * @param taskId The ID of the task to attach the file to
486
+ * @param fileData The file data as a Buffer
487
+ * @param fileName The name of the file
488
+ * @returns Promise resolving to the attachment response from ClickUp
489
+ */
490
+ async uploadTaskAttachment(taskId, fileData, fileName) {
491
+ this.logOperation('uploadTaskAttachment', { taskId, fileName, fileSize: fileData.length });
492
+ try {
493
+ return await this.makeRequest(async () => {
494
+ // Create FormData for multipart/form-data upload
495
+ const FormData = (await import('form-data')).default;
496
+ const formData = new FormData();
497
+ // Add the file to the form data
498
+ formData.append('attachment', fileData, {
499
+ filename: fileName,
500
+ contentType: 'application/octet-stream' // Let ClickUp determine the content type
501
+ });
502
+ // Use the raw axios client for this request since we need to handle FormData
503
+ const response = await this.client.post(`/task/${taskId}/attachment`, formData, {
504
+ headers: {
505
+ ...formData.getHeaders(),
506
+ 'Authorization': this.apiKey
507
+ }
508
+ });
509
+ return response.data;
510
+ });
511
+ }
512
+ catch (error) {
513
+ throw this.handleError(error, `Failed to upload attachment to task ${taskId}`);
514
+ }
515
+ }
516
+ /**
517
+ * Upload a file attachment to a ClickUp task from a URL
518
+ * @param taskId The ID of the task to attach the file to
519
+ * @param fileUrl The URL of the file to download and attach
520
+ * @param fileName Optional file name (if not provided, it will be extracted from the URL)
521
+ * @param authHeader Optional authorization header for the URL
522
+ * @returns Promise resolving to the attachment response from ClickUp
523
+ */
524
+ async uploadTaskAttachmentFromUrl(taskId, fileUrl, fileName, authHeader) {
525
+ this.logOperation('uploadTaskAttachmentFromUrl', { taskId, fileUrl, fileName });
526
+ try {
527
+ return await this.makeRequest(async () => {
528
+ // Import required modules
529
+ const axios = (await import('axios')).default;
530
+ const FormData = (await import('form-data')).default;
531
+ // Download the file from the URL
532
+ const headers = {};
533
+ if (authHeader) {
534
+ headers['Authorization'] = authHeader;
535
+ }
536
+ const response = await axios.get(fileUrl, {
537
+ responseType: 'arraybuffer',
538
+ headers
539
+ });
540
+ // Extract filename from URL if not provided
541
+ const actualFileName = fileName || fileUrl.split('/').pop() || 'downloaded-file';
542
+ // Create FormData for multipart/form-data upload
543
+ const formData = new FormData();
544
+ // Add the file to the form data
545
+ formData.append('attachment', Buffer.from(response.data), {
546
+ filename: actualFileName,
547
+ contentType: 'application/octet-stream'
548
+ });
549
+ // Upload the file to ClickUp
550
+ const uploadResponse = await this.client.post(`/task/${taskId}/attachment`, formData, {
551
+ headers: {
552
+ ...formData.getHeaders(),
553
+ 'Authorization': this.apiKey
554
+ }
555
+ });
556
+ return uploadResponse.data;
557
+ });
558
+ }
559
+ catch (error) {
560
+ throw this.handleError(error, `Failed to upload attachment from URL to task ${taskId}`);
561
+ }
562
+ }
563
+ /**
564
+ * Format task data for summary view
565
+ * @param task The task to format
566
+ * @returns TaskSummary object
567
+ */
568
+ formatTaskSummary(task) {
569
+ return {
570
+ id: task.id,
571
+ name: task.name,
572
+ status: task.status.status,
573
+ list: {
574
+ id: task.list.id,
575
+ name: task.list.name
576
+ },
577
+ due_date: task.due_date,
578
+ url: task.url,
579
+ priority: this.extractPriorityValue(task),
580
+ tags: task.tags.map(tag => ({
581
+ name: tag.name,
582
+ tag_bg: tag.tag_bg,
583
+ tag_fg: tag.tag_fg
584
+ }))
585
+ };
586
+ }
587
+ /**
588
+ * Estimates token count for a task in JSON format
589
+ * @param task ClickUp task
590
+ * @returns Estimated token count
591
+ */
592
+ estimateTaskTokens(task) {
593
+ return estimateTokensFromObject(task);
594
+ }
595
+ /**
596
+ * Get filtered tasks across the entire team/workspace using tags and other filters
597
+ * @param filters Task filters to apply including tags, list/folder/space filtering
598
+ * @returns Either a DetailedTaskResponse or WorkspaceTasksResponse depending on detail_level
599
+ */
600
+ async getWorkspaceTasks(filters = {}) {
601
+ try {
602
+ this.logOperation('getWorkspaceTasks', { filters });
603
+ const params = this.buildTaskFilterParams(filters);
604
+ const response = await this.client.get(`/team/${this.teamId}/task`, {
605
+ params
606
+ });
607
+ const tasks = response.data.tasks;
608
+ const totalCount = tasks.length; // Note: This is just the current page count
609
+ const hasMore = totalCount === 100; // ClickUp returns max 100 tasks per page
610
+ const nextPage = (filters.page || 0) + 1;
611
+ // If the estimated token count exceeds 50,000 or detail_level is 'summary',
612
+ // return summary format for efficiency and to avoid hitting token limits
613
+ const TOKEN_LIMIT = 50000;
614
+ // Estimate tokens for the full response
615
+ let tokensExceedLimit = false;
616
+ if (filters.detail_level !== 'summary' && tasks.length > 0) {
617
+ // We only need to check token count if detailed was requested
618
+ // For summary requests, we always return summary format
619
+ // First check with a sample task - if one task exceeds the limit, we definitely need summary
620
+ const sampleTask = tasks[0];
621
+ // Check if all tasks would exceed the token limit
622
+ const estimatedTokensPerTask = this.estimateTaskTokens(sampleTask);
623
+ const estimatedTotalTokens = estimatedTokensPerTask * tasks.length;
624
+ // Add 10% overhead for the response wrapper
625
+ tokensExceedLimit = estimatedTotalTokens * 1.1 > TOKEN_LIMIT;
626
+ // Double-check with more precise estimation if we're close to the limit
627
+ if (!tokensExceedLimit && estimatedTotalTokens * 1.1 > TOKEN_LIMIT * 0.8) {
628
+ // More precise check - build a representative sample and extrapolate
629
+ tokensExceedLimit = wouldExceedTokenLimit({ tasks, total_count: totalCount, has_more: hasMore, next_page: nextPage }, TOKEN_LIMIT);
630
+ }
631
+ }
632
+ // Determine if we should return summary or detailed based on request and token limit
633
+ const shouldUseSummary = filters.detail_level === 'summary' || tokensExceedLimit;
634
+ this.logOperation('getWorkspaceTasks', {
635
+ totalTasks: tasks.length,
636
+ estimatedTokens: tasks.reduce((count, task) => count + this.estimateTaskTokens(task), 0),
637
+ usingDetailedFormat: !shouldUseSummary,
638
+ requestedFormat: filters.detail_level || 'auto'
639
+ });
640
+ if (shouldUseSummary) {
641
+ return {
642
+ summaries: tasks.map(task => this.formatTaskSummary(task)),
643
+ total_count: totalCount,
644
+ has_more: hasMore,
645
+ next_page: nextPage
646
+ };
647
+ }
648
+ return {
649
+ tasks,
650
+ total_count: totalCount,
651
+ has_more: hasMore,
652
+ next_page: nextPage
653
+ };
654
+ }
655
+ catch (error) {
656
+ this.logOperation('getWorkspaceTasks', { error: error.message, status: error.response?.status });
657
+ throw this.handleError(error, 'Failed to get workspace tasks');
658
+ }
659
+ }
660
+ /**
661
+ * Get task summaries for lightweight retrieval
662
+ * @param filters Task filters to apply
663
+ * @returns WorkspaceTasksResponse with task summaries
664
+ */
665
+ async getTaskSummaries(filters = {}) {
666
+ return this.getWorkspaceTasks({ ...filters, detail_level: 'summary' });
667
+ }
668
+ /**
669
+ * Get detailed task data
670
+ * @param filters Task filters to apply
671
+ * @returns DetailedTaskResponse with full task data
672
+ */
673
+ async getTaskDetails(filters = {}) {
674
+ return this.getWorkspaceTasks({ ...filters, detail_level: 'detailed' });
675
+ }
676
+ /**
677
+ * Unified method for finding tasks by ID or name with consistent handling of global lookup
678
+ *
679
+ * This method provides a single entry point for all task lookup operations:
680
+ * - Direct lookup by task ID (regular or custom)
681
+ * - Lookup by task name within a specific list
682
+ * - Global lookup by task name across the entire workspace
683
+ *
684
+ * @param options Lookup options with the following parameters:
685
+ * - taskId: Optional task ID for direct lookup
686
+ * - customTaskId: Optional custom task ID for direct lookup
687
+ * - taskName: Optional task name to search for
688
+ * - listId: Optional list ID to scope the search
689
+ * - listName: Optional list name to scope the search
690
+ * - allowMultipleMatches: Whether to return all matches instead of throwing an error
691
+ * - useSmartDisambiguation: Whether to automatically select the most recently updated task
692
+ * - includeFullDetails: Whether to include full task details (true) or just task summaries (false)
693
+ * - includeListContext: Whether to include list/folder/space context with results
694
+ * @returns Either a single task or an array of tasks depending on options
695
+ * @throws Error if task cannot be found or if multiple matches are found when not allowed
696
+ */
697
+ async findTasks({ taskId, customTaskId, taskName, listId, listName, allowMultipleMatches = false, useSmartDisambiguation = false, includeFullDetails = true, includeListContext = true }) {
698
+ try {
699
+ this.logOperation('findTasks', {
700
+ taskId,
701
+ customTaskId,
702
+ taskName,
703
+ listId,
704
+ listName,
705
+ allowMultipleMatches,
706
+ useSmartDisambiguation
707
+ });
708
+ // Case 1: Direct task ID lookup (highest priority)
709
+ if (taskId) {
710
+ // Check if it looks like a custom ID
711
+ if (taskId.includes('-') && /^[A-Z]+\-\d+$/.test(taskId)) {
712
+ this.logOperation('findTasks', { detectedCustomId: taskId });
713
+ try {
714
+ // Try to get it as a custom ID first
715
+ let resolvedListId;
716
+ if (listId) {
717
+ resolvedListId = listId;
718
+ }
719
+ else if (listName) {
720
+ const listInfo = await findListIDByName(this.workspaceService, listName);
721
+ if (listInfo) {
722
+ resolvedListId = listInfo.id;
723
+ }
724
+ }
725
+ const foundTask = await this.getTaskByCustomId(taskId, resolvedListId);
726
+ return foundTask;
727
+ }
728
+ catch (error) {
729
+ // If it fails as a custom ID, try as a regular ID
730
+ this.logOperation('findTasks', {
731
+ message: `Failed to find task with custom ID "${taskId}", falling back to regular ID`,
732
+ error: error.message
733
+ });
734
+ return await this.getTask(taskId);
735
+ }
736
+ }
737
+ // Regular task ID
738
+ return await this.getTask(taskId);
739
+ }
740
+ // Case 2: Explicit custom task ID lookup
741
+ if (customTaskId) {
742
+ let resolvedListId;
743
+ if (listId) {
744
+ resolvedListId = listId;
745
+ }
746
+ else if (listName) {
747
+ const listInfo = await findListIDByName(this.workspaceService, listName);
748
+ if (listInfo) {
749
+ resolvedListId = listInfo.id;
750
+ }
751
+ }
752
+ return await this.getTaskByCustomId(customTaskId, resolvedListId);
753
+ }
754
+ // Case 3: Task name lookup (requires either list context or global lookup)
755
+ if (taskName) {
756
+ // Case 3a: Task name + list context - search in specific list
757
+ if (listId || listName) {
758
+ let resolvedListId;
759
+ if (listId) {
760
+ resolvedListId = listId;
761
+ }
762
+ else {
763
+ const listInfo = await findListIDByName(this.workspaceService, listName);
764
+ if (!listInfo) {
765
+ throw new Error(`List "${listName}" not found`);
766
+ }
767
+ resolvedListId = listInfo.id;
768
+ }
769
+ const foundTask = await this.findTaskByName(resolvedListId, taskName);
770
+ if (!foundTask) {
771
+ throw new Error(`Task "${taskName}" not found in list`);
772
+ }
773
+ // If includeFullDetails is true and we need context not already in the task,
774
+ // get full details, otherwise return what we already have
775
+ if (includeFullDetails && (!foundTask.list || !foundTask.list.name || !foundTask.status)) {
776
+ return await this.getTask(foundTask.id);
777
+ }
778
+ return foundTask;
779
+ }
780
+ // Case 3b: Task name without list context - global lookup across workspace
781
+ // Get lightweight task summaries for efficient first-pass filtering
782
+ const response = await this.getTaskSummaries({});
783
+ if (!this.workspaceService) {
784
+ throw new Error("Workspace service required for global task lookup");
785
+ }
786
+ // Create an index to efficiently look up list context information
787
+ const hierarchy = await this.workspaceService.getWorkspaceHierarchy();
788
+ const listContextMap = new Map();
789
+ // Function to recursively build list context map
790
+ function buildListContextMap(nodes, spaceId, spaceName, folderId, folderName) {
791
+ for (const node of nodes) {
792
+ if (node.type === 'space') {
793
+ // Process space children
794
+ if (node.children) {
795
+ buildListContextMap(node.children, node.id, node.name);
796
+ }
797
+ }
798
+ else if (node.type === 'folder') {
799
+ // Process folder children
800
+ if (node.children) {
801
+ buildListContextMap(node.children, spaceId, spaceName, node.id, node.name);
802
+ }
803
+ }
804
+ else if (node.type === 'list') {
805
+ // Add list context to map
806
+ listContextMap.set(node.id, {
807
+ listId: node.id,
808
+ listName: node.name,
809
+ spaceId: spaceId,
810
+ spaceName: spaceName,
811
+ folderId,
812
+ folderName
813
+ });
814
+ }
815
+ }
816
+ }
817
+ // Build the context map
818
+ buildListContextMap(hierarchy.root.children);
819
+ // Find tasks that match the provided name
820
+ const initialMatches = [];
821
+ // Process task summaries to find initial matches
822
+ for (const taskSummary of response.summaries) {
823
+ if (isNameMatch(taskSummary.name, taskName)) {
824
+ // Get list context information
825
+ const listContext = listContextMap.get(taskSummary.list.id);
826
+ if (listContext) {
827
+ // Store task summary and context
828
+ initialMatches.push({
829
+ id: taskSummary.id,
830
+ task: taskSummary,
831
+ listContext
832
+ });
833
+ }
834
+ }
835
+ }
836
+ // Handle the no matches case
837
+ if (initialMatches.length === 0) {
838
+ throw new Error(`Task "${taskName}" not found in any list across your workspace. Please check the task name and try again.`);
839
+ }
840
+ // Handle the single match case - we can return early if we don't need full details
841
+ if (initialMatches.length === 1 && !useSmartDisambiguation && !includeFullDetails) {
842
+ const match = initialMatches[0];
843
+ if (includeListContext) {
844
+ return {
845
+ ...match.task,
846
+ list: {
847
+ id: match.listContext.listId,
848
+ name: match.listContext.listName
849
+ },
850
+ folder: match.listContext.folderId ? {
851
+ id: match.listContext.folderId,
852
+ name: match.listContext.folderName
853
+ } : undefined,
854
+ space: {
855
+ id: match.listContext.spaceId,
856
+ name: match.listContext.spaceName
857
+ }
858
+ };
859
+ }
860
+ return match.task;
861
+ }
862
+ // For multiple matches or when we need details, fetch full task info
863
+ const fullMatches = [];
864
+ try {
865
+ // Process in sequence for better reliability
866
+ for (const match of initialMatches) {
867
+ const fullTask = await this.getTask(match.id);
868
+ if (includeListContext) {
869
+ // Enhance task with context information
870
+ fullTask.list = {
871
+ ...fullTask.list,
872
+ name: match.listContext.listName
873
+ };
874
+ if (match.listContext.folderId) {
875
+ fullTask.folder = {
876
+ id: match.listContext.folderId,
877
+ name: match.listContext.folderName
878
+ };
879
+ }
880
+ fullTask.space = {
881
+ id: match.listContext.spaceId,
882
+ name: match.listContext.spaceName
883
+ };
884
+ }
885
+ fullMatches.push(fullTask);
886
+ }
887
+ // Sort by update time for disambiguation
888
+ if (fullMatches.length > 1) {
889
+ fullMatches.sort((a, b) => {
890
+ const aDate = parseInt(a.date_updated || '0', 10);
891
+ const bDate = parseInt(b.date_updated || '0', 10);
892
+ return bDate - aDate; // Most recent first
893
+ });
894
+ }
895
+ }
896
+ catch (error) {
897
+ this.logOperation('findTasks', {
898
+ error: error.message,
899
+ message: "Failed to get detailed task information"
900
+ });
901
+ // If detailed fetch fails, use the summaries with context info
902
+ // This fallback ensures we still return something useful
903
+ if (allowMultipleMatches) {
904
+ return initialMatches.map(match => ({
905
+ ...match.task,
906
+ list: {
907
+ id: match.listContext.listId,
908
+ name: match.listContext.listName
909
+ },
910
+ folder: match.listContext.folderId ? {
911
+ id: match.listContext.folderId,
912
+ name: match.listContext.folderName
913
+ } : undefined,
914
+ space: {
915
+ id: match.listContext.spaceId,
916
+ name: match.listContext.spaceName
917
+ }
918
+ }));
919
+ }
920
+ else {
921
+ // For single result, return the first match
922
+ const match = initialMatches[0];
923
+ return {
924
+ ...match.task,
925
+ list: {
926
+ id: match.listContext.listId,
927
+ name: match.listContext.listName
928
+ },
929
+ folder: match.listContext.folderId ? {
930
+ id: match.listContext.folderId,
931
+ name: match.listContext.folderName
932
+ } : undefined,
933
+ space: {
934
+ id: match.listContext.spaceId,
935
+ name: match.listContext.spaceName
936
+ }
937
+ };
938
+ }
939
+ }
940
+ // Return results based on options
941
+ if (fullMatches.length === 1 || useSmartDisambiguation) {
942
+ return fullMatches[0]; // Return most recently updated if multiple and smart disambiguation enabled
943
+ }
944
+ else if (allowMultipleMatches) {
945
+ return fullMatches; // Return all matches
946
+ }
947
+ else {
948
+ // Format error message for multiple matches
949
+ const matchesInfo = fullMatches.map(task => {
950
+ const listName = task.list?.name || "Unknown list";
951
+ const folderName = task.folder?.name;
952
+ const spaceName = task.space?.name || "Unknown space";
953
+ const updateTime = task.date_updated
954
+ ? new Date(parseInt(task.date_updated, 10)).toLocaleString()
955
+ : "Unknown date";
956
+ const location = `list "${listName}"${folderName ? ` (folder: "${folderName}")` : ''} (space: "${spaceName}")`;
957
+ return `- "${task.name}" in ${location} - Updated ${updateTime}`;
958
+ }).join('\n');
959
+ throw new Error(`Multiple tasks found with name "${taskName}":\n${matchesInfo}\n\nPlease provide list context to disambiguate or set allowMultipleMatches to true.`);
960
+ }
961
+ }
962
+ // No valid lookup parameters provided
963
+ throw new Error("At least one of taskId, customTaskId, or taskName must be provided");
964
+ }
965
+ catch (error) {
966
+ if (error.message?.includes('Task "') && error.message?.includes('not found')) {
967
+ throw error;
968
+ }
969
+ if (error.message?.includes('Multiple tasks found')) {
970
+ throw error;
971
+ }
972
+ // Unexpected errors
973
+ throw this.handleError(error, `Error finding task: ${error.message}`);
974
+ }
975
+ }
976
+ }