@aeriondyseti/vector-memory-mcp 1.1.0-dev.6 → 2.0.0-rc

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 (106) hide show
  1. package/README.md +22 -4
  2. package/package.json +12 -18
  3. package/scripts/migrate-from-lancedb.ts +56 -0
  4. package/scripts/smoke-test.ts +699 -0
  5. package/scripts/test-runner.ts +11 -1
  6. package/src/db/connection.ts +18 -4
  7. package/src/db/conversation.repository.ts +164 -79
  8. package/src/db/memory.repository.ts +182 -170
  9. package/src/db/migrations.ts +70 -0
  10. package/src/db/sqlite-utils.ts +78 -0
  11. package/src/http/server.ts +40 -35
  12. package/src/index.ts +33 -3
  13. package/src/mcp/server.ts +2 -1
  14. package/src/migration.ts +254 -0
  15. package/dist/package.json +0 -71
  16. package/dist/scripts/test-runner.d.ts +0 -9
  17. package/dist/scripts/test-runner.d.ts.map +0 -1
  18. package/dist/scripts/test-runner.js +0 -61
  19. package/dist/scripts/test-runner.js.map +0 -1
  20. package/dist/scripts/warmup.d.ts +0 -8
  21. package/dist/scripts/warmup.d.ts.map +0 -1
  22. package/dist/scripts/warmup.js +0 -61
  23. package/dist/scripts/warmup.js.map +0 -1
  24. package/dist/src/config/index.d.ts +0 -41
  25. package/dist/src/config/index.d.ts.map +0 -1
  26. package/dist/src/config/index.js +0 -75
  27. package/dist/src/config/index.js.map +0 -1
  28. package/dist/src/db/connection.d.ts +0 -3
  29. package/dist/src/db/connection.d.ts.map +0 -1
  30. package/dist/src/db/connection.js +0 -10
  31. package/dist/src/db/connection.js.map +0 -1
  32. package/dist/src/db/conversation.repository.d.ts +0 -26
  33. package/dist/src/db/conversation.repository.d.ts.map +0 -1
  34. package/dist/src/db/conversation.repository.js +0 -73
  35. package/dist/src/db/conversation.repository.js.map +0 -1
  36. package/dist/src/db/conversation.schema.d.ts +0 -4
  37. package/dist/src/db/conversation.schema.d.ts.map +0 -1
  38. package/dist/src/db/conversation.schema.js +0 -15
  39. package/dist/src/db/conversation.schema.js.map +0 -1
  40. package/dist/src/db/lancedb-utils.d.ts +0 -57
  41. package/dist/src/db/lancedb-utils.d.ts.map +0 -1
  42. package/dist/src/db/lancedb-utils.js +0 -124
  43. package/dist/src/db/lancedb-utils.js.map +0 -1
  44. package/dist/src/db/memory.repository.d.ts +0 -40
  45. package/dist/src/db/memory.repository.d.ts.map +0 -1
  46. package/dist/src/db/memory.repository.js +0 -183
  47. package/dist/src/db/memory.repository.js.map +0 -1
  48. package/dist/src/db/schema.d.ts +0 -7
  49. package/dist/src/db/schema.d.ts.map +0 -1
  50. package/dist/src/db/schema.js +0 -19
  51. package/dist/src/db/schema.js.map +0 -1
  52. package/dist/src/http/mcp-transport.d.ts +0 -19
  53. package/dist/src/http/mcp-transport.d.ts.map +0 -1
  54. package/dist/src/http/mcp-transport.js +0 -191
  55. package/dist/src/http/mcp-transport.js.map +0 -1
  56. package/dist/src/http/server.d.ts +0 -13
  57. package/dist/src/http/server.d.ts.map +0 -1
  58. package/dist/src/http/server.js +0 -224
  59. package/dist/src/http/server.js.map +0 -1
  60. package/dist/src/index.d.ts +0 -3
  61. package/dist/src/index.d.ts.map +0 -1
  62. package/dist/src/index.js +0 -68
  63. package/dist/src/index.js.map +0 -1
  64. package/dist/src/mcp/handlers.d.ts +0 -15
  65. package/dist/src/mcp/handlers.d.ts.map +0 -1
  66. package/dist/src/mcp/handlers.js +0 -313
  67. package/dist/src/mcp/handlers.js.map +0 -1
  68. package/dist/src/mcp/server.d.ts +0 -5
  69. package/dist/src/mcp/server.d.ts.map +0 -1
  70. package/dist/src/mcp/server.js +0 -22
  71. package/dist/src/mcp/server.js.map +0 -1
  72. package/dist/src/mcp/tools.d.ts +0 -13
  73. package/dist/src/mcp/tools.d.ts.map +0 -1
  74. package/dist/src/mcp/tools.js +0 -352
  75. package/dist/src/mcp/tools.js.map +0 -1
  76. package/dist/src/services/conversation.service.d.ts +0 -38
  77. package/dist/src/services/conversation.service.d.ts.map +0 -1
  78. package/dist/src/services/conversation.service.js +0 -252
  79. package/dist/src/services/conversation.service.js.map +0 -1
  80. package/dist/src/services/embeddings.service.d.ts +0 -12
  81. package/dist/src/services/embeddings.service.d.ts.map +0 -1
  82. package/dist/src/services/embeddings.service.js +0 -37
  83. package/dist/src/services/embeddings.service.js.map +0 -1
  84. package/dist/src/services/memory.service.d.ts +0 -40
  85. package/dist/src/services/memory.service.d.ts.map +0 -1
  86. package/dist/src/services/memory.service.js +0 -258
  87. package/dist/src/services/memory.service.js.map +0 -1
  88. package/dist/src/services/parsers/claude-code.parser.d.ts +0 -8
  89. package/dist/src/services/parsers/claude-code.parser.d.ts.map +0 -1
  90. package/dist/src/services/parsers/claude-code.parser.js +0 -191
  91. package/dist/src/services/parsers/claude-code.parser.js.map +0 -1
  92. package/dist/src/services/parsers/types.d.ts +0 -9
  93. package/dist/src/services/parsers/types.d.ts.map +0 -1
  94. package/dist/src/services/parsers/types.js +0 -2
  95. package/dist/src/services/parsers/types.js.map +0 -1
  96. package/dist/src/types/conversation.d.ts +0 -99
  97. package/dist/src/types/conversation.d.ts.map +0 -1
  98. package/dist/src/types/conversation.js +0 -2
  99. package/dist/src/types/conversation.js.map +0 -1
  100. package/dist/src/types/memory.d.ts +0 -30
  101. package/dist/src/types/memory.d.ts.map +0 -1
  102. package/dist/src/types/memory.js +0 -18
  103. package/dist/src/types/memory.js.map +0 -1
  104. package/src/db/conversation.schema.ts +0 -33
  105. package/src/db/lancedb-utils.ts +0 -142
  106. package/src/db/schema.ts +0 -38
@@ -1,7 +1,12 @@
1
- import * as lancedb from "@lancedb/lancedb";
2
- import { type Table } from "@lancedb/lancedb";
3
- import { TABLE_NAME, memorySchema } from "./schema.js";
4
- import { arrowTimestampToDate, arrowVectorToArray, createFtsMutex, createRerankerMutex, escapeSql, safeParseJsonObject } from "./lancedb-utils.js";
1
+ import type { Database } from "bun:sqlite";
2
+ import {
3
+ serializeVector,
4
+ deserializeVector,
5
+ safeParseJsonObject,
6
+ sanitizeFtsQuery,
7
+ hybridRRF,
8
+ topByRRF,
9
+ } from "./sqlite-utils.js";
5
10
  import {
6
11
  type Memory,
7
12
  type HybridRow,
@@ -9,206 +14,213 @@ import {
9
14
  } from "../types/memory.js";
10
15
 
11
16
  export class MemoryRepository {
12
- // Mutex for schema migration - runs once per instance to add missing columns
13
- private migrationPromise: Promise<void> | null = null;
14
-
15
- // FTS index mutex — once created, the promise is never cleared (index persists in LanceDB)
16
- private ensureFtsIndex: () => Promise<void>;
17
-
18
- // Cached reranker — k=60 is constant, no need to recreate per search
19
- private getReranker = createRerankerMutex();
20
-
21
- constructor(private db: lancedb.Connection) {
22
- this.ensureFtsIndex = createFtsMutex(() => this.getTable());
23
- }
24
-
25
- private async getTable() {
26
- const names = await this.db.tableNames();
27
- if (names.includes(TABLE_NAME)) {
28
- const table = await this.db.openTable(TABLE_NAME);
29
- await this.ensureMigration(table);
30
- return table;
31
- }
32
- // Create with empty data to initialize schema
33
- return await this.db.createTable(TABLE_NAME, [], { schema: memorySchema });
34
- }
35
-
36
- /**
37
- * Ensures schema migration has run. Uses a mutex pattern identical to ensureFtsIndex.
38
- * Adds columns introduced after the initial schema (usefulness, access_count, last_accessed).
39
- */
40
- private ensureMigration(table: Table): Promise<void> {
41
- if (this.migrationPromise) {
42
- return this.migrationPromise;
43
- }
44
-
45
- this.migrationPromise = this.migrateSchemaIfNeeded(table).catch((error) => {
46
- this.migrationPromise = null;
47
- throw error;
48
- });
49
-
50
- return this.migrationPromise;
51
- }
52
-
53
- /**
54
- * Inspects the existing table schema and adds any missing columns with safe defaults.
55
- * This handles databases created before the hybrid memory system was introduced.
56
- */
57
- private async migrateSchemaIfNeeded(table: Table): Promise<void> {
58
- const schema = await table.schema();
59
- const existingFields = new Set(schema.fields.map((f) => f.name));
60
-
61
- const migrations: { name: string; valueSql: string }[] = [];
17
+ constructor(private db: Database) {}
62
18
 
63
- if (!existingFields.has("usefulness")) {
64
- migrations.push({ name: "usefulness", valueSql: "cast(0.0 as float)" });
65
- }
66
- if (!existingFields.has("access_count")) {
67
- migrations.push({ name: "access_count", valueSql: "cast(0 as int)" });
68
- }
69
- if (!existingFields.has("last_accessed")) {
70
- migrations.push({ name: "last_accessed", valueSql: "cast(NULL as timestamp)" });
71
- }
72
-
73
- if (migrations.length > 0) {
74
- await table.addColumns(migrations);
75
- }
76
- }
19
+ // ---------------------------------------------------------------------------
20
+ // Row mapping
21
+ // ---------------------------------------------------------------------------
77
22
 
78
23
  /**
79
- * Converts a raw LanceDB row to a Memory object.
24
+ * Converts a raw SQLite row from the `memories` table to a Memory object.
25
+ * Vector is fetched separately when needed; pass it in if available.
80
26
  */
81
- private rowToMemory(row: Record<string, unknown>): Memory {
27
+ private rowToMemory(
28
+ row: Record<string, unknown>,
29
+ embedding: number[] = [],
30
+ ): Memory {
82
31
  return {
83
32
  id: row.id as string,
84
33
  content: row.content as string,
85
- embedding: arrowVectorToArray(row.vector),
86
- metadata: safeParseJsonObject(row.metadata as string),
87
- createdAt: arrowTimestampToDate(row.created_at),
88
- updatedAt: arrowTimestampToDate(row.updated_at),
89
- supersededBy: row.superseded_by as string | null,
34
+ embedding,
35
+ metadata: safeParseJsonObject(row.metadata),
36
+ createdAt: new Date(row.created_at as number),
37
+ updatedAt: new Date(row.updated_at as number),
38
+ supersededBy: (row.superseded_by as string) ?? null,
90
39
  usefulness: (row.usefulness as number) ?? 0,
91
40
  accessCount: (row.access_count as number) ?? 0,
92
- lastAccessed: row.last_accessed
93
- ? arrowTimestampToDate(row.last_accessed)
94
- : null,
41
+ lastAccessed:
42
+ row.last_accessed != null
43
+ ? new Date(row.last_accessed as number)
44
+ : null,
95
45
  };
96
46
  }
97
47
 
98
- async insert(memory: Memory): Promise<void> {
99
- const table = await this.getTable();
100
- await table.add([
101
- {
102
- id: memory.id,
103
- vector: memory.embedding,
104
- content: memory.content,
105
- metadata: JSON.stringify(memory.metadata),
106
- created_at: memory.createdAt.getTime(),
107
- updated_at: memory.updatedAt.getTime(),
108
- superseded_by: memory.supersededBy,
109
- usefulness: memory.usefulness,
110
- access_count: memory.accessCount,
111
- last_accessed: memory.lastAccessed?.getTime() ?? null,
112
- },
113
- ]);
48
+ /**
49
+ * Fetch the embedding vector for a memory id from the vec0 table.
50
+ */
51
+ private getEmbedding(id: string): number[] {
52
+ const row = this.db
53
+ .prepare("SELECT vector FROM memories_vec WHERE id = ?")
54
+ .get(id) as { vector: Buffer } | null;
55
+ return row ? deserializeVector(row.vector) : [];
114
56
  }
115
57
 
116
- async upsert(memory: Memory): Promise<void> {
117
- const table = await this.getTable();
118
- const existing = await table.query().where(`id = '${escapeSql(memory.id)}'`).limit(1).toArray();
58
+ // ---------------------------------------------------------------------------
59
+ // Public API
60
+ // ---------------------------------------------------------------------------
119
61
 
120
- if (existing.length === 0) {
121
- return await this.insert(memory);
122
- }
62
+ async insert(memory: Memory): Promise<void> {
63
+ const tx = this.db.transaction(() => {
64
+ this.db
65
+ .prepare(
66
+ `INSERT INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed)
67
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
68
+ )
69
+ .run(
70
+ memory.id,
71
+ memory.content,
72
+ JSON.stringify(memory.metadata),
73
+ memory.createdAt.getTime(),
74
+ memory.updatedAt.getTime(),
75
+ memory.supersededBy,
76
+ memory.usefulness,
77
+ memory.accessCount,
78
+ memory.lastAccessed?.getTime() ?? null,
79
+ );
80
+
81
+ this.db
82
+ .prepare("INSERT INTO memories_vec (id, vector) VALUES (?, ?)")
83
+ .run(memory.id, serializeVector(memory.embedding));
84
+
85
+ this.db
86
+ .prepare("INSERT INTO memories_fts (id, content) VALUES (?, ?)")
87
+ .run(memory.id, memory.content);
88
+ });
123
89
 
124
- await table.update({
125
- where: `id = '${escapeSql(memory.id)}'`,
126
- values: {
127
- vector: memory.embedding,
128
- content: memory.content,
129
- metadata: JSON.stringify(memory.metadata),
130
- created_at: memory.createdAt.getTime(),
131
- updated_at: memory.updatedAt.getTime(),
132
- superseded_by: memory.supersededBy,
133
- usefulness: memory.usefulness,
134
- access_count: memory.accessCount,
135
- last_accessed: memory.lastAccessed?.getTime() ?? null,
136
- },
90
+ tx();
91
+ }
92
+
93
+ async upsert(memory: Memory): Promise<void> {
94
+ const tx = this.db.transaction(() => {
95
+ // Main table supports INSERT OR REPLACE
96
+ this.db
97
+ .prepare(
98
+ `INSERT OR REPLACE INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
100
+ )
101
+ .run(
102
+ memory.id,
103
+ memory.content,
104
+ JSON.stringify(memory.metadata),
105
+ memory.createdAt.getTime(),
106
+ memory.updatedAt.getTime(),
107
+ memory.supersededBy,
108
+ memory.usefulness,
109
+ memory.accessCount,
110
+ memory.lastAccessed?.getTime() ?? null,
111
+ );
112
+
113
+ // vec0 virtual tables don't support REPLACE — delete then insert
114
+ this.db.prepare("DELETE FROM memories_vec WHERE id = ?").run(memory.id);
115
+ this.db
116
+ .prepare("INSERT INTO memories_vec (id, vector) VALUES (?, ?)")
117
+ .run(memory.id, serializeVector(memory.embedding));
118
+
119
+ // fts5 virtual tables don't support REPLACE — delete then insert
120
+ this.db.prepare("DELETE FROM memories_fts WHERE id = ?").run(memory.id);
121
+ this.db
122
+ .prepare("INSERT INTO memories_fts (id, content) VALUES (?, ?)")
123
+ .run(memory.id, memory.content);
137
124
  });
125
+
126
+ tx();
138
127
  }
139
128
 
140
129
  async findById(id: string): Promise<Memory | null> {
141
- const table = await this.getTable();
142
- const results = await table.query().where(`id = '${escapeSql(id)}'`).limit(1).toArray();
130
+ const row = this.db
131
+ .prepare("SELECT * FROM memories WHERE id = ?")
132
+ .get(id) as Record<string, unknown> | null;
143
133
 
144
- if (results.length === 0) {
145
- return null;
146
- }
134
+ if (!row) return null;
147
135
 
148
- return this.rowToMemory(results[0] as Record<string, unknown>);
136
+ const embedding = this.getEmbedding(id);
137
+ return this.rowToMemory(row, embedding);
149
138
  }
150
139
 
151
140
  async findByIds(ids: string[]): Promise<Memory[]> {
152
141
  if (ids.length === 0) return [];
153
- const table = await this.getTable();
154
- const inList = ids.map((id) => `'${escapeSql(id)}'`).join(", ");
155
- const results = await table
156
- .query()
157
- .where(`id IN (${inList})`)
158
- .toArray();
159
- return results.map((row) => this.rowToMemory(row as Record<string, unknown>));
160
- }
161
142
 
162
- async markDeleted(id: string): Promise<boolean> {
163
- const table = await this.getTable();
164
-
165
- // Verify existence first to match previous behavior (return false if not found)
166
- const existing = await table.query().where(`id = '${escapeSql(id)}'`).limit(1).toArray();
167
- if (existing.length === 0) {
168
- return false;
169
- }
143
+ const placeholders = ids.map(() => "?").join(", ");
144
+ const rows = this.db
145
+ .prepare(`SELECT * FROM memories WHERE id IN (${placeholders})`)
146
+ .all(...ids) as Array<Record<string, unknown>>;
170
147
 
171
- const now = Date.now();
172
- await table.update({
173
- where: `id = '${escapeSql(id)}'`,
174
- values: {
175
- superseded_by: DELETED_TOMBSTONE,
176
- updated_at: now,
177
- },
148
+ return rows.map((row) => {
149
+ const embedding = this.getEmbedding(row.id as string);
150
+ return this.rowToMemory(row, embedding);
178
151
  });
152
+ }
153
+
154
+ async markDeleted(id: string): Promise<boolean> {
155
+ const result = this.db
156
+ .prepare(
157
+ "UPDATE memories SET superseded_by = ?, updated_at = ? WHERE id = ?",
158
+ )
159
+ .run(DELETED_TOMBSTONE, Date.now(), id);
179
160
 
180
- return true;
161
+ return result.changes > 0;
181
162
  }
182
163
 
183
164
  /**
184
- * Performs hybrid search combining vector similarity and full-text search.
185
- * Uses RRF (Reciprocal Rank Fusion) to combine rankings from both search methods.
186
- *
187
- * @param embedding - Query embedding vector
188
- * @param query - Text query for full-text search
189
- * @param limit - Maximum number of results to return
190
- * @returns Array of HybridRow containing full Memory data plus RRF score
165
+ * Hybrid search combining vector KNN and FTS5, fused with Reciprocal Rank Fusion.
191
166
  */
192
- async findHybrid(embedding: number[], query: string, limit: number): Promise<HybridRow[]> {
193
- await this.ensureFtsIndex();
194
-
195
- const table = await this.getTable();
196
- const reranker = await this.getReranker();
197
-
198
- const results = await table
199
- .query()
200
- .nearestTo(embedding)
201
- .fullTextSearch(query)
202
- .rerank(reranker)
203
- .limit(limit)
204
- .toArray();
205
-
206
- return results.map((row) => {
207
- const memory = this.rowToMemory(row as Record<string, unknown>);
208
- return {
167
+ async findHybrid(
168
+ embedding: number[],
169
+ query: string,
170
+ limit: number,
171
+ ): Promise<HybridRow[]> {
172
+ const candidateLimit = limit * 3;
173
+ const vecBuf = serializeVector(embedding);
174
+
175
+ // Vector KNN search
176
+ const vectorResults = this.db
177
+ .prepare(
178
+ "SELECT id, distance FROM memories_vec WHERE vector MATCH ? AND k = ? ORDER BY distance",
179
+ )
180
+ .all(vecBuf, candidateLimit) as Array<{ id: string; distance: number }>;
181
+
182
+ // Full-text search
183
+ const ftsQuery = sanitizeFtsQuery(query);
184
+ const ftsResults = this.db
185
+ .prepare(
186
+ "SELECT id FROM memories_fts WHERE memories_fts MATCH ? LIMIT ?",
187
+ )
188
+ .all(ftsQuery, candidateLimit) as Array<{ id: string }>;
189
+
190
+ // Compute RRF scores and pick top ids
191
+ const rrfScores = hybridRRF(vectorResults, ftsResults);
192
+ const topIds = topByRRF(rrfScores, limit);
193
+
194
+ if (topIds.length === 0) return [];
195
+
196
+ // Fetch full rows for the winning ids (service layer handles deleted filtering)
197
+ const placeholders = topIds.map(() => "?").join(", ");
198
+ const rows = this.db
199
+ .prepare(
200
+ `SELECT * FROM memories WHERE id IN (${placeholders})`,
201
+ )
202
+ .all(...topIds) as Array<Record<string, unknown>>;
203
+
204
+ // Build a lookup for quick access
205
+ const rowMap = new Map<string, Record<string, unknown>>();
206
+ for (const row of rows) {
207
+ rowMap.set(row.id as string, row);
208
+ }
209
+
210
+ // Return results in RRF-ranked order, skipping any that were deleted
211
+ const results: HybridRow[] = [];
212
+ for (const id of topIds) {
213
+ const row = rowMap.get(id);
214
+ if (!row) continue; // deleted or missing
215
+
216
+ const memEmbedding = this.getEmbedding(id);
217
+ const memory = this.rowToMemory(row, memEmbedding);
218
+ results.push({
209
219
  ...memory,
210
- rrfScore: (row._relevance_score as number) ?? 0,
211
- };
212
- });
220
+ rrfScore: rrfScores.get(id) ?? 0,
221
+ });
222
+ }
223
+
224
+ return results;
213
225
  }
214
226
  }
@@ -0,0 +1,70 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ /**
4
+ * Run all schema migrations. Safe to call on every startup (uses IF NOT EXISTS).
5
+ */
6
+ export function runMigrations(db: Database): void {
7
+ // -- Memories --
8
+ db.exec(`
9
+ CREATE TABLE IF NOT EXISTS memories (
10
+ id TEXT PRIMARY KEY,
11
+ content TEXT NOT NULL,
12
+ metadata TEXT NOT NULL DEFAULT '{}',
13
+ created_at INTEGER NOT NULL,
14
+ updated_at INTEGER NOT NULL,
15
+ superseded_by TEXT,
16
+ usefulness REAL NOT NULL DEFAULT 0.0,
17
+ access_count INTEGER NOT NULL DEFAULT 0,
18
+ last_accessed INTEGER
19
+ )
20
+ `);
21
+
22
+ db.exec(`
23
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
24
+ id TEXT PRIMARY KEY,
25
+ vector float[384]
26
+ )
27
+ `);
28
+
29
+ db.exec(`
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
31
+ id UNINDEXED,
32
+ content
33
+ )
34
+ `);
35
+
36
+ // -- Conversation History --
37
+ db.exec(`
38
+ CREATE TABLE IF NOT EXISTS conversation_history (
39
+ id TEXT PRIMARY KEY,
40
+ content TEXT NOT NULL,
41
+ metadata TEXT NOT NULL DEFAULT '{}',
42
+ created_at INTEGER NOT NULL,
43
+ session_id TEXT NOT NULL,
44
+ role TEXT NOT NULL,
45
+ message_index_start INTEGER NOT NULL,
46
+ message_index_end INTEGER NOT NULL,
47
+ project TEXT NOT NULL
48
+ )
49
+ `);
50
+
51
+ db.exec(`
52
+ CREATE VIRTUAL TABLE IF NOT EXISTS conversation_history_vec USING vec0(
53
+ id TEXT PRIMARY KEY,
54
+ vector float[384]
55
+ )
56
+ `);
57
+
58
+ db.exec(`
59
+ CREATE VIRTUAL TABLE IF NOT EXISTS conversation_history_fts USING fts5(
60
+ id UNINDEXED,
61
+ content
62
+ )
63
+ `);
64
+
65
+ // -- Indexes --
66
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_session_id ON conversation_history(session_id)`);
67
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_project ON conversation_history(project)`);
68
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_role ON conversation_history(role)`);
69
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_created_at ON conversation_history(created_at)`);
70
+ }
@@ -0,0 +1,78 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ /** RRF constant matching the previous LanceDB reranker default */
4
+ export const RRF_K = 60;
5
+
6
+ /**
7
+ * Serialize a number[] embedding to the raw float32 bytes sqlite-vec expects.
8
+ */
9
+ export function serializeVector(vec: number[]): Buffer {
10
+ return Buffer.from(new Float32Array(vec).buffer);
11
+ }
12
+
13
+ /**
14
+ * Deserialize raw float32 bytes from sqlite-vec back to number[].
15
+ */
16
+ export function deserializeVector(buf: Buffer): number[] {
17
+ return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
18
+ }
19
+
20
+ /**
21
+ * Sanitize a user query for FTS5 by quoting each token as a literal.
22
+ * Prevents FTS5 syntax errors from special characters like AND, OR, *, etc.
23
+ */
24
+ export function sanitizeFtsQuery(query: string): string {
25
+ const tokens = query.trim().split(/\s+/).filter(Boolean);
26
+ if (tokens.length === 0) return '""';
27
+ return tokens.map(t => `"${t.replace(/"/g, '""')}"`).join(" ");
28
+ }
29
+
30
+ /**
31
+ * Compute hybrid RRF scores from two ranked result lists.
32
+ * Returns a map of id -> combined RRF score, sorted descending.
33
+ */
34
+ export function hybridRRF(
35
+ vectorResults: Array<{ id: string }>,
36
+ ftsResults: Array<{ id: string }>,
37
+ k: number = RRF_K
38
+ ): Map<string, number> {
39
+ const scores = new Map<string, number>();
40
+
41
+ vectorResults.forEach((r, i) => {
42
+ const rank = i + 1;
43
+ scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + rank));
44
+ });
45
+
46
+ ftsResults.forEach((r, i) => {
47
+ const rank = i + 1;
48
+ scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + rank));
49
+ });
50
+
51
+ return scores;
52
+ }
53
+
54
+ /**
55
+ * Sort ids by RRF score descending and return top N.
56
+ */
57
+ export function topByRRF(scores: Map<string, number>, limit: number): string[] {
58
+ return [...scores.entries()]
59
+ .sort((a, b) => b[1] - a[1])
60
+ .slice(0, limit)
61
+ .map(([id]) => id);
62
+ }
63
+
64
+ /**
65
+ * Safely parse a JSON string, returning an empty object on failure.
66
+ * Ported from lancedb-utils.ts.
67
+ */
68
+ export function safeParseJsonObject(raw: unknown): Record<string, unknown> {
69
+ if (typeof raw !== "string") return {};
70
+ try {
71
+ const parsed = JSON.parse(raw);
72
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
73
+ ? parsed
74
+ : {};
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
@@ -1,16 +1,14 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
- import { serve as nodeServe } from "@hono/node-server";
4
3
  import { createServer } from "net";
4
+ import { writeFileSync, mkdirSync, unlinkSync } from "fs";
5
+ import { join } from "path";
5
6
  import type { MemoryService } from "../services/memory.service.js";
6
7
  import type { Config } from "../config/index.js";
7
8
  import { isDeleted } from "../types/memory.js";
8
9
  import { createMcpRoutes } from "./mcp-transport.js";
9
10
  import type { Memory, SearchIntent } from "../types/memory.js";
10
11
 
11
- // Detect runtime
12
- const isBun = typeof globalThis.Bun !== "undefined";
13
-
14
12
  /**
15
13
  * Check if a port is available by attempting to bind to it
16
14
  */
@@ -56,6 +54,31 @@ async function findAvailablePort(
56
54
  });
57
55
  }
58
56
 
57
+ /**
58
+ * Write a lockfile so hooks can discover which port this server bound to.
59
+ * Written atomically after the HTTP server successfully binds.
60
+ */
61
+ function writeLockfile(port: number): void {
62
+ const dir = join(process.cwd(), ".vector-memory");
63
+ mkdirSync(dir, { recursive: true });
64
+ writeFileSync(
65
+ join(dir, "server.lock"),
66
+ JSON.stringify({ port, pid: process.pid }),
67
+ "utf8"
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Remove the lockfile on clean shutdown so stale files don't linger.
73
+ */
74
+ export function removeLockfile(): void {
75
+ try {
76
+ unlinkSync(join(process.cwd(), ".vector-memory", "server.lock"));
77
+ } catch {
78
+ // already gone — fine
79
+ }
80
+ }
81
+
59
82
  export interface HttpServerOptions {
60
83
  memoryService: MemoryService;
61
84
  config: Config;
@@ -246,37 +269,19 @@ export async function startHttpServer(
246
269
  // Find an available port (uses configured port if available, otherwise picks a random one)
247
270
  const actualPort = await findAvailablePort(config.httpPort, config.httpHost);
248
271
 
249
- if (isBun) {
250
- // Use Bun's native server
251
- const server = Bun.serve({
252
- port: actualPort,
253
- hostname: config.httpHost,
254
- fetch: app.fetch,
255
- });
256
-
257
- console.error(
258
- `[vector-memory-mcp] HTTP server listening on http://${config.httpHost}:${actualPort}`
259
- );
260
-
261
- return {
262
- stop: () => server.stop(),
263
- port: actualPort,
264
- };
265
- } else {
266
- // Use Node.js server via @hono/node-server
267
- const server = nodeServe({
268
- fetch: app.fetch,
269
- port: actualPort,
270
- hostname: config.httpHost,
271
- });
272
+ const server = Bun.serve({
273
+ port: actualPort,
274
+ hostname: config.httpHost,
275
+ fetch: app.fetch,
276
+ });
272
277
 
273
- console.error(
274
- `[vector-memory-mcp] HTTP server listening on http://${config.httpHost}:${actualPort}`
275
- );
278
+ writeLockfile(actualPort);
279
+ console.error(
280
+ `[vector-memory-mcp] HTTP server listening on http://${config.httpHost}:${actualPort}`
281
+ );
276
282
 
277
- return {
278
- stop: () => server.close(),
279
- port: actualPort,
280
- };
281
- }
283
+ return {
284
+ stop: () => { removeLockfile(); server.stop(); },
285
+ port: actualPort,
286
+ };
282
287
  }