@aeriondyseti/vector-memory-mcp 2.4.4 → 2.5.0-dev.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/README.md +42 -1
- package/package.json +1 -1
- package/server/config/index.ts +11 -2
- package/server/core/connection.ts +110 -4
- package/server/core/consolidation.service.ts +652 -0
- package/server/core/conversation.repository.ts +137 -30
- package/server/core/conversation.service.ts +51 -51
- package/server/core/conversation.ts +17 -0
- package/server/core/memory.repository.ts +80 -22
- package/server/core/memory.service.ts +171 -49
- package/server/core/memory.ts +43 -1
- package/server/core/migrations.ts +197 -16
- package/server/core/parsers/claude-code.parser.ts +18 -4
- package/server/core/project.ts +25 -0
- package/server/core/sqlite-utils.ts +56 -5
- package/server/core/time-expr.ts +77 -0
- package/server/index.ts +92 -2
- package/server/transports/http/server.ts +82 -32
- package/server/transports/mcp/handlers.ts +71 -26
- package/server/transports/mcp/tools.ts +40 -4
|
@@ -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
|
-
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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 *
|
|
276
|
+
const candidateCount = limit * 5;
|
|
277
|
+
const project = filters?.project;
|
|
204
278
|
|
|
205
|
-
// Vector KNN search (brute-force cosine similarity in JS)
|
|
206
|
-
|
|
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 =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
220
|
-
const
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
-
/**
|
|
97
|
-
interface
|
|
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
|
|
111
|
-
private
|
|
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
|
-
|
|
117
|
+
dbPath: string,
|
|
118
118
|
private parser: SessionLogParser = new ClaudeCodeSessionParser()
|
|
119
119
|
) {
|
|
120
|
-
this.
|
|
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.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
346
|
+
const state = await this.indexSession(file, indexState);
|
|
347
|
+
this.saveIndexState([state]);
|
|
347
348
|
|
|
348
|
-
|
|
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
|
-
|
|
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 *
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
?
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
218
|
-
const
|
|
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
|
|
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
|
|
278
|
+
`SELECT * FROM memories WHERE ${conditions.join(" AND ")}`,
|
|
228
279
|
)
|
|
229
|
-
.all(...
|
|
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:
|
|
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
|
|