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