@grec0/memory-bank-mcp 0.2.11 → 0.2.12

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.
@@ -129,6 +129,18 @@ export class AgentBoard {
129
129
  getPendingTasks() {
130
130
  return this.sqlite.getPendingTasks();
131
131
  }
132
+ /**
133
+ * Get all tasks for this project (regardless of status)
134
+ */
135
+ getAllTasks() {
136
+ return this.sqlite.getAllTasks();
137
+ }
138
+ /**
139
+ * Get completed tasks for this project
140
+ */
141
+ getCompletedTasks() {
142
+ return this.sqlite.getCompletedTasks();
143
+ }
132
144
  /**
133
145
  * Claim a task
134
146
  */
@@ -219,6 +219,30 @@ export class AgentBoardSqlite {
219
219
  `).all(this.projectId);
220
220
  return rows.map(this.mapTaskRow);
221
221
  }
222
+ /**
223
+ * Get all tasks for this project (regardless of status)
224
+ */
225
+ getAllTasks() {
226
+ const db = databaseManager.getConnection();
227
+ const rows = db.prepare(`
228
+ SELECT * FROM tasks
229
+ WHERE project_id = ?
230
+ ORDER BY created_at DESC
231
+ `).all(this.projectId);
232
+ return rows.map(this.mapTaskRow);
233
+ }
234
+ /**
235
+ * Get completed tasks for this project
236
+ */
237
+ getCompletedTasks() {
238
+ const db = databaseManager.getConnection();
239
+ const rows = db.prepare(`
240
+ SELECT * FROM tasks
241
+ WHERE project_id = ? AND status = 'COMPLETED'
242
+ ORDER BY completed_at DESC
243
+ `).all(this.projectId);
244
+ return rows.map(this.mapTaskRow);
245
+ }
222
246
  /**
223
247
  * Claim a task (agent takes ownership)
224
248
  */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @fileoverview Text Similarity Utilities
3
+ * Provides functions to compare text similarity for task deduplication
4
+ */
5
+ /**
6
+ * Calculates Levenshtein distance between two strings
7
+ * (minimum number of single-character edits to transform one string into another)
8
+ */
9
+ function levenshteinDistance(str1, str2) {
10
+ const len1 = str1.length;
11
+ const len2 = str2.length;
12
+ // Create a 2D array for dynamic programming
13
+ const dp = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
14
+ // Initialize base cases
15
+ for (let i = 0; i <= len1; i++) {
16
+ dp[i][0] = i;
17
+ }
18
+ for (let j = 0; j <= len2; j++) {
19
+ dp[0][j] = j;
20
+ }
21
+ // Fill the dp table
22
+ for (let i = 1; i <= len1; i++) {
23
+ for (let j = 1; j <= len2; j++) {
24
+ if (str1[i - 1] === str2[j - 1]) {
25
+ dp[i][j] = dp[i - 1][j - 1];
26
+ }
27
+ else {
28
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, // deletion
29
+ dp[i][j - 1] + 1, // insertion
30
+ dp[i - 1][j - 1] + 1 // substitution
31
+ );
32
+ }
33
+ }
34
+ }
35
+ return dp[len1][len2];
36
+ }
37
+ /**
38
+ * Calculates similarity score between two strings (0-1 range)
39
+ * 1.0 = identical, 0.0 = completely different
40
+ */
41
+ export function textSimilarity(str1, str2) {
42
+ if (!str1 && !str2)
43
+ return 1.0; // Both empty = identical
44
+ if (!str1 || !str2)
45
+ return 0; // One empty, one not = different
46
+ // Normalize: lowercase, trim, and remove extra spaces
47
+ const s1 = str1.toLowerCase().trim().replace(/\s+/g, ' ');
48
+ const s2 = str2.toLowerCase().trim().replace(/\s+/g, ' ');
49
+ // After normalization, check again
50
+ if (s1 === s2)
51
+ return 1.0;
52
+ if (s1.length === 0 && s2.length === 0)
53
+ return 1.0;
54
+ if (s1.length === 0 || s2.length === 0)
55
+ return 0;
56
+ const maxLen = Math.max(s1.length, s2.length);
57
+ const distance = levenshteinDistance(s1, s2);
58
+ return 1 - (distance / maxLen);
59
+ }
60
+ /**
61
+ * Checks if two texts are similar based on a threshold
62
+ * @param str1 First string
63
+ * @param str2 Second string
64
+ * @param threshold Similarity threshold (0-1), default 0.8
65
+ * @returns true if similarity >= threshold
66
+ */
67
+ export function areSimilar(str1, str2, threshold = 0.8) {
68
+ return textSimilarity(str1, str2) >= threshold;
69
+ }
70
+ /**
71
+ * Finds the most similar text from a list
72
+ * @param target Target text to compare
73
+ * @param candidates List of candidate texts
74
+ * @param minScore Minimum similarity score to consider (default 0.7)
75
+ * @returns Object with best match and score, or null if no match above threshold
76
+ */
77
+ export function findMostSimilar(target, candidates, minScore = 0.7) {
78
+ if (!target || !candidates || candidates.length === 0) {
79
+ return null;
80
+ }
81
+ let bestMatch = null;
82
+ for (let i = 0; i < candidates.length; i++) {
83
+ const score = textSimilarity(target, candidates[i]);
84
+ if (score >= minScore && (!bestMatch || score > bestMatch.score)) {
85
+ bestMatch = { text: candidates[i], score, index: i };
86
+ }
87
+ }
88
+ return bestMatch;
89
+ }
90
+ /**
91
+ * Normalizes a text for comparison (lowercase, trim, remove extra spaces)
92
+ */
93
+ export function normalizeText(text) {
94
+ return text.toLowerCase().trim().replace(/\s+/g, ' ');
95
+ }
96
+ /**
97
+ * Checks if text contains all keywords (case-insensitive)
98
+ */
99
+ export function containsAllKeywords(text, keywords) {
100
+ const normalized = normalizeText(text);
101
+ return keywords.every(keyword => normalized.includes(normalizeText(keyword)));
102
+ }
@@ -1,2 +1,2 @@
1
1
  // Version of the MCP Kanban server
2
- export const VERSION = "0.2.11";
2
+ export const VERSION = "0.2.12";
@@ -1,5 +1,6 @@
1
1
  import { RegistryManager } from '../common/registryManager.js';
2
2
  import { AgentBoard } from '../common/agentBoard.js';
3
+ import { textSimilarity } from '../common/textSimilarity.js';
3
4
  export async function delegateTaskTool(params) {
4
5
  const registryManager = new RegistryManager();
5
6
  const targetProject = await registryManager.getProject(params.targetProjectId);
@@ -11,12 +12,33 @@ export async function delegateTaskTool(params) {
11
12
  }
12
13
  try {
13
14
  // Initialize board for the TARGET project path
14
- // We use targetProject.path as the workspace root for that board
15
15
  const targetBoard = new AgentBoard(targetProject.path, targetProject.projectId);
16
+ // ========================================================================
17
+ // DOUBLE CHECK: Verify no duplicate task exists before creating
18
+ // (Protection against race conditions between route and delegate)
19
+ // ========================================================================
20
+ const existingTasks = targetBoard.getAllTasks();
21
+ const TITLE_SIMILARITY_THRESHOLD = 0.85;
22
+ for (const existingTask of existingTasks) {
23
+ const similarity = textSimilarity(params.title, existingTask.title);
24
+ if (similarity >= TITLE_SIMILARITY_THRESHOLD) {
25
+ // Found a duplicate - don't create
26
+ console.error(` ⚠️ Duplicate task detected during delegation: ${existingTask.id} (similarity: ${(similarity * 100).toFixed(0)}%)`);
27
+ return {
28
+ success: true, // Not an error, just already exists
29
+ taskId: existingTask.id,
30
+ isDuplicate: true,
31
+ existingStatus: existingTask.status,
32
+ message: `Task already exists in '${params.targetProjectId}' as ${existingTask.id} (status: ${existingTask.status}). No duplicate created.`
33
+ };
34
+ }
35
+ }
36
+ // No duplicate found - safe to create
16
37
  const taskId = await targetBoard.createExternalTask(params.title, params.projectId, `${params.description}\n\nContext:\n${params.context}`);
17
38
  return {
18
39
  success: true,
19
40
  taskId,
41
+ isDuplicate: false,
20
42
  message: `Task successfully delegated to project '${params.targetProjectId}' (Task ID: ${taskId})`
21
43
  };
22
44
  }
@@ -10,6 +10,8 @@ import OpenAI from "openai";
10
10
  import { RegistryManager } from "../common/registryManager.js";
11
11
  import { searchMemory } from "./searchMemory.js";
12
12
  import { saveOrchestratorLog } from "../common/agentBoardSqlite.js";
13
+ import { AgentBoard } from "../common/agentBoard.js";
14
+ import { textSimilarity } from "../common/textSimilarity.js";
13
15
  /**
14
16
  * Builds a context string describing all projects and their responsibilities
15
17
  */
@@ -172,6 +174,104 @@ IMPORTANT:
172
174
  - Responde siempre en ESPAÑOL
173
175
 
174
176
  Respond ONLY with the JSON object after completing your analysis.`;
177
+ /**
178
+ * Checks if a delegation is a duplicate of an existing task in the target project
179
+ * @param delegation The proposed delegation
180
+ * @param targetProject The target project card
181
+ * @param registryManager Registry manager for resolving project paths
182
+ * @returns Updated delegation with deduplication metadata
183
+ */
184
+ async function checkDelegationDuplicate(delegation, targetProject, registryManager) {
185
+ try {
186
+ // Initialize AgentBoard for the target project
187
+ const targetBoard = new AgentBoard(targetProject.path, targetProject.projectId);
188
+ // Get all tasks (pending, in-progress, and completed)
189
+ const allTasks = targetBoard.getAllTasks();
190
+ if (!allTasks || allTasks.length === 0) {
191
+ // No tasks in the target project, no duplicates
192
+ return delegation;
193
+ }
194
+ // Check for duplicates based on title and description similarity
195
+ const TITLE_SIMILARITY_THRESHOLD = 0.85; // 85% similar titles = likely duplicate
196
+ const DESC_SIMILARITY_THRESHOLD = 0.75; // 75% similar descriptions = likely duplicate
197
+ for (const existingTask of allTasks) {
198
+ // Calculate similarities
199
+ const titleSimilarity = textSimilarity(delegation.taskTitle, existingTask.title);
200
+ const descSimilarity = existingTask.description
201
+ ? textSimilarity(delegation.taskDescription, existingTask.description)
202
+ : 0;
203
+ // Check if it's a duplicate
204
+ const isDuplicateByTitle = titleSimilarity >= TITLE_SIMILARITY_THRESHOLD;
205
+ const isDuplicateByDesc = descSimilarity >= DESC_SIMILARITY_THRESHOLD;
206
+ if (isDuplicateByTitle || (isDuplicateByDesc && descSimilarity > 0.5)) {
207
+ // Found a duplicate!
208
+ const maxSimilarity = Math.max(titleSimilarity, descSimilarity);
209
+ let skipReason = `Task already exists in ${targetProject.projectId}: `;
210
+ if (existingTask.status === 'COMPLETED') {
211
+ skipReason += `completed as ${existingTask.id}`;
212
+ }
213
+ else if (existingTask.status === 'IN_PROGRESS') {
214
+ skipReason += `in progress as ${existingTask.id}${existingTask.claimedBy ? ` (claimed by ${existingTask.claimedBy})` : ''}`;
215
+ }
216
+ else {
217
+ skipReason += `pending as ${existingTask.id}`;
218
+ }
219
+ skipReason += ` (similarity: ${(maxSimilarity * 100).toFixed(0)}%)`;
220
+ return {
221
+ ...delegation,
222
+ isDuplicate: true,
223
+ existingTaskId: existingTask.id,
224
+ existingTaskStatus: existingTask.status,
225
+ skipReason,
226
+ similarity: maxSimilarity,
227
+ };
228
+ }
229
+ }
230
+ // No duplicates found
231
+ return delegation;
232
+ }
233
+ catch (error) {
234
+ console.error(` Warning: Failed to check duplicates for ${delegation.targetProject}: ${error.message}`);
235
+ // In case of error, proceed without deduplication metadata
236
+ return delegation;
237
+ }
238
+ }
239
+ /**
240
+ * Filters delegations by checking against existing tasks in target projects
241
+ * @param delegations Array of proposed delegations
242
+ * @param registryManager Registry manager for resolving projects
243
+ * @returns Object with filtered delegations and skipped ones with reasons
244
+ */
245
+ async function filterDuplicateDelegations(delegations, registryManager) {
246
+ if (!delegations || delegations.length === 0) {
247
+ return { validDelegations: [], duplicateDelegations: [] };
248
+ }
249
+ console.error(`\n=== Checking for duplicate delegations ===`);
250
+ const validDelegations = [];
251
+ const duplicateDelegations = [];
252
+ for (const delegation of delegations) {
253
+ // Resolve target project
254
+ const targetProject = await registryManager.getProject(delegation.targetProject);
255
+ if (!targetProject) {
256
+ console.error(` Warning: Target project '${delegation.targetProject}' not found. Keeping delegation.`);
257
+ validDelegations.push(delegation);
258
+ continue;
259
+ }
260
+ // Check for duplicates
261
+ const checkedDelegation = await checkDelegationDuplicate(delegation, targetProject, registryManager);
262
+ if (checkedDelegation.isDuplicate) {
263
+ console.error(` ✗ DUPLICATE: ${delegation.taskTitle} → ${delegation.targetProject}`);
264
+ console.error(` ${checkedDelegation.skipReason}`);
265
+ duplicateDelegations.push(checkedDelegation);
266
+ }
267
+ else {
268
+ console.error(` ✓ VALID: ${delegation.taskTitle} → ${delegation.targetProject}`);
269
+ validDelegations.push(checkedDelegation);
270
+ }
271
+ }
272
+ console.error(`\nDeduplication results: ${validDelegations.length} valid, ${duplicateDelegations.length} duplicates`);
273
+ return { validDelegations, duplicateDelegations };
274
+ }
175
275
  /**
176
276
  * Routes a task to the appropriate project(s) based on responsibilities
177
277
  * Uses function calling to allow the AI to perform semantic searches when needed
@@ -387,13 +487,40 @@ export async function routeTaskTool(params, indexManager) {
387
487
  console.error(` → ${d.targetProject}: ${d.taskTitle}`);
388
488
  }
389
489
  }
490
+ // ========================================================================
491
+ // DEDUPLICATION: Check against existing tasks in target project boards
492
+ // ========================================================================
493
+ let finalDelegations = result.delegations || [];
494
+ let duplicateDelegations = [];
495
+ if (finalDelegations.length > 0) {
496
+ const { validDelegations, duplicateDelegations: duplicates } = await filterDuplicateDelegations(finalDelegations, registryManager);
497
+ finalDelegations = validDelegations;
498
+ duplicateDelegations = duplicates;
499
+ // Update action if all delegations were duplicates
500
+ if (validDelegations.length === 0 && result.delegations.length > 0) {
501
+ if (result.myResponsibilities && result.myResponsibilities.length > 0) {
502
+ result.action = 'proceed'; // Had delegations but all were duplicates, only local work remains
503
+ }
504
+ else {
505
+ result.action = 'proceed'; // Everything was a duplicate, nothing to do
506
+ }
507
+ }
508
+ }
509
+ // Build architecture notes with deduplication info
510
+ let architectureNotes = result.architectureNotes || '';
511
+ if (duplicateDelegations.length > 0) {
512
+ architectureNotes += `\n\n**Delegaciones filtradas (duplicadas):**\n`;
513
+ for (const dup of duplicateDelegations) {
514
+ architectureNotes += `- ${dup.taskTitle} → ${dup.targetProject}: ${dup.skipReason}\n`;
515
+ }
516
+ }
390
517
  const routeResult = {
391
518
  success: true,
392
519
  action: result.action || 'proceed',
393
520
  myResponsibilities: result.myResponsibilities || [],
394
- delegations: result.delegations || [],
521
+ delegations: finalDelegations, // Only non-duplicate delegations
395
522
  suggestedImports: result.suggestedImports || [],
396
- architectureNotes: result.architectureNotes || '',
523
+ architectureNotes,
397
524
  warning: result.warning,
398
525
  };
399
526
  // Persist to SQLite for extension visualization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grec0/memory-bank-mcp",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "MCP server for semantic code indexing with Memory Bank - AI-powered codebase understanding",
5
5
  "license": "MIT",
6
6
  "author": "@grec0",