@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.
@@ -111,15 +111,15 @@ export interface MidSessionContext {
111
111
  * Called from harmony_update_agent_progress.
112
112
  */
113
113
  export async function extractMidSessionLearnings(
114
- client: HarmonyApiClient,
114
+ _client: HarmonyApiClient,
115
115
  ctx: MidSessionContext,
116
116
  ): Promise<{ count: number; entityIds: string[] }> {
117
117
  const workspaceId = getActiveWorkspaceId();
118
118
  if (!workspaceId) return { count: 0, entityIds: [] };
119
119
 
120
- const projectId = getActiveProjectId() || undefined;
120
+ const _projectId = getActiveProjectId() || undefined;
121
121
  const now = Date.now();
122
- const entityIds: string[] = [];
122
+ const _entityIds: string[] = [];
123
123
 
124
124
  const history = sessionTaskHistory.get(ctx.cardId);
125
125
 
@@ -164,83 +164,31 @@ export async function extractMidSessionLearnings(
164
164
  }
165
165
  }
166
166
 
167
- // Rule 1: Status transitions to "blocked" → create error entity immediately
167
+ // Rule 1: Status transitions to "blocked" → track but DON'T create mid-session entities.
168
+ // Blockers are captured at session end with full context — mid-session entities are
169
+ // low-confidence duplicates that add noise to the knowledge graph.
168
170
  if (ctx.status === "blocked" && ctx.blockers?.length) {
169
- for (const blocker of ctx.blockers) {
170
- try {
171
- const result = await client.createMemoryEntity({
172
- workspace_id: workspaceId,
173
- project_id: projectId,
174
- type: "error",
175
- scope: "project",
176
- memory_tier: "draft",
177
- title: `Blocker (mid-session): ${blocker.slice(0, 100)}`,
178
- content: `Encountered while working on "${ctx.cardTitle}":\n\n${blocker}\n\nAgent: ${ctx.agentName}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
179
- confidence: 0.5,
180
- tags: ["auto-extracted", "blocker", "mid-session"],
181
- metadata: {
182
- source: "mid_session",
183
- card_id: ctx.cardId,
184
- },
185
- agent_identifier: ctx.agentIdentifier,
186
- });
187
- const entity = result.entity as { id: string };
188
- if (entity?.id) entityIds.push(entity.id);
189
- } catch {
190
- // Non-fatal
191
- }
192
- }
193
171
  sessionTaskHistory.set(ctx.cardId, {
194
172
  lastTask: ctx.currentTask || "",
195
173
  lastExtractionAt: now,
196
174
  steps: history?.steps || [],
197
175
  });
198
- return { count: entityIds.length, entityIds };
176
+ return { count: 0, entityIds: [] };
199
177
  }
200
178
 
201
- // Rule 2: Task changed significantly capture context entity
179
+ // Rule 2: Task transitions are tracked in step history (above) but no longer
180
+ // create separate context entities. The step history feeds procedure extraction
181
+ // at session end, which is more valuable than individual transition snapshots.
202
182
  if (ctx.currentTask) {
203
- const previousTask = history?.lastTask || "";
204
- const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
205
-
206
- if (similarity < 0.6 && previousTask.length > 0) {
207
- try {
208
- const result = await client.createMemoryEntity({
209
- workspace_id: workspaceId,
210
- project_id: projectId,
211
- type: "context",
212
- scope: "project",
213
- memory_tier: "draft",
214
- title: `Task transition: ${ctx.cardTitle}`,
215
- content: `Agent transitioned tasks on "${ctx.cardTitle}".\n\nPrevious: ${previousTask}\nCurrent: ${ctx.currentTask}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
216
- confidence: 0.5,
217
- tags: ["auto-extracted", "task-transition", "mid-session"],
218
- metadata: {
219
- source: "mid_session",
220
- card_id: ctx.cardId,
221
- previous_task: previousTask,
222
- current_task: ctx.currentTask,
223
- },
224
- agent_identifier: ctx.agentIdentifier,
225
- });
226
- const entity = result.entity as { id: string };
227
- if (entity?.id) entityIds.push(entity.id);
228
- } catch {
229
- // Non-fatal
230
- }
231
- }
232
-
233
- // Update lastExtractionAt only when entities were created
234
183
  const currentHistory = sessionTaskHistory.get(ctx.cardId);
235
184
  sessionTaskHistory.set(ctx.cardId, {
236
185
  lastTask: ctx.currentTask,
237
- lastExtractionAt:
238
- entityIds.length > 0 ? now : (currentHistory?.lastExtractionAt ?? 0),
186
+ lastExtractionAt: currentHistory?.lastExtractionAt ?? 0,
239
187
  steps: currentHistory?.steps || [],
240
188
  });
241
189
  }
242
190
 
243
- return { count: entityIds.length, entityIds };
191
+ return { count: 0, entityIds: [] };
244
192
  }
245
193
 
246
194
  /**
@@ -589,51 +537,71 @@ export async function extractLearnings(
589
537
  ? `\nRelated: ${relatedEntityTitles.map((t) => `[[${t}]]`).join(", ")}`
590
538
  : "";
591
539
 
592
- // Rule 1: Session had blockers → create error entities
540
+ // Rule 1: Session had blockers → create error entity (only for substantial blockers)
541
+ // Skip trivial blocker strings — only store if the blocker text contains
542
+ // enough detail to be useful to a future agent (>80 chars).
593
543
  if (session.blockers && session.blockers.length > 0) {
594
544
  for (const blocker of session.blockers) {
595
- learnings.push({
596
- title: `Blocker: ${blocker.slice(0, 100)}`,
597
- content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
598
- type: "error",
599
- tier: "reference",
600
- confidence: 0.7,
601
- tags: ["auto-extracted", "blocker", ...session.cardLabels.slice(0, 3)],
602
- metadata: {
603
- source: "active_learning",
604
- card_id: session.cardId,
605
- },
606
- });
545
+ if (blocker.length < 80) continue; // Skip trivial blockers like "stuck" or "waiting on API"
546
+
547
+ // Dedup: check if a similar error entity already exists
548
+ let isDuplicate = false;
549
+ try {
550
+ const similar = await findSimilarEntities(
551
+ client,
552
+ blocker.slice(0, 200),
553
+ blocker,
554
+ workspaceId,
555
+ { projectId, limit: 3, minRrfScore: 0.05 },
556
+ );
557
+ isDuplicate = similar.some(
558
+ (e) => e.type === "error" && (e.rrf_score ?? 0) >= 0.06,
559
+ );
560
+ } catch {
561
+ /* non-fatal */
562
+ }
563
+
564
+ if (!isDuplicate) {
565
+ learnings.push({
566
+ title: `Blocker: ${blocker.slice(0, 100)}`,
567
+ content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
568
+ type: "error",
569
+ tier: "episode",
570
+ confidence: 0.6,
571
+ tags: [
572
+ "auto-extracted",
573
+ "blocker",
574
+ ...session.cardLabels.slice(0, 3),
575
+ ],
576
+ metadata: {
577
+ source: "active_learning",
578
+ card_id: session.cardId,
579
+ },
580
+ });
581
+ }
607
582
  }
608
583
  }
609
584
 
610
- // Rule 2: Session completed → create lesson entity summarizing work
611
- // Only create when there's meaningful content beyond "completed X at 100%"
612
- const hasMeaningfulContent =
613
- (session.blockers?.length ?? 0) > 0 ||
614
- session.status === "paused" ||
615
- ((session.cardSubtasks?.length ?? 0) > 0 &&
616
- session.cardSubtasks?.some((s) => !s.done));
617
-
618
- if (session.status === "completed" && hasMeaningfulContent) {
585
+ // Rule 2: Session paused with blockers → create lesson (paused only, not clean completions).
586
+ // Clean completions produce no reusable knowledge the work is in the code/PR.
587
+ // Only create a lesson when the session was interrupted (paused with blockers),
588
+ // so a future agent can understand what was left unfinished and why.
589
+ if (session.status === "paused" && (session.blockers?.length ?? 0) > 0) {
619
590
  const durationInfo = session.sessionDurationMs
620
591
  ? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
621
592
  : "";
622
593
 
623
594
  learnings.push({
624
- title: `Session: ${session.cardTitle}`,
595
+ title: `Paused: ${session.cardTitle}`,
625
596
  content: [
626
- `Completed work on "${session.cardTitle}".`,
627
- session.currentTask ? `Final task: ${session.currentTask}` : "",
597
+ `Paused work on "${session.cardTitle}".`,
598
+ session.currentTask ? `Last task: ${session.currentTask}` : "",
628
599
  session.progressPercent !== undefined
629
600
  ? `Progress: ${session.progressPercent}%`
630
601
  : "",
631
602
  durationInfo,
632
- session.cardLabels.length > 0
633
- ? `Labels: ${session.cardLabels.join(", ")}`
634
- : "",
635
603
  session.blockers?.length
636
- ? `Blockers encountered: ${session.blockers.join("; ")}`
604
+ ? `Blockers: ${session.blockers.join("; ")}`
637
605
  : "",
638
606
  `\nAgent: ${session.agentName}`,
639
607
  wikiLinksLine,
@@ -641,11 +609,11 @@ export async function extractLearnings(
641
609
  .filter(Boolean)
642
610
  .join("\n"),
643
611
  type: "lesson",
644
- tier: "episode",
645
- confidence: 0.7,
612
+ tier: "draft",
613
+ confidence: 0.6,
646
614
  tags: [
647
615
  "auto-extracted",
648
- "session-summary",
616
+ "session-paused",
649
617
  ...session.cardLabels.slice(0, 3),
650
618
  ],
651
619
  metadata: {
@@ -655,45 +623,30 @@ export async function extractLearnings(
655
623
  });
656
624
  }
657
625
 
658
- // Rule 3: Card had "bug" label + completed → create solution entity
659
- const hasBugLabel = session.cardLabels.some((l) =>
660
- ["bug", "fix", "hotfix", "defect", "error"].includes(l.toLowerCase()),
661
- );
662
- if (hasBugLabel && session.status === "completed") {
663
- learnings.push({
664
- title: `Solution: ${session.cardTitle}`,
665
- content: [
666
- `Resolved bug: "${session.cardTitle}"`,
667
- session.currentTask ? `\nApproach: ${session.currentTask}` : "",
668
- `\nAgent: ${session.agentName}`,
669
- wikiLinksLine,
670
- ]
671
- .filter(Boolean)
672
- .join("\n"),
673
- type: "solution",
674
- tier: "reference",
675
- confidence: 0.8,
676
- tags: ["auto-extracted", "bug-fix", ...session.cardLabels.slice(0, 3)],
677
- metadata: {
678
- source: "active_learning",
679
- card_id: session.cardId,
680
- auto_confidence: true,
681
- },
682
- });
683
- }
626
+ // Rule 3: Bug solution REMOVED.
627
+ // Storing "Resolved bug: {card title}" with no detail about the actual fix
628
+ // adds zero value. The real solution is in the code diff / PR. Agents should
629
+ // use `harmony_remember` to store non-obvious root cause details manually.
684
630
 
685
631
  // Store learnings, tracking entity ID → learning for graph expansion
686
632
  const entityIds: string[] = [];
687
633
 
688
634
  // Rule 4: Successful session with tracked steps → create or reinforce procedure entity
635
+ // Thresholds raised: require 5+ distinct steps AND 10+ minute duration to avoid
636
+ // creating "procedures" from trivial tasks (e.g., a 2-step "investigate → fix" session).
689
637
  const stepHistory = sessionTaskHistory.get(session.cardId);
690
- const hasEnoughSteps = stepHistory && stepHistory.steps.length >= 2;
638
+ const MIN_PROCEDURE_STEPS = 5;
639
+ const MIN_PROCEDURE_DURATION_MS = 10 * 60 * 1000; // 10 minutes
640
+ const hasEnoughSteps =
641
+ stepHistory && stepHistory.steps.length >= MIN_PROCEDURE_STEPS;
642
+ const hasMinDuration =
643
+ (session.sessionDurationMs ?? 0) >= MIN_PROCEDURE_DURATION_MS;
691
644
  const isSuccessful =
692
645
  session.status === "completed" &&
693
646
  (session.progressPercent === undefined || session.progressPercent >= 85) &&
694
647
  !session.blockers?.length;
695
648
 
696
- if (isSuccessful && hasEnoughSteps) {
649
+ if (isSuccessful && hasEnoughSteps && hasMinDuration) {
697
650
  const procedureResult = await extractOrReinforceProcedure(
698
651
  client,
699
652
  session,
@@ -784,27 +737,12 @@ export async function extractLearnings(
784
737
  );
785
738
  }
786
739
 
787
- // Detect recurring patterns across sessions (fire-and-forget)
788
- if (entityIds.length > 0) {
789
- detectAndCreatePatterns(
790
- client,
791
- entityIds,
792
- session,
793
- workspaceId,
794
- projectId,
795
- ).catch(() => {});
796
- }
797
-
798
- // Detect recurring causal patterns (error→solution chains across sessions)
799
- if (createdPairs.length > 0) {
800
- detectCausalPatterns(
801
- client,
802
- createdPairs,
803
- session,
804
- workspaceId,
805
- projectId,
806
- ).catch(() => {});
807
- }
740
+ // Pattern detection DISABLED these create noise entities like
741
+ // "Pattern: recurring procedure (N instances)" that are just catalogs of
742
+ // entity titles, eating token budget with zero actionable content.
743
+ // The consolidation tool (harmony_consolidate_memories) serves a similar
744
+ // purpose and can be improved separately with LLM synthesis.
745
+ // See: https://github.com/getharmony/getharmony/issues/memory-quality
808
746
 
809
747
  // Clean up mid-session tracking
810
748
  clearMidSessionTracking(session.cardId);
@@ -34,7 +34,7 @@ export interface ConsolidationResult {
34
34
 
35
35
  export interface ConsolidationOptions {
36
36
  dryRun?: boolean;
37
- minClusterSize?: number;
37
+ minClusterSize?: number; // Default: 3 (was 2 — raised to avoid premature merging)
38
38
  }
39
39
 
40
40
  /**
@@ -52,7 +52,7 @@ export async function consolidateMemories(
52
52
  options?: ConsolidationOptions,
53
53
  ): Promise<ConsolidationResult> {
54
54
  const dryRun = options?.dryRun !== false; // default true
55
- const minClusterSize = options?.minClusterSize ?? 2;
55
+ const minClusterSize = options?.minClusterSize ?? 3; // raised from 2 to reduce noise
56
56
 
57
57
  const result: ConsolidationResult = {
58
58
  consolidated: 0,
@@ -153,11 +153,10 @@ export async function consolidateMemories(
153
153
  const mergedTitle = deriveClusterTitle(cluster, type);
154
154
  const memberTitles = cluster.map((e) => e.title);
155
155
 
156
- // Merge content as bullet points
157
- const mergedContent = [
158
- `Consolidated from ${cluster.length} ${type} memories:\n`,
159
- ...cluster.map((e) => `- **${e.title}**: ${e.content.slice(0, 200)}`),
160
- ].join("\n");
156
+ // Synthesize content: extract unique knowledge from each member,
157
+ // not just a bullet list of titles. Each member's content is trimmed
158
+ // to its first meaningful paragraph (skipping headers and metadata).
159
+ const mergedContent = synthesizeClusterContent(cluster, type);
161
160
 
162
161
  // Max confidence from cluster members
163
162
  const maxConfidence = Math.max(...cluster.map((e) => e.confidence));
@@ -241,6 +240,76 @@ export async function consolidateMemories(
241
240
  return result;
242
241
  }
243
242
 
243
+ /**
244
+ * Synthesize cluster content by extracting unique, actionable knowledge
245
+ * from each member entity. Skips boilerplate (headers, metadata, agent names)
246
+ * and deduplicates similar lines across members.
247
+ */
248
+ function synthesizeClusterContent(
249
+ cluster: MemoryEntity[],
250
+ type: string,
251
+ ): string {
252
+ // Lines to skip: headers, agent metadata, timestamps, progress percentages
253
+ const SKIP_PATTERNS = [
254
+ /^##\s/,
255
+ /^Agent:/,
256
+ /^Duration:/,
257
+ /^Labels:/,
258
+ /^Progress:/,
259
+ /^Session status:/,
260
+ /^Completed at/,
261
+ /^Final state:/,
262
+ /^Related:/,
263
+ /^When working on:/,
264
+ /^\d+\.\s+.+\(\d+%,\s*\+\d+%\)/, // procedure step with progress percentages
265
+ /^Last updated:/,
266
+ /^Recurring pattern:/,
267
+ /^Consolidated from/,
268
+ ];
269
+
270
+ const seenLines = new Set<string>();
271
+ const knowledgeLines: string[] = [];
272
+
273
+ for (const entity of cluster) {
274
+ const lines = entity.content.split("\n").map((l) => l.trim());
275
+
276
+ for (const line of lines) {
277
+ if (!line || line.length < 20) continue;
278
+ if (SKIP_PATTERNS.some((p) => p.test(line))) continue;
279
+
280
+ // Normalize for dedup: lowercase, strip markdown formatting
281
+ const normalized = line
282
+ .toLowerCase()
283
+ .replace(/[*_`#[\]]/g, "")
284
+ .trim();
285
+ if (seenLines.has(normalized)) continue;
286
+ seenLines.add(normalized);
287
+
288
+ knowledgeLines.push(line);
289
+ }
290
+ }
291
+
292
+ if (knowledgeLines.length === 0) {
293
+ // Fallback: if no knowledge was extractable, use a compact summary
294
+ return `${cluster.length} related ${type} entities consolidated. Original titles:\n${cluster.map((e) => `- ${e.title}`).join("\n")}`;
295
+ }
296
+
297
+ // Cap at ~400 tokens worth of content (1600 chars)
298
+ const MAX_CHARS = 1600;
299
+ const result: string[] = [
300
+ `Consolidated knowledge from ${cluster.length} ${type} entities:\n`,
301
+ ];
302
+ let charCount = result[0].length;
303
+
304
+ for (const line of knowledgeLines) {
305
+ if (charCount + line.length + 3 > MAX_CHARS) break;
306
+ result.push(`- ${line}`);
307
+ charCount += line.length + 3;
308
+ }
309
+
310
+ return result.join("\n");
311
+ }
312
+
244
313
  /**
245
314
  * Derive a cluster title from the most common meaningful words across member titles.
246
315
  */
@@ -303,12 +372,12 @@ function deriveClusterTitle(cluster: MemoryEntity[], type: string): string {
303
372
  }
304
373
  }
305
374
 
306
- // Sort by frequency, take top 3
375
+ // Sort by frequency, take top 4 for more descriptive titles
307
376
  const topWords = [...wordCounts.entries()]
308
377
  .sort((a, b) => b[1] - a[1])
309
- .slice(0, 3)
310
- .map(([word]) => word);
378
+ .slice(0, 4)
379
+ .map(([word]) => word[0].toUpperCase() + word.slice(1));
311
380
 
312
- const suffix = topWords.length > 0 ? topWords.join(", ") : "various";
313
- return `Consolidated ${type}: ${suffix}`;
381
+ const suffix = topWords.length > 0 ? topWords.join(" / ") : "Various";
382
+ return `${type[0].toUpperCase() + type.slice(1)}: ${suffix}`;
314
383
  }