@gethmy/mcp 2.2.2 → 2.2.4

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 {
@@ -101,6 +109,53 @@ const TIER_BUDGET_ALLOCATION: Record<MemoryTier, number> = {
101
109
  // Minimum guaranteed slots per tier
102
110
  const MIN_REFERENCE_SLOTS = 3;
103
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
+ };
158
+
104
159
  /**
105
160
  * Estimate token count (rough: 1 token per 4 chars)
106
161
  */
@@ -154,6 +209,73 @@ function truncateContent(
154
209
  return { text: result, truncated: true };
155
210
  }
156
211
 
212
+ /**
213
+ * Escape regex metacharacters in a string for safe use in RegExp constructor.
214
+ */
215
+ function escapeRegex(str: string): string {
216
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
217
+ }
218
+
219
+ /**
220
+ * Expand a query into multiple search variations using synonym substitution.
221
+ * Returns the original query plus up to 3 additional variations (4 total).
222
+ */
223
+ export function expandQuery(taskContext: string): string[] {
224
+ const queries = [taskContext];
225
+ const lowerQueries = [taskContext.toLowerCase()];
226
+ const words = taskContext
227
+ .toLowerCase()
228
+ .split(/\W+/)
229
+ .filter((w) => w.length > 2);
230
+
231
+ // Find words that have synonym expansions
232
+ const expandableWords = words.filter((w) => QUERY_SYNONYMS[w]);
233
+
234
+ for (const word of expandableWords) {
235
+ const synonyms = QUERY_SYNONYMS[word];
236
+ if (!synonyms) continue;
237
+ // Create a variation by replacing the word with its first synonym
238
+ const variation = taskContext.replace(
239
+ new RegExp(`\\b${escapeRegex(word)}\\b`, "gi"),
240
+ synonyms[0],
241
+ );
242
+ const lowerVariation = variation.toLowerCase();
243
+ if (
244
+ lowerVariation !== taskContext.toLowerCase() &&
245
+ !lowerQueries.includes(lowerVariation)
246
+ ) {
247
+ queries.push(variation);
248
+ lowerQueries.push(lowerVariation);
249
+ }
250
+ if (queries.length >= MAX_QUERY_VARIATIONS) break;
251
+ }
252
+
253
+ // Also extract key noun phrases as a compact query
254
+ if (words.length >= 3) {
255
+ const keyPhrases = words
256
+ .filter(
257
+ (w) =>
258
+ ![
259
+ "the",
260
+ "and",
261
+ "for",
262
+ "with",
263
+ "this",
264
+ "that",
265
+ "from",
266
+ "into",
267
+ ].includes(w),
268
+ )
269
+ .slice(0, 4)
270
+ .join(" ");
271
+ if (!lowerQueries.includes(keyPhrases)) {
272
+ queries.push(keyPhrases);
273
+ }
274
+ }
275
+
276
+ return queries.slice(0, MAX_QUERY_VARIATIONS);
277
+ }
278
+
157
279
  /**
158
280
  * Compute relevance score for an entity against task context.
159
281
  */
@@ -161,6 +283,7 @@ export function computeRelevanceScore(
161
283
  entity: ContextEntity,
162
284
  taskContext: string,
163
285
  cardLabels: string[],
286
+ graphRelations?: GraphRelation[],
164
287
  ): { score: number; reasons: string[] } {
165
288
  const reasons: string[] = [];
166
289
  let score = 0;
@@ -255,8 +378,29 @@ export function computeRelevanceScore(
255
378
  reasons.push("procedure_boost");
256
379
  }
257
380
 
381
+ // 7. Graph walk relation bonus: boost entities discovered via knowledge graph
382
+ if (graphRelations && graphRelations.length > 0) {
383
+ const entityRelations = graphRelations.filter(
384
+ (r) => r.source_id === entity.id || r.target_id === entity.id,
385
+ );
386
+ if (entityRelations.length > 0) {
387
+ // Take the highest relation bonus (don't stack all of them)
388
+ let bestBonus = 0;
389
+ let bestRelType = "";
390
+ for (const rel of entityRelations) {
391
+ const bonus = RELATION_BONUSES[rel.relation_type] ?? 0.1;
392
+ if (bonus > bestBonus) {
393
+ bestBonus = bonus;
394
+ bestRelType = rel.relation_type;
395
+ }
396
+ }
397
+ score += bestBonus;
398
+ reasons.push(`graph_walk(${bestRelType})`);
399
+ }
400
+ }
401
+
258
402
  // Clamp raw score to 0-1 range before applying tier weight
259
- score = Math.min(score, 1.0);
403
+ score = Math.max(0, Math.min(score, 1.0));
260
404
 
261
405
  // Apply tier weight
262
406
  const tierWeight = TIER_WEIGHTS[entity.memory_tier];
@@ -278,6 +422,10 @@ export async function assembleContext(
278
422
  cardLabels = [],
279
423
  tokenBudget = DEFAULT_TOKEN_BUDGET,
280
424
  client,
425
+ graphWalkEnabled = true,
426
+ queryExpansionEnabled = true,
427
+ enableLlmReranking = false,
428
+ rerankFn,
281
429
  } = options;
282
430
 
283
431
  const assemblyId = generateAssemblyId();
@@ -295,21 +443,35 @@ export async function assembleContext(
295
443
  },
296
444
  };
297
445
 
298
- // Fetch candidate entities: search by task context + list by project
299
- let candidates: ContextEntity[] = [];
446
+ // Fetch candidate entities: search by task context (with query expansion) + list by project
447
+ const candidates: ContextEntity[] = [];
300
448
 
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);
449
+ // P1: Query expansion — search with multiple query variations to catch synonym mismatches
450
+ const queries = queryExpansionEnabled
451
+ ? expandQuery(taskContext)
452
+ : [taskContext];
453
+
454
+ const searchResults = await Promise.allSettled(
455
+ queries.map((query) =>
456
+ client.searchMemoryEntities(workspaceId, query, {
457
+ project_id: projectId,
458
+ limit: 30,
459
+ }),
460
+ ),
461
+ );
462
+
463
+ const candidateIds = new Set<string>();
464
+ for (const result of searchResults) {
465
+ if (result.status !== "fulfilled") continue;
466
+ if (result.value.entities?.length > 0) {
467
+ for (const raw of result.value.entities) {
468
+ const entity = mapToContextEntity(raw);
469
+ if (!candidateIds.has(entity.id)) {
470
+ candidateIds.add(entity.id);
471
+ candidates.push(entity);
472
+ }
473
+ }
310
474
  }
311
- } catch {
312
- // Search failed, fall back to listing
313
475
  }
314
476
 
315
477
  // Also fetch by project scope if we have few candidates
@@ -321,11 +483,13 @@ export async function assembleContext(
321
483
  limit: 30,
322
484
  });
323
485
  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);
486
+ for (const raw of listResult.entities) {
487
+ const entity = mapToContextEntity(raw);
488
+ if (!candidateIds.has(entity.id)) {
489
+ candidateIds.add(entity.id);
490
+ candidates.push(entity);
491
+ }
492
+ }
329
493
  }
330
494
  } catch {
331
495
  // List failed, continue with what we have
@@ -342,17 +506,61 @@ export async function assembleContext(
342
506
  limit: 20,
343
507
  });
344
508
  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);
509
+ for (const raw of wsResult.entities) {
510
+ const entity = mapToContextEntity(raw);
511
+ if (!candidateIds.has(entity.id)) {
512
+ candidateIds.add(entity.id);
513
+ candidates.push(entity);
514
+ }
515
+ }
350
516
  }
351
517
  } catch {
352
518
  // Continue with what we have
353
519
  }
354
520
  }
355
521
 
522
+ // P0: Graph walk enrichment — discover related entities via knowledge graph
523
+ let graphRelations: GraphRelation[] = [];
524
+ if (graphWalkEnabled && candidates.length > 0) {
525
+ try {
526
+ // Take top candidates by RRF score (or first N if no RRF scores)
527
+ const seedCandidates = [...candidates]
528
+ .sort((a, b) => (b.rrf_score ?? 0) - (a.rrf_score ?? 0))
529
+ .slice(0, GRAPH_WALK_SEED_COUNT);
530
+ const seedIds = seedCandidates.map((c) => c.id);
531
+
532
+ const walkResult = await discoverRelatedContext(
533
+ client,
534
+ seedIds,
535
+ GRAPH_WALK_MAX_DEPTH,
536
+ GRAPH_WALK_MAX_ENTITIES,
537
+ GRAPH_WALK_MIN_CONFIDENCE,
538
+ );
539
+
540
+ graphRelations = walkResult.relations;
541
+
542
+ // Add discovered entities to candidate pool (skip those already present)
543
+ const newEntityIds = walkResult.entities
544
+ .filter((e) => !candidateIds.has(e.id))
545
+ .map((e) => e.id);
546
+
547
+ if (newEntityIds.length > 0) {
548
+ // Fetch full entity data in parallel (graph walk only returns summary fields)
549
+ const fetchResults = await Promise.allSettled(
550
+ newEntityIds.map((id) => client.getMemoryEntity(id)),
551
+ );
552
+ for (const result of fetchResults) {
553
+ if (result.status !== "fulfilled" || !result.value.entity) continue;
554
+ const mapped = mapToContextEntity(result.value.entity);
555
+ candidateIds.add(mapped.id);
556
+ candidates.push(mapped);
557
+ }
558
+ }
559
+ } catch {
560
+ // Graph walk failed, continue with search-only candidates
561
+ }
562
+ }
563
+
356
564
  if (candidates.length === 0) {
357
565
  return {
358
566
  context: "",
@@ -361,12 +569,13 @@ export async function assembleContext(
361
569
  };
362
570
  }
363
571
 
364
- // Score all candidates
572
+ // Score all candidates (pass graph relations for relation-type bonuses)
365
573
  const scored = candidates.map((entity) => {
366
574
  const { score, reasons } = computeRelevanceScore(
367
575
  entity,
368
576
  taskContext,
369
577
  cardLabels,
578
+ graphRelations.length > 0 ? graphRelations : undefined,
370
579
  );
371
580
  return { entity, score, reasons };
372
581
  });
@@ -374,6 +583,38 @@ export async function assembleContext(
374
583
  // Sort by score descending
375
584
  scored.sort((a, b) => b.score - a.score);
376
585
 
586
+ // P2: Optional LLM re-ranking when top scores are clustered
587
+ if (
588
+ enableLlmReranking &&
589
+ rerankFn &&
590
+ scored.length >= RERANK_MIN_CANDIDATES
591
+ ) {
592
+ const topN = scored.slice(0, RERANK_TOP_N);
593
+ const scoreRange = topN[0].score - topN[topN.length - 1].score;
594
+ // Only re-rank when scores are tightly clustered
595
+ if (scoreRange <= RERANK_CLUSTER_THRESHOLD) {
596
+ try {
597
+ const rerankCandidates = topN.map((s) => ({
598
+ id: s.entity.id,
599
+ title: s.entity.title,
600
+ snippet: s.entity.content.slice(0, 200),
601
+ }));
602
+ const rerankedIds = await rerankFn(taskContext, rerankCandidates);
603
+ // Reorder based on LLM ranking
604
+ const idOrder = new Map(rerankedIds.map((id, i) => [id, i]));
605
+ topN.sort((a, b) => {
606
+ const aIdx = idOrder.get(a.entity.id) ?? 999;
607
+ const bIdx = idOrder.get(b.entity.id) ?? 999;
608
+ return aIdx - bIdx;
609
+ });
610
+ // Splice reranked items back in
611
+ scored.splice(0, topN.length, ...topN);
612
+ } catch {
613
+ // Re-ranking failed, continue with static ordering
614
+ }
615
+ }
616
+ }
617
+
377
618
  // Reserve dedicated procedure budget, allocate remaining to tiers
378
619
  const procedureBudget = Math.floor(tokenBudget * PROCEDURE_BUDGET_FRACTION);
379
620
  const remainingBudget = tokenBudget - procedureBudget;
package/src/skills.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { areSkillsInstalled } from "./config.js";
4
4
 
5
- export const SKILLS_VERSION = "3";
5
+ export const SKILLS_VERSION = "4";
6
6
 
7
7
  const VERSION_MARKER_PREFIX = "<!-- skills-version:";
8
8
 
@@ -206,6 +206,30 @@ Do not start an agent session.
206
206
 
207
207
  ---
208
208
 
209
+ ## Step E: Auto-Detect Card for Implementation Tasks
210
+
211
+ **IMPORTANT:** This step applies when you are about to implement a plan, feature, or fix
212
+ that was NOT started via /hmy. Before writing any code, check if the work maps to an
213
+ existing Harmony card.
214
+
215
+ ### When to run this check
216
+ - User says "implement this plan", "build this feature", "fix this bug" (without /hmy)
217
+ - You are about to execute a plan file
218
+ - Skip if: the user already started with /hmy, or no Harmony MCP tools are available
219
+
220
+ ### Detection steps
221
+ 1. Call \`harmony_search_cards\` with keywords from the plan title or task description
222
+ 2. If a card matches (same feature/fix described), tell the user:
223
+ "This maps to card #N — starting a session to track progress."
224
+ 3. Call \`harmony_start_agent_session\` with \`moveToColumn: "In Progress"\`, \`addLabels: ["agent"]\`
225
+ 4. Track progress with \`harmony_update_agent_progress\` at milestones
226
+ 5. When done, call \`harmony_end_agent_session\` with \`status: "completed"\`, \`moveToColumn: "Review"\`
227
+
228
+ ### If no match
229
+ Proceed normally without a session. No action needed.
230
+
231
+ ---
232
+
209
233
  ## Key Tools Reference
210
234
 
211
235
  **Cards:** \`harmony_get_card\`, \`harmony_get_card_by_short_id\`, \`harmony_search_cards\`, \`harmony_create_card\`, \`harmony_update_card\`, \`harmony_move_card\`, \`harmony_delete_card\`, \`harmony_assign_card\`
package/src/tui/setup.ts CHANGED
@@ -299,6 +299,17 @@ When given a card reference (e.g., #42 or a card name), follow this workflow:
299
299
  7. Update progress periodically with \`harmony_update_agent_progress\`
300
300
  8. When done, call \`harmony_end_agent_session\` and move to "Review"
301
301
 
302
+ ## Auto-Detect Card for Implementation Tasks
303
+
304
+ Before implementing a plan or feature, check if it maps to an existing Harmony card:
305
+
306
+ 1. Use \`harmony_search_cards\` with keywords from the task description
307
+ 2. If a match is found, call \`harmony_start_agent_session\` (agentIdentifier: "claude-code", agentName: "Claude Code", moveToColumn: "In Progress", addLabels: ["agent"])
308
+ 3. Update progress with \`harmony_update_agent_progress\` at milestones
309
+ 4. When done, call \`harmony_end_agent_session\` with status: "completed", moveToColumn: "Review"
310
+
311
+ Skip if: work was already started with a card reference, or no matching card exists.
312
+
302
313
  ## Available Harmony Tools
303
314
 
304
315
  - \`harmony_get_card\`, \`harmony_get_card_by_short_id\`, \`harmony_search_cards\` - Find cards