@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
package/src/context-assembly.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* for a given task, producing a manifest of what was included/excluded.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
495
|
+
// Fetch candidate entities: search by task context (with query expansion) + list by project
|
|
496
|
+
const candidates: ContextEntity[] = [];
|
|
300
497
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
.
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
.
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
//
|
|
365
|
-
const
|
|
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;
|