@gethmy/mcp 1.0.0 → 2.1.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/README.md +201 -36
- package/dist/cli.js +20938 -20249
- package/dist/http.js +1957 -0
- package/dist/index.js +17833 -17888
- package/dist/lib/__tests__/active-learning.test.js +386 -0
- package/dist/lib/__tests__/agent-performance-profiles.test.js +325 -0
- package/dist/lib/__tests__/auto-session.test.js +661 -0
- package/dist/lib/__tests__/context-assembly.test.js +362 -0
- package/dist/lib/__tests__/graph-expansion.test.js +150 -0
- package/dist/lib/__tests__/integration-memory-crud.test.js +797 -0
- package/dist/lib/__tests__/integration-memory-system.test.js +281 -0
- package/dist/lib/__tests__/lifecycle-maintenance.test.js +207 -0
- package/dist/lib/__tests__/pattern-detection.test.js +295 -0
- package/dist/lib/__tests__/prompt-builder.test.js +418 -0
- package/dist/lib/active-learning.js +878 -0
- package/dist/lib/api-client.js +548 -0
- package/dist/lib/auto-session.js +173 -0
- package/dist/lib/cli.js +127 -0
- package/dist/lib/config.js +205 -0
- package/dist/lib/consolidation.js +243 -0
- package/dist/lib/context-assembly.js +606 -0
- package/dist/lib/graph-expansion.js +163 -0
- package/dist/lib/http.js +174 -0
- package/dist/lib/index.js +7 -0
- package/dist/lib/lifecycle-maintenance.js +88 -0
- package/dist/lib/prompt-builder.js +483 -0
- package/dist/lib/remote.js +166 -0
- package/dist/lib/server.js +3132 -0
- package/dist/lib/tui/agents.js +116 -0
- package/dist/lib/tui/docs.js +558 -0
- package/dist/lib/tui/setup.js +1068 -0
- package/dist/lib/tui/theme.js +95 -0
- package/dist/lib/tui/writer.js +200 -0
- package/dist/remote.js +34534 -0
- package/dist/server.js +31967 -0
- package/package.json +20 -7
- package/src/__tests__/active-learning.test.ts +483 -0
- package/src/__tests__/agent-performance-profiles.test.ts +468 -0
- package/src/__tests__/auto-session.test.ts +912 -0
- package/src/__tests__/context-assembly.test.ts +506 -0
- package/src/__tests__/graph-expansion.test.ts +285 -0
- package/src/__tests__/integration-memory-crud.test.ts +948 -0
- package/src/__tests__/integration-memory-system.test.ts +321 -0
- package/src/__tests__/lifecycle-maintenance.test.ts +238 -0
- package/src/__tests__/pattern-detection.test.ts +438 -0
- package/src/__tests__/prompt-builder.test.ts +505 -0
- package/src/active-learning.ts +1227 -0
- package/src/api-client.ts +963 -0
- package/src/auto-session.ts +218 -0
- package/src/cli.ts +166 -0
- package/src/config.ts +285 -0
- package/src/consolidation.ts +314 -0
- package/src/context-assembly.ts +842 -0
- package/src/graph-expansion.ts +234 -0
- package/src/http.ts +265 -0
- package/src/index.ts +8 -0
- package/src/lifecycle-maintenance.ts +120 -0
- package/src/prompt-builder.ts +681 -0
- package/src/remote.ts +227 -0
- package/src/server.ts +3858 -0
- package/src/tui/agents.ts +154 -0
- package/src/tui/docs.ts +650 -0
- package/src/tui/setup.ts +1281 -0
- package/src/tui/theme.ts +114 -0
- package/src/tui/writer.ts +260 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Assembly Engine
|
|
3
|
+
*
|
|
4
|
+
* Token-budget-aware context constructor that assembles relevant memories
|
|
5
|
+
* for a given task, producing a manifest of what was included/excluded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { checkPromotion } from "@harmony/memory";
|
|
9
|
+
import type { HarmonyApiClient } from "./api-client.js";
|
|
10
|
+
|
|
11
|
+
// Types
|
|
12
|
+
export type MemoryTier = "draft" | "episode" | "reference";
|
|
13
|
+
|
|
14
|
+
export interface ContextEntity {
|
|
15
|
+
id: string;
|
|
16
|
+
type: string;
|
|
17
|
+
title: string;
|
|
18
|
+
content: string;
|
|
19
|
+
confidence: number;
|
|
20
|
+
tags: string[];
|
|
21
|
+
memory_tier: MemoryTier;
|
|
22
|
+
access_count: number;
|
|
23
|
+
last_accessed_at: string | null;
|
|
24
|
+
created_at: string;
|
|
25
|
+
updated_at: string;
|
|
26
|
+
relevanceScore?: number;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
// Hybrid search signals (from DB RPC)
|
|
29
|
+
rrf_score?: number;
|
|
30
|
+
fts_rank?: number;
|
|
31
|
+
semantic_rank?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ContextManifestEntry {
|
|
35
|
+
entityId: string;
|
|
36
|
+
title: string;
|
|
37
|
+
type: string;
|
|
38
|
+
tier: MemoryTier;
|
|
39
|
+
relevanceScore: number;
|
|
40
|
+
reasons: string[];
|
|
41
|
+
tokenCount: number;
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ContextManifest {
|
|
46
|
+
assemblyId: string;
|
|
47
|
+
timestamp: string;
|
|
48
|
+
included: ContextManifestEntry[];
|
|
49
|
+
excluded: Array<{
|
|
50
|
+
entityId: string;
|
|
51
|
+
title: string;
|
|
52
|
+
type: string;
|
|
53
|
+
tier: MemoryTier;
|
|
54
|
+
relevanceScore: number;
|
|
55
|
+
reason: string;
|
|
56
|
+
}>;
|
|
57
|
+
budgetUsed: number;
|
|
58
|
+
budgetTotal: number;
|
|
59
|
+
tierBreakdown: Record<MemoryTier, { count: number; tokens: number }>;
|
|
60
|
+
procedureBreakdown?: { count: number; tokens: number; budget: number };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AssembleContextOptions {
|
|
64
|
+
workspaceId: string;
|
|
65
|
+
projectId?: string;
|
|
66
|
+
taskContext: string; // Card title + description for relevance matching
|
|
67
|
+
cardLabels?: string[];
|
|
68
|
+
cardId?: string;
|
|
69
|
+
tokenBudget?: number; // Default: 4000 tokens
|
|
70
|
+
client: HarmonyApiClient;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AssembledContext {
|
|
74
|
+
context: string;
|
|
75
|
+
manifest: ContextManifest;
|
|
76
|
+
memories: ContextEntity[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Constants
|
|
80
|
+
const DEFAULT_TOKEN_BUDGET = 4000;
|
|
81
|
+
const MAX_TOKENS_PER_ENTITY = 500;
|
|
82
|
+
const MIN_RELEVANCE_THRESHOLD = 0.1;
|
|
83
|
+
|
|
84
|
+
// Tier weight multipliers for relevance scoring
|
|
85
|
+
const TIER_WEIGHTS: Record<MemoryTier, number> = {
|
|
86
|
+
reference: 1.0,
|
|
87
|
+
episode: 0.7,
|
|
88
|
+
draft: 0.4,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Dedicated procedure budget as a fraction of total budget
|
|
92
|
+
const PROCEDURE_BUDGET_FRACTION = 0.15;
|
|
93
|
+
|
|
94
|
+
// Tier budget allocation percentages (of remaining budget after procedure reservation)
|
|
95
|
+
const TIER_BUDGET_ALLOCATION: Record<MemoryTier, number> = {
|
|
96
|
+
reference: 0.6,
|
|
97
|
+
episode: 0.3,
|
|
98
|
+
draft: 0.1,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Minimum guaranteed slots per tier
|
|
102
|
+
const MIN_REFERENCE_SLOTS = 3;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Estimate token count (rough: 1 token per 4 chars)
|
|
106
|
+
*/
|
|
107
|
+
function estimateTokens(text: string): number {
|
|
108
|
+
return Math.ceil(text.length / 4);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate a unique assembly ID
|
|
113
|
+
*/
|
|
114
|
+
function generateAssemblyId(): string {
|
|
115
|
+
return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Truncate entity content to fit within token limit.
|
|
120
|
+
* Keeps first paragraph + bullet points if present.
|
|
121
|
+
*/
|
|
122
|
+
function truncateContent(
|
|
123
|
+
content: string,
|
|
124
|
+
maxTokens: number,
|
|
125
|
+
): { text: string; truncated: boolean } {
|
|
126
|
+
const currentTokens = estimateTokens(content);
|
|
127
|
+
if (currentTokens <= maxTokens) {
|
|
128
|
+
return { text: content, truncated: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Try to keep first paragraph
|
|
132
|
+
const paragraphs = content.split(/\n\n+/);
|
|
133
|
+
let result = paragraphs[0];
|
|
134
|
+
|
|
135
|
+
// Add bullet points from subsequent paragraphs if they fit
|
|
136
|
+
for (let i = 1; i < paragraphs.length; i++) {
|
|
137
|
+
const lines = paragraphs[i]
|
|
138
|
+
.split("\n")
|
|
139
|
+
.filter((l) => l.startsWith("- ") || l.startsWith("* "));
|
|
140
|
+
if (lines.length > 0) {
|
|
141
|
+
const bulletSection = lines.join("\n");
|
|
142
|
+
if (estimateTokens(result + "\n\n" + bulletSection) <= maxTokens) {
|
|
143
|
+
result += "\n\n" + bulletSection;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Hard truncate if still too long
|
|
149
|
+
if (estimateTokens(result) > maxTokens) {
|
|
150
|
+
const maxChars = maxTokens * 4;
|
|
151
|
+
result = result.slice(0, maxChars - 3) + "...";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { text: result, truncated: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Compute relevance score for an entity against task context.
|
|
159
|
+
*/
|
|
160
|
+
export function computeRelevanceScore(
|
|
161
|
+
entity: ContextEntity,
|
|
162
|
+
taskContext: string,
|
|
163
|
+
cardLabels: string[],
|
|
164
|
+
): { score: number; reasons: string[] } {
|
|
165
|
+
const reasons: string[] = [];
|
|
166
|
+
let score = 0;
|
|
167
|
+
|
|
168
|
+
// 0. DB hybrid search signal (RRF score from FTS + vector fusion)
|
|
169
|
+
// Scaled to 0-0.3 contribution; when present, reduces reliance on word-overlap
|
|
170
|
+
const hasRrfScore = entity.rrf_score !== undefined && entity.rrf_score > 0;
|
|
171
|
+
if (hasRrfScore) {
|
|
172
|
+
// RRF scores are typically 0-0.04; normalize to 0-1 range then scale
|
|
173
|
+
const normalizedRrf = Math.min(entity.rrf_score! / 0.04, 1.0);
|
|
174
|
+
const rrfContribution = normalizedRrf * 0.3;
|
|
175
|
+
score += rrfContribution;
|
|
176
|
+
reasons.push(`hybrid_search(rrf=${entity.rrf_score!.toFixed(4)})`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 1. Text match: simple word overlap scoring (reduced weight when RRF available)
|
|
180
|
+
const textMatchWeight = hasRrfScore ? 0.15 : 0.4;
|
|
181
|
+
const taskWords = new Set(
|
|
182
|
+
taskContext
|
|
183
|
+
.toLowerCase()
|
|
184
|
+
.split(/\W+/)
|
|
185
|
+
.filter((w) => w.length > 2),
|
|
186
|
+
);
|
|
187
|
+
const entityWords = new Set(
|
|
188
|
+
`${entity.title} ${entity.content}`
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.split(/\W+/)
|
|
191
|
+
.filter((w) => w.length > 2),
|
|
192
|
+
);
|
|
193
|
+
const overlap = [...taskWords].filter((w) => entityWords.has(w));
|
|
194
|
+
if (overlap.length > 0) {
|
|
195
|
+
const textScore =
|
|
196
|
+
Math.min(overlap.length / Math.max(taskWords.size, 1), 1.0) *
|
|
197
|
+
textMatchWeight;
|
|
198
|
+
score += textScore;
|
|
199
|
+
reasons.push(`text_match(${overlap.length} words)`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 2. Tag overlap with card labels
|
|
203
|
+
if (cardLabels.length > 0 && entity.tags.length > 0) {
|
|
204
|
+
const labelSet = new Set(cardLabels.map((l) => l.toLowerCase()));
|
|
205
|
+
const tagOverlap = entity.tags.filter((t) => labelSet.has(t.toLowerCase()));
|
|
206
|
+
if (tagOverlap.length > 0) {
|
|
207
|
+
const tagScore = (tagOverlap.length / cardLabels.length) * 0.3;
|
|
208
|
+
score += tagScore;
|
|
209
|
+
reasons.push(`tag_match(${tagOverlap.join(",")})`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3. Confidence as a quality signal
|
|
214
|
+
score += entity.confidence * 0.15;
|
|
215
|
+
if (entity.confidence >= 0.9) {
|
|
216
|
+
reasons.push("high_confidence");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 4. Recency: decay based on last access with tier-specific half-lives
|
|
220
|
+
if (entity.last_accessed_at) {
|
|
221
|
+
const daysSinceAccess =
|
|
222
|
+
(Date.now() - new Date(entity.last_accessed_at).getTime()) /
|
|
223
|
+
(1000 * 60 * 60 * 24);
|
|
224
|
+
const halfLife = { draft: 7, episode: 30, reference: 180 }[
|
|
225
|
+
entity.memory_tier
|
|
226
|
+
];
|
|
227
|
+
const recencyScore = 0.5 ** (daysSinceAccess / halfLife) * 0.1;
|
|
228
|
+
score += recencyScore;
|
|
229
|
+
if (daysSinceAccess < 7) reasons.push("recently_accessed");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 5. Access frequency (log-scaled)
|
|
233
|
+
if (entity.access_count > 0) {
|
|
234
|
+
const freqScore = Math.log10(entity.access_count + 1) * 0.05;
|
|
235
|
+
score += Math.min(freqScore, 0.1);
|
|
236
|
+
if (entity.access_count >= 5)
|
|
237
|
+
reasons.push(`frequently_used(${entity.access_count})`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 6. Usefulness score from feedback loop (0-0.15 weight)
|
|
241
|
+
const usefulnessScore = (entity.metadata?.usefulness_score as number) ?? 0;
|
|
242
|
+
if (usefulnessScore >= 3) {
|
|
243
|
+
const usefulnessBoost = Math.min(usefulnessScore / 20, 0.15);
|
|
244
|
+
score += usefulnessBoost;
|
|
245
|
+
reasons.push(`useful(${usefulnessScore})`);
|
|
246
|
+
} else if (usefulnessScore === 0 && entity.access_count >= 5) {
|
|
247
|
+
// Accessed many times but never marked useful — slight penalty
|
|
248
|
+
score -= 0.02;
|
|
249
|
+
reasons.push("low_usefulness");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Procedure boost: actionable step-by-step instructions are highly valuable
|
|
253
|
+
if (entity.type === "procedure") {
|
|
254
|
+
score += 0.1;
|
|
255
|
+
reasons.push("procedure_boost");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Clamp raw score to 0-1 range before applying tier weight
|
|
259
|
+
score = Math.min(score, 1.0);
|
|
260
|
+
|
|
261
|
+
// Apply tier weight
|
|
262
|
+
const tierWeight = TIER_WEIGHTS[entity.memory_tier];
|
|
263
|
+
score *= tierWeight;
|
|
264
|
+
|
|
265
|
+
return { score, reasons };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Assemble context from knowledge graph entities with token budget management.
|
|
270
|
+
*/
|
|
271
|
+
export async function assembleContext(
|
|
272
|
+
options: AssembleContextOptions,
|
|
273
|
+
): Promise<AssembledContext> {
|
|
274
|
+
const {
|
|
275
|
+
workspaceId,
|
|
276
|
+
projectId,
|
|
277
|
+
taskContext,
|
|
278
|
+
cardLabels = [],
|
|
279
|
+
tokenBudget = DEFAULT_TOKEN_BUDGET,
|
|
280
|
+
client,
|
|
281
|
+
} = options;
|
|
282
|
+
|
|
283
|
+
const assemblyId = generateAssemblyId();
|
|
284
|
+
const manifest: ContextManifest = {
|
|
285
|
+
assemblyId,
|
|
286
|
+
timestamp: new Date().toISOString(),
|
|
287
|
+
included: [],
|
|
288
|
+
excluded: [],
|
|
289
|
+
budgetUsed: 0,
|
|
290
|
+
budgetTotal: tokenBudget,
|
|
291
|
+
tierBreakdown: {
|
|
292
|
+
draft: { count: 0, tokens: 0 },
|
|
293
|
+
episode: { count: 0, tokens: 0 },
|
|
294
|
+
reference: { count: 0, tokens: 0 },
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// Fetch candidate entities: search by task context + list by project
|
|
299
|
+
let candidates: ContextEntity[] = [];
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
// Full-text search by task context
|
|
303
|
+
const searchResult = await client.searchMemoryEntities(
|
|
304
|
+
workspaceId,
|
|
305
|
+
taskContext,
|
|
306
|
+
{ project_id: projectId, limit: 30 },
|
|
307
|
+
);
|
|
308
|
+
if (searchResult.entities?.length > 0) {
|
|
309
|
+
candidates = searchResult.entities.map(mapToContextEntity);
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// Search failed, fall back to listing
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Also fetch by project scope if we have few candidates
|
|
316
|
+
if (candidates.length < 10 && projectId) {
|
|
317
|
+
try {
|
|
318
|
+
const listResult = await client.listMemoryEntities({
|
|
319
|
+
workspace_id: workspaceId,
|
|
320
|
+
project_id: projectId,
|
|
321
|
+
limit: 30,
|
|
322
|
+
});
|
|
323
|
+
if (listResult.entities?.length > 0) {
|
|
324
|
+
const existingIds = new Set(candidates.map((c) => c.id));
|
|
325
|
+
const additional = listResult.entities
|
|
326
|
+
.map(mapToContextEntity)
|
|
327
|
+
.filter((e) => !existingIds.has(e.id));
|
|
328
|
+
candidates.push(...additional);
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// List failed, continue with what we have
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (candidates.length === 0) {
|
|
336
|
+
return {
|
|
337
|
+
context: "",
|
|
338
|
+
manifest,
|
|
339
|
+
memories: [],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Score all candidates
|
|
344
|
+
const scored = candidates.map((entity) => {
|
|
345
|
+
const { score, reasons } = computeRelevanceScore(
|
|
346
|
+
entity,
|
|
347
|
+
taskContext,
|
|
348
|
+
cardLabels,
|
|
349
|
+
);
|
|
350
|
+
return { entity, score, reasons };
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Sort by score descending
|
|
354
|
+
scored.sort((a, b) => b.score - a.score);
|
|
355
|
+
|
|
356
|
+
// Reserve dedicated procedure budget, allocate remaining to tiers
|
|
357
|
+
const procedureBudget = Math.floor(tokenBudget * PROCEDURE_BUDGET_FRACTION);
|
|
358
|
+
const remainingBudget = tokenBudget - procedureBudget;
|
|
359
|
+
|
|
360
|
+
const tierBudgets: Record<MemoryTier, number> = {
|
|
361
|
+
reference: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.reference),
|
|
362
|
+
episode: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.episode),
|
|
363
|
+
draft: Math.floor(remainingBudget * TIER_BUDGET_ALLOCATION.draft),
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const tierUsed: Record<MemoryTier, number> = {
|
|
367
|
+
reference: 0,
|
|
368
|
+
episode: 0,
|
|
369
|
+
draft: 0,
|
|
370
|
+
};
|
|
371
|
+
let procedureUsed = 0;
|
|
372
|
+
const included: Array<{
|
|
373
|
+
entity: ContextEntity;
|
|
374
|
+
score: number;
|
|
375
|
+
reasons: string[];
|
|
376
|
+
tokens: number;
|
|
377
|
+
truncated: boolean;
|
|
378
|
+
}> = [];
|
|
379
|
+
let totalUsed = 0;
|
|
380
|
+
|
|
381
|
+
// First pass: guarantee minimum reference slots
|
|
382
|
+
let referenceCount = 0;
|
|
383
|
+
for (const item of scored) {
|
|
384
|
+
if (
|
|
385
|
+
item.entity.memory_tier === "reference" &&
|
|
386
|
+
item.entity.type !== "procedure" &&
|
|
387
|
+
referenceCount < MIN_REFERENCE_SLOTS
|
|
388
|
+
) {
|
|
389
|
+
const { text, truncated } = truncateContent(
|
|
390
|
+
item.entity.content,
|
|
391
|
+
MAX_TOKENS_PER_ENTITY,
|
|
392
|
+
);
|
|
393
|
+
const tokens = estimateTokens(`### ${item.entity.title}\n${text}`);
|
|
394
|
+
if (totalUsed + tokens <= tokenBudget) {
|
|
395
|
+
included.push({ ...item, tokens, truncated });
|
|
396
|
+
item.entity.content = text;
|
|
397
|
+
totalUsed += tokens;
|
|
398
|
+
tierUsed.reference += tokens;
|
|
399
|
+
referenceCount++;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Second pass: include procedure entities with dedicated budget
|
|
405
|
+
const includedIds = new Set(included.map((i) => i.entity.id));
|
|
406
|
+
const procedureCandidates = scored.filter(
|
|
407
|
+
(item) =>
|
|
408
|
+
item.entity.type === "procedure" && !includedIds.has(item.entity.id),
|
|
409
|
+
);
|
|
410
|
+
for (const item of procedureCandidates) {
|
|
411
|
+
if (item.score < MIN_RELEVANCE_THRESHOLD) {
|
|
412
|
+
manifest.excluded.push({
|
|
413
|
+
entityId: item.entity.id,
|
|
414
|
+
title: item.entity.title,
|
|
415
|
+
type: item.entity.type,
|
|
416
|
+
tier: item.entity.memory_tier,
|
|
417
|
+
relevanceScore: item.score,
|
|
418
|
+
reason: "below_relevance_threshold",
|
|
419
|
+
});
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const { text, truncated } = truncateContent(
|
|
424
|
+
item.entity.content,
|
|
425
|
+
MAX_TOKENS_PER_ENTITY,
|
|
426
|
+
);
|
|
427
|
+
const tokens = estimateTokens(`### ${item.entity.title}\n${text}`);
|
|
428
|
+
|
|
429
|
+
// Check dedicated procedure budget, allow overflow to total remaining
|
|
430
|
+
if (procedureUsed + tokens > procedureBudget) {
|
|
431
|
+
const totalRemaining = tokenBudget - totalUsed;
|
|
432
|
+
if (tokens > totalRemaining) {
|
|
433
|
+
manifest.excluded.push({
|
|
434
|
+
entityId: item.entity.id,
|
|
435
|
+
title: item.entity.title,
|
|
436
|
+
type: item.entity.type,
|
|
437
|
+
tier: item.entity.memory_tier,
|
|
438
|
+
relevanceScore: item.score,
|
|
439
|
+
reason: "procedure_budget_exceeded",
|
|
440
|
+
});
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (totalUsed + tokens > tokenBudget) {
|
|
446
|
+
manifest.excluded.push({
|
|
447
|
+
entityId: item.entity.id,
|
|
448
|
+
title: item.entity.title,
|
|
449
|
+
type: item.entity.type,
|
|
450
|
+
tier: item.entity.memory_tier,
|
|
451
|
+
relevanceScore: item.score,
|
|
452
|
+
reason: "total_budget_exceeded",
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
included.push({ ...item, tokens, truncated });
|
|
458
|
+
item.entity.content = text;
|
|
459
|
+
totalUsed += tokens;
|
|
460
|
+
procedureUsed += tokens;
|
|
461
|
+
includedIds.add(item.entity.id);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Third pass: fill remaining budget by score (non-procedure entities)
|
|
465
|
+
for (const item of scored) {
|
|
466
|
+
if (includedIds.has(item.entity.id)) continue;
|
|
467
|
+
if (item.entity.type === "procedure") continue; // Already handled
|
|
468
|
+
if (item.score < MIN_RELEVANCE_THRESHOLD) {
|
|
469
|
+
manifest.excluded.push({
|
|
470
|
+
entityId: item.entity.id,
|
|
471
|
+
title: item.entity.title,
|
|
472
|
+
type: item.entity.type,
|
|
473
|
+
tier: item.entity.memory_tier,
|
|
474
|
+
relevanceScore: item.score,
|
|
475
|
+
reason: "below_relevance_threshold",
|
|
476
|
+
});
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const tier = item.entity.memory_tier;
|
|
481
|
+
const { text, truncated } = truncateContent(
|
|
482
|
+
item.entity.content,
|
|
483
|
+
MAX_TOKENS_PER_ENTITY,
|
|
484
|
+
);
|
|
485
|
+
const tokens = estimateTokens(`### ${item.entity.title}\n${text}`);
|
|
486
|
+
|
|
487
|
+
// Check tier budget (allow overflow to unused tiers)
|
|
488
|
+
if (tierUsed[tier] + tokens > tierBudgets[tier]) {
|
|
489
|
+
// Check if there's unused budget from other tiers
|
|
490
|
+
const totalRemaining = tokenBudget - totalUsed;
|
|
491
|
+
if (tokens > totalRemaining) {
|
|
492
|
+
manifest.excluded.push({
|
|
493
|
+
entityId: item.entity.id,
|
|
494
|
+
title: item.entity.title,
|
|
495
|
+
type: item.entity.type,
|
|
496
|
+
tier,
|
|
497
|
+
relevanceScore: item.score,
|
|
498
|
+
reason: "budget_exceeded",
|
|
499
|
+
});
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (totalUsed + tokens > tokenBudget) {
|
|
505
|
+
manifest.excluded.push({
|
|
506
|
+
entityId: item.entity.id,
|
|
507
|
+
title: item.entity.title,
|
|
508
|
+
type: item.entity.type,
|
|
509
|
+
tier,
|
|
510
|
+
relevanceScore: item.score,
|
|
511
|
+
reason: "total_budget_exceeded",
|
|
512
|
+
});
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
included.push({ ...item, tokens, truncated });
|
|
517
|
+
item.entity.content = text;
|
|
518
|
+
totalUsed += tokens;
|
|
519
|
+
tierUsed[tier] += tokens;
|
|
520
|
+
includedIds.add(item.entity.id);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Build manifest
|
|
524
|
+
manifest.budgetUsed = totalUsed;
|
|
525
|
+
const procedureItems = included.filter((i) => i.entity.type === "procedure");
|
|
526
|
+
manifest.tierBreakdown = {
|
|
527
|
+
reference: {
|
|
528
|
+
count: included.filter(
|
|
529
|
+
(i) =>
|
|
530
|
+
i.entity.memory_tier === "reference" && i.entity.type !== "procedure",
|
|
531
|
+
).length,
|
|
532
|
+
tokens: tierUsed.reference,
|
|
533
|
+
},
|
|
534
|
+
episode: {
|
|
535
|
+
count: included.filter(
|
|
536
|
+
(i) =>
|
|
537
|
+
i.entity.memory_tier === "episode" && i.entity.type !== "procedure",
|
|
538
|
+
).length,
|
|
539
|
+
tokens: tierUsed.episode,
|
|
540
|
+
},
|
|
541
|
+
draft: {
|
|
542
|
+
count: included.filter(
|
|
543
|
+
(i) =>
|
|
544
|
+
i.entity.memory_tier === "draft" && i.entity.type !== "procedure",
|
|
545
|
+
).length,
|
|
546
|
+
tokens: tierUsed.draft,
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
manifest.procedureBreakdown = {
|
|
550
|
+
count: procedureItems.length,
|
|
551
|
+
tokens: procedureUsed,
|
|
552
|
+
budget: procedureBudget,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
for (const item of included) {
|
|
556
|
+
manifest.included.push({
|
|
557
|
+
entityId: item.entity.id,
|
|
558
|
+
title: item.entity.title,
|
|
559
|
+
type: item.entity.type,
|
|
560
|
+
tier: item.entity.memory_tier,
|
|
561
|
+
relevanceScore: item.score,
|
|
562
|
+
reasons: item.reasons,
|
|
563
|
+
tokenCount: item.tokens,
|
|
564
|
+
truncated: item.truncated,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Build context string — procedures in their own section
|
|
569
|
+
const contextSections: string[] = [];
|
|
570
|
+
const nonProcedureItems = included.filter(
|
|
571
|
+
(i) => i.entity.type !== "procedure",
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
if (included.length > 0) {
|
|
575
|
+
// Procedure section first (actionable instructions)
|
|
576
|
+
if (procedureItems.length > 0) {
|
|
577
|
+
contextSections.push(
|
|
578
|
+
`## Procedures (${procedureItems.length} loaded, ${procedureUsed}/${procedureBudget} tokens)`,
|
|
579
|
+
);
|
|
580
|
+
for (const item of procedureItems) {
|
|
581
|
+
const tags =
|
|
582
|
+
item.entity.tags.length > 0
|
|
583
|
+
? ` [${item.entity.tags.join(", ")}]`
|
|
584
|
+
: "";
|
|
585
|
+
const tierLabel =
|
|
586
|
+
item.entity.memory_tier !== "reference"
|
|
587
|
+
? ` (${item.entity.memory_tier})`
|
|
588
|
+
: "";
|
|
589
|
+
contextSections.push(
|
|
590
|
+
`\n### ${item.entity.title} (confidence: ${item.entity.confidence})${tierLabel}${tags}`,
|
|
591
|
+
);
|
|
592
|
+
contextSections.push(item.entity.content);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Non-procedure memories
|
|
597
|
+
if (nonProcedureItems.length > 0) {
|
|
598
|
+
contextSections.push(
|
|
599
|
+
`\n## Relevant Memories (${nonProcedureItems.length} loaded, ${manifest.excluded.length} excluded)`,
|
|
600
|
+
);
|
|
601
|
+
contextSections.push(
|
|
602
|
+
`*Assembly: ${assemblyId} | Budget: ${totalUsed}/${tokenBudget} tokens*`,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
for (const item of nonProcedureItems) {
|
|
606
|
+
const tags =
|
|
607
|
+
item.entity.tags.length > 0
|
|
608
|
+
? ` [${item.entity.tags.join(", ")}]`
|
|
609
|
+
: "";
|
|
610
|
+
const tierLabel =
|
|
611
|
+
item.entity.memory_tier !== "reference"
|
|
612
|
+
? ` (${item.entity.memory_tier})`
|
|
613
|
+
: "";
|
|
614
|
+
contextSections.push(
|
|
615
|
+
`\n### ${item.entity.title} (${item.entity.type}, confidence: ${item.entity.confidence})${tierLabel}${tags}`,
|
|
616
|
+
);
|
|
617
|
+
contextSections.push(item.entity.content);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Increment access_count for included entities (fire-and-forget)
|
|
623
|
+
incrementAccessCounts(
|
|
624
|
+
client,
|
|
625
|
+
included.map((i) => i.entity.id),
|
|
626
|
+
).catch(() => {});
|
|
627
|
+
|
|
628
|
+
// Auto-promote entities that cross access thresholds after the bump (fire-and-forget)
|
|
629
|
+
promoteEligibleEntities(
|
|
630
|
+
client,
|
|
631
|
+
included.map((i) => i.entity),
|
|
632
|
+
).catch(() => {});
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
context: contextSections.join("\n"),
|
|
636
|
+
manifest,
|
|
637
|
+
memories: included.map((i) => i.entity),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Map raw API entity to ContextEntity
|
|
643
|
+
*/
|
|
644
|
+
export function mapToContextEntity(raw: unknown): ContextEntity {
|
|
645
|
+
const e = raw as Record<string, unknown>;
|
|
646
|
+
return {
|
|
647
|
+
id: e.id as string,
|
|
648
|
+
type: e.type as string,
|
|
649
|
+
title: e.title as string,
|
|
650
|
+
content: e.content as string,
|
|
651
|
+
confidence: (e.confidence as number) ?? 1.0,
|
|
652
|
+
tags: (e.tags as string[]) || [],
|
|
653
|
+
memory_tier: (e.memory_tier as MemoryTier) || "reference",
|
|
654
|
+
access_count: (e.access_count as number) || 0,
|
|
655
|
+
last_accessed_at: (e.last_accessed_at as string) || null,
|
|
656
|
+
created_at: (e.created_at as string) || "",
|
|
657
|
+
updated_at: (e.updated_at as string) || "",
|
|
658
|
+
metadata: (e.metadata as Record<string, unknown>) ?? undefined,
|
|
659
|
+
// Hybrid search signals (present when results come from RPC)
|
|
660
|
+
rrf_score: (e.rrf_score as number) ?? undefined,
|
|
661
|
+
fts_rank: (e.fts_rank as number) ?? undefined,
|
|
662
|
+
semantic_rank: (e.semantic_rank as number) ?? undefined,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Increment access counts for entities loaded into context.
|
|
668
|
+
* Uses batch_touch_knowledge_entities RPC for a single-roundtrip update.
|
|
669
|
+
* Falls back to individual touches if the batch endpoint is unavailable.
|
|
670
|
+
*/
|
|
671
|
+
async function incrementAccessCounts(
|
|
672
|
+
client: HarmonyApiClient,
|
|
673
|
+
entityIds: string[],
|
|
674
|
+
): Promise<void> {
|
|
675
|
+
if (entityIds.length === 0) return;
|
|
676
|
+
try {
|
|
677
|
+
await client.batchTouchMemoryEntities(entityIds);
|
|
678
|
+
} catch {
|
|
679
|
+
// Fallback: individual touches (e.g. older server version)
|
|
680
|
+
await Promise.allSettled(
|
|
681
|
+
entityIds.map((id) => client.touchMemoryEntity(id)),
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Check included entities for promotion eligibility after access count bump.
|
|
688
|
+
* Uses access_count + 1 to reflect the touch that just happened.
|
|
689
|
+
*/
|
|
690
|
+
async function promoteEligibleEntities(
|
|
691
|
+
client: HarmonyApiClient,
|
|
692
|
+
entities: ContextEntity[],
|
|
693
|
+
): Promise<void> {
|
|
694
|
+
for (const entity of entities) {
|
|
695
|
+
if (entity.memory_tier === "reference") continue;
|
|
696
|
+
if (!entity.created_at) continue;
|
|
697
|
+
|
|
698
|
+
// +1 because incrementAccessCounts just bumped it
|
|
699
|
+
const promotion = checkPromotion(
|
|
700
|
+
entity.memory_tier,
|
|
701
|
+
entity.access_count + 1,
|
|
702
|
+
entity.confidence,
|
|
703
|
+
entity.created_at,
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
if (promotion.eligible && promotion.targetTier) {
|
|
707
|
+
try {
|
|
708
|
+
await client.updateMemoryEntity(entity.id, {
|
|
709
|
+
memory_tier: promotion.targetTier,
|
|
710
|
+
metadata: {
|
|
711
|
+
...(entity.metadata || {}),
|
|
712
|
+
promoted_at: new Date().toISOString(),
|
|
713
|
+
promotion_reason: promotion.reason,
|
|
714
|
+
promoted_from: entity.memory_tier,
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
} catch {
|
|
718
|
+
// Non-fatal: promotion is best-effort
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// In-memory manifest cache (keyed by assemblyId)
|
|
725
|
+
const manifestCache = new Map<string, ContextManifest>();
|
|
726
|
+
const MAX_CACHE_SIZE = 50;
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Store a manifest for later retrieval.
|
|
730
|
+
*/
|
|
731
|
+
export function cacheManifest(manifest: ContextManifest): void {
|
|
732
|
+
if (manifestCache.size >= MAX_CACHE_SIZE) {
|
|
733
|
+
// Remove oldest entry
|
|
734
|
+
const firstKey = manifestCache.keys().next().value;
|
|
735
|
+
if (firstKey) manifestCache.delete(firstKey);
|
|
736
|
+
}
|
|
737
|
+
manifestCache.set(manifest.assemblyId, manifest);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Retrieve a cached manifest by assembly ID.
|
|
742
|
+
*/
|
|
743
|
+
export function getCachedManifest(
|
|
744
|
+
assemblyId: string,
|
|
745
|
+
): ContextManifest | undefined {
|
|
746
|
+
return manifestCache.get(assemblyId);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// --- Feedback-Driven Scoring ---
|
|
750
|
+
|
|
751
|
+
/** Track which assemblyId was used for which card session */
|
|
752
|
+
const sessionAssemblyMap = new Map<string, string>();
|
|
753
|
+
const MAX_SESSION_MAP_SIZE = 100;
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Associate an assemblyId with a card session for later feedback.
|
|
757
|
+
* Called when context is assembled during session start or prompt generation.
|
|
758
|
+
*/
|
|
759
|
+
export function trackSessionAssembly(cardId: string, assemblyId: string): void {
|
|
760
|
+
if (sessionAssemblyMap.size >= MAX_SESSION_MAP_SIZE) {
|
|
761
|
+
const firstKey = sessionAssemblyMap.keys().next().value;
|
|
762
|
+
if (firstKey) sessionAssemblyMap.delete(firstKey);
|
|
763
|
+
}
|
|
764
|
+
sessionAssemblyMap.set(cardId, assemblyId);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Get the assemblyId associated with a card session.
|
|
769
|
+
*/
|
|
770
|
+
export function getSessionAssemblyId(cardId: string): string | undefined {
|
|
771
|
+
return sessionAssemblyMap.get(cardId);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Record context feedback based on session outcome.
|
|
776
|
+
* Adjusts entity confidence based on whether the session completed successfully.
|
|
777
|
+
*
|
|
778
|
+
* - Completed successfully (status=completed, progress>=100): boost included entities
|
|
779
|
+
* - Paused/blocked: neutral or slight penalty for included entities
|
|
780
|
+
*/
|
|
781
|
+
export async function recordContextFeedback(
|
|
782
|
+
client: HarmonyApiClient,
|
|
783
|
+
cardId: string,
|
|
784
|
+
sessionStatus: "completed" | "paused",
|
|
785
|
+
progressPercent?: number,
|
|
786
|
+
hadBlockers?: boolean,
|
|
787
|
+
): Promise<{ adjusted: number }> {
|
|
788
|
+
const assemblyId = sessionAssemblyMap.get(cardId);
|
|
789
|
+
if (!assemblyId) return { adjusted: 0 };
|
|
790
|
+
|
|
791
|
+
const manifest = manifestCache.get(assemblyId);
|
|
792
|
+
if (!manifest || manifest.included.length === 0) return { adjusted: 0 };
|
|
793
|
+
|
|
794
|
+
let adjusted = 0;
|
|
795
|
+
const isSuccess =
|
|
796
|
+
sessionStatus === "completed" && (progressPercent ?? 0) >= 100;
|
|
797
|
+
|
|
798
|
+
for (const entry of manifest.included) {
|
|
799
|
+
try {
|
|
800
|
+
if (isSuccess) {
|
|
801
|
+
// Boost confidence by +0.05 (max 1.0) and increment usefulness_score
|
|
802
|
+
const { entity } = await client.getMemoryEntity(entry.entityId);
|
|
803
|
+
const e = entity as {
|
|
804
|
+
confidence: number;
|
|
805
|
+
metadata?: Record<string, unknown>;
|
|
806
|
+
};
|
|
807
|
+
const currentUsefulness = (e.metadata?.usefulness_score as number) ?? 0;
|
|
808
|
+
const newConfidence = Math.min((e.confidence ?? 0.5) + 0.05, 1.0);
|
|
809
|
+
|
|
810
|
+
await client.updateMemoryEntity(entry.entityId, {
|
|
811
|
+
confidence: newConfidence,
|
|
812
|
+
metadata: {
|
|
813
|
+
usefulness_score: currentUsefulness + 1,
|
|
814
|
+
last_feedback_at: new Date().toISOString(),
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
adjusted++;
|
|
818
|
+
} else if (hadBlockers) {
|
|
819
|
+
// Slight penalty for entities included when session had blockers
|
|
820
|
+
const { entity } = await client.getMemoryEntity(entry.entityId);
|
|
821
|
+
const e = entity as { confidence: number };
|
|
822
|
+
const newConfidence = Math.max((e.confidence ?? 0.5) - 0.02, 0.1);
|
|
823
|
+
|
|
824
|
+
await client.updateMemoryEntity(entry.entityId, {
|
|
825
|
+
confidence: newConfidence,
|
|
826
|
+
metadata: {
|
|
827
|
+
last_feedback_at: new Date().toISOString(),
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
adjusted++;
|
|
831
|
+
}
|
|
832
|
+
// Paused without blockers: no change (neutral signal)
|
|
833
|
+
} catch {
|
|
834
|
+
// Non-fatal: individual entity update failure
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Clean up tracking
|
|
839
|
+
sessionAssemblyMap.delete(cardId);
|
|
840
|
+
|
|
841
|
+
return { adjusted };
|
|
842
|
+
}
|