@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.
@@ -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
  }
@@ -87,7 +87,7 @@ export interface AssembledContext {
87
87
  // Constants
88
88
  const DEFAULT_TOKEN_BUDGET = 4000;
89
89
  const MAX_TOKENS_PER_ENTITY = 500;
90
- const MIN_RELEVANCE_THRESHOLD = 0.1;
90
+ const MIN_RELEVANCE_THRESHOLD = 0.15; // raised from 0.1 to filter low-signal entities
91
91
 
92
92
  // Tier weight multipliers for relevance scoring
93
93
  const TIER_WEIGHTS: Record<MemoryTier, number> = {
@@ -106,8 +106,8 @@ const TIER_BUDGET_ALLOCATION: Record<MemoryTier, number> = {
106
106
  draft: 0.1,
107
107
  };
108
108
 
109
- // Minimum guaranteed slots per tier
110
- const MIN_REFERENCE_SLOTS = 3;
109
+ // Minimum guaranteed slots per tier (reduced from 3 to avoid filling context with noise)
110
+ const MIN_REFERENCE_SLOTS = 1;
111
111
 
112
112
  // Graph walk configuration
113
113
  const GRAPH_WALK_MAX_DEPTH = 1;
@@ -163,6 +163,55 @@ function estimateTokens(text: string): number {
163
163
  return Math.ceil(text.length / 4);
164
164
  }
165
165
 
166
+ /**
167
+ * Content quality gate: filter out entities that waste token budget.
168
+ * Returns true if the entity passes quality checks.
169
+ */
170
+ function passesQualityGate(entity: ContextEntity): boolean {
171
+ const content = entity.content.trim();
172
+
173
+ // Gate 1: Minimum content length — entities with <50 chars of content
174
+ // are too shallow to provide value (e.g., "Resolved bug: Fix login button")
175
+ if (content.length < 50) return false;
176
+
177
+ // Gate 2: Title-content similarity — skip entities where content is just
178
+ // the title restated. Normalize both and check if content adds anything.
179
+ const normalizedTitle = entity.title
180
+ .toLowerCase()
181
+ .replace(/[^a-z0-9\s]/g, "")
182
+ .trim();
183
+ const normalizedContent = content
184
+ .toLowerCase()
185
+ .replace(/[^a-z0-9\s]/g, "")
186
+ .trim();
187
+ if (normalizedContent.length < normalizedTitle.length * 1.5) {
188
+ // Content is barely longer than the title — likely just a reformulation
189
+ return false;
190
+ }
191
+
192
+ // Gate 3: Pattern noise detection — skip "Pattern: recurring X (N instances)"
193
+ // and "Consolidated from N type memories:" entities that are just catalogs
194
+ if (
195
+ entity.type === "pattern" &&
196
+ /recurring .+ \(\d+ instances\)/i.test(entity.title)
197
+ ) {
198
+ // Check if content is just a member list (lines starting with "- ")
199
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
200
+ const bulletLines = lines.filter((l) => l.trim().startsWith("- "));
201
+ if (bulletLines.length > lines.length * 0.6) return false;
202
+ }
203
+
204
+ // Gate 4: Procedure quality — procedures must contain actual steps,
205
+ // not just a card title wrapped in a template
206
+ if (entity.type === "procedure") {
207
+ // Count numbered steps (1. ..., 2. ..., etc.)
208
+ const stepCount = (content.match(/^\d+\.\s/gm) || []).length;
209
+ if (stepCount < 3) return false;
210
+ }
211
+
212
+ return true;
213
+ }
214
+
166
215
  /**
167
216
  * Generate a unique assembly ID
168
217
  */
@@ -569,8 +618,30 @@ export async function assembleContext(
569
618
  };
570
619
  }
571
620
 
621
+ // Quality gate: filter out low-value entities before scoring
622
+ const qualityCandidates = candidates.filter((entity) => {
623
+ if (passesQualityGate(entity)) return true;
624
+ manifest.excluded.push({
625
+ entityId: entity.id,
626
+ title: entity.title,
627
+ type: entity.type,
628
+ tier: entity.memory_tier,
629
+ relevanceScore: 0,
630
+ reason: "failed_quality_gate",
631
+ });
632
+ return false;
633
+ });
634
+
635
+ if (qualityCandidates.length === 0) {
636
+ return {
637
+ context: "",
638
+ manifest,
639
+ memories: [],
640
+ };
641
+ }
642
+
572
643
  // Score all candidates (pass graph relations for relation-type bonuses)
573
- const scored = candidates.map((entity) => {
644
+ const scored = qualityCandidates.map((entity) => {
574
645
  const { score, reasons } = computeRelevanceScore(
575
646
  entity,
576
647
  taskContext,