@gethmy/mcp 2.2.4 → 2.3.0

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.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Unified Memory Cleanup
3
+ *
4
+ * Orchestrates a 5-stage cleanup pipeline: prune stale drafts, consolidate
5
+ * similar memories, detect orphans, detect duplicates, and backfill embeddings.
6
+ *
7
+ * All stages are non-fatal — individual failures are collected but never block
8
+ * the remaining stages. Defaults to dry-run mode (preview only).
9
+ */
10
+ import { evaluateLifecycle } from "@harmony/memory";
11
+ import { consolidateMemories, } from "./consolidation.js";
12
+ import { findSimilarEntities } from "./graph-expansion.js";
13
+ const ALL_STEPS = [
14
+ "prune",
15
+ "consolidate",
16
+ "orphans",
17
+ "duplicates",
18
+ "backfill",
19
+ ];
20
+ // ---------------------------------------------------------------------------
21
+ // Main orchestrator
22
+ // ---------------------------------------------------------------------------
23
+ export async function runMemoryCleanup(client, workspaceId, projectId, options) {
24
+ const dryRun = options?.dryRun !== false;
25
+ const steps = options?.steps ?? ALL_STEPS;
26
+ const maxAgeDays = options?.maxAgeDays ?? 30;
27
+ const minClusterSize = options?.minClusterSize ?? 3;
28
+ const orphanAgeDays = options?.orphanAgeDays ?? 14;
29
+ const report = {
30
+ success: true,
31
+ dryRun,
32
+ timestamp: new Date().toISOString(),
33
+ workspace: { id: workspaceId, projectId },
34
+ summary: { totalEntities: 0, issuesFound: 0, actionsTaken: 0 },
35
+ steps: {},
36
+ errors: [],
37
+ healthReport: "",
38
+ };
39
+ // Fetch all entities once (shared across steps)
40
+ let entities = [];
41
+ try {
42
+ const listResult = await client.listMemoryEntities({
43
+ workspace_id: workspaceId,
44
+ project_id: projectId,
45
+ limit: 200,
46
+ });
47
+ entities = (listResult.entities || []);
48
+ report.summary.totalEntities = entities.length;
49
+ }
50
+ catch (err) {
51
+ report.errors.push({
52
+ step: "init",
53
+ message: `Failed to fetch entities: ${err.message}`,
54
+ });
55
+ report.success = false;
56
+ report.healthReport = generateHealthReport(report);
57
+ return report;
58
+ }
59
+ // Stage 1: Prune stale drafts
60
+ if (steps.includes("prune")) {
61
+ try {
62
+ report.steps.prune = runPruneStep(entities, maxAgeDays);
63
+ if (!dryRun) {
64
+ for (const item of report.steps.prune.items) {
65
+ try {
66
+ await client.deleteMemoryEntity(item.id);
67
+ report.steps.prune.pruned++;
68
+ }
69
+ catch {
70
+ // Non-fatal
71
+ }
72
+ }
73
+ report.summary.actionsTaken += report.steps.prune.pruned;
74
+ }
75
+ report.summary.issuesFound += report.steps.prune.staleDraftsFound;
76
+ }
77
+ catch (err) {
78
+ report.errors.push({
79
+ step: "prune",
80
+ message: err.message,
81
+ });
82
+ }
83
+ }
84
+ // Stage 2: Consolidate similar memories
85
+ if (steps.includes("consolidate")) {
86
+ try {
87
+ const result = await consolidateMemories(client, workspaceId, projectId, {
88
+ dryRun,
89
+ minClusterSize,
90
+ });
91
+ report.steps.consolidate = {
92
+ clustersFound: result.clustersFound,
93
+ entitiesProcessed: result.entitiesProcessed,
94
+ consolidated: result.consolidated,
95
+ details: result.details,
96
+ };
97
+ report.summary.issuesFound += result.clustersFound;
98
+ if (!dryRun)
99
+ report.summary.actionsTaken += result.consolidated;
100
+ }
101
+ catch (err) {
102
+ report.errors.push({
103
+ step: "consolidate",
104
+ message: err.message,
105
+ });
106
+ }
107
+ }
108
+ // Stage 3: Detect orphans
109
+ if (steps.includes("orphans")) {
110
+ try {
111
+ report.steps.orphans = await runOrphanStep(client, entities, orphanAgeDays);
112
+ if (!dryRun) {
113
+ for (const item of report.steps.orphans.items) {
114
+ try {
115
+ await client.deleteMemoryEntity(item.id);
116
+ report.steps.orphans.removed++;
117
+ }
118
+ catch {
119
+ // Non-fatal
120
+ }
121
+ }
122
+ report.summary.actionsTaken += report.steps.orphans.removed;
123
+ }
124
+ report.summary.issuesFound += report.steps.orphans.orphansFound;
125
+ }
126
+ catch (err) {
127
+ report.errors.push({
128
+ step: "orphans",
129
+ message: err.message,
130
+ });
131
+ }
132
+ }
133
+ // Stage 4: Detect duplicates
134
+ if (steps.includes("duplicates")) {
135
+ try {
136
+ report.steps.duplicates = await runDuplicateStep(client, entities, workspaceId, projectId);
137
+ if (!dryRun) {
138
+ for (const pair of report.steps.duplicates.pairs) {
139
+ try {
140
+ await client.deleteMemoryEntity(pair.removeId);
141
+ report.steps.duplicates.resolved++;
142
+ }
143
+ catch {
144
+ // Non-fatal
145
+ }
146
+ }
147
+ report.summary.actionsTaken += report.steps.duplicates.resolved;
148
+ }
149
+ report.summary.issuesFound += report.steps.duplicates.duplicatePairsFound;
150
+ }
151
+ catch (err) {
152
+ report.errors.push({
153
+ step: "duplicates",
154
+ message: err.message,
155
+ });
156
+ }
157
+ }
158
+ // Stage 5: Backfill embeddings
159
+ if (steps.includes("backfill")) {
160
+ try {
161
+ if (dryRun) {
162
+ // In dry-run, just report that backfill would run
163
+ report.steps.backfill = {
164
+ processed: 0,
165
+ remaining: -1,
166
+ errors: [],
167
+ };
168
+ }
169
+ else {
170
+ const result = await client.backfillEmbeddings(workspaceId);
171
+ report.steps.backfill = {
172
+ processed: result.processed,
173
+ remaining: result.remaining,
174
+ errors: result.errors || [],
175
+ };
176
+ report.summary.actionsTaken += result.processed;
177
+ }
178
+ }
179
+ catch (err) {
180
+ report.errors.push({
181
+ step: "backfill",
182
+ message: err.message,
183
+ });
184
+ }
185
+ }
186
+ report.healthReport = generateHealthReport(report);
187
+ return report;
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // Step implementations
191
+ // ---------------------------------------------------------------------------
192
+ function runPruneStep(entities, maxAgeDays) {
193
+ const now = Date.now();
194
+ const drafts = entities.filter((e) => e.memory_tier === "draft");
195
+ const stale = [];
196
+ for (const entity of drafts) {
197
+ const ageDays = (now - new Date(entity.created_at).getTime()) / (1000 * 60 * 60 * 24);
198
+ if (ageDays < maxAgeDays)
199
+ continue;
200
+ const lifecycle = evaluateLifecycle(entity);
201
+ stale.push({
202
+ id: entity.id,
203
+ title: entity.title,
204
+ ageDays: Math.round(ageDays),
205
+ decayScore: Math.round(lifecycle.decay.score * 100) / 100,
206
+ });
207
+ }
208
+ return { staleDraftsFound: stale.length, pruned: 0, items: stale };
209
+ }
210
+ async function runOrphanStep(client, entities, orphanAgeDays) {
211
+ const now = Date.now();
212
+ const result = { orphansFound: 0, removed: 0, items: [] };
213
+ // Pre-filter: only check entities that look like orphan candidates
214
+ const candidates = entities.filter((e) => {
215
+ if (e.memory_tier === "reference")
216
+ return false;
217
+ if (e.access_count >= 2)
218
+ return false;
219
+ const ageDays = (now - new Date(e.created_at).getTime()) / (1000 * 60 * 60 * 24);
220
+ return ageDays >= orphanAgeDays;
221
+ });
222
+ for (const entity of candidates) {
223
+ try {
224
+ const related = await client.getRelatedEntities(entity.id);
225
+ const totalRelations = (related.outgoing?.length || 0) + (related.incoming?.length || 0);
226
+ if (totalRelations > 0)
227
+ continue;
228
+ const ageDays = (now - new Date(entity.created_at).getTime()) / (1000 * 60 * 60 * 24);
229
+ result.items.push({
230
+ id: entity.id,
231
+ title: entity.title,
232
+ type: entity.type,
233
+ tier: entity.memory_tier,
234
+ ageDays: Math.round(ageDays),
235
+ accessCount: entity.access_count,
236
+ });
237
+ result.orphansFound++;
238
+ }
239
+ catch {
240
+ // Non-fatal: skip this entity
241
+ }
242
+ }
243
+ return result;
244
+ }
245
+ async function runDuplicateStep(client, entities, workspaceId, projectId) {
246
+ const result = {
247
+ duplicatePairsFound: 0,
248
+ resolved: 0,
249
+ pairs: [],
250
+ };
251
+ const seenPairs = new Set();
252
+ const flaggedForRemoval = new Set();
253
+ for (const entity of entities) {
254
+ if (flaggedForRemoval.has(entity.id))
255
+ continue;
256
+ let similar;
257
+ try {
258
+ similar = await findSimilarEntities(client, entity.title, entity.content, workspaceId, { projectId, limit: 5, minRrfScore: 0.05, excludeIds: [entity.id] });
259
+ }
260
+ catch {
261
+ continue;
262
+ }
263
+ for (const match of similar) {
264
+ if (flaggedForRemoval.has(match.id))
265
+ continue;
266
+ const pairKey = [entity.id, match.id].sort().join(":");
267
+ if (seenPairs.has(pairKey))
268
+ continue;
269
+ seenPairs.add(pairKey);
270
+ const sim = titleSimilarity(entity.title, match.title);
271
+ if (sim < 0.85)
272
+ continue;
273
+ // Keep the one with higher confidence, more accesses, or higher tier
274
+ const entityScore = entityQualityScore(entity);
275
+ const matchEntity = entities.find((e) => e.id === match.id);
276
+ const matchScore = matchEntity
277
+ ? entityQualityScore(matchEntity)
278
+ : match.confidence;
279
+ const [keep, remove] = entityScore >= matchScore
280
+ ? [entity, { id: match.id, title: match.title }]
281
+ : [{ id: match.id, title: match.title }, entity];
282
+ flaggedForRemoval.add(remove.id);
283
+ result.pairs.push({
284
+ keepId: keep.id,
285
+ keepTitle: keep.title,
286
+ removeId: remove.id,
287
+ removeTitle: remove.title,
288
+ similarity: Math.round(sim * 100) / 100,
289
+ });
290
+ result.duplicatePairsFound++;
291
+ }
292
+ }
293
+ return result;
294
+ }
295
+ // ---------------------------------------------------------------------------
296
+ // Helpers
297
+ // ---------------------------------------------------------------------------
298
+ const TIER_WEIGHTS = {
299
+ reference: 3,
300
+ episode: 2,
301
+ draft: 1,
302
+ };
303
+ function entityQualityScore(entity) {
304
+ return (entity.confidence +
305
+ (TIER_WEIGHTS[entity.memory_tier] || 0) +
306
+ Math.min(entity.access_count, 10) * 0.1);
307
+ }
308
+ function titleSimilarity(a, b) {
309
+ const na = a.toLowerCase().trim();
310
+ const nb = b.toLowerCase().trim();
311
+ if (na === nb)
312
+ return 1;
313
+ const wordsA = new Set(na.split(/\W+/).filter(Boolean));
314
+ const wordsB = new Set(nb.split(/\W+/).filter(Boolean));
315
+ if (wordsA.size === 0 || wordsB.size === 0)
316
+ return 0;
317
+ let intersection = 0;
318
+ for (const w of wordsA) {
319
+ if (wordsB.has(w))
320
+ intersection++;
321
+ }
322
+ // Jaccard similarity
323
+ const union = wordsA.size + wordsB.size - intersection;
324
+ return union > 0 ? intersection / union : 0;
325
+ }
326
+ // ---------------------------------------------------------------------------
327
+ // Health report renderer
328
+ // ---------------------------------------------------------------------------
329
+ function generateHealthReport(report) {
330
+ const mode = report.dryRun ? "Dry Run (preview)" : "Executed";
331
+ const lines = [
332
+ "# Memory Health Report\n",
333
+ `**Mode:** ${mode} | **Entities:** ${report.summary.totalEntities} | **Issues:** ${report.summary.issuesFound} | **Actions:** ${report.summary.actionsTaken}`,
334
+ "",
335
+ ];
336
+ // Prune
337
+ if (report.steps.prune) {
338
+ const p = report.steps.prune;
339
+ lines.push("## Stale Drafts");
340
+ if (p.staleDraftsFound === 0) {
341
+ lines.push("No stale drafts found.\n");
342
+ }
343
+ else {
344
+ lines.push(`Found **${p.staleDraftsFound}** stale drafts${!report.dryRun ? ` (pruned ${p.pruned})` : ""}:`);
345
+ lines.push("| Title | Age | Decay |");
346
+ lines.push("|-------|-----|-------|");
347
+ for (const item of p.items.slice(0, 20)) {
348
+ lines.push(`| ${item.title} | ${item.ageDays}d | ${item.decayScore} |`);
349
+ }
350
+ lines.push("");
351
+ }
352
+ }
353
+ // Consolidate
354
+ if (report.steps.consolidate) {
355
+ const c = report.steps.consolidate;
356
+ lines.push("## Consolidation");
357
+ if (c.clustersFound === 0) {
358
+ lines.push(`Scanned ${c.entitiesProcessed} draft/episode entities — no clusters found.\n`);
359
+ }
360
+ else {
361
+ lines.push(`Found **${c.clustersFound}** clusters across ${c.entitiesProcessed} entities:`);
362
+ for (const d of c.details.slice(0, 10)) {
363
+ lines.push(`- **${d.mergedTitle}** — ${d.clusterSize} entities`);
364
+ }
365
+ lines.push("");
366
+ }
367
+ }
368
+ // Orphans
369
+ if (report.steps.orphans) {
370
+ const o = report.steps.orphans;
371
+ lines.push("## Orphaned Entities");
372
+ if (o.orphansFound === 0) {
373
+ lines.push("No orphans found.\n");
374
+ }
375
+ else {
376
+ lines.push(`Found **${o.orphansFound}** orphans${!report.dryRun ? ` (removed ${o.removed})` : ""}:`);
377
+ lines.push("| Title | Type | Tier | Age | Accesses |");
378
+ lines.push("|-------|------|------|-----|----------|");
379
+ for (const item of o.items.slice(0, 20)) {
380
+ lines.push(`| ${item.title} | ${item.type} | ${item.tier} | ${item.ageDays}d | ${item.accessCount} |`);
381
+ }
382
+ lines.push("");
383
+ }
384
+ }
385
+ // Duplicates
386
+ if (report.steps.duplicates) {
387
+ const d = report.steps.duplicates;
388
+ lines.push("## Near-Duplicates");
389
+ if (d.duplicatePairsFound === 0) {
390
+ lines.push("No duplicates found.\n");
391
+ }
392
+ else {
393
+ lines.push(`Found **${d.duplicatePairsFound}** duplicate pairs${!report.dryRun ? ` (resolved ${d.resolved})` : ""}:`);
394
+ for (const pair of d.pairs.slice(0, 20)) {
395
+ lines.push(`- "${pair.keepTitle}" ~ "${pair.removeTitle}" (${Math.round(pair.similarity * 100)}% similar, keep first)`);
396
+ }
397
+ lines.push("");
398
+ }
399
+ }
400
+ // Backfill
401
+ if (report.steps.backfill) {
402
+ const b = report.steps.backfill;
403
+ lines.push("## Embedding Coverage");
404
+ if (report.dryRun) {
405
+ lines.push("Backfill will run when executed with `dryRun: false`.\n");
406
+ }
407
+ else if (b.remaining === 0) {
408
+ lines.push(`All embeddings up to date (processed ${b.processed}).\n`);
409
+ }
410
+ else {
411
+ lines.push(`Processed ${b.processed} entities. ${b.remaining} still need embeddings.\n`);
412
+ }
413
+ }
414
+ // Errors
415
+ if (report.errors.length > 0) {
416
+ lines.push("## Errors");
417
+ for (const e of report.errors) {
418
+ lines.push(`- **${e.step}:** ${e.message}`);
419
+ }
420
+ lines.push("");
421
+ }
422
+ if (report.dryRun) {
423
+ lines.push("---\n*Run with `dryRun: false` to execute cleanup.*");
424
+ }
425
+ return lines.join("\n");
426
+ }
@@ -301,7 +301,11 @@ export function generatePrompt(options) {
301
301
  roleFraming.focus.forEach((f) => {
302
302
  sections.push(`- ${f}`);
303
303
  });
304
- sections.push(`- **Memory:** When you discover important domain knowledge, architectural decisions, or infrastructure details, store them via \`harmony_remember\`. Focus on durable knowledge that future agents would benefit from not ephemeral task details (those are auto-extracted from your session).`);
304
+ sections.push(`- **Memory:** Store reusable knowledge via \`harmony_remember\`. Only store what a future agent couldn't easily discover from the code itself, applies beyond this specific card, and includes a "because" (not just what, but why).`);
305
+ sections.push(` - GOOD: "BoardContext card state must use moveCard action, never direct setState — optimistic updates depend on action ordering"`);
306
+ sections.push(` - GOOD: "Mobile bottom bar is 64px, overlaps fixed-position drawers — always add pb-16 to drawer content"`);
307
+ sections.push(` - BAD: "Fixed the login button" (no reusable knowledge — the fix is in the code)`);
308
+ sections.push(` - BAD: "Completed card #42" (ephemeral, auto-tracked by session)`);
305
309
  // Output suggestions
306
310
  sections.push(`\n## Suggested Outputs`);
307
311
  roleFraming.outputSuggestions.forEach((s) => {
@@ -11,6 +11,7 @@ import { consolidateMemories } from "./consolidation.js";
11
11
  import { assembleContext, cacheManifest, computeRelevanceScore, getCachedManifest, mapToContextEntity, recordContextFeedback, trackSessionAssembly, } from "./context-assembly.js";
12
12
  import { autoExpandGraph } from "./graph-expansion.js";
13
13
  import { runLifecycleMaintenance } from "./lifecycle-maintenance.js";
14
+ import { runMemoryCleanup } from "./memory-cleanup.js";
14
15
  import { onboardNewUser } from "./onboard.js";
15
16
  const memorySessions = new Map();
16
17
  function initMemorySession(cardId, agentIdentifier, agentName) {
@@ -1450,6 +1451,47 @@ const TOOLS = {
1450
1451
  required: [],
1451
1452
  },
1452
1453
  },
1454
+ harmony_cleanup_memories: {
1455
+ description: "Run a unified memory cleanup: prune stale drafts, consolidate similar memories, detect orphans and duplicates, and backfill embeddings. Returns a health report. Dry-run by default — run with dryRun=false to execute.",
1456
+ inputSchema: {
1457
+ type: "object",
1458
+ properties: {
1459
+ workspaceId: {
1460
+ type: "string",
1461
+ description: "Workspace ID (optional if context set)",
1462
+ },
1463
+ projectId: {
1464
+ type: "string",
1465
+ description: "Project ID (optional)",
1466
+ },
1467
+ dryRun: {
1468
+ type: "boolean",
1469
+ description: "Preview cleanup without executing changes (default: true)",
1470
+ },
1471
+ steps: {
1472
+ type: "array",
1473
+ items: {
1474
+ type: "string",
1475
+ enum: ["prune", "consolidate", "orphans", "duplicates", "backfill"],
1476
+ },
1477
+ description: "Which cleanup steps to run (default: all). Options: prune, consolidate, orphans, duplicates, backfill.",
1478
+ },
1479
+ maxAgeDays: {
1480
+ type: "number",
1481
+ description: "Max age in days for stale draft pruning (default: 30)",
1482
+ },
1483
+ minClusterSize: {
1484
+ type: "number",
1485
+ description: "Min cluster size for consolidation (default: 3)",
1486
+ },
1487
+ orphanAgeDays: {
1488
+ type: "number",
1489
+ description: "Min age in days for orphan detection (default: 14)",
1490
+ },
1491
+ },
1492
+ required: [],
1493
+ },
1494
+ },
1453
1495
  };
1454
1496
  // Resource URIs
1455
1497
  const RESOURCES = [
@@ -3215,6 +3257,27 @@ async function handleToolCall(name, args, deps) {
3215
3257
  message: `Processed ${entitiesProcessed} entities, created ${relationsCreated} new relations.`,
3216
3258
  };
3217
3259
  }
3260
+ case "harmony_cleanup_memories": {
3261
+ const workspaceId = args.workspaceId || deps.getActiveWorkspaceId();
3262
+ if (!workspaceId) {
3263
+ throw new Error("No workspace specified. Use harmony_set_workspace_context or provide workspaceId.");
3264
+ }
3265
+ const projectId = args.projectId || deps.getActiveProjectId() || undefined;
3266
+ const report = await runMemoryCleanup(client, workspaceId, projectId, {
3267
+ dryRun: args.dryRun,
3268
+ steps: args.steps,
3269
+ maxAgeDays: args.maxAgeDays,
3270
+ minClusterSize: args.minClusterSize,
3271
+ orphanAgeDays: args.orphanAgeDays,
3272
+ });
3273
+ return {
3274
+ success: report.success,
3275
+ dryRun: report.dryRun,
3276
+ summary: report.summary,
3277
+ errors: report.errors,
3278
+ healthReport: report.healthReport,
3279
+ };
3280
+ }
3218
3281
  default:
3219
3282
  throw new Error(`Unknown tool: ${name}`);
3220
3283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.2.4",
3
+ "version": "2.3.0",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"