@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.
Files changed (94) hide show
  1. package/dist/algorithms/index.d.ts +1 -1
  2. package/dist/algorithms/index.d.ts.map +1 -1
  3. package/dist/algorithms/index.js +1 -1
  4. package/dist/algorithms/index.js.map +1 -1
  5. package/dist/algorithms/topological-sort.d.ts +21 -1
  6. package/dist/algorithms/topological-sort.d.ts.map +1 -1
  7. package/dist/algorithms/topological-sort.js +12 -1
  8. package/dist/algorithms/topological-sort.js.map +1 -1
  9. package/dist/schemas/inbox.d.ts +2 -2
  10. package/dist/schemas/index.d.ts +1 -0
  11. package/dist/schemas/index.d.ts.map +1 -1
  12. package/dist/schemas/index.js +2 -0
  13. package/dist/schemas/index.js.map +1 -1
  14. package/dist/schemas/response-format.d.ts +11 -0
  15. package/dist/schemas/response-format.d.ts.map +1 -1
  16. package/dist/schemas/response-format.js.map +1 -1
  17. package/dist/schemas/session.d.ts +521 -0
  18. package/dist/schemas/session.d.ts.map +1 -0
  19. package/dist/schemas/session.js +79 -0
  20. package/dist/schemas/session.js.map +1 -0
  21. package/dist/schemas/state.d.ts +2 -2
  22. package/dist/schemas/task.d.ts +9 -0
  23. package/dist/schemas/task.d.ts.map +1 -1
  24. package/dist/schemas/task.js +23 -6
  25. package/dist/schemas/task.js.map +1 -1
  26. package/dist/schemas/view.d.ts +18 -18
  27. package/dist/utils/clustering.d.ts +60 -0
  28. package/dist/utils/clustering.d.ts.map +1 -0
  29. package/dist/utils/clustering.js +283 -0
  30. package/dist/utils/clustering.js.map +1 -0
  31. package/dist/utils/clustering.test.d.ts +2 -0
  32. package/dist/utils/clustering.test.d.ts.map +1 -0
  33. package/dist/utils/clustering.test.js +237 -0
  34. package/dist/utils/clustering.test.js.map +1 -0
  35. package/dist/utils/env.d.ts +24 -0
  36. package/dist/utils/env.d.ts.map +1 -0
  37. package/dist/utils/env.js +40 -0
  38. package/dist/utils/env.js.map +1 -0
  39. package/dist/utils/hierarchy.d.ts.map +1 -1
  40. package/dist/utils/hierarchy.js +13 -6
  41. package/dist/utils/hierarchy.js.map +1 -1
  42. package/dist/utils/index.d.ts +6 -2
  43. package/dist/utils/index.d.ts.map +1 -1
  44. package/dist/utils/index.js +24 -2
  45. package/dist/utils/index.js.map +1 -1
  46. package/dist/utils/intent-extractor.d.ts +30 -0
  47. package/dist/utils/intent-extractor.d.ts.map +1 -0
  48. package/dist/utils/intent-extractor.js +135 -0
  49. package/dist/utils/intent-extractor.js.map +1 -0
  50. package/dist/utils/intent-extractor.test.d.ts +2 -0
  51. package/dist/utils/intent-extractor.test.d.ts.map +1 -0
  52. package/dist/utils/intent-extractor.test.js +69 -0
  53. package/dist/utils/intent-extractor.test.js.map +1 -0
  54. package/dist/utils/natural-language.d.ts.map +1 -1
  55. package/dist/utils/natural-language.js +9 -8
  56. package/dist/utils/natural-language.js.map +1 -1
  57. package/dist/utils/natural-language.test.js +22 -0
  58. package/dist/utils/natural-language.test.js.map +1 -1
  59. package/dist/utils/plan-parser.d.ts +57 -0
  60. package/dist/utils/plan-parser.d.ts.map +1 -0
  61. package/dist/utils/plan-parser.js +371 -0
  62. package/dist/utils/plan-parser.js.map +1 -0
  63. package/dist/utils/projection.d.ts.map +1 -1
  64. package/dist/utils/projection.js +43 -1
  65. package/dist/utils/projection.js.map +1 -1
  66. package/dist/utils/projection.test.js +57 -7
  67. package/dist/utils/projection.test.js.map +1 -1
  68. package/dist/utils/terminal-ui.d.ts +129 -0
  69. package/dist/utils/terminal-ui.d.ts.map +1 -1
  70. package/dist/utils/terminal-ui.js +191 -0
  71. package/dist/utils/terminal-ui.js.map +1 -1
  72. package/dist/utils/terminal-ui.test.js +227 -0
  73. package/dist/utils/terminal-ui.test.js.map +1 -1
  74. package/package.json +2 -2
  75. package/src/algorithms/index.ts +3 -0
  76. package/src/algorithms/topological-sort.ts +31 -1
  77. package/src/schemas/index.ts +11 -0
  78. package/src/schemas/response-format.ts +15 -2
  79. package/src/schemas/session.ts +100 -0
  80. package/src/schemas/task.ts +33 -16
  81. package/src/utils/clustering.test.ts +285 -0
  82. package/src/utils/clustering.ts +336 -0
  83. package/src/utils/env.ts +41 -0
  84. package/src/utils/hierarchy.ts +17 -8
  85. package/src/utils/index.ts +48 -0
  86. package/src/utils/intent-extractor.test.ts +84 -0
  87. package/src/utils/intent-extractor.ts +156 -0
  88. package/src/utils/natural-language.test.ts +27 -0
  89. package/src/utils/natural-language.ts +10 -9
  90. package/src/utils/plan-parser.ts +466 -0
  91. package/src/utils/projection.test.ts +61 -7
  92. package/src/utils/projection.ts +44 -1
  93. package/src/utils/terminal-ui.test.ts +277 -0
  94. package/src/utils/terminal-ui.ts +315 -0
@@ -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
- .refine(
32
- (data) => {
33
- const { optimistic, expected, pessimistic } = data;
34
- if (optimistic !== undefined && expected !== undefined && optimistic > expected) {
35
- return false;
36
- }
37
- if (expected !== undefined && pessimistic !== undefined && expected > pessimistic) {
38
- return false;
39
- }
40
- if (optimistic !== undefined && pessimistic !== undefined && optimistic > pessimistic) {
41
- return false;
42
- }
43
- return true;
44
- },
45
- { message: "Time estimates must satisfy: optimistic <= expected <= pessimistic" }
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
+ }