@pripanggalih/clickup-mcp 1.6.1
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.
- package/LICENSE +22 -0
- package/README.md +295 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +184 -0
- package/dist/clickup-text.d.ts +83 -0
- package/dist/clickup-text.d.ts.map +1 -0
- package/dist/clickup-text.js +563 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +135 -0
- package/dist/resources/space-resources.d.ts +6 -0
- package/dist/resources/space-resources.d.ts.map +1 -0
- package/dist/resources/space-resources.js +95 -0
- package/dist/shared/config.d.ts +11 -0
- package/dist/shared/config.d.ts.map +1 -0
- package/dist/shared/config.js +61 -0
- package/dist/shared/data-uri.d.ts +14 -0
- package/dist/shared/data-uri.d.ts.map +1 -0
- package/dist/shared/data-uri.js +34 -0
- package/dist/shared/image-processing.d.ts +13 -0
- package/dist/shared/image-processing.d.ts.map +1 -0
- package/dist/shared/image-processing.js +199 -0
- package/dist/shared/types.d.ts +21 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/utils.d.ts +71 -0
- package/dist/shared/utils.d.ts.map +1 -0
- package/dist/shared/utils.js +508 -0
- package/dist/test-utils.d.ts +23 -0
- package/dist/test-utils.d.ts.map +1 -0
- package/dist/test-utils.js +44 -0
- package/dist/tools/admin-tools.d.ts +3 -0
- package/dist/tools/admin-tools.d.ts.map +1 -0
- package/dist/tools/admin-tools.js +288 -0
- package/dist/tools/doc-tools.d.ts +4 -0
- package/dist/tools/doc-tools.d.ts.map +1 -0
- package/dist/tools/doc-tools.js +436 -0
- package/dist/tools/list-tools.d.ts +4 -0
- package/dist/tools/list-tools.d.ts.map +1 -0
- package/dist/tools/list-tools.js +175 -0
- package/dist/tools/search-tools.d.ts +3 -0
- package/dist/tools/search-tools.d.ts.map +1 -0
- package/dist/tools/search-tools.js +161 -0
- package/dist/tools/space-tools.d.ts +3 -0
- package/dist/tools/space-tools.d.ts.map +1 -0
- package/dist/tools/space-tools.js +128 -0
- package/dist/tools/task-tools.d.ts +8 -0
- package/dist/tools/task-tools.d.ts.map +1 -0
- package/dist/tools/task-tools.js +329 -0
- package/dist/tools/task-write-tools.d.ts +3 -0
- package/dist/tools/task-write-tools.d.ts.map +1 -0
- package/dist/tools/task-write-tools.js +567 -0
- package/dist/tools/time-tools.d.ts +4 -0
- package/dist/tools/time-tools.d.ts.map +1 -0
- package/dist/tools/time-tools.js +338 -0
- package/package.json +74 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerSearchTools = registerSearchTools;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const config_1 = require("../shared/config");
|
|
6
|
+
const utils_1 = require("../shared/utils");
|
|
7
|
+
const task_tools_1 = require("./task-tools");
|
|
8
|
+
const MAX_SEARCH_RESULTS = 50;
|
|
9
|
+
function registerSearchTools(server, userData) {
|
|
10
|
+
// Dynamically construct the searchTasks description
|
|
11
|
+
const searchTasksDescriptionBase = [
|
|
12
|
+
"Searches tasks (sometimes called Tickets or Cards) by name, content, assignees, and ID with fuzzy matching and support for multiple search terms (OR logic).",
|
|
13
|
+
"Can filter by multiple list_ids, space_ids, todo status, or tasks assigned to the current user. If no search terms provided, returns most recently updated tasks.",
|
|
14
|
+
"Can also be used to find tasks for the current user by providing the assigned_to_me flag."
|
|
15
|
+
];
|
|
16
|
+
if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
|
|
17
|
+
searchTasksDescriptionBase.push(`For optimal results, as your ClickUp tasks may be primarily in '${config_1.CONFIG.primaryLanguageHint}', consider providing search terms in English and '${config_1.CONFIG.primaryLanguageHint}'.`);
|
|
18
|
+
}
|
|
19
|
+
searchTasksDescriptionBase.push("Always reference tasks by their URLs when discussing search results or suggesting actions.");
|
|
20
|
+
searchTasksDescriptionBase.push("You'll get a rough overview of the tasks that match the search terms, sorted by relevance.");
|
|
21
|
+
searchTasksDescriptionBase.push("Always use getTaskById to get more specific information if a task is relevant, and always share the task URL.");
|
|
22
|
+
server.tool("searchTasks", searchTasksDescriptionBase.join("\n"), {
|
|
23
|
+
terms: zod_1.z
|
|
24
|
+
.array(zod_1.z.string())
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Array of search terms (OR logic). Can include task IDs. Optional - if not provided, returns most recent tasks."),
|
|
27
|
+
list_ids: zod_1.z
|
|
28
|
+
.array(zod_1.z.string())
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Filter tasks to specific list IDs"),
|
|
31
|
+
space_ids: zod_1.z
|
|
32
|
+
.array(zod_1.z.string())
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Filter tasks to specific space IDs"),
|
|
35
|
+
only_todo: zod_1.z
|
|
36
|
+
.boolean()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Filter for open/todo tasks only (exclude done and closed tasks)"),
|
|
39
|
+
status: zod_1.z
|
|
40
|
+
.array(zod_1.z.string())
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Filter for tasks with specific status names (overrides only_todo if provided)"),
|
|
43
|
+
assigned_to_me: zod_1.z
|
|
44
|
+
.boolean()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe(`Filter for tasks assigned to the current user (${userData.user.username} (${userData.user.id}))`),
|
|
47
|
+
}, {
|
|
48
|
+
readOnlyHint: true
|
|
49
|
+
}, async ({ terms, list_ids, space_ids, only_todo, status, assigned_to_me }) => {
|
|
50
|
+
// Get current user ID if filtering by assigned_to_me
|
|
51
|
+
const assignees = assigned_to_me ? [userData.user.id] : [];
|
|
52
|
+
const searchIndex = await (0, utils_1.getTaskSearchIndex)(space_ids, list_ids, assignees);
|
|
53
|
+
if (!searchIndex) {
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: "text",
|
|
58
|
+
text: "No tasks available or index could not be built.",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Early return for no search terms
|
|
64
|
+
if (!terms || terms.length === 0) {
|
|
65
|
+
let allTasks = searchIndex._docs || [];
|
|
66
|
+
// Apply status filtering
|
|
67
|
+
if (status && status.length > 0) {
|
|
68
|
+
const statusLower = status.map(s => s.toLowerCase());
|
|
69
|
+
allTasks = allTasks.filter((task) => statusLower.includes(task.status.status.toLowerCase()));
|
|
70
|
+
}
|
|
71
|
+
else if (only_todo) {
|
|
72
|
+
allTasks = allTasks.filter((task) => task.status.type !== "done" && task.status.type !== "closed");
|
|
73
|
+
}
|
|
74
|
+
// Sort by updated date (most recent first) and limit
|
|
75
|
+
const resultTasks = allTasks
|
|
76
|
+
.sort((a, b) => {
|
|
77
|
+
const dateA = parseInt(a.date_updated || "0");
|
|
78
|
+
const dateB = parseInt(b.date_updated || "0");
|
|
79
|
+
return dateB - dateA;
|
|
80
|
+
})
|
|
81
|
+
.slice(0, MAX_SEARCH_RESULTS);
|
|
82
|
+
if (resultTasks.length === 0) {
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: "No tasks found.",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
content: await Promise.all(resultTasks.map((task) => (0, task_tools_1.generateTaskMetadata)(task))),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Create a results map to track unique tasks with scores
|
|
97
|
+
const uniqueResults = new Map();
|
|
98
|
+
// Perform multi-term search with aggressive boosting
|
|
99
|
+
const searchResults = await (0, utils_1.performMultiTermSearch)(searchIndex, terms);
|
|
100
|
+
searchResults.forEach(task => {
|
|
101
|
+
uniqueResults.set(task.id, { item: task, score: 0.1 }); // Give search results a good score
|
|
102
|
+
});
|
|
103
|
+
// Task ID Fallback Logic
|
|
104
|
+
const potentialTaskIds = terms.filter(utils_1.isTaskId);
|
|
105
|
+
const foundTaskIdsByFuse = new Set(Array.from(uniqueResults.keys()).map(id => id.toLowerCase()));
|
|
106
|
+
const taskIdsToFetchDirectly = potentialTaskIds.filter(id => {
|
|
107
|
+
const lowerId = id.toLowerCase();
|
|
108
|
+
return !foundTaskIdsByFuse.has(lowerId);
|
|
109
|
+
});
|
|
110
|
+
if (taskIdsToFetchDirectly.length > 0) {
|
|
111
|
+
console.error(`Attempting direct fetch for task IDs: ${taskIdsToFetchDirectly.join(', ')}`);
|
|
112
|
+
const directFetchPromises = taskIdsToFetchDirectly.map(async (id) => {
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(`https://api.clickup.com/api/v2/task/${id}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
115
|
+
if (response.ok) {
|
|
116
|
+
const task = await response.json();
|
|
117
|
+
if (task && typeof task.id === 'string') {
|
|
118
|
+
const existing = uniqueResults.get(task.id);
|
|
119
|
+
if (!existing || 0 < existing.score) {
|
|
120
|
+
uniqueResults.set(task.id, { item: task, score: 0 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return task;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
console.error(`Error directly fetching task ${id}:`, error);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
await Promise.all(directFetchPromises);
|
|
133
|
+
}
|
|
134
|
+
let resultTasks = Array.from(uniqueResults.values())
|
|
135
|
+
.sort((a, b) => a.score - b.score)
|
|
136
|
+
.map(entry => entry.item);
|
|
137
|
+
// Apply status filtering
|
|
138
|
+
if (status && status.length > 0) {
|
|
139
|
+
const statusLower = status.map(s => s.toLowerCase());
|
|
140
|
+
resultTasks = resultTasks.filter((task) => statusLower.includes(task.status.status.toLowerCase()));
|
|
141
|
+
}
|
|
142
|
+
else if (only_todo) {
|
|
143
|
+
resultTasks = resultTasks.filter((task) => task.status.type !== "done" && task.status.type !== "closed");
|
|
144
|
+
}
|
|
145
|
+
// Apply result limit
|
|
146
|
+
resultTasks = resultTasks.slice(0, MAX_SEARCH_RESULTS);
|
|
147
|
+
if (resultTasks.length === 0) {
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: "No tasks found matching the search criteria.",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
content: await Promise.all(resultTasks.map((task) => (0, task_tools_1.generateTaskMetadata)(task))),
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"space-tools.d.ts","sourceRoot":"","sources":["../../src/tools/space-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAKpE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,QA6InD"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerSpaceTools = registerSpaceTools;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const utils_1 = require("../shared/utils");
|
|
6
|
+
function registerSpaceTools(server) {
|
|
7
|
+
server.tool("searchSpaces", [
|
|
8
|
+
"Searches spaces (sometimes called projects) by name or ID with fuzzy matching.",
|
|
9
|
+
"If 5 or fewer spaces match, automatically fetches all lists (sometimes called boards) and folders within those spaces to provide a complete tree structure.",
|
|
10
|
+
"If more than 5 spaces match, returns only space information with guidance to search more precisely.",
|
|
11
|
+
"You can search by space name (fuzzy matching) or provide an exact space ID.",
|
|
12
|
+
"Always reference spaces by their URLs when discussing projects or suggesting actions."
|
|
13
|
+
].join("\n"), {
|
|
14
|
+
terms: zod_1.z
|
|
15
|
+
.array(zod_1.z.string())
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Array of search terms to match against space names or IDs. If not provided, returns all spaces."),
|
|
18
|
+
archived: zod_1.z.boolean().optional().describe("Include archived spaces (default: false)")
|
|
19
|
+
}, {
|
|
20
|
+
readOnlyHint: true
|
|
21
|
+
}, async ({ terms, archived = false }) => {
|
|
22
|
+
try {
|
|
23
|
+
const searchIndex = await (0, utils_1.getSpaceSearchIndex)();
|
|
24
|
+
if (!searchIndex) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: "Error: Could not build space search index." }],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
let matchingSpaces = [];
|
|
30
|
+
if (!terms || terms.length === 0) {
|
|
31
|
+
// Return all spaces if no search terms
|
|
32
|
+
matchingSpaces = searchIndex._docs || [];
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// Perform multi-term search with aggressive boosting
|
|
36
|
+
matchingSpaces = await (0, utils_1.performMultiTermSearch)(searchIndex, terms
|
|
37
|
+
// No ID matcher or direct fetcher for spaces - they don't have direct API endpoints
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
// Filter by archived status
|
|
41
|
+
if (!archived) {
|
|
42
|
+
matchingSpaces = matchingSpaces.filter((space) => !space.archived);
|
|
43
|
+
}
|
|
44
|
+
if (matchingSpaces.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: "No spaces found matching the search criteria." }],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Conditionally fetch detailed content based on result count
|
|
50
|
+
const spaceContentPromises = matchingSpaces.map(async (space) => {
|
|
51
|
+
try {
|
|
52
|
+
if (matchingSpaces.length <= 5) {
|
|
53
|
+
// Detailed mode: fetch lists and folders for this space
|
|
54
|
+
const { lists, folders, documents } = await (0, utils_1.getSpaceContent)(space.id);
|
|
55
|
+
return { space, lists, folders, documents };
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// Summary mode: just return space without content
|
|
59
|
+
return { space, lists: [], folders: [], documents: [] };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
console.error(`Error fetching content for space ${space.id}:`, error);
|
|
64
|
+
return { space, lists: [], folders: [], documents: [] };
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const spacesWithContent = await Promise.all(spaceContentPromises);
|
|
68
|
+
const contentBlocks = [];
|
|
69
|
+
const isDetailedMode = matchingSpaces.length <= 5;
|
|
70
|
+
if (isDetailedMode) {
|
|
71
|
+
// Detailed mode: create separate blocks for each space
|
|
72
|
+
spacesWithContent.forEach(({ space, lists, folders, documents }) => {
|
|
73
|
+
// Use shared tree formatting function
|
|
74
|
+
const spaceTreeText = (0, utils_1.formatSpaceTree)(space, lists, folders, documents);
|
|
75
|
+
// Add the complete space as a single content block
|
|
76
|
+
contentBlocks.push({
|
|
77
|
+
type: "text",
|
|
78
|
+
text: spaceTreeText
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Summary mode: create a single combined block with all spaces
|
|
84
|
+
const allSpaceLines = [];
|
|
85
|
+
spacesWithContent.forEach(({ space }) => {
|
|
86
|
+
allSpaceLines.push(`🏢 SPACE: ${space.name} (space_id: ${space.id}${space.private ? ', private' : ''}${space.archived ? ', archived' : ''})`);
|
|
87
|
+
});
|
|
88
|
+
contentBlocks.push({
|
|
89
|
+
type: "text",
|
|
90
|
+
text: allSpaceLines.join('\n')
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Add tip message for summary mode (when there are too many spaces)
|
|
94
|
+
if (matchingSpaces.length > 5) {
|
|
95
|
+
contentBlocks.push({
|
|
96
|
+
type: "text",
|
|
97
|
+
text: `\n💡 Tip: Use more specific search terms to get detailed list information (≤5 spaces will show complete structure)`
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "text",
|
|
104
|
+
text: matchingSpaces.length <= 5
|
|
105
|
+
? (() => {
|
|
106
|
+
const totalLists = spacesWithContent.reduce((sum, { lists, folders }) => sum + lists.length + folders.reduce((folderSum, f) => folderSum + (f.lists?.length || 0), 0), 0);
|
|
107
|
+
const totalDocuments = spacesWithContent.reduce((sum, { documents }) => sum + documents.length, 0);
|
|
108
|
+
return `Found ${matchingSpaces.length} space(s) with complete tree structure (${totalLists} total lists, ${totalDocuments} total documents):`;
|
|
109
|
+
})()
|
|
110
|
+
: `Found ${matchingSpaces.length} space(s) - showing names and IDs only. Use more specific search terms to get detailed information:`
|
|
111
|
+
},
|
|
112
|
+
...contentBlocks
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
console.error('Error searching spaces:', error);
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: "text",
|
|
122
|
+
text: `Error searching spaces: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ContentBlock } from "../shared/types";
|
|
3
|
+
export declare function registerTaskToolsRead(server: McpServer, userData: any): void;
|
|
4
|
+
/**
|
|
5
|
+
* Helper function to generate consistent task metadata
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateTaskMetadata(task: any, timeEntries?: any[], isDetailView?: boolean): Promise<ContentBlock>;
|
|
8
|
+
//# sourceMappingURL=task-tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"task-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,YAAY,EAAyC,MAAM,iBAAiB,CAAC;AAOtF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QA2DrE;AA+MD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAmH/H"}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerTaskToolsRead = registerTaskToolsRead;
|
|
4
|
+
exports.generateTaskMetadata = generateTaskMetadata;
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const clickup_text_1 = require("../clickup-text");
|
|
7
|
+
const config_1 = require("../shared/config");
|
|
8
|
+
const utils_1 = require("../shared/utils");
|
|
9
|
+
const image_processing_1 = require("../shared/image-processing");
|
|
10
|
+
// Read-specific utility functions
|
|
11
|
+
function registerTaskToolsRead(server, userData) {
|
|
12
|
+
server.tool("getTaskById", [
|
|
13
|
+
"Get a ClickUp task with images and comments by ID.",
|
|
14
|
+
"Always use this URL when referencing tasks in conversations or sharing with others.",
|
|
15
|
+
"The response provides complete context including task details, comments, and status history."
|
|
16
|
+
].join("\n"), {
|
|
17
|
+
id: zod_1.z
|
|
18
|
+
.string()
|
|
19
|
+
.min(6)
|
|
20
|
+
.max(9)
|
|
21
|
+
.refine(val => (0, utils_1.isTaskId)(val), {
|
|
22
|
+
message: "Task ID must be 6-9 alphanumeric characters only"
|
|
23
|
+
})
|
|
24
|
+
.describe(`The 6-9 character ID of the task to get without a prefix like "#", "CU-" or "https://app.clickup.com/t/"`),
|
|
25
|
+
}, {
|
|
26
|
+
readOnlyHint: true
|
|
27
|
+
}, async ({ id }) => {
|
|
28
|
+
// 1. Load base task content, comment events, and status change events in parallel
|
|
29
|
+
const [taskDetailContentBlocks, commentEvents, statusChangeEvents] = await Promise.all([
|
|
30
|
+
loadTaskContent(id), // Returns Promise<ContentBlock[]>
|
|
31
|
+
loadTaskComments(id), // Returns Promise<DatedContentEvent[]>
|
|
32
|
+
loadTimeInStatusHistory(id), // Returns Promise<DatedContentEvent[]>
|
|
33
|
+
]);
|
|
34
|
+
// 2. Combine comment and status change events
|
|
35
|
+
const allDatedEvents = [...commentEvents, ...statusChangeEvents];
|
|
36
|
+
// 3. Sort all dated events chronologically
|
|
37
|
+
allDatedEvents.sort((a, b) => {
|
|
38
|
+
const dateA = a.date ? parseInt(a.date) : 0;
|
|
39
|
+
const dateB = b.date ? parseInt(b.date) : 0;
|
|
40
|
+
return dateA - dateB;
|
|
41
|
+
});
|
|
42
|
+
// 4. Flatten sorted events into a single ContentBlock stream
|
|
43
|
+
let processedEventBlocks = [];
|
|
44
|
+
for (const event of allDatedEvents) {
|
|
45
|
+
processedEventBlocks.push(...event.contentBlocks);
|
|
46
|
+
}
|
|
47
|
+
// 5. Combine task details with processed event blocks
|
|
48
|
+
const allContentBlocks = [...taskDetailContentBlocks, ...processedEventBlocks];
|
|
49
|
+
// 6. Download images with smart size limiting
|
|
50
|
+
const limitedContent = await (0, image_processing_1.downloadImages)(allContentBlocks);
|
|
51
|
+
return {
|
|
52
|
+
content: limitedContent,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Fetch time entries for a specific task (all time, not date-limited for detail view)
|
|
58
|
+
*/
|
|
59
|
+
async function fetchTaskTimeEntries(taskId) {
|
|
60
|
+
try {
|
|
61
|
+
// Get all team members for assignee filter
|
|
62
|
+
const teamMembers = await (0, utils_1.getAllTeamMembers)();
|
|
63
|
+
const params = new URLSearchParams({
|
|
64
|
+
task_id: taskId,
|
|
65
|
+
include_location_names: 'true',
|
|
66
|
+
start_date: '0', // overwrite the default 30 days
|
|
67
|
+
});
|
|
68
|
+
if (teamMembers.length > 0) {
|
|
69
|
+
params.append('assignee', teamMembers.join(','));
|
|
70
|
+
}
|
|
71
|
+
const response = await fetch(`https://api.clickup.com/api/v2/team/${config_1.CONFIG.teamId}/time_entries?${params}`, {
|
|
72
|
+
headers: { Authorization: config_1.CONFIG.apiKey },
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
console.error(`Error fetching time entries for task ${taskId}: ${response.status} ${response.statusText}`);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const data = await response.json();
|
|
79
|
+
return data.data || [];
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.error('Error fetching task time entries:', error);
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function loadTaskContent(taskId) {
|
|
87
|
+
const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}?include_markdown_description=true&include_subtasks=true`, { headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
88
|
+
const task = await response.json();
|
|
89
|
+
const [taskMetadata, content] = await Promise.all([
|
|
90
|
+
// Create the task metadata block using the helper functions
|
|
91
|
+
(async () => {
|
|
92
|
+
const timeEntries = await fetchTaskTimeEntries(task.id);
|
|
93
|
+
return await generateTaskMetadata(task, timeEntries, true);
|
|
94
|
+
})(),
|
|
95
|
+
// process markdown and download images
|
|
96
|
+
(0, clickup_text_1.convertMarkdownToToolCallResult)(task.markdown_description || "", task.attachments || []),
|
|
97
|
+
]);
|
|
98
|
+
return [taskMetadata, ...content];
|
|
99
|
+
}
|
|
100
|
+
async function loadTaskComments(id) {
|
|
101
|
+
const response = await fetch(`https://api.clickup.com/api/v2/task/${id}/comment?start_date=0`, // Ensure all comments are fetched
|
|
102
|
+
{ headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
console.error(`Error fetching comments for task ${id}: ${response.status} ${response.statusText}`);
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const commentsData = await response.json();
|
|
108
|
+
if (!commentsData.comments || !Array.isArray(commentsData.comments)) {
|
|
109
|
+
console.error(`Unexpected comment data structure for task ${id}`);
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const commentEvents = await Promise.all(commentsData.comments.map(async (comment) => {
|
|
113
|
+
const headerBlock = {
|
|
114
|
+
type: "text",
|
|
115
|
+
text: `Comment by ${comment.user.username} on ${timestampToIso(comment.date)}:`,
|
|
116
|
+
};
|
|
117
|
+
const commentBodyBlocks = await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(comment.comment);
|
|
118
|
+
return {
|
|
119
|
+
date: comment.date, // String timestamp from ClickUp for sorting
|
|
120
|
+
contentBlocks: [headerBlock, ...commentBodyBlocks],
|
|
121
|
+
};
|
|
122
|
+
}));
|
|
123
|
+
return commentEvents;
|
|
124
|
+
}
|
|
125
|
+
async function loadTimeInStatusHistory(taskId) {
|
|
126
|
+
const url = `https://api.clickup.com/api/v2/task/${taskId}/time_in_status`;
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(url, { headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
console.error(`Error fetching time in status for task ${taskId}: ${response.status} ${response.statusText}`);
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
// Using 'any' for less strict typing as per user preference, but keeping structure for clarity
|
|
134
|
+
const data = await response.json();
|
|
135
|
+
const events = [];
|
|
136
|
+
const processStatusEntry = (entry) => {
|
|
137
|
+
if (!entry || !entry.total_time || !entry.total_time.since || !entry.status)
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
date: entry.total_time.since,
|
|
141
|
+
contentBlocks: [{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `Status set to '${entry.status}' on ${timestampToIso(entry.total_time.since)}`,
|
|
144
|
+
}],
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
if (data.status_history && Array.isArray(data.status_history)) {
|
|
148
|
+
data.status_history.forEach((historyEntry) => {
|
|
149
|
+
const event = processStatusEntry(historyEntry);
|
|
150
|
+
if (event)
|
|
151
|
+
events.push(event);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (data.current_status) {
|
|
155
|
+
const event = processStatusEntry(data.current_status);
|
|
156
|
+
// Ensure current_status is only added if it's distinct or more recent than the last history item.
|
|
157
|
+
// The deduplication logic below handles if it's the same as the last history entry.
|
|
158
|
+
if (event)
|
|
159
|
+
events.push(event);
|
|
160
|
+
}
|
|
161
|
+
// Deduplicate events based on date and status name to avoid adding current_status if it's identical to the last history entry
|
|
162
|
+
const uniqueEvents = Array.from(new Map(events.map(event => {
|
|
163
|
+
const firstBlock = event.contentBlocks[0];
|
|
164
|
+
const textKey = firstBlock && 'text' in firstBlock ? firstBlock.text : 'unknown';
|
|
165
|
+
return [`${event.date}-${textKey}`, event];
|
|
166
|
+
})).values());
|
|
167
|
+
return uniqueEvents;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
console.error(`Exception fetching time in status for task ${taskId}:`, error);
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Formats timestamp to ISO string with local timezone (not UTC)
|
|
176
|
+
*/
|
|
177
|
+
function timestampToIso(timestamp) {
|
|
178
|
+
const date = new Date(+timestamp);
|
|
179
|
+
const year = date.getFullYear();
|
|
180
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
181
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
182
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
183
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
184
|
+
// Calculate timezone offset
|
|
185
|
+
const offset = date.getTimezoneOffset();
|
|
186
|
+
const offsetHours = Math.floor(Math.abs(offset) / 60);
|
|
187
|
+
const offsetMinutes = Math.abs(offset) % 60;
|
|
188
|
+
const sign = offset <= 0 ? '+' : '-';
|
|
189
|
+
const timezoneOffset = sign + String(offsetHours).padStart(2, '0') + ':' + String(offsetMinutes).padStart(2, '0');
|
|
190
|
+
return `${year}-${month}-${day}T${hours}:${minutes}${timezoneOffset}`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Helper function to filter and format time entries for a specific task
|
|
194
|
+
*/
|
|
195
|
+
function filterTaskTimeEntries(taskId, timeEntries) {
|
|
196
|
+
if (!timeEntries || timeEntries.length === 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
// Filter entries for this specific task
|
|
200
|
+
const taskEntries = timeEntries.filter((entry) => entry.task?.id === taskId);
|
|
201
|
+
if (taskEntries.length === 0) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
// Group time entries by user (same logic as original getTaskTimeEntries)
|
|
205
|
+
const timeByUser = new Map();
|
|
206
|
+
taskEntries.forEach((entry) => {
|
|
207
|
+
const username = entry.user?.username || 'Unknown User';
|
|
208
|
+
const currentTime = timeByUser.get(username) || 0;
|
|
209
|
+
const entryDurationMs = parseInt(entry.duration) || 0;
|
|
210
|
+
timeByUser.set(username, currentTime + entryDurationMs);
|
|
211
|
+
});
|
|
212
|
+
// Format results (same logic as original)
|
|
213
|
+
const userTimeEntries = [];
|
|
214
|
+
for (const [username, totalMs] of timeByUser.entries()) {
|
|
215
|
+
const hours = totalMs / (1000 * 60 * 60);
|
|
216
|
+
const displayHours = Math.floor(hours);
|
|
217
|
+
const displayMinutes = Math.round((hours - displayHours) * 60);
|
|
218
|
+
const timeDisplay = displayHours > 0 ?
|
|
219
|
+
`${displayHours}h ${displayMinutes}m` :
|
|
220
|
+
`${displayMinutes}m`;
|
|
221
|
+
userTimeEntries.push(`${username}: ${timeDisplay}`);
|
|
222
|
+
}
|
|
223
|
+
return userTimeEntries.length > 0 ? userTimeEntries.join(', ') : null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Helper function to generate consistent task metadata
|
|
227
|
+
*/
|
|
228
|
+
async function generateTaskMetadata(task, timeEntries, isDetailView = false) {
|
|
229
|
+
let spaceName = task.space?.name || 'Unknown Space';
|
|
230
|
+
let spaceIdForDisplay = task.space?.id || 'N/A';
|
|
231
|
+
if (spaceName === 'Unknown Space' && task.space?.id) {
|
|
232
|
+
try {
|
|
233
|
+
const spaceDetails = await (0, utils_1.getSpaceDetails)(task.space.id);
|
|
234
|
+
if (spaceDetails && spaceDetails.name) {
|
|
235
|
+
spaceName = spaceDetails.name;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Space details fetch can fail (e.g. 401) - gracefully keep "Unknown Space"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const metadataLines = [
|
|
243
|
+
`task_id: ${task.id}`,
|
|
244
|
+
`task_url: ${task.url}`,
|
|
245
|
+
`name: ${task.name}`,
|
|
246
|
+
`status: ${task.status.status}`,
|
|
247
|
+
`date_created: ${timestampToIso(task.date_created)}`,
|
|
248
|
+
`date_updated: ${timestampToIso(task.date_updated)}`,
|
|
249
|
+
`creator: ${task.creator.username} (${task.creator.id})`,
|
|
250
|
+
`assignee: ${task.assignees.map((a) => `${a.username} (${a.id})`).join(', ')}`,
|
|
251
|
+
`list: ${task.list.name} (${task.list.id})`,
|
|
252
|
+
`space: ${spaceName} (${spaceIdForDisplay})`,
|
|
253
|
+
];
|
|
254
|
+
// Add priority if it exists
|
|
255
|
+
if (task.priority !== undefined && task.priority !== null) {
|
|
256
|
+
const priorityName = task.priority.priority || 'none';
|
|
257
|
+
metadataLines.push(`priority: ${priorityName}`);
|
|
258
|
+
}
|
|
259
|
+
// Add due date if it exists
|
|
260
|
+
if (task.due_date) {
|
|
261
|
+
metadataLines.push(`due_date: ${timestampToIso(task.due_date)}`);
|
|
262
|
+
}
|
|
263
|
+
// Add start date if it exists
|
|
264
|
+
if (task.start_date) {
|
|
265
|
+
metadataLines.push(`start_date: ${timestampToIso(task.start_date)}`);
|
|
266
|
+
}
|
|
267
|
+
// Add time estimate if it exists
|
|
268
|
+
if (task.time_estimate) {
|
|
269
|
+
const hours = Math.floor(task.time_estimate / 3600000);
|
|
270
|
+
const minutes = Math.floor((task.time_estimate % 3600000) / 60000);
|
|
271
|
+
metadataLines.push(`time_estimate: ${hours}h ${minutes}m`);
|
|
272
|
+
}
|
|
273
|
+
// Add time booked (tracked time entries) - only if timeEntries provided
|
|
274
|
+
if (timeEntries) {
|
|
275
|
+
const timeBooked = filterTaskTimeEntries(task.id, timeEntries);
|
|
276
|
+
if (timeBooked) {
|
|
277
|
+
const disclaimer = isDetailView ? "" : " (last 30 days)";
|
|
278
|
+
metadataLines.push(`time_booked${disclaimer}: ${timeBooked}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Add tags if they exist
|
|
282
|
+
if (task.tags && task.tags.length > 0) {
|
|
283
|
+
metadataLines.push(`tags: ${task.tags.map((t) => t.name).join(', ')}`);
|
|
284
|
+
}
|
|
285
|
+
// Add watchers if they exist
|
|
286
|
+
if (task.watchers && task.watchers.length > 0) {
|
|
287
|
+
metadataLines.push(`watchers: ${task.watchers.map((w) => w.username).join(', ')}`);
|
|
288
|
+
}
|
|
289
|
+
// Add parent task information if it exists
|
|
290
|
+
if (typeof task.parent === "string") {
|
|
291
|
+
metadataLines.push(`parent_task_id: ${task.parent}`);
|
|
292
|
+
}
|
|
293
|
+
// Add child task information if it exists
|
|
294
|
+
if (task.subtasks && task.subtasks.length > 0) {
|
|
295
|
+
metadataLines.push(`child_task_ids: ${task.subtasks.map((st) => st.id).join(', ')}`);
|
|
296
|
+
}
|
|
297
|
+
// Add archived status if true
|
|
298
|
+
if (task.archived) {
|
|
299
|
+
metadataLines.push(`archived: true`);
|
|
300
|
+
}
|
|
301
|
+
// Add custom fields if they exist
|
|
302
|
+
if (task.custom_fields && task.custom_fields.length > 0) {
|
|
303
|
+
task.custom_fields.forEach((field) => {
|
|
304
|
+
if (field.value !== undefined && field.value !== null && field.value !== '') {
|
|
305
|
+
const fieldName = field.name.toLowerCase().replace(/\s+/g, '_');
|
|
306
|
+
let fieldValue = field.value;
|
|
307
|
+
// Handle different custom field types
|
|
308
|
+
if (field.type === 'drop_down' && typeof field.value === 'number') {
|
|
309
|
+
// For dropdown fields, find the selected option
|
|
310
|
+
const selectedOption = field.type_config?.options?.find((opt) => opt.orderindex === field.value);
|
|
311
|
+
fieldValue = selectedOption?.name || field.value;
|
|
312
|
+
}
|
|
313
|
+
else if (Array.isArray(field.value)) {
|
|
314
|
+
// For multi-select or array values
|
|
315
|
+
fieldValue = field.value.map((v) => v.name || v).join(', ');
|
|
316
|
+
}
|
|
317
|
+
else if (typeof field.value === 'object') {
|
|
318
|
+
// For object values (like users), extract meaningful data
|
|
319
|
+
fieldValue = field.value.username || field.value.name || JSON.stringify(field.value);
|
|
320
|
+
}
|
|
321
|
+
metadataLines.push(`custom_${fieldName}: ${fieldValue}`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
type: "text",
|
|
327
|
+
text: metadataLines.join("\n"),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"task-write-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAcpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAsZtE"}
|