@gethmy/mcp 2.2.3 → 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.
@@ -49,13 +49,13 @@ function levenshteinSimilarity(a, b) {
49
49
  * Extract learnings from mid-session progress updates.
50
50
  * Called from harmony_update_agent_progress.
51
51
  */
52
- export async function extractMidSessionLearnings(client, ctx) {
52
+ export async function extractMidSessionLearnings(_client, ctx) {
53
53
  const workspaceId = getActiveWorkspaceId();
54
54
  if (!workspaceId)
55
55
  return { count: 0, entityIds: [] };
56
- const projectId = getActiveProjectId() || undefined;
56
+ const _projectId = getActiveProjectId() || undefined;
57
57
  const now = Date.now();
58
- const entityIds = [];
58
+ const _entityIds = [];
59
59
  const history = sessionTaskHistory.get(ctx.cardId);
60
60
  // Always track step history regardless of rate limit
61
61
  if (ctx.currentTask) {
@@ -91,82 +91,29 @@ export async function extractMidSessionLearnings(client, ctx) {
91
91
  return { count: 0, entityIds: [] };
92
92
  }
93
93
  }
94
- // Rule 1: Status transitions to "blocked" → create error entity immediately
94
+ // Rule 1: Status transitions to "blocked" → track but DON'T create mid-session entities.
95
+ // Blockers are captured at session end with full context — mid-session entities are
96
+ // low-confidence duplicates that add noise to the knowledge graph.
95
97
  if (ctx.status === "blocked" && ctx.blockers?.length) {
96
- for (const blocker of ctx.blockers) {
97
- try {
98
- const result = await client.createMemoryEntity({
99
- workspace_id: workspaceId,
100
- project_id: projectId,
101
- type: "error",
102
- scope: "project",
103
- memory_tier: "draft",
104
- title: `Blocker (mid-session): ${blocker.slice(0, 100)}`,
105
- content: `Encountered while working on "${ctx.cardTitle}":\n\n${blocker}\n\nAgent: ${ctx.agentName}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
106
- confidence: 0.5,
107
- tags: ["auto-extracted", "blocker", "mid-session"],
108
- metadata: {
109
- source: "mid_session",
110
- card_id: ctx.cardId,
111
- },
112
- agent_identifier: ctx.agentIdentifier,
113
- });
114
- const entity = result.entity;
115
- if (entity?.id)
116
- entityIds.push(entity.id);
117
- }
118
- catch {
119
- // Non-fatal
120
- }
121
- }
122
98
  sessionTaskHistory.set(ctx.cardId, {
123
99
  lastTask: ctx.currentTask || "",
124
100
  lastExtractionAt: now,
125
101
  steps: history?.steps || [],
126
102
  });
127
- return { count: entityIds.length, entityIds };
103
+ return { count: 0, entityIds: [] };
128
104
  }
129
- // Rule 2: Task changed significantly capture context entity
105
+ // Rule 2: Task transitions are tracked in step history (above) but no longer
106
+ // create separate context entities. The step history feeds procedure extraction
107
+ // at session end, which is more valuable than individual transition snapshots.
130
108
  if (ctx.currentTask) {
131
- const previousTask = history?.lastTask || "";
132
- const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
133
- if (similarity < 0.6 && previousTask.length > 0) {
134
- try {
135
- const result = await client.createMemoryEntity({
136
- workspace_id: workspaceId,
137
- project_id: projectId,
138
- type: "context",
139
- scope: "project",
140
- memory_tier: "draft",
141
- title: `Task transition: ${ctx.cardTitle}`,
142
- content: `Agent transitioned tasks on "${ctx.cardTitle}".\n\nPrevious: ${previousTask}\nCurrent: ${ctx.currentTask}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
143
- confidence: 0.5,
144
- tags: ["auto-extracted", "task-transition", "mid-session"],
145
- metadata: {
146
- source: "mid_session",
147
- card_id: ctx.cardId,
148
- previous_task: previousTask,
149
- current_task: ctx.currentTask,
150
- },
151
- agent_identifier: ctx.agentIdentifier,
152
- });
153
- const entity = result.entity;
154
- if (entity?.id)
155
- entityIds.push(entity.id);
156
- }
157
- catch {
158
- // Non-fatal
159
- }
160
- }
161
- // Update lastExtractionAt only when entities were created
162
109
  const currentHistory = sessionTaskHistory.get(ctx.cardId);
163
110
  sessionTaskHistory.set(ctx.cardId, {
164
111
  lastTask: ctx.currentTask,
165
- lastExtractionAt: entityIds.length > 0 ? now : (currentHistory?.lastExtractionAt ?? 0),
112
+ lastExtractionAt: currentHistory?.lastExtractionAt ?? 0,
166
113
  steps: currentHistory?.steps || [],
167
114
  });
168
115
  }
169
- return { count: entityIds.length, entityIds };
116
+ return { count: 0, entityIds: [] };
170
117
  }
171
118
  /**
172
119
  * Clean up mid-session tracking for a card (call on session end).
@@ -402,47 +349,61 @@ export async function extractLearnings(client, session) {
402
349
  const wikiLinksLine = relatedEntityTitles.length > 0
403
350
  ? `\nRelated: ${relatedEntityTitles.map((t) => `[[${t}]]`).join(", ")}`
404
351
  : "";
405
- // Rule 1: Session had blockers → create error entities
352
+ // Rule 1: Session had blockers → create error entity (only for substantial blockers)
353
+ // Skip trivial blocker strings — only store if the blocker text contains
354
+ // enough detail to be useful to a future agent (>80 chars).
406
355
  if (session.blockers && session.blockers.length > 0) {
407
356
  for (const blocker of session.blockers) {
408
- learnings.push({
409
- title: `Blocker: ${blocker.slice(0, 100)}`,
410
- content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
411
- type: "error",
412
- tier: "reference",
413
- confidence: 0.7,
414
- tags: ["auto-extracted", "blocker", ...session.cardLabels.slice(0, 3)],
415
- metadata: {
416
- source: "active_learning",
417
- card_id: session.cardId,
418
- },
419
- });
357
+ if (blocker.length < 80)
358
+ continue; // Skip trivial blockers like "stuck" or "waiting on API"
359
+ // Dedup: check if a similar error entity already exists
360
+ let isDuplicate = false;
361
+ try {
362
+ const similar = await findSimilarEntities(client, blocker.slice(0, 200), blocker, workspaceId, { projectId, limit: 3, minRrfScore: 0.05 });
363
+ isDuplicate = similar.some((e) => e.type === "error" && (e.rrf_score ?? 0) >= 0.06);
364
+ }
365
+ catch {
366
+ /* non-fatal */
367
+ }
368
+ if (!isDuplicate) {
369
+ learnings.push({
370
+ title: `Blocker: ${blocker.slice(0, 100)}`,
371
+ content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
372
+ type: "error",
373
+ tier: "episode",
374
+ confidence: 0.6,
375
+ tags: [
376
+ "auto-extracted",
377
+ "blocker",
378
+ ...session.cardLabels.slice(0, 3),
379
+ ],
380
+ metadata: {
381
+ source: "active_learning",
382
+ card_id: session.cardId,
383
+ },
384
+ });
385
+ }
420
386
  }
421
387
  }
422
- // Rule 2: Session completed → create lesson entity summarizing work
423
- // Only create when there's meaningful content beyond "completed X at 100%"
424
- const hasMeaningfulContent = (session.blockers?.length ?? 0) > 0 ||
425
- session.status === "paused" ||
426
- ((session.cardSubtasks?.length ?? 0) > 0 &&
427
- session.cardSubtasks?.some((s) => !s.done));
428
- if (session.status === "completed" && hasMeaningfulContent) {
388
+ // Rule 2: Session paused with blockers → create lesson (paused only, not clean completions).
389
+ // Clean completions produce no reusable knowledge the work is in the code/PR.
390
+ // Only create a lesson when the session was interrupted (paused with blockers),
391
+ // so a future agent can understand what was left unfinished and why.
392
+ if (session.status === "paused" && (session.blockers?.length ?? 0) > 0) {
429
393
  const durationInfo = session.sessionDurationMs
430
394
  ? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
431
395
  : "";
432
396
  learnings.push({
433
- title: `Session: ${session.cardTitle}`,
397
+ title: `Paused: ${session.cardTitle}`,
434
398
  content: [
435
- `Completed work on "${session.cardTitle}".`,
436
- session.currentTask ? `Final task: ${session.currentTask}` : "",
399
+ `Paused work on "${session.cardTitle}".`,
400
+ session.currentTask ? `Last task: ${session.currentTask}` : "",
437
401
  session.progressPercent !== undefined
438
402
  ? `Progress: ${session.progressPercent}%`
439
403
  : "",
440
404
  durationInfo,
441
- session.cardLabels.length > 0
442
- ? `Labels: ${session.cardLabels.join(", ")}`
443
- : "",
444
405
  session.blockers?.length
445
- ? `Blockers encountered: ${session.blockers.join("; ")}`
406
+ ? `Blockers: ${session.blockers.join("; ")}`
446
407
  : "",
447
408
  `\nAgent: ${session.agentName}`,
448
409
  wikiLinksLine,
@@ -450,11 +411,11 @@ export async function extractLearnings(client, session) {
450
411
  .filter(Boolean)
451
412
  .join("\n"),
452
413
  type: "lesson",
453
- tier: "episode",
454
- confidence: 0.7,
414
+ tier: "draft",
415
+ confidence: 0.6,
455
416
  tags: [
456
417
  "auto-extracted",
457
- "session-summary",
418
+ "session-paused",
458
419
  ...session.cardLabels.slice(0, 3),
459
420
  ],
460
421
  metadata: {
@@ -463,39 +424,24 @@ export async function extractLearnings(client, session) {
463
424
  },
464
425
  });
465
426
  }
466
- // Rule 3: Card had "bug" label + completed → create solution entity
467
- const hasBugLabel = session.cardLabels.some((l) => ["bug", "fix", "hotfix", "defect", "error"].includes(l.toLowerCase()));
468
- if (hasBugLabel && session.status === "completed") {
469
- learnings.push({
470
- title: `Solution: ${session.cardTitle}`,
471
- content: [
472
- `Resolved bug: "${session.cardTitle}"`,
473
- session.currentTask ? `\nApproach: ${session.currentTask}` : "",
474
- `\nAgent: ${session.agentName}`,
475
- wikiLinksLine,
476
- ]
477
- .filter(Boolean)
478
- .join("\n"),
479
- type: "solution",
480
- tier: "reference",
481
- confidence: 0.8,
482
- tags: ["auto-extracted", "bug-fix", ...session.cardLabels.slice(0, 3)],
483
- metadata: {
484
- source: "active_learning",
485
- card_id: session.cardId,
486
- auto_confidence: true,
487
- },
488
- });
489
- }
427
+ // Rule 3: Bug solution REMOVED.
428
+ // Storing "Resolved bug: {card title}" with no detail about the actual fix
429
+ // adds zero value. The real solution is in the code diff / PR. Agents should
430
+ // use `harmony_remember` to store non-obvious root cause details manually.
490
431
  // Store learnings, tracking entity ID → learning for graph expansion
491
432
  const entityIds = [];
492
433
  // Rule 4: Successful session with tracked steps → create or reinforce procedure entity
434
+ // Thresholds raised: require 5+ distinct steps AND 10+ minute duration to avoid
435
+ // creating "procedures" from trivial tasks (e.g., a 2-step "investigate → fix" session).
493
436
  const stepHistory = sessionTaskHistory.get(session.cardId);
494
- const hasEnoughSteps = stepHistory && stepHistory.steps.length >= 2;
437
+ const MIN_PROCEDURE_STEPS = 5;
438
+ const MIN_PROCEDURE_DURATION_MS = 10 * 60 * 1000; // 10 minutes
439
+ const hasEnoughSteps = stepHistory && stepHistory.steps.length >= MIN_PROCEDURE_STEPS;
440
+ const hasMinDuration = (session.sessionDurationMs ?? 0) >= MIN_PROCEDURE_DURATION_MS;
495
441
  const isSuccessful = session.status === "completed" &&
496
442
  (session.progressPercent === undefined || session.progressPercent >= 85) &&
497
443
  !session.blockers?.length;
498
- if (isSuccessful && hasEnoughSteps) {
444
+ if (isSuccessful && hasEnoughSteps && hasMinDuration) {
499
445
  const procedureResult = await extractOrReinforceProcedure(client, session, stepHistory.steps, workspaceId, projectId, wikiLinksLine);
500
446
  if (procedureResult) {
501
447
  if (procedureResult.mode === "created") {
@@ -547,14 +493,12 @@ export async function extractLearnings(client, session) {
547
493
  if (createdPairs.length >= 2) {
548
494
  linkSessionEntities(client, createdPairs, workspaceId, projectId).catch(() => { });
549
495
  }
550
- // Detect recurring patterns across sessions (fire-and-forget)
551
- if (entityIds.length > 0) {
552
- detectAndCreatePatterns(client, entityIds, session, workspaceId, projectId).catch(() => { });
553
- }
554
- // Detect recurring causal patterns (error→solution chains across sessions)
555
- if (createdPairs.length > 0) {
556
- detectCausalPatterns(client, createdPairs, session, workspaceId, projectId).catch(() => { });
557
- }
496
+ // Pattern detection DISABLED these create noise entities like
497
+ // "Pattern: recurring procedure (N instances)" that are just catalogs of
498
+ // entity titles, eating token budget with zero actionable content.
499
+ // The consolidation tool (harmony_consolidate_memories) serves a similar
500
+ // purpose and can be improved separately with LLM synthesis.
501
+ // See: https://github.com/getharmony/getharmony/issues/memory-quality
558
502
  // Clean up mid-session tracking
559
503
  clearMidSessionTracking(session.cardId);
560
504
  return { count: entityIds.length, entityIds };
@@ -15,7 +15,7 @@ import { findSimilarEntities } from "./graph-expansion.js";
15
15
  */
16
16
  export async function consolidateMemories(client, workspaceId, projectId, options) {
17
17
  const dryRun = options?.dryRun !== false; // default true
18
- const minClusterSize = options?.minClusterSize ?? 2;
18
+ const minClusterSize = options?.minClusterSize ?? 3; // raised from 2 to reduce noise
19
19
  const result = {
20
20
  consolidated: 0,
21
21
  clustersFound: 0,
@@ -92,11 +92,10 @@ export async function consolidateMemories(client, workspaceId, projectId, option
92
92
  // Derive title from most common words across cluster titles
93
93
  const mergedTitle = deriveClusterTitle(cluster, type);
94
94
  const memberTitles = cluster.map((e) => e.title);
95
- // Merge content as bullet points
96
- const mergedContent = [
97
- `Consolidated from ${cluster.length} ${type} memories:\n`,
98
- ...cluster.map((e) => `- **${e.title}**: ${e.content.slice(0, 200)}`),
99
- ].join("\n");
95
+ // Synthesize content: extract unique knowledge from each member,
96
+ // not just a bullet list of titles. Each member's content is trimmed
97
+ // to its first meaningful paragraph (skipping headers and metadata).
98
+ const mergedContent = synthesizeClusterContent(cluster, type);
100
99
  // Max confidence from cluster members
101
100
  const maxConfidence = Math.max(...cluster.map((e) => e.confidence));
102
101
  // Union of all tags (deduped)
@@ -173,6 +172,67 @@ export async function consolidateMemories(client, workspaceId, projectId, option
173
172
  }
174
173
  return result;
175
174
  }
175
+ /**
176
+ * Synthesize cluster content by extracting unique, actionable knowledge
177
+ * from each member entity. Skips boilerplate (headers, metadata, agent names)
178
+ * and deduplicates similar lines across members.
179
+ */
180
+ function synthesizeClusterContent(cluster, type) {
181
+ // Lines to skip: headers, agent metadata, timestamps, progress percentages
182
+ const SKIP_PATTERNS = [
183
+ /^##\s/,
184
+ /^Agent:/,
185
+ /^Duration:/,
186
+ /^Labels:/,
187
+ /^Progress:/,
188
+ /^Session status:/,
189
+ /^Completed at/,
190
+ /^Final state:/,
191
+ /^Related:/,
192
+ /^When working on:/,
193
+ /^\d+\.\s+.+\(\d+%,\s*\+\d+%\)/, // procedure step with progress percentages
194
+ /^Last updated:/,
195
+ /^Recurring pattern:/,
196
+ /^Consolidated from/,
197
+ ];
198
+ const seenLines = new Set();
199
+ const knowledgeLines = [];
200
+ for (const entity of cluster) {
201
+ const lines = entity.content.split("\n").map((l) => l.trim());
202
+ for (const line of lines) {
203
+ if (!line || line.length < 20)
204
+ continue;
205
+ if (SKIP_PATTERNS.some((p) => p.test(line)))
206
+ continue;
207
+ // Normalize for dedup: lowercase, strip markdown formatting
208
+ const normalized = line
209
+ .toLowerCase()
210
+ .replace(/[*_`#[\]]/g, "")
211
+ .trim();
212
+ if (seenLines.has(normalized))
213
+ continue;
214
+ seenLines.add(normalized);
215
+ knowledgeLines.push(line);
216
+ }
217
+ }
218
+ if (knowledgeLines.length === 0) {
219
+ // Fallback: if no knowledge was extractable, use a compact summary
220
+ return `${cluster.length} related ${type} entities consolidated. Original titles:\n${cluster.map((e) => `- ${e.title}`).join("\n")}`;
221
+ }
222
+ // Cap at ~400 tokens worth of content (1600 chars)
223
+ const MAX_CHARS = 1600;
224
+ const result = [
225
+ `Consolidated knowledge from ${cluster.length} ${type} entities:\n`,
226
+ ];
227
+ let charCount = result[0].length;
228
+ for (const line of knowledgeLines) {
229
+ if (charCount + line.length + 3 > MAX_CHARS)
230
+ break;
231
+ result.push(`- ${line}`);
232
+ charCount += line.length + 3;
233
+ }
234
+ return result.join("\n");
235
+ }
176
236
  /**
177
237
  * Derive a cluster title from the most common meaningful words across member titles.
178
238
  */
@@ -233,11 +293,11 @@ function deriveClusterTitle(cluster, type) {
233
293
  wordCounts.set(word, (wordCounts.get(word) || 0) + 1);
234
294
  }
235
295
  }
236
- // Sort by frequency, take top 3
296
+ // Sort by frequency, take top 4 for more descriptive titles
237
297
  const topWords = [...wordCounts.entries()]
238
298
  .sort((a, b) => b[1] - a[1])
239
- .slice(0, 3)
240
- .map(([word]) => word);
241
- const suffix = topWords.length > 0 ? topWords.join(", ") : "various";
242
- return `Consolidated ${type}: ${suffix}`;
299
+ .slice(0, 4)
300
+ .map(([word]) => word[0].toUpperCase() + word.slice(1));
301
+ const suffix = topWords.length > 0 ? topWords.join(" / ") : "Various";
302
+ return `${type[0].toUpperCase() + type.slice(1)}: ${suffix}`;
243
303
  }