@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.
- package/dist/cli.js +201 -18
- package/dist/index.js +165 -17
- package/dist/lib/context-assembly.js +218 -26
- package/dist/lib/skills.js +25 -1
- package/dist/lib/tui/setup.js +11 -0
- package/package.json +1 -1
- package/src/context-assembly.ts +267 -26
- package/src/skills.ts +25 -1
- package/src/tui/setup.ts +11 -0
|
@@ -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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
.
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
.
|
|
224
|
-
|
|
225
|
-
|
|
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;
|
package/dist/lib/skills.js
CHANGED
|
@@ -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 = "
|
|
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\`
|
package/dist/lib/tui/setup.js
CHANGED
|
@@ -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
|