@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.
@@ -5,7 +5,8 @@
5
5
  * for a given task, producing a manifest of what was included/excluded.
6
6
  */
7
7
 
8
- import { checkPromotion } from "@harmony/memory";
8
+ import type { GraphRelation } from "@harmony/memory";
9
+ import { checkPromotion, discoverRelatedContext } from "@harmony/memory";
9
10
  import type { HarmonyApiClient } from "./api-client.js";
10
11
 
11
12
  // Types
@@ -68,6 +69,13 @@ export interface AssembleContextOptions {
68
69
  cardId?: string;
69
70
  tokenBudget?: number; // Default: 4000 tokens
70
71
  client: HarmonyApiClient;
72
+ graphWalkEnabled?: boolean; // Default: true — enrich candidates via knowledge graph relations
73
+ queryExpansionEnabled?: boolean; // Default: true — expand query with synonyms/variations
74
+ enableLlmReranking?: boolean; // Default: false — LLM re-ranking when scores are clustered
75
+ rerankFn?: (
76
+ taskContext: string,
77
+ candidates: Array<{ id: string; title: string; snippet: string }>,
78
+ ) => Promise<string[]>; // Custom re-rank function
71
79
  }
72
80
 
73
81
  export interface AssembledContext {
@@ -79,7 +87,7 @@ export interface AssembledContext {
79
87
  // Constants
80
88
  const DEFAULT_TOKEN_BUDGET = 4000;
81
89
  const MAX_TOKENS_PER_ENTITY = 500;
82
- const MIN_RELEVANCE_THRESHOLD = 0.1;
90
+ const MIN_RELEVANCE_THRESHOLD = 0.15; // raised from 0.1 to filter low-signal entities
83
91
 
84
92
  // Tier weight multipliers for relevance scoring
85
93
  const TIER_WEIGHTS: Record<MemoryTier, number> = {
@@ -98,8 +106,55 @@ const TIER_BUDGET_ALLOCATION: Record<MemoryTier, number> = {
98
106
  draft: 0.1,
99
107
  };
100
108
 
101
- // Minimum guaranteed slots per tier
102
- 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
+
112
+ // Graph walk configuration
113
+ const GRAPH_WALK_MAX_DEPTH = 1;
114
+ const GRAPH_WALK_MAX_ENTITIES = 10;
115
+ const GRAPH_WALK_MIN_CONFIDENCE = 0.5;
116
+ const GRAPH_WALK_SEED_COUNT = 5;
117
+
118
+ // Query expansion configuration
119
+ const MAX_QUERY_VARIATIONS = 4;
120
+
121
+ // LLM re-ranking configuration
122
+ const RERANK_CLUSTER_THRESHOLD = 0.05;
123
+ const RERANK_TOP_N = 10;
124
+ const RERANK_MIN_CANDIDATES = 5;
125
+
126
+ // Graph walk relation-type bonuses for relevance scoring
127
+ const RELATION_BONUSES: Record<string, number> = {
128
+ depends_on: 0.15,
129
+ resolved_by: 0.2,
130
+ relates_to: 0.1,
131
+ implements: 0.15,
132
+ blocks: 0.15,
133
+ references: 0.1,
134
+ extends: 0.1,
135
+ caused_by: 0.15,
136
+ };
137
+
138
+ // Synonym map for query expansion (common dev term variations)
139
+ // NOTE: Avoid circular references (auth->login, login->auth) — first synonym
140
+ // is used for replacement, so each key should expand to non-overlapping terms.
141
+ const QUERY_SYNONYMS: Record<string, string[]> = {
142
+ auth: ["authentication", "authorization", "session"],
143
+ authentication: ["auth", "session", "sign-in"],
144
+ login: ["sign-in", "authentication", "session"],
145
+ bug: ["error", "issue", "defect", "problem"],
146
+ error: ["exception", "failure", "issue"],
147
+ fix: ["resolve", "patch", "repair", "correct"],
148
+ deploy: ["deployment", "release", "ship", "publish"],
149
+ test: ["testing", "spec", "assertion", "verify"],
150
+ config: ["configuration", "settings", "setup"],
151
+ db: ["database", "storage", "persistence"],
152
+ database: ["storage", "persistence", "data store"],
153
+ api: ["endpoint", "route", "service"],
154
+ ui: ["frontend", "component", "view"],
155
+ perf: ["performance", "speed", "latency"],
156
+ performance: ["speed", "latency", "optimization"],
157
+ };
103
158
 
104
159
  /**
105
160
  * Estimate token count (rough: 1 token per 4 chars)
@@ -108,6 +163,55 @@ function estimateTokens(text: string): number {
108
163
  return Math.ceil(text.length / 4);
109
164
  }
110
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
+
111
215
  /**
112
216
  * Generate a unique assembly ID
113
217
  */
@@ -154,6 +258,73 @@ function truncateContent(
154
258
  return { text: result, truncated: true };
155
259
  }
156
260
 
261
+ /**
262
+ * Escape regex metacharacters in a string for safe use in RegExp constructor.
263
+ */
264
+ function escapeRegex(str: string): string {
265
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
266
+ }
267
+
268
+ /**
269
+ * Expand a query into multiple search variations using synonym substitution.
270
+ * Returns the original query plus up to 3 additional variations (4 total).
271
+ */
272
+ export function expandQuery(taskContext: string): string[] {
273
+ const queries = [taskContext];
274
+ const lowerQueries = [taskContext.toLowerCase()];
275
+ const words = taskContext
276
+ .toLowerCase()
277
+ .split(/\W+/)
278
+ .filter((w) => w.length > 2);
279
+
280
+ // Find words that have synonym expansions
281
+ const expandableWords = words.filter((w) => QUERY_SYNONYMS[w]);
282
+
283
+ for (const word of expandableWords) {
284
+ const synonyms = QUERY_SYNONYMS[word];
285
+ if (!synonyms) continue;
286
+ // Create a variation by replacing the word with its first synonym
287
+ const variation = taskContext.replace(
288
+ new RegExp(`\\b${escapeRegex(word)}\\b`, "gi"),
289
+ synonyms[0],
290
+ );
291
+ const lowerVariation = variation.toLowerCase();
292
+ if (
293
+ lowerVariation !== taskContext.toLowerCase() &&
294
+ !lowerQueries.includes(lowerVariation)
295
+ ) {
296
+ queries.push(variation);
297
+ lowerQueries.push(lowerVariation);
298
+ }
299
+ if (queries.length >= MAX_QUERY_VARIATIONS) break;
300
+ }
301
+
302
+ // Also extract key noun phrases as a compact query
303
+ if (words.length >= 3) {
304
+ const keyPhrases = words
305
+ .filter(
306
+ (w) =>
307
+ ![
308
+ "the",
309
+ "and",
310
+ "for",
311
+ "with",
312
+ "this",
313
+ "that",
314
+ "from",
315
+ "into",
316
+ ].includes(w),
317
+ )
318
+ .slice(0, 4)
319
+ .join(" ");
320
+ if (!lowerQueries.includes(keyPhrases)) {
321
+ queries.push(keyPhrases);
322
+ }
323
+ }
324
+
325
+ return queries.slice(0, MAX_QUERY_VARIATIONS);
326
+ }
327
+
157
328
  /**
158
329
  * Compute relevance score for an entity against task context.
159
330
  */
@@ -161,6 +332,7 @@ export function computeRelevanceScore(
161
332
  entity: ContextEntity,
162
333
  taskContext: string,
163
334
  cardLabels: string[],
335
+ graphRelations?: GraphRelation[],
164
336
  ): { score: number; reasons: string[] } {
165
337
  const reasons: string[] = [];
166
338
  let score = 0;
@@ -255,8 +427,29 @@ export function computeRelevanceScore(
255
427
  reasons.push("procedure_boost");
256
428
  }
257
429
 
430
+ // 7. Graph walk relation bonus: boost entities discovered via knowledge graph
431
+ if (graphRelations && graphRelations.length > 0) {
432
+ const entityRelations = graphRelations.filter(
433
+ (r) => r.source_id === entity.id || r.target_id === entity.id,
434
+ );
435
+ if (entityRelations.length > 0) {
436
+ // Take the highest relation bonus (don't stack all of them)
437
+ let bestBonus = 0;
438
+ let bestRelType = "";
439
+ for (const rel of entityRelations) {
440
+ const bonus = RELATION_BONUSES[rel.relation_type] ?? 0.1;
441
+ if (bonus > bestBonus) {
442
+ bestBonus = bonus;
443
+ bestRelType = rel.relation_type;
444
+ }
445
+ }
446
+ score += bestBonus;
447
+ reasons.push(`graph_walk(${bestRelType})`);
448
+ }
449
+ }
450
+
258
451
  // Clamp raw score to 0-1 range before applying tier weight
259
- score = Math.min(score, 1.0);
452
+ score = Math.max(0, Math.min(score, 1.0));
260
453
 
261
454
  // Apply tier weight
262
455
  const tierWeight = TIER_WEIGHTS[entity.memory_tier];
@@ -278,6 +471,10 @@ export async function assembleContext(
278
471
  cardLabels = [],
279
472
  tokenBudget = DEFAULT_TOKEN_BUDGET,
280
473
  client,
474
+ graphWalkEnabled = true,
475
+ queryExpansionEnabled = true,
476
+ enableLlmReranking = false,
477
+ rerankFn,
281
478
  } = options;
282
479
 
283
480
  const assemblyId = generateAssemblyId();
@@ -295,21 +492,35 @@ export async function assembleContext(
295
492
  },
296
493
  };
297
494
 
298
- // Fetch candidate entities: search by task context + list by project
299
- let candidates: ContextEntity[] = [];
495
+ // Fetch candidate entities: search by task context (with query expansion) + list by project
496
+ const candidates: ContextEntity[] = [];
300
497
 
301
- try {
302
- // Full-text search by task context
303
- const searchResult = await client.searchMemoryEntities(
304
- workspaceId,
305
- taskContext,
306
- { project_id: projectId, limit: 30 },
307
- );
308
- if (searchResult.entities?.length > 0) {
309
- candidates = searchResult.entities.map(mapToContextEntity);
498
+ // P1: Query expansion — search with multiple query variations to catch synonym mismatches
499
+ const queries = queryExpansionEnabled
500
+ ? expandQuery(taskContext)
501
+ : [taskContext];
502
+
503
+ const searchResults = await Promise.allSettled(
504
+ queries.map((query) =>
505
+ client.searchMemoryEntities(workspaceId, query, {
506
+ project_id: projectId,
507
+ limit: 30,
508
+ }),
509
+ ),
510
+ );
511
+
512
+ const candidateIds = new Set<string>();
513
+ for (const result of searchResults) {
514
+ if (result.status !== "fulfilled") continue;
515
+ if (result.value.entities?.length > 0) {
516
+ for (const raw of result.value.entities) {
517
+ const entity = mapToContextEntity(raw);
518
+ if (!candidateIds.has(entity.id)) {
519
+ candidateIds.add(entity.id);
520
+ candidates.push(entity);
521
+ }
522
+ }
310
523
  }
311
- } catch {
312
- // Search failed, fall back to listing
313
524
  }
314
525
 
315
526
  // Also fetch by project scope if we have few candidates
@@ -321,11 +532,13 @@ export async function assembleContext(
321
532
  limit: 30,
322
533
  });
323
534
  if (listResult.entities?.length > 0) {
324
- const existingIds = new Set(candidates.map((c) => c.id));
325
- const additional = listResult.entities
326
- .map(mapToContextEntity)
327
- .filter((e) => !existingIds.has(e.id));
328
- candidates.push(...additional);
535
+ for (const raw of listResult.entities) {
536
+ const entity = mapToContextEntity(raw);
537
+ if (!candidateIds.has(entity.id)) {
538
+ candidateIds.add(entity.id);
539
+ candidates.push(entity);
540
+ }
541
+ }
329
542
  }
330
543
  } catch {
331
544
  // List failed, continue with what we have
@@ -342,17 +555,61 @@ export async function assembleContext(
342
555
  limit: 20,
343
556
  });
344
557
  if (wsResult.entities?.length > 0) {
345
- const existingIds = new Set(candidates.map((c) => c.id));
346
- const additional = wsResult.entities
347
- .map(mapToContextEntity)
348
- .filter((e) => !existingIds.has(e.id));
349
- candidates.push(...additional);
558
+ for (const raw of wsResult.entities) {
559
+ const entity = mapToContextEntity(raw);
560
+ if (!candidateIds.has(entity.id)) {
561
+ candidateIds.add(entity.id);
562
+ candidates.push(entity);
563
+ }
564
+ }
350
565
  }
351
566
  } catch {
352
567
  // Continue with what we have
353
568
  }
354
569
  }
355
570
 
571
+ // P0: Graph walk enrichment — discover related entities via knowledge graph
572
+ let graphRelations: GraphRelation[] = [];
573
+ if (graphWalkEnabled && candidates.length > 0) {
574
+ try {
575
+ // Take top candidates by RRF score (or first N if no RRF scores)
576
+ const seedCandidates = [...candidates]
577
+ .sort((a, b) => (b.rrf_score ?? 0) - (a.rrf_score ?? 0))
578
+ .slice(0, GRAPH_WALK_SEED_COUNT);
579
+ const seedIds = seedCandidates.map((c) => c.id);
580
+
581
+ const walkResult = await discoverRelatedContext(
582
+ client,
583
+ seedIds,
584
+ GRAPH_WALK_MAX_DEPTH,
585
+ GRAPH_WALK_MAX_ENTITIES,
586
+ GRAPH_WALK_MIN_CONFIDENCE,
587
+ );
588
+
589
+ graphRelations = walkResult.relations;
590
+
591
+ // Add discovered entities to candidate pool (skip those already present)
592
+ const newEntityIds = walkResult.entities
593
+ .filter((e) => !candidateIds.has(e.id))
594
+ .map((e) => e.id);
595
+
596
+ if (newEntityIds.length > 0) {
597
+ // Fetch full entity data in parallel (graph walk only returns summary fields)
598
+ const fetchResults = await Promise.allSettled(
599
+ newEntityIds.map((id) => client.getMemoryEntity(id)),
600
+ );
601
+ for (const result of fetchResults) {
602
+ if (result.status !== "fulfilled" || !result.value.entity) continue;
603
+ const mapped = mapToContextEntity(result.value.entity);
604
+ candidateIds.add(mapped.id);
605
+ candidates.push(mapped);
606
+ }
607
+ }
608
+ } catch {
609
+ // Graph walk failed, continue with search-only candidates
610
+ }
611
+ }
612
+
356
613
  if (candidates.length === 0) {
357
614
  return {
358
615
  context: "",
@@ -361,12 +618,35 @@ export async function assembleContext(
361
618
  };
362
619
  }
363
620
 
364
- // Score all candidates
365
- const scored = candidates.map((entity) => {
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
+
643
+ // Score all candidates (pass graph relations for relation-type bonuses)
644
+ const scored = qualityCandidates.map((entity) => {
366
645
  const { score, reasons } = computeRelevanceScore(
367
646
  entity,
368
647
  taskContext,
369
648
  cardLabels,
649
+ graphRelations.length > 0 ? graphRelations : undefined,
370
650
  );
371
651
  return { entity, score, reasons };
372
652
  });
@@ -374,6 +654,38 @@ export async function assembleContext(
374
654
  // Sort by score descending
375
655
  scored.sort((a, b) => b.score - a.score);
376
656
 
657
+ // P2: Optional LLM re-ranking when top scores are clustered
658
+ if (
659
+ enableLlmReranking &&
660
+ rerankFn &&
661
+ scored.length >= RERANK_MIN_CANDIDATES
662
+ ) {
663
+ const topN = scored.slice(0, RERANK_TOP_N);
664
+ const scoreRange = topN[0].score - topN[topN.length - 1].score;
665
+ // Only re-rank when scores are tightly clustered
666
+ if (scoreRange <= RERANK_CLUSTER_THRESHOLD) {
667
+ try {
668
+ const rerankCandidates = topN.map((s) => ({
669
+ id: s.entity.id,
670
+ title: s.entity.title,
671
+ snippet: s.entity.content.slice(0, 200),
672
+ }));
673
+ const rerankedIds = await rerankFn(taskContext, rerankCandidates);
674
+ // Reorder based on LLM ranking
675
+ const idOrder = new Map(rerankedIds.map((id, i) => [id, i]));
676
+ topN.sort((a, b) => {
677
+ const aIdx = idOrder.get(a.entity.id) ?? 999;
678
+ const bIdx = idOrder.get(b.entity.id) ?? 999;
679
+ return aIdx - bIdx;
680
+ });
681
+ // Splice reranked items back in
682
+ scored.splice(0, topN.length, ...topN);
683
+ } catch {
684
+ // Re-ranking failed, continue with static ordering
685
+ }
686
+ }
687
+ }
688
+
377
689
  // Reserve dedicated procedure budget, allocate remaining to tiers
378
690
  const procedureBudget = Math.floor(tokenBudget * PROCEDURE_BUDGET_FRACTION);
379
691
  const remainingBudget = tokenBudget - procedureBudget;