@gethmy/mcp 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +711 -59
  3. package/dist/index.js +5 -3
  4. package/dist/lib/__tests__/active-learning.test.js +386 -0
  5. package/dist/lib/__tests__/agent-performance-profiles.test.js +325 -0
  6. package/dist/lib/__tests__/auto-session.test.js +661 -0
  7. package/dist/lib/__tests__/context-assembly.test.js +362 -0
  8. package/dist/lib/__tests__/graph-expansion.test.js +150 -0
  9. package/dist/lib/__tests__/integration-memory-crud.test.js +797 -0
  10. package/dist/lib/__tests__/integration-memory-system.test.js +281 -0
  11. package/dist/lib/__tests__/lifecycle-maintenance.test.js +207 -0
  12. package/dist/lib/__tests__/pattern-detection.test.js +295 -0
  13. package/dist/lib/__tests__/prompt-builder.test.js +418 -0
  14. package/dist/lib/active-learning.js +878 -0
  15. package/dist/lib/api-client.js +550 -0
  16. package/dist/lib/auto-session.js +173 -0
  17. package/dist/lib/cli.js +127 -0
  18. package/dist/lib/config.js +205 -0
  19. package/dist/lib/consolidation.js +243 -0
  20. package/dist/lib/context-assembly.js +606 -0
  21. package/dist/lib/graph-expansion.js +163 -0
  22. package/dist/lib/http.js +174 -0
  23. package/dist/lib/index.js +7 -0
  24. package/dist/lib/lifecycle-maintenance.js +88 -0
  25. package/dist/lib/prompt-builder.js +483 -0
  26. package/dist/lib/remote.js +166 -0
  27. package/dist/lib/server.js +3132 -0
  28. package/dist/lib/tui/agents.js +116 -0
  29. package/dist/lib/tui/docs.js +744 -0
  30. package/dist/lib/tui/setup.js +1068 -0
  31. package/dist/lib/tui/theme.js +95 -0
  32. package/dist/lib/tui/writer.js +200 -0
  33. package/package.json +15 -6
  34. package/src/__tests__/active-learning.test.ts +483 -0
  35. package/src/__tests__/agent-performance-profiles.test.ts +468 -0
  36. package/src/__tests__/auto-session.test.ts +912 -0
  37. package/src/__tests__/context-assembly.test.ts +506 -0
  38. package/src/__tests__/graph-expansion.test.ts +285 -0
  39. package/src/__tests__/integration-memory-crud.test.ts +948 -0
  40. package/src/__tests__/integration-memory-system.test.ts +321 -0
  41. package/src/__tests__/lifecycle-maintenance.test.ts +238 -0
  42. package/src/__tests__/pattern-detection.test.ts +438 -0
  43. package/src/__tests__/prompt-builder.test.ts +505 -0
  44. package/src/active-learning.ts +1227 -0
  45. package/src/api-client.ts +969 -0
  46. package/src/auto-session.ts +218 -0
  47. package/src/cli.ts +166 -0
  48. package/src/config.ts +285 -0
  49. package/src/consolidation.ts +314 -0
  50. package/src/context-assembly.ts +842 -0
  51. package/src/graph-expansion.ts +234 -0
  52. package/src/http.ts +265 -0
  53. package/src/index.ts +8 -0
  54. package/src/lifecycle-maintenance.ts +120 -0
  55. package/src/prompt-builder.ts +681 -0
  56. package/src/remote.ts +227 -0
  57. package/src/server.ts +3858 -0
  58. package/src/tui/agents.ts +154 -0
  59. package/src/tui/docs.ts +863 -0
  60. package/src/tui/setup.ts +1281 -0
  61. package/src/tui/theme.ts +114 -0
  62. package/src/tui/writer.ts +260 -0
@@ -0,0 +1,362 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cacheManifest, computeRelevanceScore, getCachedManifest, getSessionAssemblyId, mapToContextEntity, trackSessionAssembly, } from "../context-assembly.js";
3
+ function makeEntity(overrides = {}) {
4
+ return {
5
+ id: "test-id",
6
+ type: "pattern",
7
+ title: "Test Pattern",
8
+ content: "Some test content about React hooks and performance optimization.",
9
+ confidence: 0.9,
10
+ tags: ["react", "performance"],
11
+ memory_tier: "reference",
12
+ access_count: 5,
13
+ last_accessed_at: new Date().toISOString(),
14
+ created_at: new Date().toISOString(),
15
+ updated_at: new Date().toISOString(),
16
+ ...overrides,
17
+ };
18
+ }
19
+ // ─── computeRelevanceScore ──────────────────────────────────────────
20
+ describe("computeRelevanceScore", () => {
21
+ test("text match contributes to score", () => {
22
+ const entity = makeEntity({
23
+ title: "React hooks patterns",
24
+ content: "How to use React hooks effectively for state management",
25
+ });
26
+ const { score, reasons } = computeRelevanceScore(entity, "React hooks state management", []);
27
+ expect(score).toBeGreaterThan(0);
28
+ expect(reasons).toContainEqual(expect.stringContaining("text_match"));
29
+ });
30
+ test("no text match gives lower score", () => {
31
+ const entity = makeEntity({
32
+ title: "Database migration guide",
33
+ content: "How to run PostgreSQL migrations",
34
+ });
35
+ const { score: matchScore } = computeRelevanceScore(entity, "Database migration PostgreSQL", []);
36
+ const entity2 = makeEntity({
37
+ title: "Unrelated topic",
38
+ content: "Nothing matching here at all xyz abc",
39
+ });
40
+ const { score: noMatchScore } = computeRelevanceScore(entity2, "Database migration PostgreSQL", []);
41
+ expect(matchScore).toBeGreaterThan(noMatchScore);
42
+ });
43
+ test("tag overlap boosts score", () => {
44
+ const entity = makeEntity({ tags: ["react", "hooks"] });
45
+ const withLabels = computeRelevanceScore(entity, "test", [
46
+ "react",
47
+ "hooks",
48
+ ]);
49
+ const withoutLabels = computeRelevanceScore(entity, "test", []);
50
+ expect(withLabels.score).toBeGreaterThan(withoutLabels.score);
51
+ expect(withLabels.reasons).toContainEqual(expect.stringContaining("tag_match"));
52
+ });
53
+ test("high confidence gives higher score", () => {
54
+ const highConf = makeEntity({ confidence: 1.0 });
55
+ const lowConf = makeEntity({ confidence: 0.3 });
56
+ const high = computeRelevanceScore(highConf, "test", []);
57
+ const low = computeRelevanceScore(lowConf, "test", []);
58
+ expect(high.score).toBeGreaterThan(low.score);
59
+ });
60
+ test("recently accessed gets recency bonus", () => {
61
+ const recent = makeEntity({
62
+ last_accessed_at: new Date().toISOString(),
63
+ });
64
+ const old = makeEntity({
65
+ last_accessed_at: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
66
+ });
67
+ const recentScore = computeRelevanceScore(recent, "test", []);
68
+ const oldScore = computeRelevanceScore(old, "test", []);
69
+ expect(recentScore.score).toBeGreaterThan(oldScore.score);
70
+ });
71
+ test("tier weight applied: reference > episode > draft", () => {
72
+ const base = {
73
+ title: "Test",
74
+ content: "matching content here",
75
+ confidence: 0.9,
76
+ tags: [],
77
+ access_count: 5,
78
+ last_accessed_at: new Date().toISOString(),
79
+ };
80
+ const ref = computeRelevanceScore(makeEntity({ ...base, memory_tier: "reference" }), "matching content", []);
81
+ const ep = computeRelevanceScore(makeEntity({ ...base, memory_tier: "episode" }), "matching content", []);
82
+ const dr = computeRelevanceScore(makeEntity({ ...base, memory_tier: "draft" }), "matching content", []);
83
+ expect(ref.score).toBeGreaterThan(ep.score);
84
+ expect(ep.score).toBeGreaterThan(dr.score);
85
+ });
86
+ // --- RRF / hybrid search signal ---
87
+ test("RRF score contributes when present", () => {
88
+ // Use content with no text overlap to isolate RRF signal
89
+ const withRrf = makeEntity({
90
+ rrf_score: 0.03,
91
+ title: "Unrelated xyz",
92
+ content: "Unrelated abc def",
93
+ tags: [],
94
+ });
95
+ const withoutRrf = makeEntity({
96
+ rrf_score: undefined,
97
+ title: "Unrelated xyz",
98
+ content: "Unrelated abc def",
99
+ tags: [],
100
+ });
101
+ const rrf = computeRelevanceScore(withRrf, "something else entirely", []);
102
+ const noRrf = computeRelevanceScore(withoutRrf, "something else entirely", []);
103
+ expect(rrf.score).toBeGreaterThan(noRrf.score);
104
+ expect(rrf.reasons).toContainEqual(expect.stringContaining("hybrid_search"));
105
+ });
106
+ test("RRF score reduces text match weight", () => {
107
+ // With RRF, text_match weight drops from 0.4 to 0.15
108
+ // So text overlap contributes less when RRF is present
109
+ const entity = makeEntity({
110
+ title: "React hooks patterns",
111
+ content: "How to use React hooks",
112
+ rrf_score: 0.04,
113
+ });
114
+ const { reasons } = computeRelevanceScore(entity, "React hooks patterns", []);
115
+ // Should still have text_match but hybrid_search should be present too
116
+ expect(reasons).toContainEqual(expect.stringContaining("hybrid_search"));
117
+ expect(reasons).toContainEqual(expect.stringContaining("text_match"));
118
+ });
119
+ test("RRF score of 0 is treated as absent", () => {
120
+ const withZeroRrf = makeEntity({ rrf_score: 0 });
121
+ const withoutRrf = makeEntity({ rrf_score: undefined });
122
+ const zero = computeRelevanceScore(withZeroRrf, "test", []);
123
+ const none = computeRelevanceScore(withoutRrf, "test", []);
124
+ // Zero RRF should not contribute, so scores should be equal
125
+ expect(zero.score).toBe(none.score);
126
+ });
127
+ // --- Procedure boost ---
128
+ test("procedure type gets a boost", () => {
129
+ const procedure = makeEntity({ type: "procedure" });
130
+ const pattern = makeEntity({ type: "pattern" });
131
+ const procScore = computeRelevanceScore(procedure, "test", []);
132
+ const patScore = computeRelevanceScore(pattern, "test", []);
133
+ expect(procScore.score).toBeGreaterThan(patScore.score);
134
+ expect(procScore.reasons).toContainEqual(expect.stringContaining("procedure_boost"));
135
+ });
136
+ // --- Usefulness feedback ---
137
+ test("high usefulness score boosts relevance", () => {
138
+ const useful = makeEntity({
139
+ metadata: { usefulness_score: 10 },
140
+ });
141
+ const notUseful = makeEntity({
142
+ metadata: { usefulness_score: 0 },
143
+ });
144
+ const usefulResult = computeRelevanceScore(useful, "test", []);
145
+ const notUsefulResult = computeRelevanceScore(notUseful, "test", []);
146
+ expect(usefulResult.score).toBeGreaterThan(notUsefulResult.score);
147
+ expect(usefulResult.reasons).toContainEqual(expect.stringContaining("useful"));
148
+ });
149
+ test("low usefulness with high access gives penalty", () => {
150
+ const lowUsefulness = makeEntity({
151
+ access_count: 10,
152
+ metadata: { usefulness_score: 0 },
153
+ });
154
+ const noMetadata = makeEntity({
155
+ access_count: 10,
156
+ metadata: { usefulness_score: 5 },
157
+ });
158
+ const low = computeRelevanceScore(lowUsefulness, "test", []);
159
+ const normal = computeRelevanceScore(noMetadata, "test", []);
160
+ expect(low.reasons).toContainEqual(expect.stringContaining("low_usefulness"));
161
+ expect(low.score).toBeLessThan(normal.score);
162
+ });
163
+ // --- Access frequency ---
164
+ test("frequently accessed entities get a bonus", () => {
165
+ const frequent = makeEntity({ access_count: 20 });
166
+ const rare = makeEntity({ access_count: 0 });
167
+ const freqResult = computeRelevanceScore(frequent, "test", []);
168
+ const rareResult = computeRelevanceScore(rare, "test", []);
169
+ expect(freqResult.score).toBeGreaterThan(rareResult.score);
170
+ expect(freqResult.reasons).toContainEqual(expect.stringContaining("frequently_used"));
171
+ });
172
+ // --- Score clamping ---
173
+ test("score is clamped to 0-1 range before tier weight", () => {
174
+ // Stack everything possible to try to exceed 1.0
175
+ const entity = makeEntity({
176
+ title: "React hooks state management patterns best practices",
177
+ content: "React hooks state management patterns best practices guide",
178
+ confidence: 1.0,
179
+ tags: ["react", "hooks", "state"],
180
+ memory_tier: "reference",
181
+ access_count: 100,
182
+ rrf_score: 0.04,
183
+ last_accessed_at: new Date().toISOString(),
184
+ metadata: { usefulness_score: 20 },
185
+ type: "procedure",
186
+ });
187
+ const { score } = computeRelevanceScore(entity, "React hooks state management patterns best practices", ["react", "hooks", "state"]);
188
+ // Reference tier weight is 1.0, so max score should be 1.0
189
+ expect(score).toBeLessThanOrEqual(1.0);
190
+ expect(score).toBeGreaterThan(0);
191
+ });
192
+ // --- Tag matching is case-insensitive ---
193
+ test("tag matching is case-insensitive", () => {
194
+ const entity = makeEntity({ tags: ["React", "HOOKS"] });
195
+ const { reasons } = computeRelevanceScore(entity, "test", [
196
+ "react",
197
+ "hooks",
198
+ ]);
199
+ expect(reasons).toContainEqual(expect.stringContaining("tag_match"));
200
+ });
201
+ // --- No last_accessed_at means no recency bonus ---
202
+ test("null last_accessed_at skips recency scoring", () => {
203
+ const noAccess = makeEntity({ last_accessed_at: null });
204
+ const { reasons } = computeRelevanceScore(noAccess, "test", []);
205
+ expect(reasons).not.toContainEqual(expect.stringContaining("recently_accessed"));
206
+ });
207
+ // --- Words shorter than 3 chars are filtered ---
208
+ test("short words (<3 chars) are ignored in text matching", () => {
209
+ const entity = makeEntity({
210
+ title: "A B C",
211
+ content: "to be or not to be",
212
+ });
213
+ const { reasons } = computeRelevanceScore(entity, "to be or not", []);
214
+ // "to", "be", "or" are all ≤2 chars, "not" is 3 chars
215
+ // Only "not" should be considered
216
+ expect(reasons).toContainEqual(expect.stringContaining("text_match"));
217
+ });
218
+ });
219
+ // ─── mapToContextEntity ─────────────────────────────────────────────
220
+ describe("mapToContextEntity", () => {
221
+ test("maps raw API response to ContextEntity", () => {
222
+ const raw = {
223
+ id: "abc-123",
224
+ type: "pattern",
225
+ title: "Test",
226
+ content: "Content",
227
+ confidence: 0.8,
228
+ tags: ["tag1"],
229
+ memory_tier: "episode",
230
+ access_count: 3,
231
+ last_accessed_at: "2025-01-01T00:00:00Z",
232
+ updated_at: "2025-01-01T00:00:00Z",
233
+ };
234
+ const entity = mapToContextEntity(raw);
235
+ expect(entity.id).toBe("abc-123");
236
+ expect(entity.memory_tier).toBe("episode");
237
+ expect(entity.access_count).toBe(3);
238
+ });
239
+ test("defaults missing fields", () => {
240
+ const raw = {
241
+ id: "abc",
242
+ type: "context",
243
+ title: "Test",
244
+ content: "Content",
245
+ };
246
+ const entity = mapToContextEntity(raw);
247
+ expect(entity.memory_tier).toBe("reference");
248
+ expect(entity.access_count).toBe(0);
249
+ expect(entity.confidence).toBe(1.0);
250
+ expect(entity.tags).toEqual([]);
251
+ expect(entity.last_accessed_at).toBeNull();
252
+ });
253
+ test("preserves hybrid search signals when present", () => {
254
+ const raw = {
255
+ id: "abc",
256
+ type: "pattern",
257
+ title: "Test",
258
+ content: "Content",
259
+ rrf_score: 0.025,
260
+ fts_rank: 3,
261
+ semantic_rank: 5,
262
+ };
263
+ const entity = mapToContextEntity(raw);
264
+ expect(entity.rrf_score).toBe(0.025);
265
+ expect(entity.fts_rank).toBe(3);
266
+ expect(entity.semantic_rank).toBe(5);
267
+ });
268
+ test("metadata is preserved when present", () => {
269
+ const raw = {
270
+ id: "abc",
271
+ type: "pattern",
272
+ title: "Test",
273
+ content: "Content",
274
+ metadata: { usefulness_score: 5, custom_field: "value" },
275
+ };
276
+ const entity = mapToContextEntity(raw);
277
+ expect(entity.metadata).toEqual({
278
+ usefulness_score: 5,
279
+ custom_field: "value",
280
+ });
281
+ });
282
+ });
283
+ // ─── Manifest caching ──────────────────────────────────────────────
284
+ describe("manifest caching", () => {
285
+ test("cacheManifest stores and getCachedManifest retrieves", () => {
286
+ const manifest = {
287
+ assemblyId: "ctx_test_cache1",
288
+ timestamp: new Date().toISOString(),
289
+ included: [],
290
+ excluded: [],
291
+ budgetUsed: 0,
292
+ budgetTotal: 4000,
293
+ tierBreakdown: {
294
+ draft: { count: 0, tokens: 0 },
295
+ episode: { count: 0, tokens: 0 },
296
+ reference: { count: 0, tokens: 0 },
297
+ },
298
+ };
299
+ cacheManifest(manifest);
300
+ const retrieved = getCachedManifest("ctx_test_cache1");
301
+ expect(retrieved).toBeDefined();
302
+ expect(retrieved?.assemblyId).toBe("ctx_test_cache1");
303
+ });
304
+ test("getCachedManifest returns undefined for unknown ID", () => {
305
+ const result = getCachedManifest("ctx_nonexistent_12345");
306
+ expect(result).toBeUndefined();
307
+ });
308
+ test("cacheManifest evicts oldest when exceeding MAX_CACHE_SIZE", () => {
309
+ // Fill cache with 50+ manifests (MAX_CACHE_SIZE = 50)
310
+ const firstId = `ctx_evict_first_${Date.now()}`;
311
+ cacheManifest({
312
+ assemblyId: firstId,
313
+ timestamp: new Date().toISOString(),
314
+ included: [],
315
+ excluded: [],
316
+ budgetUsed: 0,
317
+ budgetTotal: 4000,
318
+ tierBreakdown: {
319
+ draft: { count: 0, tokens: 0 },
320
+ episode: { count: 0, tokens: 0 },
321
+ reference: { count: 0, tokens: 0 },
322
+ },
323
+ });
324
+ // Add 50 more to push the first one out
325
+ for (let i = 0; i < 50; i++) {
326
+ cacheManifest({
327
+ assemblyId: `ctx_evict_fill_${i}_${Date.now()}`,
328
+ timestamp: new Date().toISOString(),
329
+ included: [],
330
+ excluded: [],
331
+ budgetUsed: 0,
332
+ budgetTotal: 4000,
333
+ tierBreakdown: {
334
+ draft: { count: 0, tokens: 0 },
335
+ episode: { count: 0, tokens: 0 },
336
+ reference: { count: 0, tokens: 0 },
337
+ },
338
+ });
339
+ }
340
+ // The first entry should have been evicted
341
+ const firstResult = getCachedManifest(firstId);
342
+ expect(firstResult).toBeUndefined();
343
+ });
344
+ });
345
+ // ─── Session assembly tracking ──────────────────────────────────────
346
+ describe("session assembly tracking", () => {
347
+ test("trackSessionAssembly and getSessionAssemblyId round-trip", () => {
348
+ const cardId = "card-track-test-1";
349
+ const assemblyId = "ctx_track_test_1";
350
+ trackSessionAssembly(cardId, assemblyId);
351
+ expect(getSessionAssemblyId(cardId)).toBe(assemblyId);
352
+ });
353
+ test("getSessionAssemblyId returns undefined for untracked card", () => {
354
+ expect(getSessionAssemblyId("card-nonexistent-xyz")).toBeUndefined();
355
+ });
356
+ test("trackSessionAssembly overwrites previous value for same card", () => {
357
+ const cardId = "card-overwrite-test";
358
+ trackSessionAssembly(cardId, "ctx_old");
359
+ trackSessionAssembly(cardId, "ctx_new");
360
+ expect(getSessionAssemblyId(cardId)).toBe("ctx_new");
361
+ });
362
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Unit tests for graph-expansion.ts (autoExpandGraph).
3
+ *
4
+ * Run with: bun test packages/mcp-server/src/__tests__/graph-expansion.test.ts
5
+ */
6
+ import { describe, expect, mock, test } from "bun:test";
7
+ import { autoExpandGraph } from "../graph-expansion.js";
8
+ function makeMockClient(entities = [], relationError) {
9
+ const createdRelations = [];
10
+ return {
11
+ createdRelations,
12
+ searchMemoryEntities: mock(async () => ({
13
+ entities,
14
+ count: entities.length,
15
+ })),
16
+ createMemoryRelation: mock(async (data) => {
17
+ if (relationError)
18
+ throw relationError;
19
+ createdRelations.push(data);
20
+ return { relation: { id: "rel-1", ...data } };
21
+ }),
22
+ };
23
+ }
24
+ // ---- Tests ----
25
+ describe("autoExpandGraph", () => {
26
+ describe("basic relation creation", () => {
27
+ test("creates relations for each matching entity", async () => {
28
+ const client = makeMockClient([
29
+ { id: "other-1", confidence: 0.9 },
30
+ { id: "other-2", confidence: 0.8 },
31
+ ]);
32
+ const result = await autoExpandGraph(client, "new-entity", "React hooks guide", "How to use useState and useEffect effectively.", ["react"], "ws-1");
33
+ expect(result.relationsCreated).toBe(2);
34
+ expect(client.createdRelations).toHaveLength(2);
35
+ expect(client.createdRelations[0]).toMatchObject({
36
+ source_id: "new-entity",
37
+ target_id: "other-1",
38
+ relation_type: "relates_to",
39
+ confidence: 0.6,
40
+ });
41
+ });
42
+ test("excludes self from relations", async () => {
43
+ const client = makeMockClient([
44
+ { id: "new-entity", confidence: 1.0 }, // same as entityId
45
+ { id: "other-1", confidence: 0.9 },
46
+ ]);
47
+ const result = await autoExpandGraph(client, "new-entity", "Title", "Content", [], "ws-1");
48
+ expect(result.relationsCreated).toBe(1);
49
+ expect(client.createdRelations[0].target_id).toBe("other-1");
50
+ });
51
+ test("excludes entities with confidence below 0.4", async () => {
52
+ const client = makeMockClient([
53
+ { id: "low-conf", confidence: 0.3 },
54
+ { id: "ok-conf", confidence: 0.4 },
55
+ { id: "no-conf" }, // undefined confidence → treated as 1
56
+ ]);
57
+ const result = await autoExpandGraph(client, "new-entity", "Title", "Content", [], "ws-1");
58
+ expect(result.relationsCreated).toBe(2);
59
+ const targetIds = client.createdRelations.map((r) => r.target_id);
60
+ expect(targetIds).toContain("ok-conf");
61
+ expect(targetIds).toContain("no-conf");
62
+ expect(targetIds).not.toContain("low-conf");
63
+ });
64
+ test("respects maxRelations cap", async () => {
65
+ const entities = Array.from({ length: 10 }, (_, i) => ({
66
+ id: `entity-${i}`,
67
+ confidence: 0.9,
68
+ }));
69
+ const client = makeMockClient(entities);
70
+ const result = await autoExpandGraph(client, "new-entity", "Title", "Content", [], "ws-1", undefined, 3);
71
+ expect(result.relationsCreated).toBe(3);
72
+ expect(client.createdRelations).toHaveLength(3);
73
+ });
74
+ test("returns 0 when no entities returned by search", async () => {
75
+ const client = makeMockClient([]);
76
+ const result = await autoExpandGraph(client, "new-entity", "Unique topic", "Nothing similar exists yet.", [], "ws-1");
77
+ expect(result.relationsCreated).toBe(0);
78
+ expect(client.createMemoryRelation).not.toHaveBeenCalled();
79
+ });
80
+ });
81
+ describe("search query construction", () => {
82
+ test("builds query from title + content snippet", async () => {
83
+ const client = makeMockClient([]);
84
+ await autoExpandGraph(client, "eid", "Authentication guide", "How to implement JWT-based authentication in Next.js applications using Supabase.", ["auth"], "ws-1");
85
+ const [calledWorkspaceId, calledQuery] = client.searchMemoryEntities.mock
86
+ .calls[0];
87
+ expect(calledWorkspaceId).toBe("ws-1");
88
+ expect(calledQuery).toContain("Authentication guide");
89
+ expect(calledQuery).toContain("JWT");
90
+ });
91
+ test("content snippet is limited to 200 characters", async () => {
92
+ const longContent = "X".repeat(500);
93
+ const client = makeMockClient([]);
94
+ await autoExpandGraph(client, "eid", "Title", longContent, [], "ws-1");
95
+ const [, calledQuery] = client.searchMemoryEntities.mock.calls[0];
96
+ // Query = "Title " + 200 chars max from content
97
+ expect(calledQuery.length).toBeLessThanOrEqual("Title".length + 1 + 200);
98
+ });
99
+ test("passes projectId to search", async () => {
100
+ const client = makeMockClient([]);
101
+ await autoExpandGraph(client, "eid", "Title", "Content", [], "ws-1", "proj-42");
102
+ const [, , opts] = client.searchMemoryEntities.mock.calls[0];
103
+ expect(opts?.project_id).toBe("proj-42");
104
+ });
105
+ });
106
+ describe("error handling", () => {
107
+ test("returns 0 when searchMemoryEntities throws", async () => {
108
+ const client = {
109
+ searchMemoryEntities: mock(async () => {
110
+ throw new Error("Network error");
111
+ }),
112
+ createMemoryRelation: mock(async () => ({ relation: {} })),
113
+ };
114
+ const result = await autoExpandGraph(client, "eid", "Title", "Content", [], "ws-1");
115
+ expect(result.relationsCreated).toBe(0);
116
+ expect(client.createMemoryRelation).not.toHaveBeenCalled();
117
+ });
118
+ test("skips individual relation failures, counts successes", async () => {
119
+ let callCount = 0;
120
+ const client = {
121
+ searchMemoryEntities: mock(async () => ({
122
+ entities: [
123
+ { id: "a", confidence: 0.9 },
124
+ { id: "b", confidence: 0.9 },
125
+ { id: "c", confidence: 0.9 },
126
+ ],
127
+ count: 3,
128
+ })),
129
+ createMemoryRelation: mock(async () => {
130
+ callCount++;
131
+ if (callCount === 2)
132
+ throw new Error("Relation failed");
133
+ return { relation: {} };
134
+ }),
135
+ };
136
+ const result = await autoExpandGraph(client, "new-entity", "Title", "Content", [], "ws-1");
137
+ // a succeeds, b fails, c succeeds → 2 created
138
+ expect(result.relationsCreated).toBe(2);
139
+ });
140
+ test("silently handles 409 conflict (duplicate relation)", async () => {
141
+ const conflictError = Object.assign(new Error("Conflict"), {
142
+ status: 409,
143
+ });
144
+ const client = makeMockClient([{ id: "existing", confidence: 0.9 }], conflictError);
145
+ const result = await autoExpandGraph(client, "new-entity", "Title", "Content", [], "ws-1");
146
+ // 409 is swallowed, count stays 0
147
+ expect(result.relationsCreated).toBe(0);
148
+ });
149
+ });
150
+ });