@gethmy/mcp 2.2.3 → 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.
@@ -4,7 +4,7 @@
4
4
  * Token-budget-aware context constructor that assembles relevant memories
5
5
  * for a given task, producing a manifest of what was included/excluded.
6
6
  */
7
- import { checkPromotion } from "@harmony/memory";
7
+ import { checkPromotion, discoverRelatedContext } from "@harmony/memory";
8
8
  // Constants
9
9
  const DEFAULT_TOKEN_BUDGET = 4000;
10
10
  const MAX_TOKENS_PER_ENTITY = 500;
@@ -25,6 +25,48 @@ const TIER_BUDGET_ALLOCATION = {
25
25
  };
26
26
  // Minimum guaranteed slots per tier
27
27
  const MIN_REFERENCE_SLOTS = 3;
28
+ // Graph walk configuration
29
+ const GRAPH_WALK_MAX_DEPTH = 1;
30
+ const GRAPH_WALK_MAX_ENTITIES = 10;
31
+ const GRAPH_WALK_MIN_CONFIDENCE = 0.5;
32
+ const GRAPH_WALK_SEED_COUNT = 5;
33
+ // Query expansion configuration
34
+ const MAX_QUERY_VARIATIONS = 4;
35
+ // LLM re-ranking configuration
36
+ const RERANK_CLUSTER_THRESHOLD = 0.05;
37
+ const RERANK_TOP_N = 10;
38
+ const RERANK_MIN_CANDIDATES = 5;
39
+ // Graph walk relation-type bonuses for relevance scoring
40
+ const RELATION_BONUSES = {
41
+ depends_on: 0.15,
42
+ resolved_by: 0.2,
43
+ relates_to: 0.1,
44
+ implements: 0.15,
45
+ blocks: 0.15,
46
+ references: 0.1,
47
+ extends: 0.1,
48
+ caused_by: 0.15,
49
+ };
50
+ // Synonym map for query expansion (common dev term variations)
51
+ // NOTE: Avoid circular references (auth->login, login->auth) — first synonym
52
+ // is used for replacement, so each key should expand to non-overlapping terms.
53
+ const QUERY_SYNONYMS = {
54
+ auth: ["authentication", "authorization", "session"],
55
+ authentication: ["auth", "session", "sign-in"],
56
+ login: ["sign-in", "authentication", "session"],
57
+ bug: ["error", "issue", "defect", "problem"],
58
+ error: ["exception", "failure", "issue"],
59
+ fix: ["resolve", "patch", "repair", "correct"],
60
+ deploy: ["deployment", "release", "ship", "publish"],
61
+ test: ["testing", "spec", "assertion", "verify"],
62
+ config: ["configuration", "settings", "setup"],
63
+ db: ["database", "storage", "persistence"],
64
+ database: ["storage", "persistence", "data store"],
65
+ api: ["endpoint", "route", "service"],
66
+ ui: ["frontend", "component", "view"],
67
+ perf: ["performance", "speed", "latency"],
68
+ performance: ["speed", "latency", "optimization"],
69
+ };
28
70
  /**
29
71
  * Estimate token count (rough: 1 token per 4 chars)
30
72
  */
@@ -68,10 +110,65 @@ function truncateContent(content, maxTokens) {
68
110
  }
69
111
  return { text: result, truncated: true };
70
112
  }
113
+ /**
114
+ * Escape regex metacharacters in a string for safe use in RegExp constructor.
115
+ */
116
+ function escapeRegex(str) {
117
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
118
+ }
119
+ /**
120
+ * Expand a query into multiple search variations using synonym substitution.
121
+ * Returns the original query plus up to 3 additional variations (4 total).
122
+ */
123
+ export function expandQuery(taskContext) {
124
+ const queries = [taskContext];
125
+ const lowerQueries = [taskContext.toLowerCase()];
126
+ const words = taskContext
127
+ .toLowerCase()
128
+ .split(/\W+/)
129
+ .filter((w) => w.length > 2);
130
+ // Find words that have synonym expansions
131
+ const expandableWords = words.filter((w) => QUERY_SYNONYMS[w]);
132
+ for (const word of expandableWords) {
133
+ const synonyms = QUERY_SYNONYMS[word];
134
+ if (!synonyms)
135
+ continue;
136
+ // Create a variation by replacing the word with its first synonym
137
+ const variation = taskContext.replace(new RegExp(`\\b${escapeRegex(word)}\\b`, "gi"), synonyms[0]);
138
+ const lowerVariation = variation.toLowerCase();
139
+ if (lowerVariation !== taskContext.toLowerCase() &&
140
+ !lowerQueries.includes(lowerVariation)) {
141
+ queries.push(variation);
142
+ lowerQueries.push(lowerVariation);
143
+ }
144
+ if (queries.length >= MAX_QUERY_VARIATIONS)
145
+ break;
146
+ }
147
+ // Also extract key noun phrases as a compact query
148
+ if (words.length >= 3) {
149
+ const keyPhrases = words
150
+ .filter((w) => ![
151
+ "the",
152
+ "and",
153
+ "for",
154
+ "with",
155
+ "this",
156
+ "that",
157
+ "from",
158
+ "into",
159
+ ].includes(w))
160
+ .slice(0, 4)
161
+ .join(" ");
162
+ if (!lowerQueries.includes(keyPhrases)) {
163
+ queries.push(keyPhrases);
164
+ }
165
+ }
166
+ return queries.slice(0, MAX_QUERY_VARIATIONS);
167
+ }
71
168
  /**
72
169
  * Compute relevance score for an entity against task context.
73
170
  */
74
- export function computeRelevanceScore(entity, taskContext, cardLabels) {
171
+ export function computeRelevanceScore(entity, taskContext, cardLabels, graphRelations) {
75
172
  const reasons = [];
76
173
  let score = 0;
77
174
  // 0. DB hybrid search signal (RRF score from FTS + vector fusion)
@@ -150,8 +247,26 @@ export function computeRelevanceScore(entity, taskContext, cardLabels) {
150
247
  score += 0.1;
151
248
  reasons.push("procedure_boost");
152
249
  }
250
+ // 7. Graph walk relation bonus: boost entities discovered via knowledge graph
251
+ if (graphRelations && graphRelations.length > 0) {
252
+ const entityRelations = graphRelations.filter((r) => r.source_id === entity.id || r.target_id === entity.id);
253
+ if (entityRelations.length > 0) {
254
+ // Take the highest relation bonus (don't stack all of them)
255
+ let bestBonus = 0;
256
+ let bestRelType = "";
257
+ for (const rel of entityRelations) {
258
+ const bonus = RELATION_BONUSES[rel.relation_type] ?? 0.1;
259
+ if (bonus > bestBonus) {
260
+ bestBonus = bonus;
261
+ bestRelType = rel.relation_type;
262
+ }
263
+ }
264
+ score += bestBonus;
265
+ reasons.push(`graph_walk(${bestRelType})`);
266
+ }
267
+ }
153
268
  // Clamp raw score to 0-1 range before applying tier weight
154
- score = Math.min(score, 1.0);
269
+ score = Math.max(0, Math.min(score, 1.0));
155
270
  // Apply tier weight
156
271
  const tierWeight = TIER_WEIGHTS[entity.memory_tier];
157
272
  score *= tierWeight;
@@ -161,7 +276,7 @@ export function computeRelevanceScore(entity, taskContext, cardLabels) {
161
276
  * Assemble context from knowledge graph entities with token budget management.
162
277
  */
163
278
  export async function assembleContext(options) {
164
- const { workspaceId, projectId, taskContext, cardLabels = [], tokenBudget = DEFAULT_TOKEN_BUDGET, client, } = options;
279
+ const { workspaceId, projectId, taskContext, cardLabels = [], tokenBudget = DEFAULT_TOKEN_BUDGET, client, graphWalkEnabled = true, queryExpansionEnabled = true, enableLlmReranking = false, rerankFn, } = options;
165
280
  const assemblyId = generateAssemblyId();
166
281
  const manifest = {
167
282
  assemblyId,
@@ -176,18 +291,30 @@ export async function assembleContext(options) {
176
291
  reference: { count: 0, tokens: 0 },
177
292
  },
178
293
  };
179
- // Fetch candidate entities: search by task context + list by project
180
- let candidates = [];
181
- try {
182
- // Full-text search by task context
183
- const searchResult = await client.searchMemoryEntities(workspaceId, taskContext, { project_id: projectId, limit: 30 });
184
- if (searchResult.entities?.length > 0) {
185
- candidates = searchResult.entities.map(mapToContextEntity);
294
+ // Fetch candidate entities: search by task context (with query expansion) + list by project
295
+ const candidates = [];
296
+ // P1: Query expansion — search with multiple query variations to catch synonym mismatches
297
+ const queries = queryExpansionEnabled
298
+ ? expandQuery(taskContext)
299
+ : [taskContext];
300
+ const searchResults = await Promise.allSettled(queries.map((query) => client.searchMemoryEntities(workspaceId, query, {
301
+ project_id: projectId,
302
+ limit: 30,
303
+ })));
304
+ const candidateIds = new Set();
305
+ for (const result of searchResults) {
306
+ if (result.status !== "fulfilled")
307
+ continue;
308
+ if (result.value.entities?.length > 0) {
309
+ for (const raw of result.value.entities) {
310
+ const entity = mapToContextEntity(raw);
311
+ if (!candidateIds.has(entity.id)) {
312
+ candidateIds.add(entity.id);
313
+ candidates.push(entity);
314
+ }
315
+ }
186
316
  }
187
317
  }
188
- catch {
189
- // Search failed, fall back to listing
190
- }
191
318
  // Also fetch by project scope if we have few candidates
192
319
  if (candidates.length < 10 && projectId) {
193
320
  try {
@@ -197,11 +324,13 @@ export async function assembleContext(options) {
197
324
  limit: 30,
198
325
  });
199
326
  if (listResult.entities?.length > 0) {
200
- const existingIds = new Set(candidates.map((c) => c.id));
201
- const additional = listResult.entities
202
- .map(mapToContextEntity)
203
- .filter((e) => !existingIds.has(e.id));
204
- candidates.push(...additional);
327
+ for (const raw of listResult.entities) {
328
+ const entity = mapToContextEntity(raw);
329
+ if (!candidateIds.has(entity.id)) {
330
+ candidateIds.add(entity.id);
331
+ candidates.push(entity);
332
+ }
333
+ }
205
334
  }
206
335
  }
207
336
  catch {
@@ -218,17 +347,50 @@ export async function assembleContext(options) {
218
347
  limit: 20,
219
348
  });
220
349
  if (wsResult.entities?.length > 0) {
221
- const existingIds = new Set(candidates.map((c) => c.id));
222
- const additional = wsResult.entities
223
- .map(mapToContextEntity)
224
- .filter((e) => !existingIds.has(e.id));
225
- candidates.push(...additional);
350
+ for (const raw of wsResult.entities) {
351
+ const entity = mapToContextEntity(raw);
352
+ if (!candidateIds.has(entity.id)) {
353
+ candidateIds.add(entity.id);
354
+ candidates.push(entity);
355
+ }
356
+ }
226
357
  }
227
358
  }
228
359
  catch {
229
360
  // Continue with what we have
230
361
  }
231
362
  }
363
+ // P0: Graph walk enrichment — discover related entities via knowledge graph
364
+ let graphRelations = [];
365
+ if (graphWalkEnabled && candidates.length > 0) {
366
+ try {
367
+ // Take top candidates by RRF score (or first N if no RRF scores)
368
+ const seedCandidates = [...candidates]
369
+ .sort((a, b) => (b.rrf_score ?? 0) - (a.rrf_score ?? 0))
370
+ .slice(0, GRAPH_WALK_SEED_COUNT);
371
+ const seedIds = seedCandidates.map((c) => c.id);
372
+ const walkResult = await discoverRelatedContext(client, seedIds, GRAPH_WALK_MAX_DEPTH, GRAPH_WALK_MAX_ENTITIES, GRAPH_WALK_MIN_CONFIDENCE);
373
+ graphRelations = walkResult.relations;
374
+ // Add discovered entities to candidate pool (skip those already present)
375
+ const newEntityIds = walkResult.entities
376
+ .filter((e) => !candidateIds.has(e.id))
377
+ .map((e) => e.id);
378
+ if (newEntityIds.length > 0) {
379
+ // Fetch full entity data in parallel (graph walk only returns summary fields)
380
+ const fetchResults = await Promise.allSettled(newEntityIds.map((id) => client.getMemoryEntity(id)));
381
+ for (const result of fetchResults) {
382
+ if (result.status !== "fulfilled" || !result.value.entity)
383
+ continue;
384
+ const mapped = mapToContextEntity(result.value.entity);
385
+ candidateIds.add(mapped.id);
386
+ candidates.push(mapped);
387
+ }
388
+ }
389
+ }
390
+ catch {
391
+ // Graph walk failed, continue with search-only candidates
392
+ }
393
+ }
232
394
  if (candidates.length === 0) {
233
395
  return {
234
396
  context: "",
@@ -236,13 +398,43 @@ export async function assembleContext(options) {
236
398
  memories: [],
237
399
  };
238
400
  }
239
- // Score all candidates
401
+ // Score all candidates (pass graph relations for relation-type bonuses)
240
402
  const scored = candidates.map((entity) => {
241
- const { score, reasons } = computeRelevanceScore(entity, taskContext, cardLabels);
403
+ const { score, reasons } = computeRelevanceScore(entity, taskContext, cardLabels, graphRelations.length > 0 ? graphRelations : undefined);
242
404
  return { entity, score, reasons };
243
405
  });
244
406
  // Sort by score descending
245
407
  scored.sort((a, b) => b.score - a.score);
408
+ // P2: Optional LLM re-ranking when top scores are clustered
409
+ if (enableLlmReranking &&
410
+ rerankFn &&
411
+ scored.length >= RERANK_MIN_CANDIDATES) {
412
+ const topN = scored.slice(0, RERANK_TOP_N);
413
+ const scoreRange = topN[0].score - topN[topN.length - 1].score;
414
+ // Only re-rank when scores are tightly clustered
415
+ if (scoreRange <= RERANK_CLUSTER_THRESHOLD) {
416
+ try {
417
+ const rerankCandidates = topN.map((s) => ({
418
+ id: s.entity.id,
419
+ title: s.entity.title,
420
+ snippet: s.entity.content.slice(0, 200),
421
+ }));
422
+ const rerankedIds = await rerankFn(taskContext, rerankCandidates);
423
+ // Reorder based on LLM ranking
424
+ const idOrder = new Map(rerankedIds.map((id, i) => [id, i]));
425
+ topN.sort((a, b) => {
426
+ const aIdx = idOrder.get(a.entity.id) ?? 999;
427
+ const bIdx = idOrder.get(b.entity.id) ?? 999;
428
+ return aIdx - bIdx;
429
+ });
430
+ // Splice reranked items back in
431
+ scored.splice(0, topN.length, ...topN);
432
+ }
433
+ catch {
434
+ // Re-ranking failed, continue with static ordering
435
+ }
436
+ }
437
+ }
246
438
  // Reserve dedicated procedure budget, allocate remaining to tiers
247
439
  const procedureBudget = Math.floor(tokenBudget * PROCEDURE_BUDGET_FRACTION);
248
440
  const remainingBudget = tokenBudget - procedureBudget;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { areSkillsInstalled } from "./config.js";
4
- export const SKILLS_VERSION = "3";
4
+ export const SKILLS_VERSION = "4";
5
5
  const VERSION_MARKER_PREFIX = "<!-- skills-version:";
6
6
  /**
7
7
  * Legacy workflow prompt used by Codex, Cursor agents.
@@ -202,6 +202,30 @@ Do not start an agent session.
202
202
 
203
203
  ---
204
204
 
205
+ ## Step E: Auto-Detect Card for Implementation Tasks
206
+
207
+ **IMPORTANT:** This step applies when you are about to implement a plan, feature, or fix
208
+ that was NOT started via /hmy. Before writing any code, check if the work maps to an
209
+ existing Harmony card.
210
+
211
+ ### When to run this check
212
+ - User says "implement this plan", "build this feature", "fix this bug" (without /hmy)
213
+ - You are about to execute a plan file
214
+ - Skip if: the user already started with /hmy, or no Harmony MCP tools are available
215
+
216
+ ### Detection steps
217
+ 1. Call \`harmony_search_cards\` with keywords from the plan title or task description
218
+ 2. If a card matches (same feature/fix described), tell the user:
219
+ "This maps to card #N — starting a session to track progress."
220
+ 3. Call \`harmony_start_agent_session\` with \`moveToColumn: "In Progress"\`, \`addLabels: ["agent"]\`
221
+ 4. Track progress with \`harmony_update_agent_progress\` at milestones
222
+ 5. When done, call \`harmony_end_agent_session\` with \`status: "completed"\`, \`moveToColumn: "Review"\`
223
+
224
+ ### If no match
225
+ Proceed normally without a session. No action needed.
226
+
227
+ ---
228
+
205
229
  ## Key Tools Reference
206
230
 
207
231
  **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\`
@@ -204,6 +204,17 @@ When given a card reference (e.g., #42 or a card name), follow this workflow:
204
204
  7. Update progress periodically with \`harmony_update_agent_progress\`
205
205
  8. When done, call \`harmony_end_agent_session\` and move to "Review"
206
206
 
207
+ ## Auto-Detect Card for Implementation Tasks
208
+
209
+ Before implementing a plan or feature, check if it maps to an existing Harmony card:
210
+
211
+ 1. Use \`harmony_search_cards\` with keywords from the task description
212
+ 2. If a match is found, call \`harmony_start_agent_session\` (agentIdentifier: "claude-code", agentName: "Claude Code", moveToColumn: "In Progress", addLabels: ["agent"])
213
+ 3. Update progress with \`harmony_update_agent_progress\` at milestones
214
+ 4. When done, call \`harmony_end_agent_session\` with status: "completed", moveToColumn: "Review"
215
+
216
+ Skip if: work was already started with a card reference, or no matching card exists.
217
+
207
218
  ## Available Harmony Tools
208
219
 
209
220
  - \`harmony_get_card\`, \`harmony_get_card_by_short_id\`, \`harmony_search_cards\` - Find cards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"