@aeriondyseti/vector-memory-mcp 2.4.4 → 2.5.0-dev.2

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.
@@ -2,12 +2,13 @@ import type { Database } from "bun:sqlite";
2
2
  import type {
3
3
  ConversationHybridRow,
4
4
  HistoryFilters,
5
+ IndexedSession,
5
6
  } from "./conversation";
6
7
  import {
7
8
  serializeVector,
8
9
  safeParseJsonObject,
9
10
  sanitizeFtsQuery,
10
- hybridRRF,
11
+ hybridRRFWithSignals,
11
12
  topByRRF,
12
13
  knnSearch,
13
14
  } from "./sqlite-utils";
@@ -15,6 +16,75 @@ import {
15
16
  export class ConversationRepository {
16
17
  constructor(private db: Database) {}
17
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // Index state (replaces conversation_index_state.json — lives in the db so
21
+ // concurrent server processes share one consistent view)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ loadIndexState(): Map<string, IndexedSession> {
25
+ const rows = this.db
26
+ .prepare("SELECT * FROM conversation_index_state")
27
+ .all() as Array<{
28
+ session_id: string;
29
+ file_path: string;
30
+ project: string;
31
+ last_modified: number;
32
+ chunk_count: number;
33
+ message_count: number;
34
+ indexed_at: number;
35
+ first_message_at: number;
36
+ last_message_at: number;
37
+ }>;
38
+
39
+ const map = new Map<string, IndexedSession>();
40
+ for (const r of rows) {
41
+ map.set(r.session_id, {
42
+ sessionId: r.session_id,
43
+ filePath: r.file_path,
44
+ project: r.project,
45
+ lastModified: r.last_modified,
46
+ chunkCount: r.chunk_count,
47
+ messageCount: r.message_count,
48
+ indexedAt: new Date(r.indexed_at),
49
+ firstMessageAt: new Date(r.first_message_at),
50
+ lastMessageAt: new Date(r.last_message_at),
51
+ });
52
+ }
53
+ return map;
54
+ }
55
+
56
+ upsertIndexState(sessions: IndexedSession[]): void {
57
+ if (sessions.length === 0) return;
58
+ const upsert = this.db.prepare(
59
+ `INSERT OR REPLACE INTO conversation_index_state
60
+ (session_id, file_path, project, last_modified, chunk_count, message_count, indexed_at, first_message_at, last_message_at)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
62
+ );
63
+ const tx = this.db.transaction(() => {
64
+ for (const s of sessions) {
65
+ upsert.run(
66
+ s.sessionId,
67
+ s.filePath,
68
+ s.project,
69
+ s.lastModified,
70
+ s.chunkCount,
71
+ s.messageCount,
72
+ s.indexedAt.getTime(),
73
+ s.firstMessageAt.getTime(),
74
+ s.lastMessageAt.getTime()
75
+ );
76
+ }
77
+ });
78
+ tx();
79
+ }
80
+
81
+ countIndexState(): number {
82
+ const row = this.db
83
+ .prepare("SELECT COUNT(*) AS n FROM conversation_index_state")
84
+ .get() as { n: number };
85
+ return row.n;
86
+ }
87
+
18
88
  async insertBatch(
19
89
  rows: Array<{
20
90
  id: string;
@@ -188,11 +258,14 @@ export class ConversationRepository {
188
258
  /**
189
259
  * Hybrid search combining vector KNN and FTS5, fused with Reciprocal Rank Fusion.
190
260
  *
191
- * NOTE: Filters (session, role, project, date) are applied AFTER candidate selection
192
- * and RRF scoring, not pushed into the KNN/FTS queries. This is an intentional
193
- * performance tradeoff KNN is brute-force JS-side (no SQL pre-filter possible),
194
- * and filtering post-RRF avoids duplicating filter logic across both retrieval paths.
195
- * The consequence is that filtered queries may return fewer than `limit` results.
261
+ * The project filter is applied PRE-candidate-selection (pushed into both
262
+ * the KNN scan and the FTS query) so project-scoped searches rank within
263
+ * the project's own chunks post-filtering a global top-K would return
264
+ * false-empty results for projects with few chunks in a shared database.
265
+ *
266
+ * Remaining filters (session, role, date) are applied AFTER candidate
267
+ * selection and RRF scoring, so those filtered queries may return fewer
268
+ * than `limit` results.
196
269
  */
197
270
  async findHybrid(
198
271
  embedding: number[],
@@ -200,24 +273,52 @@ export class ConversationRepository {
200
273
  limit: number,
201
274
  filters?: HistoryFilters
202
275
  ): Promise<ConversationHybridRow[]> {
203
- const candidateCount = limit * 3;
276
+ const candidateCount = limit * 5;
277
+ const project = filters?.project;
204
278
 
205
- // Vector KNN search (brute-force cosine similarity in JS)
206
- const vecResults = knnSearch(this.db, "conversation_history_vec", embedding, candidateCount);
279
+ // Vector KNN search (brute-force cosine similarity in JS), pre-filtered
280
+ // by project when scoped
281
+ const vecResults = knnSearch(
282
+ this.db,
283
+ "conversation_history_vec",
284
+ embedding,
285
+ candidateCount,
286
+ project !== undefined
287
+ ? {
288
+ sql: `SELECT v.id, v.vector FROM conversation_history_vec v
289
+ JOIN conversation_history c ON v.id = c.id WHERE c.project = ?`,
290
+ params: [project],
291
+ }
292
+ : undefined,
293
+ );
207
294
 
208
- // FTS5 search
295
+ // FTS5 search, pre-filtered by project when scoped
209
296
  const ftsQuery = sanitizeFtsQuery(query);
210
- const ftsResults = this.db
211
- .prepare(
212
- `SELECT id FROM conversation_history_fts
213
- WHERE conversation_history_fts MATCH ?
214
- ORDER BY rank
215
- LIMIT ?`
216
- )
217
- .all(ftsQuery, candidateCount) as Array<{ id: string }>;
297
+ const ftsResults = (
298
+ project !== undefined
299
+ ? this.db
300
+ .prepare(
301
+ `SELECT conversation_history_fts.id FROM conversation_history_fts
302
+ JOIN conversation_history c ON conversation_history_fts.id = c.id
303
+ WHERE conversation_history_fts MATCH ? AND c.project = ?
304
+ ORDER BY rank
305
+ LIMIT ?`
306
+ )
307
+ .all(ftsQuery, project, candidateCount)
308
+ : this.db
309
+ .prepare(
310
+ `SELECT id FROM conversation_history_fts
311
+ WHERE conversation_history_fts MATCH ?
312
+ ORDER BY rank
313
+ LIMIT ?`
314
+ )
315
+ .all(ftsQuery, candidateCount)
316
+ ) as Array<{ id: string }>;
218
317
 
219
- // Compute RRF scores and get top ids
220
- const rrfScores = hybridRRF(vecResults, ftsResults);
318
+ // Compute RRF scores with search signals for confidence scoring
319
+ const signalsMap = hybridRRFWithSignals(vecResults, ftsResults);
320
+ const rrfScores = new Map<string, number>();
321
+ for (const [id, s] of signalsMap) rrfScores.set(id, s.rrfScore);
221
322
  const topIds = topByRRF(rrfScores, limit);
222
323
 
223
324
  if (topIds.length === 0) return [];
@@ -274,17 +375,23 @@ export class ConversationRepository {
274
375
  project: string;
275
376
  }>;
276
377
 
277
- // Build a lookup for ordering by RRF score
278
- const scoreMap = new Map(topIds.map((id) => [id, rrfScores.get(id)!]));
279
-
280
378
  return fullRows
281
- .map((row) => ({
282
- id: row.id,
283
- content: row.content,
284
- metadata: safeParseJsonObject(row.metadata),
285
- createdAt: new Date(row.created_at),
286
- rrfScore: scoreMap.get(row.id) ?? 0,
287
- }))
379
+ .map((row) => {
380
+ const signals = signalsMap.get(row.id)!;
381
+ return {
382
+ id: row.id,
383
+ content: row.content,
384
+ metadata: safeParseJsonObject(row.metadata),
385
+ createdAt: new Date(row.created_at),
386
+ rrfScore: signals.rrfScore,
387
+ signals: {
388
+ cosineSimilarity: signals.cosineSimilarity,
389
+ ftsMatch: signals.ftsMatch,
390
+ knnRank: signals.knnRank,
391
+ ftsRank: signals.ftsRank,
392
+ },
393
+ };
394
+ })
288
395
  .sort((a, b) => b.rrfScore - a.rrfScore);
289
396
  }
290
397
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "crypto";
2
- import { readFile, writeFile, mkdir } from "fs/promises";
2
+ import { readFile } from "fs/promises";
3
3
  import { dirname, join } from "path";
4
4
  import type { ConversationRepository } from "./conversation.repository";
5
5
  import type {
@@ -93,8 +93,8 @@ export function chunkMessages(
93
93
  return chunks;
94
94
  }
95
95
 
96
- /** Serializable index state format */
97
- interface IndexStateEntry {
96
+ /** Legacy JSON index state format (pre-table), imported on first run. */
97
+ interface LegacyIndexStateEntry {
98
98
  sessionId: string;
99
99
  filePath: string;
100
100
  project: string;
@@ -107,65 +107,63 @@ interface IndexStateEntry {
107
107
  }
108
108
 
109
109
  export class ConversationHistoryService {
110
- private indexStatePath: string;
111
- private indexStateCache: Map<string, IndexedSession> | null = null;
110
+ private legacyIndexStatePath: string;
111
+ private legacyImportAttempted = false;
112
112
 
113
113
  constructor(
114
114
  private repository: ConversationRepository,
115
115
  private embeddings: EmbeddingsService,
116
116
  public readonly config: ConversationHistoryConfig,
117
- private dbPath: string,
117
+ dbPath: string,
118
118
  private parser: SessionLogParser = new ClaudeCodeSessionParser()
119
119
  ) {
120
- this.indexStatePath = join(
120
+ this.legacyIndexStatePath = join(
121
121
  dirname(dbPath),
122
122
  "conversation_index_state.json"
123
123
  );
124
124
  }
125
125
 
126
+ /**
127
+ * Index state lives in the conversation_index_state table (shared by all
128
+ * server processes) — read fresh each time, never cached per-process.
129
+ * A pre-existing conversation_index_state.json is imported once.
130
+ */
126
131
  private async loadIndexState(): Promise<Map<string, IndexedSession>> {
127
- if (this.indexStateCache) return this.indexStateCache;
128
- try {
129
- const raw = await readFile(this.indexStatePath, "utf-8");
130
- const entries: IndexStateEntry[] = JSON.parse(raw);
131
- const map = new Map<string, IndexedSession>();
132
- for (const e of entries) {
133
- map.set(e.sessionId, {
134
- sessionId: e.sessionId,
135
- filePath: e.filePath,
136
- project: e.project,
137
- lastModified: e.lastModified,
138
- chunkCount: e.chunkCount,
139
- messageCount: e.messageCount,
140
- indexedAt: new Date(e.indexedAt),
141
- firstMessageAt: new Date(e.firstMessageAt),
142
- lastMessageAt: new Date(e.lastMessageAt),
143
- });
132
+ if (!this.legacyImportAttempted) {
133
+ this.legacyImportAttempted = true;
134
+ if (this.repository.countIndexState() === 0) {
135
+ await this.importLegacyIndexState();
144
136
  }
145
- this.indexStateCache = map;
146
- return map;
137
+ }
138
+ return this.repository.loadIndexState();
139
+ }
140
+
141
+ private async importLegacyIndexState(): Promise<void> {
142
+ let entries: LegacyIndexStateEntry[];
143
+ try {
144
+ const raw = await readFile(this.legacyIndexStatePath, "utf-8");
145
+ entries = JSON.parse(raw);
147
146
  } catch {
148
- const map = new Map<string, IndexedSession>();
149
- this.indexStateCache = map;
150
- return map;
147
+ return; // no legacy state — fine
151
148
  }
149
+
150
+ this.repository.upsertIndexState(
151
+ entries.map((e) => ({
152
+ sessionId: e.sessionId,
153
+ filePath: e.filePath,
154
+ project: e.project,
155
+ lastModified: e.lastModified,
156
+ chunkCount: e.chunkCount,
157
+ messageCount: e.messageCount,
158
+ indexedAt: new Date(e.indexedAt),
159
+ firstMessageAt: new Date(e.firstMessageAt),
160
+ lastMessageAt: new Date(e.lastMessageAt),
161
+ }))
162
+ );
152
163
  }
153
164
 
154
- private async saveIndexState(state: Map<string, IndexedSession>): Promise<void> {
155
- const entries: IndexStateEntry[] = [...state.values()].map((s) => ({
156
- sessionId: s.sessionId,
157
- filePath: s.filePath,
158
- project: s.project,
159
- lastModified: s.lastModified,
160
- chunkCount: s.chunkCount,
161
- messageCount: s.messageCount,
162
- indexedAt: s.indexedAt.toISOString(),
163
- firstMessageAt: s.firstMessageAt.toISOString(),
164
- lastMessageAt: s.lastMessageAt.toISOString(),
165
- }));
166
- await mkdir(dirname(this.indexStatePath), { recursive: true });
167
- await writeFile(this.indexStatePath, JSON.stringify(entries, null, 2));
168
- this.indexStateCache = state;
165
+ private saveIndexState(sessions: IndexedSession[]): void {
166
+ this.repository.upsertIndexState(sessions);
169
167
  }
170
168
 
171
169
  async indexConversations(
@@ -207,6 +205,7 @@ export class ConversationHistoryService {
207
205
  let skipped = 0;
208
206
  const errors: string[] = [];
209
207
  const details: SessionIndexDetail[] = [];
208
+ const updated: IndexedSession[] = [];
210
209
 
211
210
  for (const file of sessionFiles) {
212
211
  const existing = indexState.get(file.sessionId);
@@ -218,6 +217,7 @@ export class ConversationHistoryService {
218
217
 
219
218
  try {
220
219
  const state = await this.indexSession(file, indexState);
220
+ updated.push(state);
221
221
  indexed++;
222
222
  details.push({
223
223
  sessionId: file.sessionId,
@@ -233,7 +233,7 @@ export class ConversationHistoryService {
233
233
  }
234
234
  }
235
235
 
236
- await this.saveIndexState(indexState);
236
+ this.saveIndexState(updated);
237
237
  return { indexed, skipped, errors, details };
238
238
  }
239
239
 
@@ -297,11 +297,12 @@ export class ConversationHistoryService {
297
297
  // Atomically replace old chunks with new ones
298
298
  await this.repository.replaceSession(file.sessionId, rows);
299
299
 
300
- // Update index state
300
+ // Update index state. Prefer the parsed project (cwd-derived) over the
301
+ // lossy directory-name decode carried by the file listing.
301
302
  const session: IndexedSession = {
302
303
  sessionId: file.sessionId,
303
304
  filePath: file.filePath,
304
- project: file.project,
305
+ project: messages[0].project,
305
306
  lastModified: file.lastModified.getTime(),
306
307
  chunkCount: chunks.length,
307
308
  messageCount: messages.length,
@@ -342,11 +343,10 @@ export class ConversationHistoryService {
342
343
  lastModified: new Date(),
343
344
  };
344
345
 
345
- await this.indexSession(file, indexState);
346
- await this.saveIndexState(indexState);
346
+ const state = await this.indexSession(file, indexState);
347
+ this.saveIndexState([state]);
347
348
 
348
- const updated = indexState.get(sessionId)!;
349
- return { success: true, chunkCount: updated.chunkCount };
349
+ return { success: true, chunkCount: state.chunkCount };
350
350
  }
351
351
 
352
352
  async listIndexedSessions(
@@ -47,6 +47,8 @@ export interface IndexedSession {
47
47
  lastMessageAt: Date;
48
48
  }
49
49
 
50
+ import type { SearchSignals } from "./memory";
51
+
50
52
  /** Raw row from conversation_history table with RRF score */
51
53
  export interface ConversationHybridRow {
52
54
  id: string;
@@ -54,6 +56,7 @@ export interface ConversationHybridRow {
54
56
  metadata: Record<string, unknown>;
55
57
  createdAt: Date;
56
58
  rrfScore: number;
59
+ signals: SearchSignals;
57
60
  }
58
61
 
59
62
  /** Unified search result with source provenance */
@@ -65,6 +68,10 @@ export interface SearchResult {
65
68
  updatedAt: Date;
66
69
  source: "memory" | "conversation_history";
67
70
  score: number;
71
+ /** Absolute relevance confidence (0.0-1.0). Based on cosine similarity + retrieval agreement. */
72
+ confidence: number;
73
+ /** Canonical project path this result belongs to (null = untagged/legacy). */
74
+ project: string | null;
68
75
  // Memory-specific fields
69
76
  supersededBy: string | null;
70
77
  usefulness?: number;
@@ -110,10 +117,20 @@ export interface HistoryFilters {
110
117
  /** Options for the integrated search across both sources */
111
118
  export interface SearchOptions {
112
119
  limit?: number;
120
+ /**
121
+ * Project scope: "all" (default) searches every project with a ranking
122
+ * boost for the current one; "project" restricts to the current project;
123
+ * any other string is an explicit canonical project path to restrict to.
124
+ */
125
+ scope?: string;
113
126
  includeDeleted?: boolean;
114
127
  includeHistory?: boolean;
115
128
  historyOnly?: boolean;
116
129
  historyWeight?: number;
117
130
  historyFilters?: HistoryFilters;
118
131
  offset?: number;
132
+ /** Filter both memories and history created after this date. Merged into historyFilters; explicit historyFilters.after takes precedence. */
133
+ after?: Date;
134
+ /** Filter both memories and history created before this date. Merged into historyFilters; explicit historyFilters.before takes precedence. */
135
+ before?: Date;
119
136
  }
@@ -4,7 +4,7 @@ import {
4
4
  deserializeVector,
5
5
  safeParseJsonObject,
6
6
  sanitizeFtsQuery,
7
- hybridRRF,
7
+ hybridRRFWithSignals,
8
8
  topByRRF,
9
9
  knnSearch,
10
10
  batchedQuery,
@@ -49,6 +49,7 @@ export class MemoryRepository {
49
49
  row.last_accessed != null
50
50
  ? new Date(row.last_accessed as number)
51
51
  : null,
52
+ project: (row.project as string) ?? null,
52
53
  };
53
54
  }
54
55
 
@@ -70,8 +71,8 @@ export class MemoryRepository {
70
71
  const tx = this.db.transaction(() => {
71
72
  this.db
72
73
  .prepare(
73
- `INSERT INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed)
74
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
74
+ `INSERT INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed, project)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
75
76
  )
76
77
  .run(
77
78
  memory.id,
@@ -83,6 +84,7 @@ export class MemoryRepository {
83
84
  memory.usefulness,
84
85
  memory.accessCount,
85
86
  memory.lastAccessed?.getTime() ?? null,
87
+ memory.project,
86
88
  );
87
89
 
88
90
  this.db
@@ -102,8 +104,8 @@ export class MemoryRepository {
102
104
  // Main table supports INSERT OR REPLACE
103
105
  this.db
104
106
  .prepare(
105
- `INSERT OR REPLACE INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed)
106
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
107
+ `INSERT OR REPLACE INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed, project)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
107
109
  )
108
110
  .run(
109
111
  memory.id,
@@ -115,6 +117,7 @@ export class MemoryRepository {
115
117
  memory.usefulness,
116
118
  memory.accessCount,
117
119
  memory.lastAccessed?.getTime() ?? null,
120
+ memory.project,
118
121
  );
119
122
 
120
123
  this.db.prepare("DELETE FROM memories_vec WHERE id = ?").run(memory.id);
@@ -193,40 +196,88 @@ export class MemoryRepository {
193
196
 
194
197
  /**
195
198
  * Hybrid search combining vector KNN and FTS5, fused with Reciprocal Rank Fusion.
199
+ *
200
+ * The project filter is applied PRE-candidate-selection (pushed into both
201
+ * the KNN scan and the FTS query) so project-scoped searches rank within
202
+ * the project's own corpus — post-filtering a global top-K would return
203
+ * false-empty results for small projects in a large shared database.
204
+ *
205
+ * Date filters remain post-RRF on the final row fetch, so date-filtered
206
+ * queries may return fewer than `limit` results.
196
207
  */
197
208
  async findHybrid(
198
209
  embedding: number[],
199
210
  query: string,
200
211
  limit: number,
212
+ filters?: { after?: Date; before?: Date; project?: string },
201
213
  ): Promise<HybridRow[]> {
202
- const candidateLimit = limit * 3;
203
-
204
- // Vector KNN search (brute-force cosine similarity in JS)
205
- const vectorResults = knnSearch(this.db, "memories_vec", embedding, candidateLimit);
206
-
207
- // Full-text search
214
+ const candidateLimit = limit * 5;
215
+ const project = filters?.project;
216
+
217
+ // Vector KNN search (brute-force cosine similarity in JS), pre-filtered
218
+ // by project when scoped
219
+ const vectorResults = knnSearch(
220
+ this.db,
221
+ "memories_vec",
222
+ embedding,
223
+ candidateLimit,
224
+ project !== undefined
225
+ ? {
226
+ sql: `SELECT v.id, v.vector FROM memories_vec v
227
+ JOIN memories m ON v.id = m.id WHERE m.project = ?`,
228
+ params: [project],
229
+ }
230
+ : undefined,
231
+ );
232
+
233
+ // Full-text search, pre-filtered by project when scoped
208
234
  const ftsQuery = sanitizeFtsQuery(query);
209
235
  const ftsResults: Array<{ id: string }> = ftsQuery
210
- ? (this.db
211
- .prepare(
212
- "SELECT id FROM memories_fts WHERE memories_fts MATCH ? LIMIT ?",
213
- )
214
- .all(ftsQuery, candidateLimit) as Array<{ id: string }>)
236
+ ? project !== undefined
237
+ ? (this.db
238
+ .prepare(
239
+ `SELECT memories_fts.id FROM memories_fts
240
+ JOIN memories m ON memories_fts.id = m.id
241
+ WHERE memories_fts MATCH ? AND m.project = ? LIMIT ?`,
242
+ )
243
+ .all(ftsQuery, project, candidateLimit) as Array<{ id: string }>)
244
+ : (this.db
245
+ .prepare(
246
+ "SELECT id FROM memories_fts WHERE memories_fts MATCH ? LIMIT ?",
247
+ )
248
+ .all(ftsQuery, candidateLimit) as Array<{ id: string }>)
215
249
  : [];
216
250
 
217
- // Compute RRF scores and pick top ids
218
- const rrfScores = hybridRRF(vectorResults, ftsResults);
251
+ // Compute RRF scores with search signals for confidence scoring
252
+ const signalsMap = hybridRRFWithSignals(vectorResults, ftsResults);
253
+ const rrfScores = new Map<string, number>();
254
+ for (const [id, s] of signalsMap) rrfScores.set(id, s.rrfScore);
219
255
  const topIds = topByRRF(rrfScores, limit);
220
256
 
221
257
  if (topIds.length === 0) return [];
222
258
 
223
- // Fetch full rows for the winning ids (service layer handles deleted filtering)
259
+ // Fetch full rows for the winning ids, applying date filters if present
260
+ const conditions: string[] = [];
261
+ const params: (string | number)[] = [];
262
+
224
263
  const placeholders = topIds.map(() => "?").join(", ");
264
+ conditions.push(`id IN (${placeholders})`);
265
+ params.push(...topIds);
266
+
267
+ if (filters?.after) {
268
+ conditions.push("created_at > ?");
269
+ params.push(filters.after.getTime());
270
+ }
271
+ if (filters?.before) {
272
+ conditions.push("created_at < ?");
273
+ params.push(filters.before.getTime());
274
+ }
275
+
225
276
  const rows = this.db
226
277
  .prepare(
227
- `SELECT * FROM memories WHERE id IN (${placeholders})`,
278
+ `SELECT * FROM memories WHERE ${conditions.join(" AND ")}`,
228
279
  )
229
- .all(...topIds) as Array<Record<string, unknown>>;
280
+ .all(...params) as Array<Record<string, unknown>>;
230
281
 
231
282
  // Build a lookup for quick access
232
283
  const rowMap = new Map<string, Record<string, unknown>>();
@@ -242,9 +293,16 @@ export class MemoryRepository {
242
293
 
243
294
  const memEmbedding = this.getEmbedding(id);
244
295
  const memory = this.rowToMemory(row, memEmbedding);
296
+ const signals = signalsMap.get(id)!;
245
297
  results.push({
246
298
  ...memory,
247
- rrfScore: rrfScores.get(id) ?? 0,
299
+ rrfScore: signals.rrfScore,
300
+ signals: {
301
+ cosineSimilarity: signals.cosineSimilarity,
302
+ ftsMatch: signals.ftsMatch,
303
+ knnRank: signals.knnRank,
304
+ ftsRank: signals.ftsRank,
305
+ },
248
306
  });
249
307
  }
250
308