tlc-claude-code 1.8.5 → 2.0.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 (76) 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/recall.md +87 -0
  4. package/.claude/commands/tlc/remember.md +71 -0
  5. package/CLAUDE.md +84 -201
  6. package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
  7. package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
  8. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
  9. package/dashboard-web/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/server/index.js +13 -0
  12. package/server/lib/bug-writer.js +204 -0
  13. package/server/lib/bug-writer.test.js +279 -0
  14. package/server/lib/claude-cascade.js +247 -0
  15. package/server/lib/claude-cascade.test.js +245 -0
  16. package/server/lib/context-injection.js +121 -0
  17. package/server/lib/context-injection.test.js +340 -0
  18. package/server/lib/conversation-chunker.js +320 -0
  19. package/server/lib/conversation-chunker.test.js +573 -0
  20. package/server/lib/embedding-client.js +160 -0
  21. package/server/lib/embedding-client.test.js +243 -0
  22. package/server/lib/global-config.js +198 -0
  23. package/server/lib/global-config.test.js +288 -0
  24. package/server/lib/inherited-search.js +184 -0
  25. package/server/lib/inherited-search.test.js +343 -0
  26. package/server/lib/memory-api.js +180 -0
  27. package/server/lib/memory-api.test.js +322 -0
  28. package/server/lib/memory-hooks-capture.test.js +350 -0
  29. package/server/lib/memory-hooks.js +101 -0
  30. package/server/lib/memory-inheritance.js +179 -0
  31. package/server/lib/memory-inheritance.test.js +360 -0
  32. package/server/lib/plan-writer.js +196 -0
  33. package/server/lib/plan-writer.test.js +298 -0
  34. package/server/lib/project-scanner.js +267 -0
  35. package/server/lib/project-scanner.test.js +389 -0
  36. package/server/lib/project-status.js +302 -0
  37. package/server/lib/project-status.test.js +470 -0
  38. package/server/lib/projects-registry.js +237 -0
  39. package/server/lib/projects-registry.test.js +275 -0
  40. package/server/lib/recall-command.js +207 -0
  41. package/server/lib/recall-command.test.js +306 -0
  42. package/server/lib/remember-command.js +96 -0
  43. package/server/lib/remember-command.test.js +265 -0
  44. package/server/lib/rich-capture.js +221 -0
  45. package/server/lib/rich-capture.test.js +312 -0
  46. package/server/lib/roadmap-api.js +200 -0
  47. package/server/lib/roadmap-api.test.js +318 -0
  48. package/server/lib/semantic-recall.js +242 -0
  49. package/server/lib/semantic-recall.test.js +446 -0
  50. package/server/lib/setup-generator.js +315 -0
  51. package/server/lib/setup-generator.test.js +303 -0
  52. package/server/lib/test-inventory.js +112 -0
  53. package/server/lib/test-inventory.test.js +360 -0
  54. package/server/lib/vector-indexer.js +246 -0
  55. package/server/lib/vector-indexer.test.js +459 -0
  56. package/server/lib/vector-store.js +260 -0
  57. package/server/lib/vector-store.test.js +706 -0
  58. package/server/lib/workspace-api.js +811 -0
  59. package/server/lib/workspace-api.test.js +743 -0
  60. package/server/lib/workspace-bootstrap.js +164 -0
  61. package/server/lib/workspace-bootstrap.test.js +503 -0
  62. package/server/lib/workspace-context.js +129 -0
  63. package/server/lib/workspace-context.test.js +214 -0
  64. package/server/lib/workspace-detector.js +162 -0
  65. package/server/lib/workspace-detector.test.js +193 -0
  66. package/server/lib/workspace-init.js +307 -0
  67. package/server/lib/workspace-init.test.js +244 -0
  68. package/server/lib/workspace-snapshot.js +236 -0
  69. package/server/lib/workspace-snapshot.test.js +444 -0
  70. package/server/lib/workspace-watcher.js +162 -0
  71. package/server/lib/workspace-watcher.test.js +257 -0
  72. package/server/package-lock.json +552 -0
  73. package/server/package.json +4 -0
  74. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  75. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  76. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Semantic Recall — searches and ranks memory results using vector similarity,
3
+ * recency decay, and project relevance.
4
+ *
5
+ * Scoring formula:
6
+ * combinedScore = vectorSimilarity * 0.5 + recency * 0.25 + projectRelevance * 0.25
7
+ * Permanent memories receive a 1.2x boost on the combined score.
8
+ * Recency uses exponential decay with a 7-day half-life.
9
+ *
10
+ * @module semantic-recall
11
+ */
12
+
13
+ /** Weight for vector similarity in the combined score */
14
+ const SIMILARITY_WEIGHT = 0.5;
15
+
16
+ /** Weight for recency in the combined score */
17
+ const RECENCY_WEIGHT = 0.25;
18
+
19
+ /** Weight for project relevance in the combined score */
20
+ const PROJECT_RELEVANCE_WEIGHT = 0.25;
21
+
22
+ /** Boost multiplier for permanent memories */
23
+ const PERMANENT_BOOST = 1.2;
24
+
25
+ /** Half-life for recency decay in days */
26
+ const RECENCY_HALF_LIFE_DAYS = 7;
27
+
28
+ /** Minimum results before auto-widening from project to workspace scope */
29
+ const AUTO_WIDEN_THRESHOLD = 3;
30
+
31
+ /** Default maximum number of results to return */
32
+ const DEFAULT_LIMIT = 10;
33
+
34
+ /** Default number of results for context injection */
35
+ const CONTEXT_INJECTION_LIMIT = 5;
36
+
37
+ /**
38
+ * Calculate recency score using exponential decay with 7-day half-life.
39
+ * Returns 1.0 for very recent timestamps, decaying toward 0 for older ones.
40
+ *
41
+ * @param {number} timestamp - Unix timestamp in milliseconds
42
+ * @returns {number} Recency score between 0 and 1
43
+ */
44
+ function calculateRecency(timestamp) {
45
+ const now = Date.now();
46
+ const ageMs = Math.max(0, now - timestamp);
47
+ const ageDays = ageMs / (24 * 60 * 60 * 1000);
48
+ return Math.exp(-ageDays * Math.LN2 / RECENCY_HALF_LIFE_DAYS);
49
+ }
50
+
51
+ /**
52
+ * Calculate the combined score for a memory result.
53
+ *
54
+ * @param {object} result - Raw vector store result
55
+ * @param {object} context - Current context with projectId
56
+ * @returns {number} Combined score
57
+ */
58
+ function calculateScore(result, context) {
59
+ const vectorSimilarity = result.similarity || 0;
60
+ const recency = calculateRecency(result.timestamp);
61
+ const projectRelevance = result.project === context.projectId ? 1.0 : 0.0;
62
+
63
+ let score = vectorSimilarity * SIMILARITY_WEIGHT
64
+ + recency * RECENCY_WEIGHT
65
+ + projectRelevance * PROJECT_RELEVANCE_WEIGHT;
66
+
67
+ if (result.permanent) {
68
+ score *= PERMANENT_BOOST;
69
+ }
70
+
71
+ return score;
72
+ }
73
+
74
+ /**
75
+ * Apply scope-based filtering to raw results.
76
+ *
77
+ * @param {Array} results - Scored results
78
+ * @param {string} scope - 'project', 'workspace', or 'global'
79
+ * @param {object} context - Current context
80
+ * @returns {Array} Filtered results
81
+ */
82
+ function filterByScope(results, scope, context) {
83
+ if (scope === 'global') {
84
+ return results;
85
+ }
86
+
87
+ if (scope === 'workspace') {
88
+ return results.filter((r) => r._raw.workspace === context.workspace);
89
+ }
90
+
91
+ // scope === 'project'
92
+ const projectFiltered = results.filter((r) => r._raw.project === context.projectId);
93
+
94
+ // Auto-widen to workspace if too few project-scoped results
95
+ // Only widen if the workspace scope actually provides enough results
96
+ if (projectFiltered.length < AUTO_WIDEN_THRESHOLD) {
97
+ const workspaceFiltered = results.filter((r) => r._raw.workspace === context.workspace);
98
+ if (workspaceFiltered.length >= AUTO_WIDEN_THRESHOLD) {
99
+ return workspaceFiltered;
100
+ }
101
+ }
102
+
103
+ return projectFiltered;
104
+ }
105
+
106
+ /**
107
+ * Deduplicate results by id, keeping only the entry with the highest score.
108
+ *
109
+ * @param {Array} results - Scored results (may contain duplicate ids)
110
+ * @returns {Array} Deduplicated results
111
+ */
112
+ function deduplicateById(results) {
113
+ const bestById = new Map();
114
+
115
+ for (const result of results) {
116
+ const existing = bestById.get(result.id);
117
+ if (!existing || result.score > existing.score) {
118
+ bestById.set(result.id, result);
119
+ }
120
+ }
121
+
122
+ return [...bestById.values()];
123
+ }
124
+
125
+ /**
126
+ * Transform a raw vector store result into the public result shape.
127
+ *
128
+ * @param {object} raw - Raw result from vector store
129
+ * @param {number} score - Calculated combined score
130
+ * @returns {object} Public result object
131
+ */
132
+ function toPublicResult(raw, score) {
133
+ return {
134
+ id: raw.id,
135
+ text: raw.text,
136
+ score,
137
+ type: raw.type,
138
+ source: {
139
+ project: raw.project,
140
+ workspace: raw.workspace,
141
+ branch: raw.branch,
142
+ sourceFile: raw.sourceFile,
143
+ },
144
+ date: raw.timestamp,
145
+ permanent: raw.permanent || false,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Create a semantic recall instance for searching and ranking memory results.
151
+ *
152
+ * @param {object} deps - Dependencies
153
+ * @param {object} deps.vectorStore - Vector store for similarity search
154
+ * @param {object} deps.embeddingClient - Client for generating embeddings
155
+ * @returns {object} Object with recall and recallForContext methods
156
+ */
157
+ export function createSemanticRecall({ vectorStore, embeddingClient }) {
158
+ /**
159
+ * Perform a semantic search with scoring, filtering, and deduplication.
160
+ *
161
+ * @param {string} query - Search query text
162
+ * @param {object} context - Current context
163
+ * @param {string} context.projectId - Current project identifier
164
+ * @param {string} context.workspace - Current workspace path
165
+ * @param {string} [context.branch] - Current branch name
166
+ * @param {Array} [context.touchedFiles] - Recently touched files
167
+ * @param {object} [options] - Search options
168
+ * @param {string} [options.scope='project'] - Search scope: 'project', 'workspace', or 'global'
169
+ * @param {number} [options.limit=10] - Maximum number of results
170
+ * @param {number} [options.minScore] - Minimum vector similarity threshold
171
+ * @param {Array<string>} [options.types] - Filter to specific memory types
172
+ * @returns {Promise<Array>} Scored and ranked results
173
+ */
174
+ async function recall(query, context, options = {}) {
175
+ if (!query || query.length === 0) {
176
+ return [];
177
+ }
178
+
179
+ const embedding = await embeddingClient.embed(query);
180
+ if (!embedding) {
181
+ return [];
182
+ }
183
+
184
+ const {
185
+ scope = 'project',
186
+ limit = DEFAULT_LIMIT,
187
+ minScore,
188
+ types,
189
+ } = options;
190
+
191
+ const rawResults = vectorStore.search({ embedding, limit: limit * 3 });
192
+
193
+ // Score all raw results and attach the raw data for filtering
194
+ let scored = rawResults.map((raw) => {
195
+ const score = calculateScore(raw, context);
196
+ return { ...toPublicResult(raw, score), _raw: raw };
197
+ });
198
+
199
+ // Filter by type if specified
200
+ if (types && types.length > 0) {
201
+ scored = scored.filter((r) => types.includes(r.type));
202
+ }
203
+
204
+ // Apply scope filtering (including auto-widening)
205
+ scored = filterByScope(scored, scope, context);
206
+
207
+ // Deduplicate by id
208
+ scored = deduplicateById(scored);
209
+
210
+ // Filter by minimum vector similarity score
211
+ if (minScore !== undefined) {
212
+ scored = scored.filter((r) => r._raw.similarity >= minScore);
213
+ }
214
+
215
+ // Sort descending by score
216
+ scored.sort((a, b) => b.score - a.score);
217
+
218
+ // Apply limit
219
+ scored = scored.slice(0, limit);
220
+
221
+ // Strip internal _raw property before returning
222
+ return scored.map(({ _raw, ...rest }) => rest);
223
+ }
224
+
225
+ /**
226
+ * Convenience method for context injection — returns top 5 memories
227
+ * relevant to the given project root and context.
228
+ *
229
+ * @param {string} projectRoot - Project root path used as query basis
230
+ * @param {object} context - Current context
231
+ * @returns {Promise<Array>} Top 5 results sorted by score
232
+ */
233
+ async function recallForContext(projectRoot, context) {
234
+ const query = context.projectId
235
+ ? `${context.projectId} project context`
236
+ : projectRoot;
237
+
238
+ return recall(query, context, { limit: CONTEXT_INJECTION_LIMIT });
239
+ }
240
+
241
+ return { recall, recallForContext };
242
+ }
@@ -0,0 +1,446 @@
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('sorts results by combined score highest first', async () => {
107
+ // mem-1: high similarity but old (low recency)
108
+ // mem-2: moderate similarity but recent (high recency)
109
+ // mem-3: low similarity, same project (high project relevance)
110
+ const now = Date.now();
111
+ const mockResults = [
112
+ createMockResult({
113
+ id: 'mem-1',
114
+ text: 'Old but relevant',
115
+ similarity: 0.95,
116
+ timestamp: now - 30 * 86400000, // 30 days ago
117
+ project: 'other-project',
118
+ }),
119
+ createMockResult({
120
+ id: 'mem-2',
121
+ text: 'Recent and moderately relevant',
122
+ similarity: 0.70,
123
+ timestamp: now - 3600000, // 1 hour ago
124
+ project: 'my-project',
125
+ }),
126
+ createMockResult({
127
+ id: 'mem-3',
128
+ text: 'Medium all around',
129
+ similarity: 0.80,
130
+ timestamp: now - 7 * 86400000, // 7 days ago
131
+ project: 'my-project',
132
+ }),
133
+ ];
134
+ mockVectorStore.search.mockReturnValue(mockResults);
135
+
136
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
137
+ const results = await recall.recall('something', context);
138
+
139
+ expect(results).toHaveLength(3);
140
+ // Results should be sorted descending by combined score
141
+ for (let i = 0; i < results.length - 1; i++) {
142
+ expect(results[i].score).toBeGreaterThanOrEqual(results[i + 1].score);
143
+ }
144
+ });
145
+
146
+ it('filters to current project only with project scope', async () => {
147
+ const mockResults = [
148
+ createMockResult({ id: 'mem-1', project: 'my-project', similarity: 0.90 }),
149
+ createMockResult({ id: 'mem-2', project: 'other-project', similarity: 0.88 }),
150
+ ];
151
+ mockVectorStore.search.mockReturnValue(mockResults);
152
+
153
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
154
+ const results = await recall.recall('query', context, { scope: 'project' });
155
+
156
+ // Only results matching the current project should be returned
157
+ const projectIds = results.map((r) => r.source?.project || r.id);
158
+ expect(results.length).toBeLessThanOrEqual(mockResults.length);
159
+ // Every result should belong to 'my-project'
160
+ results.forEach((r) => {
161
+ // The result set should not contain 'other-project' entries
162
+ expect(r.id).not.toBe('mem-2');
163
+ });
164
+ });
165
+
166
+ it('includes workspace-level memories with workspace scope', async () => {
167
+ const mockResults = [
168
+ createMockResult({ id: 'mem-1', project: 'my-project', workspace: '/ws', similarity: 0.90 }),
169
+ createMockResult({ id: 'mem-2', project: 'other-project', workspace: '/ws', similarity: 0.85 }),
170
+ createMockResult({ id: 'mem-3', project: 'outside-project', workspace: '/other-ws', similarity: 0.80 }),
171
+ ];
172
+ mockVectorStore.search.mockReturnValue(mockResults);
173
+
174
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
175
+ const results = await recall.recall('query', context, { scope: 'workspace' });
176
+
177
+ // Should include mem-1 and mem-2 (same workspace) but not mem-3 (different workspace)
178
+ const ids = results.map((r) => r.id);
179
+ expect(ids).toContain('mem-1');
180
+ expect(ids).toContain('mem-2');
181
+ expect(ids).not.toContain('mem-3');
182
+ });
183
+
184
+ it('searches everything with global scope (no project/workspace filter)', async () => {
185
+ const mockResults = [
186
+ createMockResult({ id: 'mem-1', project: 'project-a', workspace: '/ws-a', similarity: 0.90 }),
187
+ createMockResult({ id: 'mem-2', project: 'project-b', workspace: '/ws-b', similarity: 0.85 }),
188
+ createMockResult({ id: 'mem-3', project: 'project-c', workspace: '/ws-c', similarity: 0.80 }),
189
+ ];
190
+ mockVectorStore.search.mockReturnValue(mockResults);
191
+
192
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
193
+ const results = await recall.recall('query', context, { scope: 'global' });
194
+
195
+ // All results should be present regardless of project or workspace
196
+ expect(results).toHaveLength(3);
197
+ const ids = results.map((r) => r.id);
198
+ expect(ids).toContain('mem-1');
199
+ expect(ids).toContain('mem-2');
200
+ expect(ids).toContain('mem-3');
201
+ });
202
+
203
+ it('auto-widens from project to workspace when few results (<3)', async () => {
204
+ // First call (project scope) returns only 2 results
205
+ // Second call (widened to workspace) returns more
206
+ const projectResults = [
207
+ createMockResult({ id: 'mem-1', project: 'my-project', workspace: '/ws', similarity: 0.90 }),
208
+ ];
209
+ const workspaceResults = [
210
+ createMockResult({ id: 'mem-1', project: 'my-project', workspace: '/ws', similarity: 0.90 }),
211
+ createMockResult({ id: 'mem-2', project: 'other-project', workspace: '/ws', similarity: 0.85 }),
212
+ createMockResult({ id: 'mem-3', project: 'other-project', workspace: '/ws', similarity: 0.80 }),
213
+ ];
214
+
215
+ // The store returns all results; filtering happens in recall
216
+ // On first pass with project scope only 1 matches, so it widens
217
+ mockVectorStore.search.mockReturnValue(workspaceResults);
218
+
219
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
220
+ // Default scope is 'project', which should auto-widen
221
+ const results = await recall.recall('query', context, { scope: 'project' });
222
+
223
+ // After auto-widening, should have more than the 1 project-only result
224
+ expect(results.length).toBeGreaterThanOrEqual(2);
225
+ });
226
+
227
+ it('boosts permanent memories by 1.2x', async () => {
228
+ const now = Date.now();
229
+ const mockResults = [
230
+ createMockResult({
231
+ id: 'mem-ephemeral',
232
+ text: 'Ephemeral memory',
233
+ similarity: 0.90,
234
+ permanent: false,
235
+ timestamp: now - 86400000,
236
+ project: 'my-project',
237
+ }),
238
+ createMockResult({
239
+ id: 'mem-permanent',
240
+ text: 'Permanent memory',
241
+ similarity: 0.90,
242
+ permanent: true,
243
+ timestamp: now - 86400000,
244
+ project: 'my-project',
245
+ }),
246
+ ];
247
+ mockVectorStore.search.mockReturnValue(mockResults);
248
+
249
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
250
+ const results = await recall.recall('query', context, { scope: 'global' });
251
+
252
+ const ephemeral = results.find((r) => r.id === 'mem-ephemeral');
253
+ const permanent = results.find((r) => r.id === 'mem-permanent');
254
+
255
+ expect(ephemeral).toBeDefined();
256
+ expect(permanent).toBeDefined();
257
+ // Permanent should have a higher score due to the 1.2x boost
258
+ expect(permanent.score).toBeGreaterThan(ephemeral.score);
259
+ });
260
+
261
+ it('respects minScore threshold', async () => {
262
+ const mockResults = [
263
+ createMockResult({ id: 'mem-high', similarity: 0.95, project: 'my-project' }),
264
+ createMockResult({ id: 'mem-mid', similarity: 0.60, project: 'my-project' }),
265
+ createMockResult({ id: 'mem-low', similarity: 0.20, project: 'my-project' }),
266
+ ];
267
+ mockVectorStore.search.mockReturnValue(mockResults);
268
+
269
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
270
+ const results = await recall.recall('query', context, { minScore: 0.5, scope: 'global' });
271
+
272
+ // All returned results should have a combined score >= 0.5
273
+ results.forEach((r) => {
274
+ expect(r.score).toBeGreaterThanOrEqual(0.5);
275
+ });
276
+ // The low-similarity result should be filtered out
277
+ const ids = results.map((r) => r.id);
278
+ expect(ids).not.toContain('mem-low');
279
+ });
280
+
281
+ it('respects limit option', async () => {
282
+ const mockResults = [
283
+ createMockResult({ id: 'mem-1', similarity: 0.95, project: 'my-project' }),
284
+ createMockResult({ id: 'mem-2', similarity: 0.90, project: 'my-project' }),
285
+ createMockResult({ id: 'mem-3', similarity: 0.85, project: 'my-project' }),
286
+ createMockResult({ id: 'mem-4', similarity: 0.80, project: 'my-project' }),
287
+ createMockResult({ id: 'mem-5', similarity: 0.75, project: 'my-project' }),
288
+ ];
289
+ mockVectorStore.search.mockReturnValue(mockResults);
290
+
291
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
292
+ const results = await recall.recall('query', context, { limit: 2, scope: 'global' });
293
+
294
+ expect(results).toHaveLength(2);
295
+ });
296
+
297
+ it('falls back to empty results when vector store returns nothing and embedding fails', async () => {
298
+ mockVectorStore.search.mockReturnValue([]);
299
+ mockEmbeddingClient.embed.mockResolvedValue(null);
300
+
301
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
302
+ const results = await recall.recall('some query', context);
303
+
304
+ expect(results).toEqual([]);
305
+ });
306
+
307
+ it('deduplicates overlapping results by id keeping highest score', async () => {
308
+ const now = Date.now();
309
+ const mockResults = [
310
+ createMockResult({
311
+ id: 'mem-dup',
312
+ text: 'Duplicate entry version A',
313
+ similarity: 0.90,
314
+ timestamp: now - 86400000,
315
+ project: 'my-project',
316
+ }),
317
+ createMockResult({
318
+ id: 'mem-dup',
319
+ text: 'Duplicate entry version B',
320
+ similarity: 0.70,
321
+ timestamp: now - 3600000,
322
+ project: 'my-project',
323
+ }),
324
+ createMockResult({
325
+ id: 'mem-unique',
326
+ text: 'Unique entry',
327
+ similarity: 0.80,
328
+ timestamp: now - 86400000,
329
+ project: 'my-project',
330
+ }),
331
+ ];
332
+ mockVectorStore.search.mockReturnValue(mockResults);
333
+
334
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
335
+ const results = await recall.recall('query', context, { scope: 'global' });
336
+
337
+ // Should only have 2 unique ids
338
+ const ids = results.map((r) => r.id);
339
+ const uniqueIds = [...new Set(ids)];
340
+ expect(uniqueIds).toHaveLength(2);
341
+ expect(ids).toHaveLength(2);
342
+
343
+ // The duplicate should keep the higher-scoring version
344
+ const dupResult = results.find((r) => r.id === 'mem-dup');
345
+ expect(dupResult).toBeDefined();
346
+ // The one with similarity 0.90 should win
347
+ expect(dupResult.text).toBe('Duplicate entry version A');
348
+ });
349
+
350
+ it('returns empty results for an empty query', async () => {
351
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
352
+ const results = await recall.recall('', context);
353
+
354
+ expect(results).toEqual([]);
355
+ // Should not call the vector store or embedding client for empty queries
356
+ expect(mockVectorStore.search).not.toHaveBeenCalled();
357
+ expect(mockEmbeddingClient.embed).not.toHaveBeenCalled();
358
+ });
359
+
360
+ it('calculates combined score correctly using the formula', async () => {
361
+ // Score = vectorSimilarity * 0.5 + recency * 0.25 + projectRelevance * 0.25
362
+ // We need to verify the math with known values
363
+ const now = Date.now();
364
+ const mockResults = [
365
+ createMockResult({
366
+ id: 'mem-verify',
367
+ text: 'Verify scoring',
368
+ similarity: 0.80,
369
+ // Very recent: recency should be close to 1.0
370
+ timestamp: now - 60000, // 1 minute ago
371
+ project: 'my-project', // Same project as context: projectRelevance = 1.0
372
+ permanent: false,
373
+ }),
374
+ ];
375
+ mockVectorStore.search.mockReturnValue(mockResults);
376
+
377
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
378
+ const results = await recall.recall('scoring test', context, { scope: 'global' });
379
+
380
+ expect(results).toHaveLength(1);
381
+ const result = results[0];
382
+
383
+ // vectorSimilarity = 0.80, recency ~= 1.0 (very recent), projectRelevance = 1.0
384
+ // Expected score ~= 0.80 * 0.5 + 1.0 * 0.25 + 1.0 * 0.25 = 0.40 + 0.25 + 0.25 = 0.90
385
+ // Allow some tolerance for recency calculation (timestamp-based)
386
+ expect(result.score).toBeGreaterThan(0.85);
387
+ expect(result.score).toBeLessThanOrEqual(1.0);
388
+ });
389
+
390
+ it('filters by type when types option is provided', async () => {
391
+ const mockResults = [
392
+ createMockResult({ id: 'mem-decision', type: 'decision', similarity: 0.90, project: 'my-project' }),
393
+ createMockResult({ id: 'mem-convo', type: 'conversation', similarity: 0.88, project: 'my-project' }),
394
+ createMockResult({ id: 'mem-gotcha', type: 'gotcha', similarity: 0.85, project: 'my-project' }),
395
+ ];
396
+ mockVectorStore.search.mockReturnValue(mockResults);
397
+
398
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
399
+
400
+ // Filter to only decisions
401
+ const decisionsOnly = await recall.recall('query', context, {
402
+ types: ['decision'],
403
+ scope: 'global',
404
+ });
405
+ expect(decisionsOnly.every((r) => r.type === 'decision')).toBe(true);
406
+ expect(decisionsOnly).toHaveLength(1);
407
+
408
+ // Filter to decisions and conversations
409
+ const mixed = await recall.recall('query', context, {
410
+ types: ['decision', 'conversation'],
411
+ scope: 'global',
412
+ });
413
+ expect(mixed.every((r) => ['decision', 'conversation'].includes(r.type))).toBe(true);
414
+ expect(mixed).toHaveLength(2);
415
+ });
416
+ });
417
+
418
+ describe('recallForContext', () => {
419
+ it('returns top-K results for context injection with default of 5', async () => {
420
+ const now = Date.now();
421
+ const mockResults = [];
422
+ for (let i = 0; i < 10; i++) {
423
+ mockResults.push(
424
+ createMockResult({
425
+ id: `mem-${i}`,
426
+ text: `Memory entry ${i}`,
427
+ similarity: 0.95 - i * 0.05,
428
+ timestamp: now - i * 86400000,
429
+ project: 'my-project',
430
+ })
431
+ );
432
+ }
433
+ mockVectorStore.search.mockReturnValue(mockResults);
434
+
435
+ const context = { projectId: 'my-project', workspace: '/ws', branch: 'main', touchedFiles: [] };
436
+ const results = await recall.recallForContext('/path/to/project', context);
437
+
438
+ // Default top-K should be 5
439
+ expect(results).toHaveLength(5);
440
+ // Results should be sorted by score descending
441
+ for (let i = 0; i < results.length - 1; i++) {
442
+ expect(results[i].score).toBeGreaterThanOrEqual(results[i + 1].score);
443
+ }
444
+ });
445
+ });
446
+ });