@gethmy/mcp 2.2.4 → 2.3.1
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 +637 -335
- package/dist/index.js +637 -335
- package/dist/lib/active-learning.js +73 -129
- package/dist/lib/api-client.js +6 -0
- package/dist/lib/consolidation.js +71 -11
- package/dist/lib/context-assembly.js +69 -4
- package/dist/lib/memory-cleanup.js +455 -0
- package/dist/lib/prompt-builder.js +5 -1
- package/dist/lib/server.js +77 -0
- package/package.json +1 -1
- package/src/active-learning.ts +83 -145
- package/src/api-client.ts +37 -1
- package/src/consolidation.ts +81 -12
- package/src/context-assembly.ts +75 -4
- package/src/memory-cleanup.ts +658 -0
- package/src/prompt-builder.ts +13 -1
- package/src/server.ts +89 -0
|
@@ -49,13 +49,13 @@ function levenshteinSimilarity(a, b) {
|
|
|
49
49
|
* Extract learnings from mid-session progress updates.
|
|
50
50
|
* Called from harmony_update_agent_progress.
|
|
51
51
|
*/
|
|
52
|
-
export async function extractMidSessionLearnings(
|
|
52
|
+
export async function extractMidSessionLearnings(_client, ctx) {
|
|
53
53
|
const workspaceId = getActiveWorkspaceId();
|
|
54
54
|
if (!workspaceId)
|
|
55
55
|
return { count: 0, entityIds: [] };
|
|
56
|
-
const
|
|
56
|
+
const _projectId = getActiveProjectId() || undefined;
|
|
57
57
|
const now = Date.now();
|
|
58
|
-
const
|
|
58
|
+
const _entityIds = [];
|
|
59
59
|
const history = sessionTaskHistory.get(ctx.cardId);
|
|
60
60
|
// Always track step history regardless of rate limit
|
|
61
61
|
if (ctx.currentTask) {
|
|
@@ -91,82 +91,29 @@ export async function extractMidSessionLearnings(client, ctx) {
|
|
|
91
91
|
return { count: 0, entityIds: [] };
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
-
// Rule 1: Status transitions to "blocked" → create
|
|
94
|
+
// Rule 1: Status transitions to "blocked" → track but DON'T create mid-session entities.
|
|
95
|
+
// Blockers are captured at session end with full context — mid-session entities are
|
|
96
|
+
// low-confidence duplicates that add noise to the knowledge graph.
|
|
95
97
|
if (ctx.status === "blocked" && ctx.blockers?.length) {
|
|
96
|
-
for (const blocker of ctx.blockers) {
|
|
97
|
-
try {
|
|
98
|
-
const result = await client.createMemoryEntity({
|
|
99
|
-
workspace_id: workspaceId,
|
|
100
|
-
project_id: projectId,
|
|
101
|
-
type: "error",
|
|
102
|
-
scope: "project",
|
|
103
|
-
memory_tier: "draft",
|
|
104
|
-
title: `Blocker (mid-session): ${blocker.slice(0, 100)}`,
|
|
105
|
-
content: `Encountered while working on "${ctx.cardTitle}":\n\n${blocker}\n\nAgent: ${ctx.agentName}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
|
|
106
|
-
confidence: 0.5,
|
|
107
|
-
tags: ["auto-extracted", "blocker", "mid-session"],
|
|
108
|
-
metadata: {
|
|
109
|
-
source: "mid_session",
|
|
110
|
-
card_id: ctx.cardId,
|
|
111
|
-
},
|
|
112
|
-
agent_identifier: ctx.agentIdentifier,
|
|
113
|
-
});
|
|
114
|
-
const entity = result.entity;
|
|
115
|
-
if (entity?.id)
|
|
116
|
-
entityIds.push(entity.id);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// Non-fatal
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
98
|
sessionTaskHistory.set(ctx.cardId, {
|
|
123
99
|
lastTask: ctx.currentTask || "",
|
|
124
100
|
lastExtractionAt: now,
|
|
125
101
|
steps: history?.steps || [],
|
|
126
102
|
});
|
|
127
|
-
return { count:
|
|
103
|
+
return { count: 0, entityIds: [] };
|
|
128
104
|
}
|
|
129
|
-
// Rule 2: Task
|
|
105
|
+
// Rule 2: Task transitions are tracked in step history (above) but no longer
|
|
106
|
+
// create separate context entities. The step history feeds procedure extraction
|
|
107
|
+
// at session end, which is more valuable than individual transition snapshots.
|
|
130
108
|
if (ctx.currentTask) {
|
|
131
|
-
const previousTask = history?.lastTask || "";
|
|
132
|
-
const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
|
|
133
|
-
if (similarity < 0.6 && previousTask.length > 0) {
|
|
134
|
-
try {
|
|
135
|
-
const result = await client.createMemoryEntity({
|
|
136
|
-
workspace_id: workspaceId,
|
|
137
|
-
project_id: projectId,
|
|
138
|
-
type: "context",
|
|
139
|
-
scope: "project",
|
|
140
|
-
memory_tier: "draft",
|
|
141
|
-
title: `Task transition: ${ctx.cardTitle}`,
|
|
142
|
-
content: `Agent transitioned tasks on "${ctx.cardTitle}".\n\nPrevious: ${previousTask}\nCurrent: ${ctx.currentTask}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
|
|
143
|
-
confidence: 0.5,
|
|
144
|
-
tags: ["auto-extracted", "task-transition", "mid-session"],
|
|
145
|
-
metadata: {
|
|
146
|
-
source: "mid_session",
|
|
147
|
-
card_id: ctx.cardId,
|
|
148
|
-
previous_task: previousTask,
|
|
149
|
-
current_task: ctx.currentTask,
|
|
150
|
-
},
|
|
151
|
-
agent_identifier: ctx.agentIdentifier,
|
|
152
|
-
});
|
|
153
|
-
const entity = result.entity;
|
|
154
|
-
if (entity?.id)
|
|
155
|
-
entityIds.push(entity.id);
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
// Non-fatal
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
// Update lastExtractionAt only when entities were created
|
|
162
109
|
const currentHistory = sessionTaskHistory.get(ctx.cardId);
|
|
163
110
|
sessionTaskHistory.set(ctx.cardId, {
|
|
164
111
|
lastTask: ctx.currentTask,
|
|
165
|
-
lastExtractionAt:
|
|
112
|
+
lastExtractionAt: currentHistory?.lastExtractionAt ?? 0,
|
|
166
113
|
steps: currentHistory?.steps || [],
|
|
167
114
|
});
|
|
168
115
|
}
|
|
169
|
-
return { count:
|
|
116
|
+
return { count: 0, entityIds: [] };
|
|
170
117
|
}
|
|
171
118
|
/**
|
|
172
119
|
* Clean up mid-session tracking for a card (call on session end).
|
|
@@ -402,47 +349,61 @@ export async function extractLearnings(client, session) {
|
|
|
402
349
|
const wikiLinksLine = relatedEntityTitles.length > 0
|
|
403
350
|
? `\nRelated: ${relatedEntityTitles.map((t) => `[[${t}]]`).join(", ")}`
|
|
404
351
|
: "";
|
|
405
|
-
// Rule 1: Session had blockers → create error
|
|
352
|
+
// Rule 1: Session had blockers → create error entity (only for substantial blockers)
|
|
353
|
+
// Skip trivial blocker strings — only store if the blocker text contains
|
|
354
|
+
// enough detail to be useful to a future agent (>80 chars).
|
|
406
355
|
if (session.blockers && session.blockers.length > 0) {
|
|
407
356
|
for (const blocker of session.blockers) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
357
|
+
if (blocker.length < 80)
|
|
358
|
+
continue; // Skip trivial blockers like "stuck" or "waiting on API"
|
|
359
|
+
// Dedup: check if a similar error entity already exists
|
|
360
|
+
let isDuplicate = false;
|
|
361
|
+
try {
|
|
362
|
+
const similar = await findSimilarEntities(client, blocker.slice(0, 200), blocker, workspaceId, { projectId, limit: 3, minRrfScore: 0.05 });
|
|
363
|
+
isDuplicate = similar.some((e) => e.type === "error" && (e.rrf_score ?? 0) >= 0.06);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
/* non-fatal */
|
|
367
|
+
}
|
|
368
|
+
if (!isDuplicate) {
|
|
369
|
+
learnings.push({
|
|
370
|
+
title: `Blocker: ${blocker.slice(0, 100)}`,
|
|
371
|
+
content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
|
|
372
|
+
type: "error",
|
|
373
|
+
tier: "episode",
|
|
374
|
+
confidence: 0.6,
|
|
375
|
+
tags: [
|
|
376
|
+
"auto-extracted",
|
|
377
|
+
"blocker",
|
|
378
|
+
...session.cardLabels.slice(0, 3),
|
|
379
|
+
],
|
|
380
|
+
metadata: {
|
|
381
|
+
source: "active_learning",
|
|
382
|
+
card_id: session.cardId,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
420
386
|
}
|
|
421
387
|
}
|
|
422
|
-
// Rule 2: Session
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
session.cardSubtasks?.some((s) => !s.done));
|
|
428
|
-
if (session.status === "completed" && hasMeaningfulContent) {
|
|
388
|
+
// Rule 2: Session paused with blockers → create lesson (paused only, not clean completions).
|
|
389
|
+
// Clean completions produce no reusable knowledge — the work is in the code/PR.
|
|
390
|
+
// Only create a lesson when the session was interrupted (paused with blockers),
|
|
391
|
+
// so a future agent can understand what was left unfinished and why.
|
|
392
|
+
if (session.status === "paused" && (session.blockers?.length ?? 0) > 0) {
|
|
429
393
|
const durationInfo = session.sessionDurationMs
|
|
430
394
|
? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
|
|
431
395
|
: "";
|
|
432
396
|
learnings.push({
|
|
433
|
-
title: `
|
|
397
|
+
title: `Paused: ${session.cardTitle}`,
|
|
434
398
|
content: [
|
|
435
|
-
`
|
|
436
|
-
session.currentTask ? `
|
|
399
|
+
`Paused work on "${session.cardTitle}".`,
|
|
400
|
+
session.currentTask ? `Last task: ${session.currentTask}` : "",
|
|
437
401
|
session.progressPercent !== undefined
|
|
438
402
|
? `Progress: ${session.progressPercent}%`
|
|
439
403
|
: "",
|
|
440
404
|
durationInfo,
|
|
441
|
-
session.cardLabels.length > 0
|
|
442
|
-
? `Labels: ${session.cardLabels.join(", ")}`
|
|
443
|
-
: "",
|
|
444
405
|
session.blockers?.length
|
|
445
|
-
? `Blockers
|
|
406
|
+
? `Blockers: ${session.blockers.join("; ")}`
|
|
446
407
|
: "",
|
|
447
408
|
`\nAgent: ${session.agentName}`,
|
|
448
409
|
wikiLinksLine,
|
|
@@ -450,11 +411,11 @@ export async function extractLearnings(client, session) {
|
|
|
450
411
|
.filter(Boolean)
|
|
451
412
|
.join("\n"),
|
|
452
413
|
type: "lesson",
|
|
453
|
-
tier: "
|
|
454
|
-
confidence: 0.
|
|
414
|
+
tier: "draft",
|
|
415
|
+
confidence: 0.6,
|
|
455
416
|
tags: [
|
|
456
417
|
"auto-extracted",
|
|
457
|
-
"session-
|
|
418
|
+
"session-paused",
|
|
458
419
|
...session.cardLabels.slice(0, 3),
|
|
459
420
|
],
|
|
460
421
|
metadata: {
|
|
@@ -463,39 +424,24 @@ export async function extractLearnings(client, session) {
|
|
|
463
424
|
},
|
|
464
425
|
});
|
|
465
426
|
}
|
|
466
|
-
// Rule 3:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
title: `Solution: ${session.cardTitle}`,
|
|
471
|
-
content: [
|
|
472
|
-
`Resolved bug: "${session.cardTitle}"`,
|
|
473
|
-
session.currentTask ? `\nApproach: ${session.currentTask}` : "",
|
|
474
|
-
`\nAgent: ${session.agentName}`,
|
|
475
|
-
wikiLinksLine,
|
|
476
|
-
]
|
|
477
|
-
.filter(Boolean)
|
|
478
|
-
.join("\n"),
|
|
479
|
-
type: "solution",
|
|
480
|
-
tier: "reference",
|
|
481
|
-
confidence: 0.8,
|
|
482
|
-
tags: ["auto-extracted", "bug-fix", ...session.cardLabels.slice(0, 3)],
|
|
483
|
-
metadata: {
|
|
484
|
-
source: "active_learning",
|
|
485
|
-
card_id: session.cardId,
|
|
486
|
-
auto_confidence: true,
|
|
487
|
-
},
|
|
488
|
-
});
|
|
489
|
-
}
|
|
427
|
+
// Rule 3: Bug solution — REMOVED.
|
|
428
|
+
// Storing "Resolved bug: {card title}" with no detail about the actual fix
|
|
429
|
+
// adds zero value. The real solution is in the code diff / PR. Agents should
|
|
430
|
+
// use `harmony_remember` to store non-obvious root cause details manually.
|
|
490
431
|
// Store learnings, tracking entity ID → learning for graph expansion
|
|
491
432
|
const entityIds = [];
|
|
492
433
|
// Rule 4: Successful session with tracked steps → create or reinforce procedure entity
|
|
434
|
+
// Thresholds raised: require 5+ distinct steps AND 10+ minute duration to avoid
|
|
435
|
+
// creating "procedures" from trivial tasks (e.g., a 2-step "investigate → fix" session).
|
|
493
436
|
const stepHistory = sessionTaskHistory.get(session.cardId);
|
|
494
|
-
const
|
|
437
|
+
const MIN_PROCEDURE_STEPS = 5;
|
|
438
|
+
const MIN_PROCEDURE_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
|
439
|
+
const hasEnoughSteps = stepHistory && stepHistory.steps.length >= MIN_PROCEDURE_STEPS;
|
|
440
|
+
const hasMinDuration = (session.sessionDurationMs ?? 0) >= MIN_PROCEDURE_DURATION_MS;
|
|
495
441
|
const isSuccessful = session.status === "completed" &&
|
|
496
442
|
(session.progressPercent === undefined || session.progressPercent >= 85) &&
|
|
497
443
|
!session.blockers?.length;
|
|
498
|
-
if (isSuccessful && hasEnoughSteps) {
|
|
444
|
+
if (isSuccessful && hasEnoughSteps && hasMinDuration) {
|
|
499
445
|
const procedureResult = await extractOrReinforceProcedure(client, session, stepHistory.steps, workspaceId, projectId, wikiLinksLine);
|
|
500
446
|
if (procedureResult) {
|
|
501
447
|
if (procedureResult.mode === "created") {
|
|
@@ -547,14 +493,12 @@ export async function extractLearnings(client, session) {
|
|
|
547
493
|
if (createdPairs.length >= 2) {
|
|
548
494
|
linkSessionEntities(client, createdPairs, workspaceId, projectId).catch(() => { });
|
|
549
495
|
}
|
|
550
|
-
//
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
detectCausalPatterns(client, createdPairs, session, workspaceId, projectId).catch(() => { });
|
|
557
|
-
}
|
|
496
|
+
// Pattern detection DISABLED — these create noise entities like
|
|
497
|
+
// "Pattern: recurring procedure (N instances)" that are just catalogs of
|
|
498
|
+
// entity titles, eating token budget with zero actionable content.
|
|
499
|
+
// The consolidation tool (harmony_consolidate_memories) serves a similar
|
|
500
|
+
// purpose and can be improved separately with LLM synthesis.
|
|
501
|
+
// See: https://github.com/getharmony/getharmony/issues/memory-quality
|
|
558
502
|
// Clean up mid-session tracking
|
|
559
503
|
clearMidSessionTracking(session.cardId);
|
|
560
504
|
return { count: entityIds.length, entityIds };
|
package/dist/lib/api-client.js
CHANGED
|
@@ -314,6 +314,12 @@ export class HarmonyApiClient {
|
|
|
314
314
|
async endAgentSession(cardId, data) {
|
|
315
315
|
return this.request("DELETE", `/cards/${cardId}/agent-context`, data);
|
|
316
316
|
}
|
|
317
|
+
async flushActivityLog(cardId, data) {
|
|
318
|
+
return this.request("POST", `/cards/${cardId}/agent-activity-log`, data);
|
|
319
|
+
}
|
|
320
|
+
async getActivityLog(cardId, sessionId) {
|
|
321
|
+
return this.request("GET", `/cards/${cardId}/agent-activity-log?sessionId=${sessionId}`);
|
|
322
|
+
}
|
|
317
323
|
async getAgentSession(cardId, options) {
|
|
318
324
|
const params = new URLSearchParams();
|
|
319
325
|
if (options?.includeEnded)
|
|
@@ -15,7 +15,7 @@ import { findSimilarEntities } from "./graph-expansion.js";
|
|
|
15
15
|
*/
|
|
16
16
|
export async function consolidateMemories(client, workspaceId, projectId, options) {
|
|
17
17
|
const dryRun = options?.dryRun !== false; // default true
|
|
18
|
-
const minClusterSize = options?.minClusterSize ?? 2
|
|
18
|
+
const minClusterSize = options?.minClusterSize ?? 3; // raised from 2 to reduce noise
|
|
19
19
|
const result = {
|
|
20
20
|
consolidated: 0,
|
|
21
21
|
clustersFound: 0,
|
|
@@ -92,11 +92,10 @@ export async function consolidateMemories(client, workspaceId, projectId, option
|
|
|
92
92
|
// Derive title from most common words across cluster titles
|
|
93
93
|
const mergedTitle = deriveClusterTitle(cluster, type);
|
|
94
94
|
const memberTitles = cluster.map((e) => e.title);
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
].join("\n");
|
|
95
|
+
// Synthesize content: extract unique knowledge from each member,
|
|
96
|
+
// not just a bullet list of titles. Each member's content is trimmed
|
|
97
|
+
// to its first meaningful paragraph (skipping headers and metadata).
|
|
98
|
+
const mergedContent = synthesizeClusterContent(cluster, type);
|
|
100
99
|
// Max confidence from cluster members
|
|
101
100
|
const maxConfidence = Math.max(...cluster.map((e) => e.confidence));
|
|
102
101
|
// Union of all tags (deduped)
|
|
@@ -173,6 +172,67 @@ export async function consolidateMemories(client, workspaceId, projectId, option
|
|
|
173
172
|
}
|
|
174
173
|
return result;
|
|
175
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Synthesize cluster content by extracting unique, actionable knowledge
|
|
177
|
+
* from each member entity. Skips boilerplate (headers, metadata, agent names)
|
|
178
|
+
* and deduplicates similar lines across members.
|
|
179
|
+
*/
|
|
180
|
+
function synthesizeClusterContent(cluster, type) {
|
|
181
|
+
// Lines to skip: headers, agent metadata, timestamps, progress percentages
|
|
182
|
+
const SKIP_PATTERNS = [
|
|
183
|
+
/^##\s/,
|
|
184
|
+
/^Agent:/,
|
|
185
|
+
/^Duration:/,
|
|
186
|
+
/^Labels:/,
|
|
187
|
+
/^Progress:/,
|
|
188
|
+
/^Session status:/,
|
|
189
|
+
/^Completed at/,
|
|
190
|
+
/^Final state:/,
|
|
191
|
+
/^Related:/,
|
|
192
|
+
/^When working on:/,
|
|
193
|
+
/^\d+\.\s+.+\(\d+%,\s*\+\d+%\)/, // procedure step with progress percentages
|
|
194
|
+
/^Last updated:/,
|
|
195
|
+
/^Recurring pattern:/,
|
|
196
|
+
/^Consolidated from/,
|
|
197
|
+
];
|
|
198
|
+
const seenLines = new Set();
|
|
199
|
+
const knowledgeLines = [];
|
|
200
|
+
for (const entity of cluster) {
|
|
201
|
+
const lines = entity.content.split("\n").map((l) => l.trim());
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
if (!line || line.length < 20)
|
|
204
|
+
continue;
|
|
205
|
+
if (SKIP_PATTERNS.some((p) => p.test(line)))
|
|
206
|
+
continue;
|
|
207
|
+
// Normalize for dedup: lowercase, strip markdown formatting
|
|
208
|
+
const normalized = line
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
.replace(/[*_`#[\]]/g, "")
|
|
211
|
+
.trim();
|
|
212
|
+
if (seenLines.has(normalized))
|
|
213
|
+
continue;
|
|
214
|
+
seenLines.add(normalized);
|
|
215
|
+
knowledgeLines.push(line);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (knowledgeLines.length === 0) {
|
|
219
|
+
// Fallback: if no knowledge was extractable, use a compact summary
|
|
220
|
+
return `${cluster.length} related ${type} entities consolidated. Original titles:\n${cluster.map((e) => `- ${e.title}`).join("\n")}`;
|
|
221
|
+
}
|
|
222
|
+
// Cap at ~400 tokens worth of content (1600 chars)
|
|
223
|
+
const MAX_CHARS = 1600;
|
|
224
|
+
const result = [
|
|
225
|
+
`Consolidated knowledge from ${cluster.length} ${type} entities:\n`,
|
|
226
|
+
];
|
|
227
|
+
let charCount = result[0].length;
|
|
228
|
+
for (const line of knowledgeLines) {
|
|
229
|
+
if (charCount + line.length + 3 > MAX_CHARS)
|
|
230
|
+
break;
|
|
231
|
+
result.push(`- ${line}`);
|
|
232
|
+
charCount += line.length + 3;
|
|
233
|
+
}
|
|
234
|
+
return result.join("\n");
|
|
235
|
+
}
|
|
176
236
|
/**
|
|
177
237
|
* Derive a cluster title from the most common meaningful words across member titles.
|
|
178
238
|
*/
|
|
@@ -233,11 +293,11 @@ function deriveClusterTitle(cluster, type) {
|
|
|
233
293
|
wordCounts.set(word, (wordCounts.get(word) || 0) + 1);
|
|
234
294
|
}
|
|
235
295
|
}
|
|
236
|
-
// Sort by frequency, take top
|
|
296
|
+
// Sort by frequency, take top 4 for more descriptive titles
|
|
237
297
|
const topWords = [...wordCounts.entries()]
|
|
238
298
|
.sort((a, b) => b[1] - a[1])
|
|
239
|
-
.slice(0,
|
|
240
|
-
.map(([word]) => word);
|
|
241
|
-
const suffix = topWords.length > 0 ? topWords.join("
|
|
242
|
-
return
|
|
299
|
+
.slice(0, 4)
|
|
300
|
+
.map(([word]) => word[0].toUpperCase() + word.slice(1));
|
|
301
|
+
const suffix = topWords.length > 0 ? topWords.join(" / ") : "Various";
|
|
302
|
+
return `${type[0].toUpperCase() + type.slice(1)}: ${suffix}`;
|
|
243
303
|
}
|
|
@@ -8,7 +8,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,8 +23,8 @@ 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
28
|
// Graph walk configuration
|
|
29
29
|
const GRAPH_WALK_MAX_DEPTH = 1;
|
|
30
30
|
const GRAPH_WALK_MAX_ENTITIES = 10;
|
|
@@ -73,6 +73,50 @@ const QUERY_SYNONYMS = {
|
|
|
73
73
|
function estimateTokens(text) {
|
|
74
74
|
return Math.ceil(text.length / 4);
|
|
75
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
|
+
}
|
|
76
120
|
/**
|
|
77
121
|
* Generate a unique assembly ID
|
|
78
122
|
*/
|
|
@@ -398,8 +442,29 @@ export async function assembleContext(options) {
|
|
|
398
442
|
memories: [],
|
|
399
443
|
};
|
|
400
444
|
}
|
|
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
|
+
}
|
|
401
466
|
// Score all candidates (pass graph relations for relation-type bonuses)
|
|
402
|
-
const scored =
|
|
467
|
+
const scored = qualityCandidates.map((entity) => {
|
|
403
468
|
const { score, reasons } = computeRelevanceScore(entity, taskContext, cardLabels, graphRelations.length > 0 ? graphRelations : undefined);
|
|
404
469
|
return { entity, score, reasons };
|
|
405
470
|
});
|