@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.
- package/dist/cli.js +780 -352
- package/dist/index.js +744 -351
- package/dist/lib/active-learning.js +73 -129
- package/dist/lib/consolidation.js +71 -11
- package/dist/lib/context-assembly.js +287 -30
- package/dist/lib/memory-cleanup.js +426 -0
- package/dist/lib/prompt-builder.js +5 -1
- package/dist/lib/server.js +63 -0
- package/dist/lib/skills.js +25 -1
- package/dist/lib/tui/setup.js +11 -0
- package/package.json +1 -1
- package/src/active-learning.ts +83 -145
- package/src/consolidation.ts +81 -12
- package/src/context-assembly.ts +342 -30
- package/src/memory-cleanup.ts +616 -0
- package/src/prompt-builder.ts +13 -1
- package/src/server.ts +74 -0
- package/src/skills.ts +25 -1
- package/src/tui/setup.ts +11 -0
|
@@ -4,11 +4,11 @@
|
|
|
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;
|
|
11
|
-
const MIN_RELEVANCE_THRESHOLD = 0.1
|
|
11
|
+
const MIN_RELEVANCE_THRESHOLD = 0.15; // raised from 0.1 to filter low-signal entities
|
|
12
12
|
// Tier weight multipliers for relevance scoring
|
|
13
13
|
const TIER_WEIGHTS = {
|
|
14
14
|
reference: 1.0,
|
|
@@ -23,14 +23,100 @@ const TIER_BUDGET_ALLOCATION = {
|
|
|
23
23
|
episode: 0.3,
|
|
24
24
|
draft: 0.1,
|
|
25
25
|
};
|
|
26
|
-
// Minimum guaranteed slots per tier
|
|
27
|
-
const MIN_REFERENCE_SLOTS =
|
|
26
|
+
// Minimum guaranteed slots per tier (reduced from 3 to avoid filling context with noise)
|
|
27
|
+
const MIN_REFERENCE_SLOTS = 1;
|
|
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
|
*/
|
|
31
73
|
function estimateTokens(text) {
|
|
32
74
|
return Math.ceil(text.length / 4);
|
|
33
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Content quality gate: filter out entities that waste token budget.
|
|
78
|
+
* Returns true if the entity passes quality checks.
|
|
79
|
+
*/
|
|
80
|
+
function passesQualityGate(entity) {
|
|
81
|
+
const content = entity.content.trim();
|
|
82
|
+
// Gate 1: Minimum content length — entities with <50 chars of content
|
|
83
|
+
// are too shallow to provide value (e.g., "Resolved bug: Fix login button")
|
|
84
|
+
if (content.length < 50)
|
|
85
|
+
return false;
|
|
86
|
+
// Gate 2: Title-content similarity — skip entities where content is just
|
|
87
|
+
// the title restated. Normalize both and check if content adds anything.
|
|
88
|
+
const normalizedTitle = entity.title
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
91
|
+
.trim();
|
|
92
|
+
const normalizedContent = content
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
95
|
+
.trim();
|
|
96
|
+
if (normalizedContent.length < normalizedTitle.length * 1.5) {
|
|
97
|
+
// Content is barely longer than the title — likely just a reformulation
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
// Gate 3: Pattern noise detection — skip "Pattern: recurring X (N instances)"
|
|
101
|
+
// and "Consolidated from N type memories:" entities that are just catalogs
|
|
102
|
+
if (entity.type === "pattern" &&
|
|
103
|
+
/recurring .+ \(\d+ instances\)/i.test(entity.title)) {
|
|
104
|
+
// Check if content is just a member list (lines starting with "- ")
|
|
105
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
106
|
+
const bulletLines = lines.filter((l) => l.trim().startsWith("- "));
|
|
107
|
+
if (bulletLines.length > lines.length * 0.6)
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
// Gate 4: Procedure quality — procedures must contain actual steps,
|
|
111
|
+
// not just a card title wrapped in a template
|
|
112
|
+
if (entity.type === "procedure") {
|
|
113
|
+
// Count numbered steps (1. ..., 2. ..., etc.)
|
|
114
|
+
const stepCount = (content.match(/^\d+\.\s/gm) || []).length;
|
|
115
|
+
if (stepCount < 3)
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
34
120
|
/**
|
|
35
121
|
* Generate a unique assembly ID
|
|
36
122
|
*/
|
|
@@ -68,10 +154,65 @@ function truncateContent(content, maxTokens) {
|
|
|
68
154
|
}
|
|
69
155
|
return { text: result, truncated: true };
|
|
70
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Escape regex metacharacters in a string for safe use in RegExp constructor.
|
|
159
|
+
*/
|
|
160
|
+
function escapeRegex(str) {
|
|
161
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Expand a query into multiple search variations using synonym substitution.
|
|
165
|
+
* Returns the original query plus up to 3 additional variations (4 total).
|
|
166
|
+
*/
|
|
167
|
+
export function expandQuery(taskContext) {
|
|
168
|
+
const queries = [taskContext];
|
|
169
|
+
const lowerQueries = [taskContext.toLowerCase()];
|
|
170
|
+
const words = taskContext
|
|
171
|
+
.toLowerCase()
|
|
172
|
+
.split(/\W+/)
|
|
173
|
+
.filter((w) => w.length > 2);
|
|
174
|
+
// Find words that have synonym expansions
|
|
175
|
+
const expandableWords = words.filter((w) => QUERY_SYNONYMS[w]);
|
|
176
|
+
for (const word of expandableWords) {
|
|
177
|
+
const synonyms = QUERY_SYNONYMS[word];
|
|
178
|
+
if (!synonyms)
|
|
179
|
+
continue;
|
|
180
|
+
// Create a variation by replacing the word with its first synonym
|
|
181
|
+
const variation = taskContext.replace(new RegExp(`\\b${escapeRegex(word)}\\b`, "gi"), synonyms[0]);
|
|
182
|
+
const lowerVariation = variation.toLowerCase();
|
|
183
|
+
if (lowerVariation !== taskContext.toLowerCase() &&
|
|
184
|
+
!lowerQueries.includes(lowerVariation)) {
|
|
185
|
+
queries.push(variation);
|
|
186
|
+
lowerQueries.push(lowerVariation);
|
|
187
|
+
}
|
|
188
|
+
if (queries.length >= MAX_QUERY_VARIATIONS)
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
// Also extract key noun phrases as a compact query
|
|
192
|
+
if (words.length >= 3) {
|
|
193
|
+
const keyPhrases = words
|
|
194
|
+
.filter((w) => ![
|
|
195
|
+
"the",
|
|
196
|
+
"and",
|
|
197
|
+
"for",
|
|
198
|
+
"with",
|
|
199
|
+
"this",
|
|
200
|
+
"that",
|
|
201
|
+
"from",
|
|
202
|
+
"into",
|
|
203
|
+
].includes(w))
|
|
204
|
+
.slice(0, 4)
|
|
205
|
+
.join(" ");
|
|
206
|
+
if (!lowerQueries.includes(keyPhrases)) {
|
|
207
|
+
queries.push(keyPhrases);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return queries.slice(0, MAX_QUERY_VARIATIONS);
|
|
211
|
+
}
|
|
71
212
|
/**
|
|
72
213
|
* Compute relevance score for an entity against task context.
|
|
73
214
|
*/
|
|
74
|
-
export function computeRelevanceScore(entity, taskContext, cardLabels) {
|
|
215
|
+
export function computeRelevanceScore(entity, taskContext, cardLabels, graphRelations) {
|
|
75
216
|
const reasons = [];
|
|
76
217
|
let score = 0;
|
|
77
218
|
// 0. DB hybrid search signal (RRF score from FTS + vector fusion)
|
|
@@ -150,8 +291,26 @@ export function computeRelevanceScore(entity, taskContext, cardLabels) {
|
|
|
150
291
|
score += 0.1;
|
|
151
292
|
reasons.push("procedure_boost");
|
|
152
293
|
}
|
|
294
|
+
// 7. Graph walk relation bonus: boost entities discovered via knowledge graph
|
|
295
|
+
if (graphRelations && graphRelations.length > 0) {
|
|
296
|
+
const entityRelations = graphRelations.filter((r) => r.source_id === entity.id || r.target_id === entity.id);
|
|
297
|
+
if (entityRelations.length > 0) {
|
|
298
|
+
// Take the highest relation bonus (don't stack all of them)
|
|
299
|
+
let bestBonus = 0;
|
|
300
|
+
let bestRelType = "";
|
|
301
|
+
for (const rel of entityRelations) {
|
|
302
|
+
const bonus = RELATION_BONUSES[rel.relation_type] ?? 0.1;
|
|
303
|
+
if (bonus > bestBonus) {
|
|
304
|
+
bestBonus = bonus;
|
|
305
|
+
bestRelType = rel.relation_type;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
score += bestBonus;
|
|
309
|
+
reasons.push(`graph_walk(${bestRelType})`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
153
312
|
// Clamp raw score to 0-1 range before applying tier weight
|
|
154
|
-
score = Math.min(score, 1.0);
|
|
313
|
+
score = Math.max(0, Math.min(score, 1.0));
|
|
155
314
|
// Apply tier weight
|
|
156
315
|
const tierWeight = TIER_WEIGHTS[entity.memory_tier];
|
|
157
316
|
score *= tierWeight;
|
|
@@ -161,7 +320,7 @@ export function computeRelevanceScore(entity, taskContext, cardLabels) {
|
|
|
161
320
|
* Assemble context from knowledge graph entities with token budget management.
|
|
162
321
|
*/
|
|
163
322
|
export async function assembleContext(options) {
|
|
164
|
-
const { workspaceId, projectId, taskContext, cardLabels = [], tokenBudget = DEFAULT_TOKEN_BUDGET, client, } = options;
|
|
323
|
+
const { workspaceId, projectId, taskContext, cardLabels = [], tokenBudget = DEFAULT_TOKEN_BUDGET, client, graphWalkEnabled = true, queryExpansionEnabled = true, enableLlmReranking = false, rerankFn, } = options;
|
|
165
324
|
const assemblyId = generateAssemblyId();
|
|
166
325
|
const manifest = {
|
|
167
326
|
assemblyId,
|
|
@@ -176,18 +335,30 @@ export async function assembleContext(options) {
|
|
|
176
335
|
reference: { count: 0, tokens: 0 },
|
|
177
336
|
},
|
|
178
337
|
};
|
|
179
|
-
// Fetch candidate entities: search by task context + list by project
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
338
|
+
// Fetch candidate entities: search by task context (with query expansion) + list by project
|
|
339
|
+
const candidates = [];
|
|
340
|
+
// P1: Query expansion — search with multiple query variations to catch synonym mismatches
|
|
341
|
+
const queries = queryExpansionEnabled
|
|
342
|
+
? expandQuery(taskContext)
|
|
343
|
+
: [taskContext];
|
|
344
|
+
const searchResults = await Promise.allSettled(queries.map((query) => client.searchMemoryEntities(workspaceId, query, {
|
|
345
|
+
project_id: projectId,
|
|
346
|
+
limit: 30,
|
|
347
|
+
})));
|
|
348
|
+
const candidateIds = new Set();
|
|
349
|
+
for (const result of searchResults) {
|
|
350
|
+
if (result.status !== "fulfilled")
|
|
351
|
+
continue;
|
|
352
|
+
if (result.value.entities?.length > 0) {
|
|
353
|
+
for (const raw of result.value.entities) {
|
|
354
|
+
const entity = mapToContextEntity(raw);
|
|
355
|
+
if (!candidateIds.has(entity.id)) {
|
|
356
|
+
candidateIds.add(entity.id);
|
|
357
|
+
candidates.push(entity);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
186
360
|
}
|
|
187
361
|
}
|
|
188
|
-
catch {
|
|
189
|
-
// Search failed, fall back to listing
|
|
190
|
-
}
|
|
191
362
|
// Also fetch by project scope if we have few candidates
|
|
192
363
|
if (candidates.length < 10 && projectId) {
|
|
193
364
|
try {
|
|
@@ -197,11 +368,13 @@ export async function assembleContext(options) {
|
|
|
197
368
|
limit: 30,
|
|
198
369
|
});
|
|
199
370
|
if (listResult.entities?.length > 0) {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
.
|
|
203
|
-
|
|
204
|
-
|
|
371
|
+
for (const raw of listResult.entities) {
|
|
372
|
+
const entity = mapToContextEntity(raw);
|
|
373
|
+
if (!candidateIds.has(entity.id)) {
|
|
374
|
+
candidateIds.add(entity.id);
|
|
375
|
+
candidates.push(entity);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
205
378
|
}
|
|
206
379
|
}
|
|
207
380
|
catch {
|
|
@@ -218,17 +391,50 @@ export async function assembleContext(options) {
|
|
|
218
391
|
limit: 20,
|
|
219
392
|
});
|
|
220
393
|
if (wsResult.entities?.length > 0) {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
.
|
|
224
|
-
|
|
225
|
-
|
|
394
|
+
for (const raw of wsResult.entities) {
|
|
395
|
+
const entity = mapToContextEntity(raw);
|
|
396
|
+
if (!candidateIds.has(entity.id)) {
|
|
397
|
+
candidateIds.add(entity.id);
|
|
398
|
+
candidates.push(entity);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
226
401
|
}
|
|
227
402
|
}
|
|
228
403
|
catch {
|
|
229
404
|
// Continue with what we have
|
|
230
405
|
}
|
|
231
406
|
}
|
|
407
|
+
// P0: Graph walk enrichment — discover related entities via knowledge graph
|
|
408
|
+
let graphRelations = [];
|
|
409
|
+
if (graphWalkEnabled && candidates.length > 0) {
|
|
410
|
+
try {
|
|
411
|
+
// Take top candidates by RRF score (or first N if no RRF scores)
|
|
412
|
+
const seedCandidates = [...candidates]
|
|
413
|
+
.sort((a, b) => (b.rrf_score ?? 0) - (a.rrf_score ?? 0))
|
|
414
|
+
.slice(0, GRAPH_WALK_SEED_COUNT);
|
|
415
|
+
const seedIds = seedCandidates.map((c) => c.id);
|
|
416
|
+
const walkResult = await discoverRelatedContext(client, seedIds, GRAPH_WALK_MAX_DEPTH, GRAPH_WALK_MAX_ENTITIES, GRAPH_WALK_MIN_CONFIDENCE);
|
|
417
|
+
graphRelations = walkResult.relations;
|
|
418
|
+
// Add discovered entities to candidate pool (skip those already present)
|
|
419
|
+
const newEntityIds = walkResult.entities
|
|
420
|
+
.filter((e) => !candidateIds.has(e.id))
|
|
421
|
+
.map((e) => e.id);
|
|
422
|
+
if (newEntityIds.length > 0) {
|
|
423
|
+
// Fetch full entity data in parallel (graph walk only returns summary fields)
|
|
424
|
+
const fetchResults = await Promise.allSettled(newEntityIds.map((id) => client.getMemoryEntity(id)));
|
|
425
|
+
for (const result of fetchResults) {
|
|
426
|
+
if (result.status !== "fulfilled" || !result.value.entity)
|
|
427
|
+
continue;
|
|
428
|
+
const mapped = mapToContextEntity(result.value.entity);
|
|
429
|
+
candidateIds.add(mapped.id);
|
|
430
|
+
candidates.push(mapped);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Graph walk failed, continue with search-only candidates
|
|
436
|
+
}
|
|
437
|
+
}
|
|
232
438
|
if (candidates.length === 0) {
|
|
233
439
|
return {
|
|
234
440
|
context: "",
|
|
@@ -236,13 +442,64 @@ export async function assembleContext(options) {
|
|
|
236
442
|
memories: [],
|
|
237
443
|
};
|
|
238
444
|
}
|
|
239
|
-
//
|
|
240
|
-
const
|
|
241
|
-
|
|
445
|
+
// Quality gate: filter out low-value entities before scoring
|
|
446
|
+
const qualityCandidates = candidates.filter((entity) => {
|
|
447
|
+
if (passesQualityGate(entity))
|
|
448
|
+
return true;
|
|
449
|
+
manifest.excluded.push({
|
|
450
|
+
entityId: entity.id,
|
|
451
|
+
title: entity.title,
|
|
452
|
+
type: entity.type,
|
|
453
|
+
tier: entity.memory_tier,
|
|
454
|
+
relevanceScore: 0,
|
|
455
|
+
reason: "failed_quality_gate",
|
|
456
|
+
});
|
|
457
|
+
return false;
|
|
458
|
+
});
|
|
459
|
+
if (qualityCandidates.length === 0) {
|
|
460
|
+
return {
|
|
461
|
+
context: "",
|
|
462
|
+
manifest,
|
|
463
|
+
memories: [],
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
// Score all candidates (pass graph relations for relation-type bonuses)
|
|
467
|
+
const scored = qualityCandidates.map((entity) => {
|
|
468
|
+
const { score, reasons } = computeRelevanceScore(entity, taskContext, cardLabels, graphRelations.length > 0 ? graphRelations : undefined);
|
|
242
469
|
return { entity, score, reasons };
|
|
243
470
|
});
|
|
244
471
|
// Sort by score descending
|
|
245
472
|
scored.sort((a, b) => b.score - a.score);
|
|
473
|
+
// P2: Optional LLM re-ranking when top scores are clustered
|
|
474
|
+
if (enableLlmReranking &&
|
|
475
|
+
rerankFn &&
|
|
476
|
+
scored.length >= RERANK_MIN_CANDIDATES) {
|
|
477
|
+
const topN = scored.slice(0, RERANK_TOP_N);
|
|
478
|
+
const scoreRange = topN[0].score - topN[topN.length - 1].score;
|
|
479
|
+
// Only re-rank when scores are tightly clustered
|
|
480
|
+
if (scoreRange <= RERANK_CLUSTER_THRESHOLD) {
|
|
481
|
+
try {
|
|
482
|
+
const rerankCandidates = topN.map((s) => ({
|
|
483
|
+
id: s.entity.id,
|
|
484
|
+
title: s.entity.title,
|
|
485
|
+
snippet: s.entity.content.slice(0, 200),
|
|
486
|
+
}));
|
|
487
|
+
const rerankedIds = await rerankFn(taskContext, rerankCandidates);
|
|
488
|
+
// Reorder based on LLM ranking
|
|
489
|
+
const idOrder = new Map(rerankedIds.map((id, i) => [id, i]));
|
|
490
|
+
topN.sort((a, b) => {
|
|
491
|
+
const aIdx = idOrder.get(a.entity.id) ?? 999;
|
|
492
|
+
const bIdx = idOrder.get(b.entity.id) ?? 999;
|
|
493
|
+
return aIdx - bIdx;
|
|
494
|
+
});
|
|
495
|
+
// Splice reranked items back in
|
|
496
|
+
scored.splice(0, topN.length, ...topN);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// Re-ranking failed, continue with static ordering
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
246
503
|
// Reserve dedicated procedure budget, allocate remaining to tiers
|
|
247
504
|
const procedureBudget = Math.floor(tokenBudget * PROCEDURE_BUDGET_FRACTION);
|
|
248
505
|
const remainingBudget = tokenBudget - procedureBudget;
|