tlc-claude-code 1.8.5 → 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 (138) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/deploy.md +194 -2
  4. package/.claude/commands/tlc/e2e-verify.md +214 -0
  5. package/.claude/commands/tlc/guard.md +191 -0
  6. package/.claude/commands/tlc/help.md +32 -0
  7. package/.claude/commands/tlc/init.md +73 -37
  8. package/.claude/commands/tlc/llm.md +19 -4
  9. package/.claude/commands/tlc/preflight.md +134 -0
  10. package/.claude/commands/tlc/recall.md +87 -0
  11. package/.claude/commands/tlc/remember.md +71 -0
  12. package/.claude/commands/tlc/review.md +17 -4
  13. package/.claude/commands/tlc/watchci.md +159 -0
  14. package/.claude/hooks/tlc-block-tools.sh +41 -0
  15. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  16. package/.claude/hooks/tlc-post-build.sh +38 -0
  17. package/.claude/hooks/tlc-post-push.sh +22 -0
  18. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  19. package/.claude/hooks/tlc-session-init.sh +123 -0
  20. package/CLAUDE.md +96 -201
  21. package/bin/install.js +171 -2
  22. package/bin/postinstall.js +45 -26
  23. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  24. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  25. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  26. package/dashboard-web/dist/index.html +2 -2
  27. package/docker-compose.dev.yml +18 -12
  28. package/package.json +3 -1
  29. package/server/index.js +240 -1
  30. package/server/lib/bug-writer.js +204 -0
  31. package/server/lib/bug-writer.test.js +279 -0
  32. package/server/lib/capture-bridge.js +242 -0
  33. package/server/lib/capture-bridge.test.js +363 -0
  34. package/server/lib/capture-guard.js +140 -0
  35. package/server/lib/capture-guard.test.js +182 -0
  36. package/server/lib/claude-cascade.js +247 -0
  37. package/server/lib/claude-cascade.test.js +245 -0
  38. package/server/lib/command-runner.js +159 -0
  39. package/server/lib/command-runner.test.js +92 -0
  40. package/server/lib/context-injection.js +121 -0
  41. package/server/lib/context-injection.test.js +340 -0
  42. package/server/lib/conversation-chunker.js +320 -0
  43. package/server/lib/conversation-chunker.test.js +573 -0
  44. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  45. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  46. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  47. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  48. package/server/lib/deploy/security-gates.js +11 -24
  49. package/server/lib/deploy/security-gates.test.js +9 -2
  50. package/server/lib/deploy-engine.js +182 -0
  51. package/server/lib/deploy-engine.test.js +147 -0
  52. package/server/lib/docker-api.js +137 -0
  53. package/server/lib/docker-api.test.js +202 -0
  54. package/server/lib/docker-client.js +297 -0
  55. package/server/lib/docker-client.test.js +308 -0
  56. package/server/lib/embedding-client.js +160 -0
  57. package/server/lib/embedding-client.test.js +243 -0
  58. package/server/lib/global-config.js +198 -0
  59. package/server/lib/global-config.test.js +288 -0
  60. package/server/lib/inherited-search.js +184 -0
  61. package/server/lib/inherited-search.test.js +343 -0
  62. package/server/lib/input-sanitizer.js +86 -0
  63. package/server/lib/input-sanitizer.test.js +117 -0
  64. package/server/lib/launchd-agent.js +225 -0
  65. package/server/lib/launchd-agent.test.js +185 -0
  66. package/server/lib/memory-api.js +182 -0
  67. package/server/lib/memory-api.test.js +320 -0
  68. package/server/lib/memory-bridge-e2e.test.js +160 -0
  69. package/server/lib/memory-committer.js +18 -4
  70. package/server/lib/memory-committer.test.js +21 -0
  71. package/server/lib/memory-hooks-capture.test.js +415 -0
  72. package/server/lib/memory-hooks-integration.test.js +98 -0
  73. package/server/lib/memory-hooks.js +139 -0
  74. package/server/lib/memory-inheritance.js +179 -0
  75. package/server/lib/memory-inheritance.test.js +360 -0
  76. package/server/lib/memory-store-adapter.js +105 -0
  77. package/server/lib/memory-store-adapter.test.js +141 -0
  78. package/server/lib/memory-wiring-e2e.test.js +93 -0
  79. package/server/lib/nginx-config.js +114 -0
  80. package/server/lib/nginx-config.test.js +82 -0
  81. package/server/lib/ollama-health.js +91 -0
  82. package/server/lib/ollama-health.test.js +74 -0
  83. package/server/lib/plan-writer.js +196 -0
  84. package/server/lib/plan-writer.test.js +298 -0
  85. package/server/lib/port-guard.js +44 -0
  86. package/server/lib/port-guard.test.js +65 -0
  87. package/server/lib/project-scanner.js +302 -0
  88. package/server/lib/project-scanner.test.js +541 -0
  89. package/server/lib/project-status.js +302 -0
  90. package/server/lib/project-status.test.js +470 -0
  91. package/server/lib/projects-registry.js +237 -0
  92. package/server/lib/projects-registry.test.js +275 -0
  93. package/server/lib/recall-command.js +207 -0
  94. package/server/lib/recall-command.test.js +306 -0
  95. package/server/lib/remember-command.js +98 -0
  96. package/server/lib/remember-command.test.js +288 -0
  97. package/server/lib/rich-capture.js +221 -0
  98. package/server/lib/rich-capture.test.js +312 -0
  99. package/server/lib/roadmap-api.js +200 -0
  100. package/server/lib/roadmap-api.test.js +318 -0
  101. package/server/lib/security/crypto-utils.test.js +2 -2
  102. package/server/lib/semantic-recall.js +242 -0
  103. package/server/lib/semantic-recall.test.js +463 -0
  104. package/server/lib/setup-generator.js +315 -0
  105. package/server/lib/setup-generator.test.js +303 -0
  106. package/server/lib/ssh-client.js +184 -0
  107. package/server/lib/ssh-client.test.js +127 -0
  108. package/server/lib/test-inventory.js +112 -0
  109. package/server/lib/test-inventory.test.js +360 -0
  110. package/server/lib/vector-indexer.js +246 -0
  111. package/server/lib/vector-indexer.test.js +459 -0
  112. package/server/lib/vector-store.js +260 -0
  113. package/server/lib/vector-store.test.js +706 -0
  114. package/server/lib/vps-api.js +184 -0
  115. package/server/lib/vps-api.test.js +208 -0
  116. package/server/lib/vps-bootstrap.js +124 -0
  117. package/server/lib/vps-bootstrap.test.js +79 -0
  118. package/server/lib/vps-monitor.js +126 -0
  119. package/server/lib/vps-monitor.test.js +98 -0
  120. package/server/lib/workspace-api.js +992 -0
  121. package/server/lib/workspace-api.test.js +1217 -0
  122. package/server/lib/workspace-bootstrap.js +164 -0
  123. package/server/lib/workspace-bootstrap.test.js +503 -0
  124. package/server/lib/workspace-context.js +129 -0
  125. package/server/lib/workspace-context.test.js +214 -0
  126. package/server/lib/workspace-detector.js +162 -0
  127. package/server/lib/workspace-detector.test.js +193 -0
  128. package/server/lib/workspace-init.js +307 -0
  129. package/server/lib/workspace-init.test.js +244 -0
  130. package/server/lib/workspace-snapshot.js +236 -0
  131. package/server/lib/workspace-snapshot.test.js +444 -0
  132. package/server/lib/workspace-watcher.js +162 -0
  133. package/server/lib/workspace-watcher.test.js +257 -0
  134. package/server/package-lock.json +1306 -17
  135. package/server/package.json +7 -0
  136. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  137. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  138. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Semantic Recall Tests
3
+ * Tests for semantically searching and ranking memory results
4
+ * using vector similarity, recency, and project relevance.
5
+ *
6
+ * Scoring formula:
7
+ * vectorSimilarity * 0.5 + recency * 0.25 + projectRelevance * 0.25
8
+ * Permanent memories boosted 1.2x
9
+ * Deduplication by id (keep highest score)
10
+ */
11
+
12
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
13
+ import { createSemanticRecall } from './semantic-recall.js';
14
+
15
+ /**
16
+ * Creates a mock embedding client that returns deterministic embeddings.
17
+ * @returns {object} Mock embedding client with vi.fn() methods
18
+ */
19
+ function createMockEmbeddingClient() {
20
+ return {
21
+ embed: vi.fn().mockResolvedValue(new Float32Array(1024).fill(0.5)),
22
+ embedBatch: vi.fn().mockResolvedValue([]),
23
+ isAvailable: vi.fn().mockResolvedValue(true),
24
+ getModelInfo: () => ({ model: 'mxbai-embed-large', dimensions: 1024 }),
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Creates a mock vector store that tracks all search calls.
30
+ * @returns {object} Mock vector store with vi.fn() methods
31
+ */
32
+ function createMockVectorStore() {
33
+ return {
34
+ insert: vi.fn(),
35
+ search: vi.fn().mockReturnValue([]),
36
+ delete: vi.fn(),
37
+ count: vi.fn().mockReturnValue(0),
38
+ rebuild: vi.fn(),
39
+ getAll: vi.fn().mockReturnValue([]),
40
+ close: vi.fn(),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Creates a mock search result entry.
46
+ * @param {object} overrides - Properties to override
47
+ * @returns {object} A mock search result
48
+ */
49
+ function createMockResult(overrides = {}) {
50
+ return {
51
+ id: 'mem-1',
52
+ text: 'Use Postgres for production',
53
+ type: 'decision',
54
+ project: 'my-project',
55
+ workspace: '/ws',
56
+ branch: 'main',
57
+ timestamp: Date.now() - 86400000, // 1 day ago
58
+ sourceFile: 'decisions/use-postgres.md',
59
+ permanent: false,
60
+ similarity: 0.92,
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe('semantic-recall', () => {
66
+ let mockVectorStore;
67
+ let mockEmbeddingClient;
68
+ let recall;
69
+
70
+ beforeEach(() => {
71
+ mockVectorStore = createMockVectorStore();
72
+ mockEmbeddingClient = createMockEmbeddingClient();
73
+ recall = createSemanticRecall({
74
+ vectorStore: mockVectorStore,
75
+ embeddingClient: mockEmbeddingClient,
76
+ });
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ describe('recall', () => {
84
+ it('returns semantically similar results for a query', async () => {
85
+ const mockResults = [
86
+ createMockResult({ id: 'mem-1', text: 'Use Postgres for production', similarity: 0.92 }),
87
+ createMockResult({ id: 'mem-2', text: 'Postgres JSONB for flexible schema', similarity: 0.85 }),
88
+ ];
89
+ mockVectorStore.search.mockReturnValue(mockResults);
90
+
91
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
92
+ const results = await recall.recall('database choice', context);
93
+
94
+ expect(results).toHaveLength(2);
95
+ expect(results[0]).toHaveProperty('id');
96
+ expect(results[0]).toHaveProperty('text');
97
+ expect(results[0]).toHaveProperty('score');
98
+ expect(results[0]).toHaveProperty('type');
99
+ expect(results[0]).toHaveProperty('source');
100
+ expect(results[0]).toHaveProperty('date');
101
+ expect(results[0]).toHaveProperty('permanent');
102
+ expect(mockEmbeddingClient.embed).toHaveBeenCalledWith('database choice');
103
+ expect(mockVectorStore.search).toHaveBeenCalled();
104
+ });
105
+
106
+ it('calls vectorStore.search with (embedding, {limit}) not ({embedding, limit})', async () => {
107
+ const mockResults = [createMockResult()];
108
+ mockVectorStore.search.mockReturnValue(mockResults);
109
+
110
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
111
+ await recall.recall('test query', context, { limit: 5 });
112
+
113
+ // The first argument must be the embedding (Float32Array), not an object
114
+ const firstArg = mockVectorStore.search.mock.calls[0][0];
115
+ expect(firstArg).toBeInstanceOf(Float32Array);
116
+
117
+ // The second argument must be the options object with limit
118
+ const secondArg = mockVectorStore.search.mock.calls[0][1];
119
+ expect(secondArg).toHaveProperty('limit');
120
+ expect(typeof secondArg.limit).toBe('number');
121
+ });
122
+
123
+ it('sorts results by combined score highest first', async () => {
124
+ // mem-1: high similarity but old (low recency)
125
+ // mem-2: moderate similarity but recent (high recency)
126
+ // mem-3: low similarity, same project (high project relevance)
127
+ const now = Date.now();
128
+ const mockResults = [
129
+ createMockResult({
130
+ id: 'mem-1',
131
+ text: 'Old but relevant',
132
+ similarity: 0.95,
133
+ timestamp: now - 30 * 86400000, // 30 days ago
134
+ project: 'other-project',
135
+ }),
136
+ createMockResult({
137
+ id: 'mem-2',
138
+ text: 'Recent and moderately relevant',
139
+ similarity: 0.70,
140
+ timestamp: now - 3600000, // 1 hour ago
141
+ project: 'my-project',
142
+ }),
143
+ createMockResult({
144
+ id: 'mem-3',
145
+ text: 'Medium all around',
146
+ similarity: 0.80,
147
+ timestamp: now - 7 * 86400000, // 7 days ago
148
+ project: 'my-project',
149
+ }),
150
+ ];
151
+ mockVectorStore.search.mockReturnValue(mockResults);
152
+
153
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
154
+ const results = await recall.recall('something', context);
155
+
156
+ expect(results).toHaveLength(3);
157
+ // Results should be sorted descending by combined score
158
+ for (let i = 0; i < results.length - 1; i++) {
159
+ expect(results[i].score).toBeGreaterThanOrEqual(results[i + 1].score);
160
+ }
161
+ });
162
+
163
+ it('filters to current project only with project scope', async () => {
164
+ const mockResults = [
165
+ createMockResult({ id: 'mem-1', project: 'my-project', similarity: 0.90 }),
166
+ createMockResult({ id: 'mem-2', project: 'other-project', similarity: 0.88 }),
167
+ ];
168
+ mockVectorStore.search.mockReturnValue(mockResults);
169
+
170
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
171
+ const results = await recall.recall('query', context, { scope: 'project' });
172
+
173
+ // Only results matching the current project should be returned
174
+ const projectIds = results.map((r) => r.source?.project || r.id);
175
+ expect(results.length).toBeLessThanOrEqual(mockResults.length);
176
+ // Every result should belong to 'my-project'
177
+ results.forEach((r) => {
178
+ // The result set should not contain 'other-project' entries
179
+ expect(r.id).not.toBe('mem-2');
180
+ });
181
+ });
182
+
183
+ it('includes workspace-level memories with workspace scope', async () => {
184
+ const mockResults = [
185
+ createMockResult({ id: 'mem-1', project: 'my-project', workspace: '/ws', similarity: 0.90 }),
186
+ createMockResult({ id: 'mem-2', project: 'other-project', workspace: '/ws', similarity: 0.85 }),
187
+ createMockResult({ id: 'mem-3', project: 'outside-project', workspace: '/other-ws', similarity: 0.80 }),
188
+ ];
189
+ mockVectorStore.search.mockReturnValue(mockResults);
190
+
191
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
192
+ const results = await recall.recall('query', context, { scope: 'workspace' });
193
+
194
+ // Should include mem-1 and mem-2 (same workspace) but not mem-3 (different workspace)
195
+ const ids = results.map((r) => r.id);
196
+ expect(ids).toContain('mem-1');
197
+ expect(ids).toContain('mem-2');
198
+ expect(ids).not.toContain('mem-3');
199
+ });
200
+
201
+ it('searches everything with global scope (no project/workspace filter)', async () => {
202
+ const mockResults = [
203
+ createMockResult({ id: 'mem-1', project: 'project-a', workspace: '/ws-a', similarity: 0.90 }),
204
+ createMockResult({ id: 'mem-2', project: 'project-b', workspace: '/ws-b', similarity: 0.85 }),
205
+ createMockResult({ id: 'mem-3', project: 'project-c', workspace: '/ws-c', similarity: 0.80 }),
206
+ ];
207
+ mockVectorStore.search.mockReturnValue(mockResults);
208
+
209
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
210
+ const results = await recall.recall('query', context, { scope: 'global' });
211
+
212
+ // All results should be present regardless of project or workspace
213
+ expect(results).toHaveLength(3);
214
+ const ids = results.map((r) => r.id);
215
+ expect(ids).toContain('mem-1');
216
+ expect(ids).toContain('mem-2');
217
+ expect(ids).toContain('mem-3');
218
+ });
219
+
220
+ it('auto-widens from project to workspace when few results (<3)', async () => {
221
+ // First call (project scope) returns only 2 results
222
+ // Second call (widened to workspace) returns more
223
+ const projectResults = [
224
+ createMockResult({ id: 'mem-1', project: 'my-project', workspace: '/ws', similarity: 0.90 }),
225
+ ];
226
+ const workspaceResults = [
227
+ createMockResult({ id: 'mem-1', project: 'my-project', workspace: '/ws', similarity: 0.90 }),
228
+ createMockResult({ id: 'mem-2', project: 'other-project', workspace: '/ws', similarity: 0.85 }),
229
+ createMockResult({ id: 'mem-3', project: 'other-project', workspace: '/ws', similarity: 0.80 }),
230
+ ];
231
+
232
+ // The store returns all results; filtering happens in recall
233
+ // On first pass with project scope only 1 matches, so it widens
234
+ mockVectorStore.search.mockReturnValue(workspaceResults);
235
+
236
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
237
+ // Default scope is 'project', which should auto-widen
238
+ const results = await recall.recall('query', context, { scope: 'project' });
239
+
240
+ // After auto-widening, should have more than the 1 project-only result
241
+ expect(results.length).toBeGreaterThanOrEqual(2);
242
+ });
243
+
244
+ it('boosts permanent memories by 1.2x', async () => {
245
+ const now = Date.now();
246
+ const mockResults = [
247
+ createMockResult({
248
+ id: 'mem-ephemeral',
249
+ text: 'Ephemeral memory',
250
+ similarity: 0.90,
251
+ permanent: false,
252
+ timestamp: now - 86400000,
253
+ project: 'my-project',
254
+ }),
255
+ createMockResult({
256
+ id: 'mem-permanent',
257
+ text: 'Permanent memory',
258
+ similarity: 0.90,
259
+ permanent: true,
260
+ timestamp: now - 86400000,
261
+ project: 'my-project',
262
+ }),
263
+ ];
264
+ mockVectorStore.search.mockReturnValue(mockResults);
265
+
266
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
267
+ const results = await recall.recall('query', context, { scope: 'global' });
268
+
269
+ const ephemeral = results.find((r) => r.id === 'mem-ephemeral');
270
+ const permanent = results.find((r) => r.id === 'mem-permanent');
271
+
272
+ expect(ephemeral).toBeDefined();
273
+ expect(permanent).toBeDefined();
274
+ // Permanent should have a higher score due to the 1.2x boost
275
+ expect(permanent.score).toBeGreaterThan(ephemeral.score);
276
+ });
277
+
278
+ it('respects minScore threshold', async () => {
279
+ const mockResults = [
280
+ createMockResult({ id: 'mem-high', similarity: 0.95, project: 'my-project' }),
281
+ createMockResult({ id: 'mem-mid', similarity: 0.60, project: 'my-project' }),
282
+ createMockResult({ id: 'mem-low', similarity: 0.20, project: 'my-project' }),
283
+ ];
284
+ mockVectorStore.search.mockReturnValue(mockResults);
285
+
286
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
287
+ const results = await recall.recall('query', context, { minScore: 0.5, scope: 'global' });
288
+
289
+ // All returned results should have a combined score >= 0.5
290
+ results.forEach((r) => {
291
+ expect(r.score).toBeGreaterThanOrEqual(0.5);
292
+ });
293
+ // The low-similarity result should be filtered out
294
+ const ids = results.map((r) => r.id);
295
+ expect(ids).not.toContain('mem-low');
296
+ });
297
+
298
+ it('respects limit option', async () => {
299
+ const mockResults = [
300
+ createMockResult({ id: 'mem-1', similarity: 0.95, project: 'my-project' }),
301
+ createMockResult({ id: 'mem-2', similarity: 0.90, project: 'my-project' }),
302
+ createMockResult({ id: 'mem-3', similarity: 0.85, project: 'my-project' }),
303
+ createMockResult({ id: 'mem-4', similarity: 0.80, project: 'my-project' }),
304
+ createMockResult({ id: 'mem-5', similarity: 0.75, project: 'my-project' }),
305
+ ];
306
+ mockVectorStore.search.mockReturnValue(mockResults);
307
+
308
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
309
+ const results = await recall.recall('query', context, { limit: 2, scope: 'global' });
310
+
311
+ expect(results).toHaveLength(2);
312
+ });
313
+
314
+ it('falls back to empty results when vector store returns nothing and embedding fails', async () => {
315
+ mockVectorStore.search.mockReturnValue([]);
316
+ mockEmbeddingClient.embed.mockResolvedValue(null);
317
+
318
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
319
+ const results = await recall.recall('some query', context);
320
+
321
+ expect(results).toEqual([]);
322
+ });
323
+
324
+ it('deduplicates overlapping results by id keeping highest score', async () => {
325
+ const now = Date.now();
326
+ const mockResults = [
327
+ createMockResult({
328
+ id: 'mem-dup',
329
+ text: 'Duplicate entry version A',
330
+ similarity: 0.90,
331
+ timestamp: now - 86400000,
332
+ project: 'my-project',
333
+ }),
334
+ createMockResult({
335
+ id: 'mem-dup',
336
+ text: 'Duplicate entry version B',
337
+ similarity: 0.70,
338
+ timestamp: now - 3600000,
339
+ project: 'my-project',
340
+ }),
341
+ createMockResult({
342
+ id: 'mem-unique',
343
+ text: 'Unique entry',
344
+ similarity: 0.80,
345
+ timestamp: now - 86400000,
346
+ project: 'my-project',
347
+ }),
348
+ ];
349
+ mockVectorStore.search.mockReturnValue(mockResults);
350
+
351
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
352
+ const results = await recall.recall('query', context, { scope: 'global' });
353
+
354
+ // Should only have 2 unique ids
355
+ const ids = results.map((r) => r.id);
356
+ const uniqueIds = [...new Set(ids)];
357
+ expect(uniqueIds).toHaveLength(2);
358
+ expect(ids).toHaveLength(2);
359
+
360
+ // The duplicate should keep the higher-scoring version
361
+ const dupResult = results.find((r) => r.id === 'mem-dup');
362
+ expect(dupResult).toBeDefined();
363
+ // The one with similarity 0.90 should win
364
+ expect(dupResult.text).toBe('Duplicate entry version A');
365
+ });
366
+
367
+ it('returns empty results for an empty query', async () => {
368
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
369
+ const results = await recall.recall('', context);
370
+
371
+ expect(results).toEqual([]);
372
+ // Should not call the vector store or embedding client for empty queries
373
+ expect(mockVectorStore.search).not.toHaveBeenCalled();
374
+ expect(mockEmbeddingClient.embed).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it('calculates combined score correctly using the formula', async () => {
378
+ // Score = vectorSimilarity * 0.5 + recency * 0.25 + projectRelevance * 0.25
379
+ // We need to verify the math with known values
380
+ const now = Date.now();
381
+ const mockResults = [
382
+ createMockResult({
383
+ id: 'mem-verify',
384
+ text: 'Verify scoring',
385
+ similarity: 0.80,
386
+ // Very recent: recency should be close to 1.0
387
+ timestamp: now - 60000, // 1 minute ago
388
+ project: 'my-project', // Same project as context: projectRelevance = 1.0
389
+ permanent: false,
390
+ }),
391
+ ];
392
+ mockVectorStore.search.mockReturnValue(mockResults);
393
+
394
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
395
+ const results = await recall.recall('scoring test', context, { scope: 'global' });
396
+
397
+ expect(results).toHaveLength(1);
398
+ const result = results[0];
399
+
400
+ // vectorSimilarity = 0.80, recency ~= 1.0 (very recent), projectRelevance = 1.0
401
+ // Expected score ~= 0.80 * 0.5 + 1.0 * 0.25 + 1.0 * 0.25 = 0.40 + 0.25 + 0.25 = 0.90
402
+ // Allow some tolerance for recency calculation (timestamp-based)
403
+ expect(result.score).toBeGreaterThan(0.85);
404
+ expect(result.score).toBeLessThanOrEqual(1.0);
405
+ });
406
+
407
+ it('filters by type when types option is provided', async () => {
408
+ const mockResults = [
409
+ createMockResult({ id: 'mem-decision', type: 'decision', similarity: 0.90, project: 'my-project' }),
410
+ createMockResult({ id: 'mem-convo', type: 'conversation', similarity: 0.88, project: 'my-project' }),
411
+ createMockResult({ id: 'mem-gotcha', type: 'gotcha', similarity: 0.85, project: 'my-project' }),
412
+ ];
413
+ mockVectorStore.search.mockReturnValue(mockResults);
414
+
415
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
416
+
417
+ // Filter to only decisions
418
+ const decisionsOnly = await recall.recall('query', context, {
419
+ types: ['decision'],
420
+ scope: 'global',
421
+ });
422
+ expect(decisionsOnly.every((r) => r.type === 'decision')).toBe(true);
423
+ expect(decisionsOnly).toHaveLength(1);
424
+
425
+ // Filter to decisions and conversations
426
+ const mixed = await recall.recall('query', context, {
427
+ types: ['decision', 'conversation'],
428
+ scope: 'global',
429
+ });
430
+ expect(mixed.every((r) => ['decision', 'conversation'].includes(r.type))).toBe(true);
431
+ expect(mixed).toHaveLength(2);
432
+ });
433
+ });
434
+
435
+ describe('recallForContext', () => {
436
+ it('returns top-K results for context injection with default of 5', async () => {
437
+ const now = Date.now();
438
+ const mockResults = [];
439
+ for (let i = 0; i < 10; i++) {
440
+ mockResults.push(
441
+ createMockResult({
442
+ id: `mem-${i}`,
443
+ text: `Memory entry ${i}`,
444
+ similarity: 0.95 - i * 0.05,
445
+ timestamp: now - i * 86400000,
446
+ project: 'my-project',
447
+ })
448
+ );
449
+ }
450
+ mockVectorStore.search.mockReturnValue(mockResults);
451
+
452
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
453
+ const results = await recall.recallForContext('/path/to/project', context);
454
+
455
+ // Default top-K should be 5
456
+ expect(results).toHaveLength(5);
457
+ // Results should be sorted by score descending
458
+ for (let i = 0; i < results.length - 1; i++) {
459
+ expect(results[i].score).toBeGreaterThanOrEqual(results[i + 1].score);
460
+ }
461
+ });
462
+ });
463
+ });