@task-mcp/shared 1.0.28 → 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/algorithms/index.d.ts +1 -1
- package/dist/algorithms/index.d.ts.map +1 -1
- package/dist/algorithms/index.js +1 -1
- package/dist/algorithms/index.js.map +1 -1
- package/dist/algorithms/topological-sort.d.ts +21 -1
- package/dist/algorithms/topological-sort.d.ts.map +1 -1
- package/dist/algorithms/topological-sort.js +12 -1
- package/dist/algorithms/topological-sort.js.map +1 -1
- package/dist/schemas/inbox.d.ts +2 -2
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -0
- package/dist/schemas/index.js.map +1 -1
- 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 +521 -0
- package/dist/schemas/session.d.ts.map +1 -0
- package/dist/schemas/session.js +79 -0
- package/dist/schemas/session.js.map +1 -0
- 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 +23 -6
- package/dist/schemas/task.js.map +1 -1
- package/dist/schemas/view.d.ts +18 -18
- 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 +13 -6
- package/dist/utils/hierarchy.js.map +1 -1
- package/dist/utils/index.d.ts +6 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +24 -2
- 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 +57 -0
- package/dist/utils/plan-parser.d.ts.map +1 -0
- package/dist/utils/plan-parser.js +371 -0
- package/dist/utils/plan-parser.js.map +1 -0
- 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.d.ts +129 -0
- package/dist/utils/terminal-ui.d.ts.map +1 -1
- package/dist/utils/terminal-ui.js +191 -0
- package/dist/utils/terminal-ui.js.map +1 -1
- package/dist/utils/terminal-ui.test.js +227 -0
- package/dist/utils/terminal-ui.test.js.map +1 -1
- package/package.json +2 -2
- package/src/algorithms/index.ts +3 -0
- package/src/algorithms/topological-sort.ts +31 -1
- package/src/schemas/index.ts +11 -0
- package/src/schemas/response-format.ts +15 -2
- package/src/schemas/session.ts +100 -0
- package/src/schemas/task.ts +33 -16
- 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 +17 -8
- package/src/utils/index.ts +48 -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 +466 -0
- package/src/utils/projection.test.ts +61 -7
- package/src/utils/projection.ts +44 -1
- package/src/utils/terminal-ui.test.ts +277 -0
- package/src/utils/terminal-ui.ts +315 -0
package/src/schemas/task.ts
CHANGED
|
@@ -28,22 +28,36 @@ export const TimeEstimate = z
|
|
|
28
28
|
pessimistic: z.number().min(0).max(10080).optional(), // minutes
|
|
29
29
|
confidence: z.enum(["low", "medium", "high"]).optional(),
|
|
30
30
|
})
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
.superRefine((data, ctx) => {
|
|
32
|
+
const { optimistic, expected, pessimistic } = data;
|
|
33
|
+
|
|
34
|
+
// Check: optimistic <= expected
|
|
35
|
+
if (optimistic !== undefined && expected !== undefined && optimistic > expected) {
|
|
36
|
+
ctx.addIssue({
|
|
37
|
+
code: z.ZodIssueCode.custom,
|
|
38
|
+
message: `optimistic (${optimistic}min) must be <= expected (${expected}min)`,
|
|
39
|
+
path: ["optimistic"],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check: expected <= pessimistic
|
|
44
|
+
if (expected !== undefined && pessimistic !== undefined && expected > pessimistic) {
|
|
45
|
+
ctx.addIssue({
|
|
46
|
+
code: z.ZodIssueCode.custom,
|
|
47
|
+
message: `expected (${expected}min) must be <= pessimistic (${pessimistic}min)`,
|
|
48
|
+
path: ["expected"],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check: optimistic <= pessimistic (when expected is not provided)
|
|
53
|
+
if (optimistic !== undefined && pessimistic !== undefined && optimistic > pessimistic) {
|
|
54
|
+
ctx.addIssue({
|
|
55
|
+
code: z.ZodIssueCode.custom,
|
|
56
|
+
message: `optimistic (${optimistic}min) must be <= pessimistic (${pessimistic}min)`,
|
|
57
|
+
path: ["optimistic"],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
47
61
|
export type TimeEstimate = z.infer<typeof TimeEstimate>;
|
|
48
62
|
|
|
49
63
|
// Recurrence pattern (discriminated union for type-safe pattern handling)
|
|
@@ -185,6 +199,7 @@ export const Task = z.object({
|
|
|
185
199
|
// Hierarchy (subtask support)
|
|
186
200
|
parentId: z.string().nullable().optional(), // Parent task ID for subtasks (null to unlink)
|
|
187
201
|
level: z.number().optional(), // Hierarchy depth (0=root, 1=subtask, 2=sub-subtask, max 3)
|
|
202
|
+
optional: z.boolean().optional(), // If true, this subtask is not required for parent completion
|
|
188
203
|
|
|
189
204
|
// Dependencies
|
|
190
205
|
dependencies: z.array(Dependency).optional(),
|
|
@@ -231,6 +246,7 @@ export const TaskCreateInput = z.object({
|
|
|
231
246
|
description: z.string().optional(),
|
|
232
247
|
priority: Priority.optional(),
|
|
233
248
|
parentId: z.string().nullable().optional(), // Parent task ID for creating subtasks (null to unlink)
|
|
249
|
+
optional: z.boolean().optional(), // If true, this subtask is not required for parent completion
|
|
234
250
|
dependencies: z.array(Dependency).optional(),
|
|
235
251
|
estimate: TimeEstimate.optional(),
|
|
236
252
|
dueDate: z.string().optional(),
|
|
@@ -252,6 +268,7 @@ export const TaskUpdateInput = z.object({
|
|
|
252
268
|
status: TaskStatus.optional(),
|
|
253
269
|
priority: Priority.optional(),
|
|
254
270
|
parentId: z.string().nullable().optional(), // Parent task ID for moving task in hierarchy (null to unlink)
|
|
271
|
+
optional: z.boolean().optional(), // If true, this subtask is not required for parent completion
|
|
255
272
|
dependencies: z.array(Dependency).optional(),
|
|
256
273
|
estimate: TimeEstimate.optional(),
|
|
257
274
|
actualMinutes: z.number().optional(),
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
tokenize,
|
|
4
|
+
jaccardSimilarity,
|
|
5
|
+
calculateTaskSimilarity,
|
|
6
|
+
findRelatedTasks,
|
|
7
|
+
clusterTasks,
|
|
8
|
+
} from "./clustering.js";
|
|
9
|
+
import type { Task } from "../schemas/task.js";
|
|
10
|
+
|
|
11
|
+
// Helper to create minimal task for testing
|
|
12
|
+
function createTask(overrides: Partial<Task> & { id: string; title: string }): Task {
|
|
13
|
+
return {
|
|
14
|
+
status: "pending",
|
|
15
|
+
priority: "medium",
|
|
16
|
+
workspace: "test-workspace",
|
|
17
|
+
tags: [],
|
|
18
|
+
contexts: [],
|
|
19
|
+
createdAt: new Date().toISOString(),
|
|
20
|
+
updatedAt: new Date().toISOString(),
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("tokenize", () => {
|
|
26
|
+
test("splits on whitespace and punctuation", () => {
|
|
27
|
+
expect(tokenize("Fix the bug")).toEqual(["fix", "bug"]);
|
|
28
|
+
expect(tokenize("user-authentication")).toEqual(["user", "authentication"]);
|
|
29
|
+
expect(tokenize("task.create")).toEqual(["task", "create"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("removes stop words", () => {
|
|
33
|
+
expect(tokenize("the user is authenticated")).toEqual(["user", "authenticated"]);
|
|
34
|
+
expect(tokenize("to be or not to be")).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("removes short tokens (< 2 chars)", () => {
|
|
38
|
+
// "I" and "a" are 1 char, removed. "am" is a stop word
|
|
39
|
+
expect(tokenize("I am a user")).toEqual(["user"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("handles Korean text", () => {
|
|
43
|
+
expect(tokenize("버그 수정하기")).toEqual(["버그", "수정하기"]);
|
|
44
|
+
// Korean particles are removed
|
|
45
|
+
expect(tokenize("사용자를")).toEqual(["사용자를"]); // 를 is suffix, not separate word
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("handles empty input", () => {
|
|
49
|
+
expect(tokenize("")).toEqual([]);
|
|
50
|
+
expect(tokenize(" ")).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("jaccardSimilarity", () => {
|
|
55
|
+
test("returns 1 for identical sets", () => {
|
|
56
|
+
expect(jaccardSimilarity(["fix", "bug"], ["fix", "bug"])).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns 0 for completely different sets", () => {
|
|
60
|
+
expect(jaccardSimilarity(["fix", "bug"], ["create", "feature"])).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns 0.5 for half overlap", () => {
|
|
64
|
+
expect(jaccardSimilarity(["fix", "bug"], ["fix", "error"])).toBeCloseTo(0.333, 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("handles empty arrays", () => {
|
|
68
|
+
expect(jaccardSimilarity([], [])).toBe(0);
|
|
69
|
+
expect(jaccardSimilarity(["fix"], [])).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("calculateTaskSimilarity", () => {
|
|
74
|
+
test("returns high similarity for tasks with same intent", () => {
|
|
75
|
+
const task1 = createTask({
|
|
76
|
+
id: "1",
|
|
77
|
+
title: "Fix login bug",
|
|
78
|
+
workContext: { intent: "Fix issue or bug" },
|
|
79
|
+
});
|
|
80
|
+
const task2 = createTask({
|
|
81
|
+
id: "2",
|
|
82
|
+
title: "Debug authentication",
|
|
83
|
+
workContext: { intent: "Fix issue or bug" },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const similarity = calculateTaskSimilarity(task1, task2);
|
|
87
|
+
expect(similarity).toBeGreaterThan(0.3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns high similarity for tasks with similar titles", () => {
|
|
91
|
+
const task1 = createTask({
|
|
92
|
+
id: "1",
|
|
93
|
+
title: "Implement user authentication",
|
|
94
|
+
});
|
|
95
|
+
const task2 = createTask({
|
|
96
|
+
id: "2",
|
|
97
|
+
title: "Implement admin authentication",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const similarity = calculateTaskSimilarity(task1, task2);
|
|
101
|
+
expect(similarity).toBeGreaterThan(0.3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns low similarity for unrelated tasks", () => {
|
|
105
|
+
const task1 = createTask({
|
|
106
|
+
id: "1",
|
|
107
|
+
title: "Fix login bug",
|
|
108
|
+
workContext: { intent: "Fix issue or bug" },
|
|
109
|
+
});
|
|
110
|
+
const task2 = createTask({
|
|
111
|
+
id: "2",
|
|
112
|
+
title: "Write documentation",
|
|
113
|
+
workContext: { intent: "Document functionality" },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const similarity = calculateTaskSimilarity(task1, task2);
|
|
117
|
+
expect(similarity).toBeLessThan(0.2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("handles tasks with missing fields", () => {
|
|
121
|
+
const task1 = createTask({ id: "1", title: "Task one" });
|
|
122
|
+
const task2 = createTask({ id: "2", title: "Task two" });
|
|
123
|
+
|
|
124
|
+
// Should not throw
|
|
125
|
+
const similarity = calculateTaskSimilarity(task1, task2);
|
|
126
|
+
expect(similarity).toBeGreaterThanOrEqual(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("findRelatedTasks", () => {
|
|
131
|
+
const tasks: Task[] = [
|
|
132
|
+
createTask({
|
|
133
|
+
id: "auth-1",
|
|
134
|
+
title: "Implement user authentication",
|
|
135
|
+
workContext: { intent: "Implement new functionality" },
|
|
136
|
+
}),
|
|
137
|
+
createTask({
|
|
138
|
+
id: "auth-2",
|
|
139
|
+
title: "Implement OAuth authentication",
|
|
140
|
+
workContext: { intent: "Implement new functionality" },
|
|
141
|
+
}),
|
|
142
|
+
createTask({
|
|
143
|
+
id: "bug-1",
|
|
144
|
+
title: "Fix login bug",
|
|
145
|
+
workContext: { intent: "Fix issue or bug" },
|
|
146
|
+
}),
|
|
147
|
+
createTask({
|
|
148
|
+
id: "doc-1",
|
|
149
|
+
title: "Write API documentation",
|
|
150
|
+
workContext: { intent: "Document functionality" },
|
|
151
|
+
}),
|
|
152
|
+
createTask({
|
|
153
|
+
id: "completed-1",
|
|
154
|
+
title: "Implement session authentication",
|
|
155
|
+
status: "completed",
|
|
156
|
+
workContext: { intent: "Implement new functionality" },
|
|
157
|
+
}),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
test("finds related tasks by similarity", () => {
|
|
161
|
+
const related = findRelatedTasks(tasks[0]!, tasks);
|
|
162
|
+
|
|
163
|
+
// auth-2 should be most similar to auth-1
|
|
164
|
+
expect(related.length).toBeGreaterThan(0);
|
|
165
|
+
expect(related[0]!.task.id).toBe("auth-2");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("excludes completed tasks by default", () => {
|
|
169
|
+
const related = findRelatedTasks(tasks[0]!, tasks);
|
|
170
|
+
|
|
171
|
+
const hasCompleted = related.some((r) => r.task.status === "completed");
|
|
172
|
+
expect(hasCompleted).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("includes completed tasks when requested", () => {
|
|
176
|
+
const related = findRelatedTasks(tasks[0]!, tasks, { excludeCompleted: false });
|
|
177
|
+
|
|
178
|
+
const hasCompleted = related.some((r) => r.task.status === "completed");
|
|
179
|
+
expect(hasCompleted).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("respects limit option", () => {
|
|
183
|
+
const related = findRelatedTasks(tasks[0]!, tasks, { limit: 1 });
|
|
184
|
+
|
|
185
|
+
expect(related.length).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("excludes self", () => {
|
|
189
|
+
const related = findRelatedTasks(tasks[0]!, tasks);
|
|
190
|
+
|
|
191
|
+
const hasSelf = related.some((r) => r.task.id === tasks[0]!.id);
|
|
192
|
+
expect(hasSelf).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("excludes siblings when excludeSameParent is true", () => {
|
|
196
|
+
const parentId = "parent-1";
|
|
197
|
+
const tasksWithParent = [
|
|
198
|
+
createTask({ id: "child-1", title: "Task one", parentId }),
|
|
199
|
+
createTask({ id: "child-2", title: "Task one similar", parentId }),
|
|
200
|
+
createTask({ id: "other", title: "Task one other" }),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const related = findRelatedTasks(tasksWithParent[0]!, tasksWithParent, {
|
|
204
|
+
excludeSameParent: true,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const hasSibling = related.some((r) => r.task.id === "child-2");
|
|
208
|
+
expect(hasSibling).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("clusterTasks", () => {
|
|
213
|
+
test("groups similar tasks together", () => {
|
|
214
|
+
const tasks: Task[] = [
|
|
215
|
+
createTask({
|
|
216
|
+
id: "auth-1",
|
|
217
|
+
title: "Implement user authentication",
|
|
218
|
+
priority: "high",
|
|
219
|
+
workContext: { intent: "Implement new functionality" },
|
|
220
|
+
}),
|
|
221
|
+
createTask({
|
|
222
|
+
id: "auth-2",
|
|
223
|
+
title: "Implement OAuth authentication",
|
|
224
|
+
priority: "medium",
|
|
225
|
+
workContext: { intent: "Implement new functionality" },
|
|
226
|
+
}),
|
|
227
|
+
createTask({
|
|
228
|
+
id: "bug-1",
|
|
229
|
+
title: "Fix critical login bug",
|
|
230
|
+
priority: "critical",
|
|
231
|
+
workContext: { intent: "Fix issue or bug" },
|
|
232
|
+
}),
|
|
233
|
+
createTask({
|
|
234
|
+
id: "bug-2",
|
|
235
|
+
title: "Fix authentication bug",
|
|
236
|
+
priority: "high",
|
|
237
|
+
workContext: { intent: "Fix issue or bug" },
|
|
238
|
+
}),
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const clusters = clusterTasks(tasks, { minClusterSize: 2 });
|
|
242
|
+
|
|
243
|
+
// Should have at least one cluster
|
|
244
|
+
expect(clusters.length).toBeGreaterThan(0);
|
|
245
|
+
|
|
246
|
+
// Each cluster should have at least minClusterSize members
|
|
247
|
+
for (const cluster of clusters) {
|
|
248
|
+
expect(cluster.members.length).toBeGreaterThanOrEqual(2);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("uses higher priority tasks as representatives", () => {
|
|
253
|
+
const tasks: Task[] = [
|
|
254
|
+
createTask({
|
|
255
|
+
id: "low",
|
|
256
|
+
title: "Implement feature",
|
|
257
|
+
priority: "low",
|
|
258
|
+
workContext: { intent: "Implement new functionality" },
|
|
259
|
+
}),
|
|
260
|
+
createTask({
|
|
261
|
+
id: "high",
|
|
262
|
+
title: "Implement similar feature",
|
|
263
|
+
priority: "high",
|
|
264
|
+
workContext: { intent: "Implement new functionality" },
|
|
265
|
+
}),
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
const clusters = clusterTasks(tasks, { minClusterSize: 2, similarityThreshold: 0.1 });
|
|
269
|
+
|
|
270
|
+
if (clusters.length > 0) {
|
|
271
|
+
expect(clusters[0]!.representative.id).toBe("high");
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("returns empty array for no matching clusters", () => {
|
|
276
|
+
const tasks: Task[] = [
|
|
277
|
+
createTask({ id: "1", title: "Unique task one" }),
|
|
278
|
+
createTask({ id: "2", title: "Different topic completely" }),
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
const clusters = clusterTasks(tasks, { minClusterSize: 2, similarityThreshold: 0.5 });
|
|
282
|
+
|
|
283
|
+
expect(clusters.length).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -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
|
+
}
|