@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,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
+ }