@task-mcp/shared 1.0.29 → 1.0.30
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/dist/schemas/inbox.d.ts +2 -2
- package/dist/schemas/response-format.d.ts +11 -0
- package/dist/schemas/response-format.d.ts.map +1 -1
- package/dist/schemas/response-format.js.map +1 -1
- package/dist/schemas/session.d.ts +12 -12
- package/dist/schemas/state.d.ts +2 -2
- package/dist/schemas/task.d.ts +9 -0
- package/dist/schemas/task.d.ts.map +1 -1
- package/dist/schemas/task.js +3 -0
- package/dist/schemas/task.js.map +1 -1
- package/dist/utils/clustering.d.ts +60 -0
- package/dist/utils/clustering.d.ts.map +1 -0
- package/dist/utils/clustering.js +283 -0
- package/dist/utils/clustering.js.map +1 -0
- package/dist/utils/clustering.test.d.ts +2 -0
- package/dist/utils/clustering.test.d.ts.map +1 -0
- package/dist/utils/clustering.test.js +237 -0
- package/dist/utils/clustering.test.js.map +1 -0
- package/dist/utils/env.d.ts +24 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +40 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/hierarchy.d.ts.map +1 -1
- package/dist/utils/hierarchy.js +7 -3
- package/dist/utils/hierarchy.js.map +1 -1
- package/dist/utils/index.d.ts +4 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +7 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/intent-extractor.d.ts +30 -0
- package/dist/utils/intent-extractor.d.ts.map +1 -0
- package/dist/utils/intent-extractor.js +135 -0
- package/dist/utils/intent-extractor.js.map +1 -0
- package/dist/utils/intent-extractor.test.d.ts +2 -0
- package/dist/utils/intent-extractor.test.d.ts.map +1 -0
- package/dist/utils/intent-extractor.test.js +69 -0
- package/dist/utils/intent-extractor.test.js.map +1 -0
- package/dist/utils/natural-language.d.ts.map +1 -1
- package/dist/utils/natural-language.js +9 -8
- package/dist/utils/natural-language.js.map +1 -1
- package/dist/utils/natural-language.test.js +22 -0
- package/dist/utils/natural-language.test.js.map +1 -1
- package/dist/utils/plan-parser.d.ts.map +1 -1
- package/dist/utils/plan-parser.js +2 -8
- package/dist/utils/plan-parser.js.map +1 -1
- package/dist/utils/projection.d.ts.map +1 -1
- package/dist/utils/projection.js +43 -1
- package/dist/utils/projection.js.map +1 -1
- package/dist/utils/projection.test.js +57 -7
- package/dist/utils/projection.test.js.map +1 -1
- package/dist/utils/terminal-ui.test.js +13 -22
- package/dist/utils/terminal-ui.test.js.map +1 -1
- package/package.json +1 -1
- package/src/schemas/response-format.ts +15 -2
- package/src/schemas/task.ts +3 -0
- package/src/utils/clustering.test.ts +285 -0
- package/src/utils/clustering.ts +336 -0
- package/src/utils/env.ts +41 -0
- package/src/utils/hierarchy.ts +9 -5
- package/src/utils/index.ts +17 -0
- package/src/utils/intent-extractor.test.ts +84 -0
- package/src/utils/intent-extractor.ts +156 -0
- package/src/utils/natural-language.test.ts +27 -0
- package/src/utils/natural-language.ts +10 -9
- package/src/utils/plan-parser.ts +4 -16
- package/src/utils/projection.test.ts +61 -7
- package/src/utils/projection.ts +44 -1
- package/src/utils/terminal-ui.test.ts +13 -22
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic clustering utilities for task similarity
|
|
3
|
+
*
|
|
4
|
+
* Uses lightweight text similarity without external APIs.
|
|
5
|
+
* Designed for session resumption to suggest related tasks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Task } from "../schemas/task.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Stop words to ignore in similarity calculation
|
|
12
|
+
* Common words that don't contribute to semantic meaning
|
|
13
|
+
*/
|
|
14
|
+
const STOP_WORDS = new Set([
|
|
15
|
+
// English
|
|
16
|
+
"a",
|
|
17
|
+
"am",
|
|
18
|
+
"an",
|
|
19
|
+
"the",
|
|
20
|
+
"is",
|
|
21
|
+
"are",
|
|
22
|
+
"was",
|
|
23
|
+
"were",
|
|
24
|
+
"be",
|
|
25
|
+
"been",
|
|
26
|
+
"being",
|
|
27
|
+
"have",
|
|
28
|
+
"has",
|
|
29
|
+
"had",
|
|
30
|
+
"do",
|
|
31
|
+
"does",
|
|
32
|
+
"did",
|
|
33
|
+
"will",
|
|
34
|
+
"would",
|
|
35
|
+
"could",
|
|
36
|
+
"should",
|
|
37
|
+
"may",
|
|
38
|
+
"might",
|
|
39
|
+
"must",
|
|
40
|
+
"can",
|
|
41
|
+
"to",
|
|
42
|
+
"of",
|
|
43
|
+
"in",
|
|
44
|
+
"for",
|
|
45
|
+
"on",
|
|
46
|
+
"with",
|
|
47
|
+
"at",
|
|
48
|
+
"by",
|
|
49
|
+
"from",
|
|
50
|
+
"as",
|
|
51
|
+
"into",
|
|
52
|
+
"through",
|
|
53
|
+
"during",
|
|
54
|
+
"before",
|
|
55
|
+
"after",
|
|
56
|
+
"above",
|
|
57
|
+
"below",
|
|
58
|
+
"and",
|
|
59
|
+
"or",
|
|
60
|
+
"but",
|
|
61
|
+
"if",
|
|
62
|
+
"then",
|
|
63
|
+
"else",
|
|
64
|
+
"when",
|
|
65
|
+
"where",
|
|
66
|
+
"why",
|
|
67
|
+
"how",
|
|
68
|
+
"all",
|
|
69
|
+
"each",
|
|
70
|
+
"every",
|
|
71
|
+
"both",
|
|
72
|
+
"few",
|
|
73
|
+
"more",
|
|
74
|
+
"most",
|
|
75
|
+
"other",
|
|
76
|
+
"some",
|
|
77
|
+
"such",
|
|
78
|
+
"no",
|
|
79
|
+
"nor",
|
|
80
|
+
"not",
|
|
81
|
+
"only",
|
|
82
|
+
"own",
|
|
83
|
+
"same",
|
|
84
|
+
"so",
|
|
85
|
+
"than",
|
|
86
|
+
"too",
|
|
87
|
+
"very",
|
|
88
|
+
"just",
|
|
89
|
+
"also",
|
|
90
|
+
"this",
|
|
91
|
+
"that",
|
|
92
|
+
"these",
|
|
93
|
+
"those",
|
|
94
|
+
"it",
|
|
95
|
+
"its",
|
|
96
|
+
// Korean particles (common suffixes)
|
|
97
|
+
"을",
|
|
98
|
+
"를",
|
|
99
|
+
"이",
|
|
100
|
+
"가",
|
|
101
|
+
"은",
|
|
102
|
+
"는",
|
|
103
|
+
"에",
|
|
104
|
+
"에서",
|
|
105
|
+
"으로",
|
|
106
|
+
"로",
|
|
107
|
+
"와",
|
|
108
|
+
"과",
|
|
109
|
+
"의",
|
|
110
|
+
"도",
|
|
111
|
+
"만",
|
|
112
|
+
"까지",
|
|
113
|
+
"부터",
|
|
114
|
+
"하다",
|
|
115
|
+
"하기",
|
|
116
|
+
"해서",
|
|
117
|
+
"하고",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tokenize text into meaningful words
|
|
122
|
+
* - Converts to lowercase
|
|
123
|
+
* - Splits on whitespace and punctuation
|
|
124
|
+
* - Removes stop words
|
|
125
|
+
* - Removes short tokens (< 2 chars)
|
|
126
|
+
*/
|
|
127
|
+
export function tokenize(text: string): string[] {
|
|
128
|
+
if (!text) return [];
|
|
129
|
+
|
|
130
|
+
return text
|
|
131
|
+
.toLowerCase()
|
|
132
|
+
.split(/[\s\-_.,;:!?()\[\]{}'"\/\\]+/)
|
|
133
|
+
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Calculate Jaccard similarity between two token sets
|
|
138
|
+
* Returns value between 0 (no overlap) and 1 (identical)
|
|
139
|
+
*/
|
|
140
|
+
export function jaccardSimilarity(tokensA: string[], tokensB: string[]): number {
|
|
141
|
+
if (tokensA.length === 0 && tokensB.length === 0) return 0;
|
|
142
|
+
if (tokensA.length === 0 || tokensB.length === 0) return 0;
|
|
143
|
+
|
|
144
|
+
const setA = new Set(tokensA);
|
|
145
|
+
const setB = new Set(tokensB);
|
|
146
|
+
|
|
147
|
+
let intersection = 0;
|
|
148
|
+
for (const token of setA) {
|
|
149
|
+
if (setB.has(token)) {
|
|
150
|
+
intersection++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const union = setA.size + setB.size - intersection;
|
|
155
|
+
return union === 0 ? 0 : intersection / union;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Calculate weighted similarity between two tasks
|
|
160
|
+
*
|
|
161
|
+
* Weights:
|
|
162
|
+
* - Intent: 0.4 (highest priority - captures purpose)
|
|
163
|
+
* - Title: 0.3 (strong signal)
|
|
164
|
+
* - Description: 0.2 (additional context)
|
|
165
|
+
* - Tags: 0.1 (explicit categorization)
|
|
166
|
+
*/
|
|
167
|
+
export function calculateTaskSimilarity(task1: Task, task2: Task): number {
|
|
168
|
+
// Extract text fields
|
|
169
|
+
const intent1 = task1.workContext?.intent || "";
|
|
170
|
+
const intent2 = task2.workContext?.intent || "";
|
|
171
|
+
|
|
172
|
+
const title1 = task1.title || "";
|
|
173
|
+
const title2 = task2.title || "";
|
|
174
|
+
|
|
175
|
+
const desc1 = task1.description || "";
|
|
176
|
+
const desc2 = task2.description || "";
|
|
177
|
+
|
|
178
|
+
const tags1 = task1.tags?.join(" ") || "";
|
|
179
|
+
const tags2 = task2.tags?.join(" ") || "";
|
|
180
|
+
|
|
181
|
+
// Tokenize
|
|
182
|
+
const intentTokens1 = tokenize(intent1);
|
|
183
|
+
const intentTokens2 = tokenize(intent2);
|
|
184
|
+
|
|
185
|
+
const titleTokens1 = tokenize(title1);
|
|
186
|
+
const titleTokens2 = tokenize(title2);
|
|
187
|
+
|
|
188
|
+
const descTokens1 = tokenize(desc1);
|
|
189
|
+
const descTokens2 = tokenize(desc2);
|
|
190
|
+
|
|
191
|
+
const tagTokens1 = tokenize(tags1);
|
|
192
|
+
const tagTokens2 = tokenize(tags2);
|
|
193
|
+
|
|
194
|
+
// Calculate component similarities
|
|
195
|
+
const intentSim = jaccardSimilarity(intentTokens1, intentTokens2);
|
|
196
|
+
const titleSim = jaccardSimilarity(titleTokens1, titleTokens2);
|
|
197
|
+
const descSim = jaccardSimilarity(descTokens1, descTokens2);
|
|
198
|
+
const tagSim = jaccardSimilarity(tagTokens1, tagTokens2);
|
|
199
|
+
|
|
200
|
+
// Weighted combination
|
|
201
|
+
// Adjust weights based on available content
|
|
202
|
+
let totalWeight = 0;
|
|
203
|
+
let weightedSum = 0;
|
|
204
|
+
|
|
205
|
+
if (intentTokens1.length > 0 || intentTokens2.length > 0) {
|
|
206
|
+
weightedSum += intentSim * 0.4;
|
|
207
|
+
totalWeight += 0.4;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (titleTokens1.length > 0 || titleTokens2.length > 0) {
|
|
211
|
+
weightedSum += titleSim * 0.3;
|
|
212
|
+
totalWeight += 0.3;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (descTokens1.length > 0 || descTokens2.length > 0) {
|
|
216
|
+
weightedSum += descSim * 0.2;
|
|
217
|
+
totalWeight += 0.2;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (tagTokens1.length > 0 || tagTokens2.length > 0) {
|
|
221
|
+
weightedSum += tagSim * 0.1;
|
|
222
|
+
totalWeight += 0.1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return totalWeight === 0 ? 0 : weightedSum / totalWeight;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Find tasks related to a given task
|
|
230
|
+
*
|
|
231
|
+
* @param task - The reference task
|
|
232
|
+
* @param allTasks - All tasks to search from
|
|
233
|
+
* @param options - Configuration options
|
|
234
|
+
* @returns Array of related tasks sorted by similarity (highest first)
|
|
235
|
+
*/
|
|
236
|
+
export function findRelatedTasks(
|
|
237
|
+
task: Task,
|
|
238
|
+
allTasks: Task[],
|
|
239
|
+
options: {
|
|
240
|
+
limit?: number;
|
|
241
|
+
minSimilarity?: number;
|
|
242
|
+
excludeCompleted?: boolean;
|
|
243
|
+
excludeSameParent?: boolean;
|
|
244
|
+
} = {}
|
|
245
|
+
): Array<{ task: Task; similarity: number }> {
|
|
246
|
+
const {
|
|
247
|
+
limit = 5,
|
|
248
|
+
minSimilarity = 0.1,
|
|
249
|
+
excludeCompleted = true,
|
|
250
|
+
excludeSameParent = true,
|
|
251
|
+
} = options;
|
|
252
|
+
|
|
253
|
+
const candidates = allTasks.filter((t) => {
|
|
254
|
+
// Exclude self
|
|
255
|
+
if (t.id === task.id) return false;
|
|
256
|
+
|
|
257
|
+
// Exclude completed if requested
|
|
258
|
+
if (excludeCompleted && t.status === "completed") return false;
|
|
259
|
+
|
|
260
|
+
// Exclude same parent (siblings are already contextually related)
|
|
261
|
+
if (excludeSameParent && task.parentId && t.parentId === task.parentId) return false;
|
|
262
|
+
|
|
263
|
+
// Exclude direct parent/child relationships
|
|
264
|
+
if (t.parentId === task.id || task.parentId === t.id) return false;
|
|
265
|
+
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Calculate similarities
|
|
270
|
+
const withSimilarity = candidates
|
|
271
|
+
.map((t) => ({
|
|
272
|
+
task: t,
|
|
273
|
+
similarity: calculateTaskSimilarity(task, t),
|
|
274
|
+
}))
|
|
275
|
+
.filter((item) => item.similarity >= minSimilarity)
|
|
276
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
277
|
+
.slice(0, limit);
|
|
278
|
+
|
|
279
|
+
return withSimilarity;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Cluster tasks by similarity
|
|
284
|
+
* Returns groups of related tasks
|
|
285
|
+
*/
|
|
286
|
+
export function clusterTasks(
|
|
287
|
+
tasks: Task[],
|
|
288
|
+
options: {
|
|
289
|
+
minClusterSize?: number;
|
|
290
|
+
similarityThreshold?: number;
|
|
291
|
+
} = {}
|
|
292
|
+
): Array<{ representative: Task; members: Task[]; avgSimilarity: number }> {
|
|
293
|
+
const { minClusterSize = 2, similarityThreshold = 0.2 } = options;
|
|
294
|
+
|
|
295
|
+
// Simple greedy clustering
|
|
296
|
+
const assigned = new Set<string>();
|
|
297
|
+
const clusters: Array<{ representative: Task; members: Task[]; avgSimilarity: number }> = [];
|
|
298
|
+
|
|
299
|
+
// Sort by priority to use higher priority tasks as cluster representatives
|
|
300
|
+
const sortedTasks = [...tasks].sort((a, b) => {
|
|
301
|
+
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
302
|
+
return (priorityOrder[a.priority] ?? 2) - (priorityOrder[b.priority] ?? 2);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
for (const task of sortedTasks) {
|
|
306
|
+
if (assigned.has(task.id)) continue;
|
|
307
|
+
|
|
308
|
+
// Find related tasks that haven't been assigned
|
|
309
|
+
const related = findRelatedTasks(task, tasks, {
|
|
310
|
+
minSimilarity: similarityThreshold,
|
|
311
|
+
excludeCompleted: false,
|
|
312
|
+
excludeSameParent: false,
|
|
313
|
+
limit: 10,
|
|
314
|
+
}).filter((r) => !assigned.has(r.task.id));
|
|
315
|
+
|
|
316
|
+
if (related.length >= minClusterSize - 1) {
|
|
317
|
+
// Create cluster
|
|
318
|
+
const members = [task, ...related.map((r) => r.task)];
|
|
319
|
+
const avgSimilarity =
|
|
320
|
+
related.length > 0 ? related.reduce((sum, r) => sum + r.similarity, 0) / related.length : 0;
|
|
321
|
+
|
|
322
|
+
clusters.push({
|
|
323
|
+
representative: task,
|
|
324
|
+
members,
|
|
325
|
+
avgSimilarity,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Mark as assigned
|
|
329
|
+
for (const member of members) {
|
|
330
|
+
assigned.add(member.id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return clusters;
|
|
336
|
+
}
|
package/src/utils/env.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment detection utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if running in test environment.
|
|
7
|
+
* Detects: NODE_ENV=test, BUN_TEST (set by bun test), VITEST, JEST_WORKER_ID
|
|
8
|
+
*/
|
|
9
|
+
export function isTestEnv(): boolean {
|
|
10
|
+
return (
|
|
11
|
+
process.env.NODE_ENV === "test" ||
|
|
12
|
+
process.env["BUN_TEST"] !== undefined ||
|
|
13
|
+
process.env["VITEST"] !== undefined ||
|
|
14
|
+
process.env["JEST_WORKER_ID"] !== undefined
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if debug mode is enabled via environment variable
|
|
20
|
+
*/
|
|
21
|
+
export function isDebugEnabled(): boolean {
|
|
22
|
+
const debugEnv = process.env["TASK_MCP_DEBUG"];
|
|
23
|
+
return debugEnv === "true" || debugEnv === "1";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if verbose logging should be suppressed.
|
|
28
|
+
*
|
|
29
|
+
* Logs are suppressed if:
|
|
30
|
+
* - In test environment AND
|
|
31
|
+
* - TASK_MCP_DEBUG is not set AND
|
|
32
|
+
* - TASK_MCP_TEST_VERBOSE is not set
|
|
33
|
+
*
|
|
34
|
+
* Set TASK_MCP_TEST_VERBOSE=true to enable logs in specific tests.
|
|
35
|
+
*/
|
|
36
|
+
export function shouldSuppressLogs(): boolean {
|
|
37
|
+
if (!isTestEnv()) return false;
|
|
38
|
+
if (isDebugEnabled()) return false;
|
|
39
|
+
if (process.env["TASK_MCP_TEST_VERBOSE"] === "true") return false;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
package/src/utils/hierarchy.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Task } from "../schemas/task.js";
|
|
2
|
+
import { shouldSuppressLogs } from "./env.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Maximum allowed hierarchy depth (0-indexed)
|
|
@@ -34,9 +35,11 @@ export function getTaskLevel(tasks: Task[], taskId: string): number {
|
|
|
34
35
|
while (currentTask.parentId) {
|
|
35
36
|
const parent = taskMap.get(currentTask.parentId);
|
|
36
37
|
if (!parent) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
if (!shouldSuppressLogs()) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[task-mcp] Detached parent reference: task "${currentTask.id}" references non-existent parent "${currentTask.parentId}"`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
40
43
|
break;
|
|
41
44
|
}
|
|
42
45
|
level++;
|
|
@@ -240,8 +243,9 @@ export function buildTaskTree(
|
|
|
240
243
|
return [buildNode(rootTask, 0, new Set())];
|
|
241
244
|
}
|
|
242
245
|
|
|
243
|
-
// Return all root tasks (no parent)
|
|
244
|
-
|
|
246
|
+
// Return all root tasks (no parent OR parent not in filtered tasks)
|
|
247
|
+
// This ensures orphaned subtasks (whose parent was filtered out) are still visible
|
|
248
|
+
const rootTasks = tasks.filter((t) => !t.parentId || !taskMap.has(t.parentId));
|
|
245
249
|
return rootTasks.map((task) => buildNode(task, 0, new Set()));
|
|
246
250
|
}
|
|
247
251
|
|
package/src/utils/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ export {
|
|
|
42
42
|
MAX_HIERARCHY_DEPTH,
|
|
43
43
|
getTaskLevel,
|
|
44
44
|
validateHierarchyDepth,
|
|
45
|
+
validateHierarchy,
|
|
46
|
+
type HierarchyValidationResult,
|
|
45
47
|
getAncestorIds,
|
|
46
48
|
getDescendantIds,
|
|
47
49
|
getChildTasks,
|
|
@@ -171,3 +173,18 @@ export {
|
|
|
171
173
|
type ParsePlanOptions,
|
|
172
174
|
type ParsePlanResult,
|
|
173
175
|
} from "./plan-parser.js";
|
|
176
|
+
|
|
177
|
+
// Intent extractor (auto-generate intent from title/description)
|
|
178
|
+
export { extractIntent, generateFallbackIntent, getIntent } from "./intent-extractor.js";
|
|
179
|
+
|
|
180
|
+
// Semantic clustering (task similarity for session resumption)
|
|
181
|
+
export {
|
|
182
|
+
tokenize,
|
|
183
|
+
jaccardSimilarity,
|
|
184
|
+
calculateTaskSimilarity,
|
|
185
|
+
findRelatedTasks,
|
|
186
|
+
clusterTasks,
|
|
187
|
+
} from "./clustering.js";
|
|
188
|
+
|
|
189
|
+
// Environment detection
|
|
190
|
+
export { isTestEnv, isDebugEnabled, shouldSuppressLogs } from "./env.js";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { extractIntent, generateFallbackIntent, getIntent } from "./intent-extractor.js";
|
|
3
|
+
|
|
4
|
+
describe("extractIntent", () => {
|
|
5
|
+
test("extracts intent from fix patterns", () => {
|
|
6
|
+
expect(extractIntent("Fix login bug")).toBe("Fix issue or bug");
|
|
7
|
+
expect(extractIntent("Debug authentication issue")).toBe("Fix issue or bug");
|
|
8
|
+
expect(extractIntent("로그인 버그 수정")).toBe("Fix issue or bug");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("extracts intent from implement patterns", () => {
|
|
12
|
+
expect(extractIntent("Implement user authentication")).toBe("Implement new functionality");
|
|
13
|
+
expect(extractIntent("Create new dashboard")).toBe("Implement new functionality");
|
|
14
|
+
expect(extractIntent("사용자 인증 구현")).toBe("Implement new functionality");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("extracts intent from refactor patterns", () => {
|
|
18
|
+
expect(extractIntent("Refactor database layer")).toBe("Improve code quality");
|
|
19
|
+
expect(extractIntent("API 리팩토링")).toBe("Improve code quality");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("extracts intent from add patterns", () => {
|
|
23
|
+
expect(extractIntent("Add dark mode support")).toBe("Extend with new feature");
|
|
24
|
+
expect(extractIntent("다크 모드 추가")).toBe("Extend with new feature");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("extracts intent from update patterns", () => {
|
|
28
|
+
expect(extractIntent("Update dependencies")).toBe("Modify existing behavior");
|
|
29
|
+
expect(extractIntent("의존성 업데이트")).toBe("Modify existing behavior");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("extracts intent from remove patterns", () => {
|
|
33
|
+
expect(extractIntent("Remove deprecated code")).toBe("Remove or clean up");
|
|
34
|
+
expect(extractIntent("레거시 코드 삭제")).toBe("Remove or clean up");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("extracts intent from test patterns", () => {
|
|
38
|
+
expect(extractIntent("Test payment flow")).toBe("Verify functionality");
|
|
39
|
+
expect(extractIntent("결제 플로우 테스트")).toBe("Verify functionality");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("extracts intent from description when title has no match", () => {
|
|
43
|
+
expect(extractIntent("API endpoints", "Fix the bug in user endpoint")).toBe("Fix issue or bug");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns undefined when no pattern matches", () => {
|
|
47
|
+
expect(extractIntent("Random task title")).toBeUndefined();
|
|
48
|
+
expect(extractIntent("Something else")).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("generateFallbackIntent", () => {
|
|
53
|
+
test("generates intent from verb-like first word", () => {
|
|
54
|
+
expect(generateFallbackIntent("Get user data")).toBe("Get operation");
|
|
55
|
+
expect(generateFallbackIntent("Run tests")).toBe("Run operation");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("generates fallback for non-verb titles", () => {
|
|
59
|
+
expect(generateFallbackIntent("Dashboard redesign")).toBe("Complete: Dashboard redesign");
|
|
60
|
+
// Test truncation for titles > 50 chars
|
|
61
|
+
const longTitle =
|
|
62
|
+
"This is a very long task title that definitely exceeds fifty characters limit";
|
|
63
|
+
expect(generateFallbackIntent(longTitle)).toContain("...");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("getIntent", () => {
|
|
68
|
+
test("returns extracted intent when pattern matches", () => {
|
|
69
|
+
expect(getIntent("Fix bug")).toBe("Fix issue or bug");
|
|
70
|
+
expect(getIntent("Implement feature")).toBe("Implement new functionality");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns fallback when autoGenerate is true", () => {
|
|
74
|
+
expect(getIntent("Random task")).toBe("Complete: Random task");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns undefined when autoGenerate is false and no match", () => {
|
|
78
|
+
expect(getIntent("Random task", undefined, false)).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("uses description for extraction", () => {
|
|
82
|
+
expect(getIntent("Task", "Fix the authentication bug")).toBe("Fix issue or bug");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent Extractor
|
|
3
|
+
*
|
|
4
|
+
* Extracts intent from task title/description using pattern matching.
|
|
5
|
+
* Provides fallback intent generation when workContext.intent is not provided.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface IntentPattern {
|
|
9
|
+
patterns: RegExp[];
|
|
10
|
+
intent: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const INTENT_PATTERNS: IntentPattern[] = [
|
|
14
|
+
// Fix/Bug patterns
|
|
15
|
+
{
|
|
16
|
+
patterns: [/^fix\b/i, /^debug\b/i, /^resolve\b/i, /버그/, /수정/, /고치/],
|
|
17
|
+
intent: "Fix issue or bug",
|
|
18
|
+
},
|
|
19
|
+
// Implementation patterns
|
|
20
|
+
{
|
|
21
|
+
patterns: [/^implement\b/i, /^create\b/i, /^build\b/i, /구현/, /만들/],
|
|
22
|
+
intent: "Implement new functionality",
|
|
23
|
+
},
|
|
24
|
+
// Refactoring patterns
|
|
25
|
+
{
|
|
26
|
+
patterns: [/^refactor\b/i, /^restructure\b/i, /^reorganize\b/i, /리팩/, /개선/],
|
|
27
|
+
intent: "Improve code quality",
|
|
28
|
+
},
|
|
29
|
+
// Add/Extend patterns
|
|
30
|
+
{
|
|
31
|
+
patterns: [/^add\b/i, /^extend\b/i, /^include\b/i, /추가/, /확장/],
|
|
32
|
+
intent: "Extend with new feature",
|
|
33
|
+
},
|
|
34
|
+
// Update/Modify patterns
|
|
35
|
+
{
|
|
36
|
+
patterns: [/^update\b/i, /^modify\b/i, /^change\b/i, /업데이트/, /변경/],
|
|
37
|
+
intent: "Modify existing behavior",
|
|
38
|
+
},
|
|
39
|
+
// Remove/Delete patterns
|
|
40
|
+
{
|
|
41
|
+
patterns: [/^remove\b/i, /^delete\b/i, /^clean\b/i, /삭제/, /제거/],
|
|
42
|
+
intent: "Remove or clean up",
|
|
43
|
+
},
|
|
44
|
+
// Test patterns
|
|
45
|
+
{
|
|
46
|
+
patterns: [/^test\b/i, /^verify\b/i, /^validate\b/i, /테스트/, /검증/],
|
|
47
|
+
intent: "Verify functionality",
|
|
48
|
+
},
|
|
49
|
+
// Documentation patterns
|
|
50
|
+
{
|
|
51
|
+
patterns: [/^document\b/i, /^write doc/i, /문서/, /docs/i],
|
|
52
|
+
intent: "Document or explain",
|
|
53
|
+
},
|
|
54
|
+
// Research/Analysis patterns
|
|
55
|
+
{
|
|
56
|
+
patterns: [/^research\b/i, /^analyze\b/i, /^investigate\b/i, /조사/, /분석/],
|
|
57
|
+
intent: "Research or analyze",
|
|
58
|
+
},
|
|
59
|
+
// Migration patterns
|
|
60
|
+
{
|
|
61
|
+
patterns: [/^migrate\b/i, /^upgrade\b/i, /^convert\b/i, /마이그레이션/, /전환/],
|
|
62
|
+
intent: "Migrate or upgrade",
|
|
63
|
+
},
|
|
64
|
+
// Setup/Config patterns
|
|
65
|
+
{
|
|
66
|
+
patterns: [/^setup\b/i, /^configure\b/i, /^initialize\b/i, /설정/, /초기화/],
|
|
67
|
+
intent: "Setup or configure",
|
|
68
|
+
},
|
|
69
|
+
// Review patterns
|
|
70
|
+
{
|
|
71
|
+
patterns: [/^review\b/i, /^check\b/i, /^audit\b/i, /리뷰/, /검토/],
|
|
72
|
+
intent: "Review or audit",
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract intent from task title and description
|
|
78
|
+
*
|
|
79
|
+
* @param title - Task title
|
|
80
|
+
* @param description - Optional task description
|
|
81
|
+
* @returns Extracted intent or undefined if no pattern matches
|
|
82
|
+
*/
|
|
83
|
+
export function extractIntent(title: string, description?: string): string | undefined {
|
|
84
|
+
const text = title.toLowerCase();
|
|
85
|
+
|
|
86
|
+
// Try to match patterns against title
|
|
87
|
+
for (const { patterns, intent } of INTENT_PATTERNS) {
|
|
88
|
+
for (const pattern of patterns) {
|
|
89
|
+
if (pattern.test(text)) {
|
|
90
|
+
return intent;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// If no match found in title, try description
|
|
96
|
+
if (description) {
|
|
97
|
+
const descText = description.toLowerCase();
|
|
98
|
+
for (const { patterns, intent } of INTENT_PATTERNS) {
|
|
99
|
+
for (const pattern of patterns) {
|
|
100
|
+
if (pattern.test(descText)) {
|
|
101
|
+
return intent;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate a fallback intent based on title structure
|
|
112
|
+
*
|
|
113
|
+
* Used when no pattern matches but we still want some intent
|
|
114
|
+
*/
|
|
115
|
+
export function generateFallbackIntent(title: string): string {
|
|
116
|
+
// Extract first verb-like word
|
|
117
|
+
const words = title.split(/\s+/);
|
|
118
|
+
const firstWord = words[0]?.toLowerCase() || "";
|
|
119
|
+
|
|
120
|
+
// If it looks like a verb (common task action words)
|
|
121
|
+
const verbPatterns =
|
|
122
|
+
/^(get|set|make|do|run|use|find|show|hide|load|save|send|fetch|call|start|stop|enable|disable)/i;
|
|
123
|
+
if (verbPatterns.test(firstWord)) {
|
|
124
|
+
return `${firstWord.charAt(0).toUpperCase() + firstWord.slice(1)} operation`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Default: use truncated title as intent
|
|
128
|
+
const truncatedTitle = title.length > 50 ? title.slice(0, 47) + "..." : title;
|
|
129
|
+
return `Complete: ${truncatedTitle}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get intent for a task, extracting from content or generating fallback
|
|
134
|
+
*
|
|
135
|
+
* @param title - Task title
|
|
136
|
+
* @param description - Optional task description
|
|
137
|
+
* @param autoGenerate - Whether to generate fallback if no pattern matches (default: true)
|
|
138
|
+
* @returns Intent string
|
|
139
|
+
*/
|
|
140
|
+
export function getIntent(
|
|
141
|
+
title: string,
|
|
142
|
+
description?: string,
|
|
143
|
+
autoGenerate: boolean = true
|
|
144
|
+
): string | undefined {
|
|
145
|
+
const extracted = extractIntent(title, description);
|
|
146
|
+
|
|
147
|
+
if (extracted) {
|
|
148
|
+
return extracted;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (autoGenerate) {
|
|
152
|
+
return generateFallbackIntent(title);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|