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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/CLAUDE.md +84 -201
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/index.js +13 -0
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/memory-api.js +180 -0
- package/server/lib/memory-api.test.js +322 -0
- package/server/lib/memory-hooks-capture.test.js +350 -0
- package/server/lib/memory-hooks.js +101 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/project-scanner.js +267 -0
- package/server/lib/project-scanner.test.js +389 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +96 -0
- package/server/lib/remember-command.test.js +265 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +446 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/workspace-api.js +811 -0
- package/server/lib/workspace-api.test.js +743 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +552 -0
- package/server/package.json +4 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- 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
|
+
});
|