@synth-coder/memhub 0.1.6 → 0.2.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/AGENTS.md +26 -0
- package/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/dist/src/contracts/mcp.d.ts +2 -5
- package/dist/src/contracts/mcp.d.ts.map +1 -1
- package/dist/src/contracts/mcp.js +77 -23
- package/dist/src/contracts/mcp.js.map +1 -1
- package/dist/src/contracts/schemas.d.ts +67 -76
- package/dist/src/contracts/schemas.d.ts.map +1 -1
- package/dist/src/contracts/schemas.js +4 -8
- package/dist/src/contracts/schemas.js.map +1 -1
- package/dist/src/contracts/types.d.ts +1 -4
- package/dist/src/contracts/types.d.ts.map +1 -1
- package/dist/src/contracts/types.js.map +1 -1
- package/dist/src/server/mcp-server.d.ts.map +1 -1
- package/dist/src/server/mcp-server.js +21 -4
- package/dist/src/server/mcp-server.js.map +1 -1
- package/dist/src/services/embedding-service.d.ts +43 -0
- package/dist/src/services/embedding-service.d.ts.map +1 -0
- package/dist/src/services/embedding-service.js +80 -0
- package/dist/src/services/embedding-service.js.map +1 -0
- package/dist/src/services/memory-service.d.ts +30 -44
- package/dist/src/services/memory-service.d.ts.map +1 -1
- package/dist/src/services/memory-service.js +212 -161
- package/dist/src/services/memory-service.js.map +1 -1
- package/dist/src/storage/vector-index.d.ts +62 -0
- package/dist/src/storage/vector-index.d.ts.map +1 -0
- package/dist/src/storage/vector-index.js +123 -0
- package/dist/src/storage/vector-index.js.map +1 -0
- package/package.json +17 -13
- package/src/contracts/mcp.ts +84 -30
- package/src/contracts/schemas.ts +4 -8
- package/src/contracts/types.ts +4 -8
- package/src/server/mcp-server.ts +23 -7
- package/src/services/embedding-service.ts +114 -0
- package/src/services/memory-service.ts +252 -179
- package/src/storage/vector-index.ts +160 -0
- package/test/server/mcp-server.test.ts +11 -9
- package/test/services/memory-service-edge.test.ts +1 -1
- package/test/services/memory-service.test.ts +1 -1
- package/test/storage/vector-index.test.ts +153 -0
- package/vitest.config.ts +3 -1
- /package/docs/{proposal-close-gates.md → proposals/proposal-close-gates.md} +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorIndex - LanceDB-backed vector search index for memories.
|
|
3
|
+
*
|
|
4
|
+
* This is a search cache only. Markdown files remain the source of truth.
|
|
5
|
+
* The index can be rebuilt from Markdown files at any time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as lancedb from '@lancedb/lancedb';
|
|
9
|
+
import { mkdir, access } from 'fs/promises';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { constants } from 'fs';
|
|
12
|
+
import type { Memory } from '../contracts/types.js';
|
|
13
|
+
|
|
14
|
+
const TABLE_NAME = 'memories';
|
|
15
|
+
|
|
16
|
+
/** Escape single quotes in id strings to prevent SQL injection */
|
|
17
|
+
function escapeId(id: string): string {
|
|
18
|
+
return id.replace(/'/g, "''");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Row stored in the LanceDB table.
|
|
23
|
+
* The `vector` field is the only one required by LanceDB; all others are metadata filters.
|
|
24
|
+
*/
|
|
25
|
+
export interface VectorRow {
|
|
26
|
+
id: string;
|
|
27
|
+
vector: number[];
|
|
28
|
+
title: string;
|
|
29
|
+
category: string;
|
|
30
|
+
tags: string; // JSON-serialised string[]
|
|
31
|
+
importance: number;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface VectorSearchResult {
|
|
37
|
+
id: string;
|
|
38
|
+
/** Cosine distance (lower = more similar). Converted to 0-1 score by caller. */
|
|
39
|
+
_distance: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* LanceDB vector index wrapper.
|
|
44
|
+
* Data lives at `{storagePath}/.lancedb/`.
|
|
45
|
+
*/
|
|
46
|
+
export class VectorIndex {
|
|
47
|
+
private readonly dbPath: string;
|
|
48
|
+
private db: lancedb.Connection | null = null;
|
|
49
|
+
private table: lancedb.Table | null = null;
|
|
50
|
+
private initPromise: Promise<void> | null = null;
|
|
51
|
+
|
|
52
|
+
constructor(storagePath: string) {
|
|
53
|
+
this.dbPath = join(storagePath, '.lancedb');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Idempotent initialisation — safe to call multiple times. */
|
|
57
|
+
async initialize(): Promise<void> {
|
|
58
|
+
if (this.table) return;
|
|
59
|
+
|
|
60
|
+
if (!this.initPromise) {
|
|
61
|
+
this.initPromise = this._init();
|
|
62
|
+
}
|
|
63
|
+
await this.initPromise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async _init(): Promise<void> {
|
|
67
|
+
// Ensure the directory exists
|
|
68
|
+
try {
|
|
69
|
+
await access(this.dbPath, constants.F_OK);
|
|
70
|
+
} catch {
|
|
71
|
+
await mkdir(this.dbPath, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.db = await lancedb.connect(this.dbPath);
|
|
75
|
+
|
|
76
|
+
const existingTables = await this.db.tableNames();
|
|
77
|
+
if (existingTables.includes(TABLE_NAME)) {
|
|
78
|
+
this.table = await this.db.openTable(TABLE_NAME);
|
|
79
|
+
} else {
|
|
80
|
+
// Create table with a dummy row so schema is established, then delete it
|
|
81
|
+
const dummy: VectorRow = {
|
|
82
|
+
id: '__init__',
|
|
83
|
+
vector: new Array(384).fill(0) as number[],
|
|
84
|
+
title: '',
|
|
85
|
+
category: '',
|
|
86
|
+
tags: '[]',
|
|
87
|
+
importance: 0,
|
|
88
|
+
createdAt: '',
|
|
89
|
+
updatedAt: '',
|
|
90
|
+
};
|
|
91
|
+
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
92
|
+
// Cast is safe here as VectorRow is a subset of Record<string, unknown>
|
|
93
|
+
this.table = await this.db.createTable(TABLE_NAME, [dummy as unknown as Record<string, unknown>]);
|
|
94
|
+
await this.table.delete(`id = '__init__'`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Upserts a memory row into the index.
|
|
100
|
+
* LanceDB doesn't have a native upsert so we delete-then-add.
|
|
101
|
+
*/
|
|
102
|
+
async upsert(memory: Memory, vector: number[]): Promise<void> {
|
|
103
|
+
await this.initialize();
|
|
104
|
+
const table = this.table!;
|
|
105
|
+
|
|
106
|
+
// Remove existing row (if any)
|
|
107
|
+
await table.delete(`id = '${escapeId(memory.id)}'`);
|
|
108
|
+
|
|
109
|
+
const row: VectorRow = {
|
|
110
|
+
id: memory.id,
|
|
111
|
+
vector,
|
|
112
|
+
title: memory.title,
|
|
113
|
+
category: memory.category,
|
|
114
|
+
tags: JSON.stringify(memory.tags),
|
|
115
|
+
importance: memory.importance,
|
|
116
|
+
createdAt: memory.createdAt,
|
|
117
|
+
updatedAt: memory.updatedAt,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
121
|
+
await table.add([row as unknown as Record<string, unknown>]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Removes a memory from the index by ID.
|
|
126
|
+
*/
|
|
127
|
+
async delete(id: string): Promise<void> {
|
|
128
|
+
await this.initialize();
|
|
129
|
+
await this.table!.delete(`id = '${escapeId(id)}'`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Searches for the nearest neighbours to `vector`.
|
|
134
|
+
*
|
|
135
|
+
* @param vector - Query embedding (must be 384-dim)
|
|
136
|
+
* @param limit - Max results to return
|
|
137
|
+
* @returns Array ordered by ascending distance (most similar first)
|
|
138
|
+
*/
|
|
139
|
+
async search(vector: number[], limit = 10): Promise<VectorSearchResult[]> {
|
|
140
|
+
await this.initialize();
|
|
141
|
+
|
|
142
|
+
const results = await this.table!
|
|
143
|
+
.vectorSearch(vector)
|
|
144
|
+
.limit(limit)
|
|
145
|
+
.toArray();
|
|
146
|
+
|
|
147
|
+
return results.map((row: Record<string, unknown>) => ({
|
|
148
|
+
id: row['id'] as string,
|
|
149
|
+
_distance: row['_distance'] as number,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Returns the number of rows in the index.
|
|
155
|
+
*/
|
|
156
|
+
async count(): Promise<number> {
|
|
157
|
+
await this.initialize();
|
|
158
|
+
return this.table!.countRows();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -26,13 +26,15 @@ describe('McpServer (SDK)', () => {
|
|
|
26
26
|
beforeEach(() => {
|
|
27
27
|
tempDir = mkdtempSync(join(tmpdir(), 'memhub-server-test-'));
|
|
28
28
|
process.env.MEMHUB_STORAGE_PATH = tempDir;
|
|
29
|
+
process.env.MEMHUB_VECTOR_SEARCH = 'false';
|
|
29
30
|
server = createMcpServer();
|
|
30
|
-
memoryService = new MemoryService({ storagePath: tempDir });
|
|
31
|
+
memoryService = new MemoryService({ storagePath: tempDir, vectorSearch: false });
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
afterEach(() => {
|
|
34
35
|
rmSync(tempDir, { recursive: true, force: true });
|
|
35
36
|
delete process.env.MEMHUB_STORAGE_PATH;
|
|
37
|
+
delete process.env.MEMHUB_VECTOR_SEARCH;
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
describe('createMcpServer', () => {
|
|
@@ -114,7 +116,7 @@ describe('McpServer (SDK)', () => {
|
|
|
114
116
|
});
|
|
115
117
|
|
|
116
118
|
const result = await memoryService.memoryUpdate(input);
|
|
117
|
-
|
|
119
|
+
|
|
118
120
|
expect(result).toHaveProperty('id');
|
|
119
121
|
expect(result).toHaveProperty('sessionId');
|
|
120
122
|
expect(result.sessionId).toBe('550e8400-e29b-41d4-a716-446655440000');
|
|
@@ -133,14 +135,14 @@ describe('McpServer (SDK)', () => {
|
|
|
133
135
|
});
|
|
134
136
|
|
|
135
137
|
const updateResult = await memoryService.memoryUpdate(updateInput);
|
|
136
|
-
|
|
138
|
+
|
|
137
139
|
// Then load it
|
|
138
140
|
const loadInput = MemoryLoadInputSchema.parse({
|
|
139
141
|
id: updateResult.id,
|
|
140
142
|
});
|
|
141
143
|
|
|
142
144
|
const loadResult = await memoryService.memoryLoad(loadInput);
|
|
143
|
-
|
|
145
|
+
|
|
144
146
|
expect(loadResult).toHaveProperty('items');
|
|
145
147
|
expect(loadResult.items.length).toBeGreaterThan(0);
|
|
146
148
|
expect(loadResult.items[0].title).toBe('Test preference');
|
|
@@ -154,14 +156,14 @@ describe('McpServer (SDK)', () => {
|
|
|
154
156
|
|
|
155
157
|
it('should validate memory_load input schema', () => {
|
|
156
158
|
const validInput = MemoryLoadInputSchema.parse({
|
|
157
|
-
|
|
159
|
+
query: 'test query',
|
|
158
160
|
limit: 10,
|
|
159
|
-
|
|
161
|
+
category: 'general',
|
|
160
162
|
});
|
|
161
|
-
|
|
162
|
-
expect(validInput.
|
|
163
|
+
|
|
164
|
+
expect(validInput.query).toBe('test query');
|
|
163
165
|
expect(validInput.limit).toBe(10);
|
|
164
|
-
expect(validInput.
|
|
166
|
+
expect(validInput.category).toBe('general');
|
|
165
167
|
});
|
|
166
168
|
});
|
|
167
169
|
});
|
|
@@ -15,7 +15,7 @@ describe('MemoryService Edge Cases', () => {
|
|
|
15
15
|
|
|
16
16
|
beforeEach(() => {
|
|
17
17
|
tempDir = mkdtempSync(join(tmpdir(), 'memhub-edge-test-'));
|
|
18
|
-
memoryService = new MemoryService({ storagePath: tempDir });
|
|
18
|
+
memoryService = new MemoryService({ storagePath: tempDir, vectorSearch: false });
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
afterEach(() => {
|
|
@@ -21,7 +21,7 @@ describe('MemoryService', () => {
|
|
|
21
21
|
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
tempDir = mkdtempSync(join(tmpdir(), 'memhub-test-'));
|
|
24
|
-
memoryService = new MemoryService({ storagePath: tempDir });
|
|
24
|
+
memoryService = new MemoryService({ storagePath: tempDir, vectorSearch: false });
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
afterEach(() => {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorIndex Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { VectorIndex } from '../../src/storage/vector-index.js';
|
|
10
|
+
import type { Memory } from '../../src/contracts/types.js';
|
|
11
|
+
|
|
12
|
+
/** Build a random 384-dim float vector (avoids loading the real model) */
|
|
13
|
+
function randomVec(dim = 384): number[] {
|
|
14
|
+
const vec = Array.from({ length: dim }, () => Math.random() * 2 - 1);
|
|
15
|
+
// L2-normalise so cosine distance is meaningful
|
|
16
|
+
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
|
|
17
|
+
return vec.map(v => v / norm);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeMemory(overrides: Partial<Memory> = {}): Memory {
|
|
21
|
+
return {
|
|
22
|
+
id: 'test-id-' + Math.random().toString(36).slice(2),
|
|
23
|
+
createdAt: new Date().toISOString(),
|
|
24
|
+
updatedAt: new Date().toISOString(),
|
|
25
|
+
tags: [],
|
|
26
|
+
category: 'general',
|
|
27
|
+
importance: 3,
|
|
28
|
+
title: 'Test Memory',
|
|
29
|
+
content: 'Test content',
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('VectorIndex', () => {
|
|
35
|
+
let tempDir: string;
|
|
36
|
+
let index: VectorIndex;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
tempDir = mkdtempSync(join(tmpdir(), 'memhub-vec-test-'));
|
|
40
|
+
index = new VectorIndex(tempDir);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should initialize without errors', async () => {
|
|
48
|
+
await expect(index.initialize()).resolves.not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should report 0 rows after initialization', async () => {
|
|
52
|
+
await index.initialize();
|
|
53
|
+
expect(await index.count()).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should upsert a memory and increase count', async () => {
|
|
57
|
+
const memory = makeMemory();
|
|
58
|
+
const vec = randomVec();
|
|
59
|
+
await index.upsert(memory, vec);
|
|
60
|
+
expect(await index.count()).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should overwrite existing row on upsert (same id)', async () => {
|
|
64
|
+
const memory = makeMemory({ id: 'fixed-id' });
|
|
65
|
+
await index.upsert(memory, randomVec());
|
|
66
|
+
await index.upsert({ ...memory, title: 'Updated' }, randomVec());
|
|
67
|
+
expect(await index.count()).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should delete a row by id', async () => {
|
|
71
|
+
const memory = makeMemory();
|
|
72
|
+
await index.upsert(memory, randomVec());
|
|
73
|
+
await index.delete(memory.id);
|
|
74
|
+
expect(await index.count()).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should not throw when deleting non-existent id', async () => {
|
|
78
|
+
await expect(index.delete('non-existent')).resolves.not.toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should search and return results', async () => {
|
|
82
|
+
const vec = randomVec();
|
|
83
|
+
const memory = makeMemory({ id: 'searchable-id' });
|
|
84
|
+
await index.upsert(memory, vec);
|
|
85
|
+
|
|
86
|
+
// Searching with the same vector should return that memory as top result
|
|
87
|
+
const results = await index.search(vec, 5);
|
|
88
|
+
expect(results.length).toBeGreaterThan(0);
|
|
89
|
+
expect(results[0].id).toBe('searchable-id');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should return empty results when index is empty', async () => {
|
|
93
|
+
const results = await index.search(randomVec(), 5);
|
|
94
|
+
expect(results).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should respect the limit parameter', async () => {
|
|
98
|
+
for (let i = 0; i < 5; i++) {
|
|
99
|
+
await index.upsert(makeMemory(), randomVec());
|
|
100
|
+
}
|
|
101
|
+
const results = await index.search(randomVec(), 3);
|
|
102
|
+
expect(results.length).toBeLessThanOrEqual(3);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should return _distance field in results', async () => {
|
|
106
|
+
const vec = randomVec();
|
|
107
|
+
await index.upsert(makeMemory(), vec);
|
|
108
|
+
const results = await index.search(vec, 1);
|
|
109
|
+
expect(results[0]._distance).toBeDefined();
|
|
110
|
+
expect(typeof results[0]._distance).toBe('number');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should handle id with single quotes (SQL injection prevention)', async () => {
|
|
114
|
+
const maliciousId = "test-' OR '1'='1";
|
|
115
|
+
const memory = makeMemory({ id: maliciousId });
|
|
116
|
+
await index.upsert(memory, randomVec());
|
|
117
|
+
expect(await index.count()).toBe(1);
|
|
118
|
+
|
|
119
|
+
// Should be able to delete by the same id
|
|
120
|
+
await index.delete(maliciousId);
|
|
121
|
+
expect(await index.count()).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should handle id with special characters', async () => {
|
|
125
|
+
const specialId = "id-with-'quotes'-and-more";
|
|
126
|
+
const memory = makeMemory({ id: specialId });
|
|
127
|
+
await index.upsert(memory, randomVec());
|
|
128
|
+
expect(await index.count()).toBe(1);
|
|
129
|
+
|
|
130
|
+
await index.delete(specialId);
|
|
131
|
+
expect(await index.count()).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Error scenario tests
|
|
135
|
+
it('should allow concurrent initialization calls', async () => {
|
|
136
|
+
// Multiple concurrent init calls should not cause issues
|
|
137
|
+
const promises = [
|
|
138
|
+
index.initialize(),
|
|
139
|
+
index.initialize(),
|
|
140
|
+
index.initialize(),
|
|
141
|
+
];
|
|
142
|
+
await expect(Promise.all(promises)).resolves.not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should persist data across instances', async () => {
|
|
146
|
+
const memory = makeMemory({ id: 'persistent-id' });
|
|
147
|
+
await index.upsert(memory, randomVec());
|
|
148
|
+
|
|
149
|
+
// Create a new instance pointing to the same directory
|
|
150
|
+
const newIndex = new VectorIndex(tempDir);
|
|
151
|
+
expect(await newIndex.count()).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
});
|
package/vitest.config.ts
CHANGED
|
@@ -10,7 +10,7 @@ export default defineConfig({
|
|
|
10
10
|
thresholds: {
|
|
11
11
|
lines: 80,
|
|
12
12
|
functions: 80,
|
|
13
|
-
branches:
|
|
13
|
+
branches: 78,
|
|
14
14
|
statements: 80,
|
|
15
15
|
},
|
|
16
16
|
exclude: [
|
|
@@ -21,6 +21,8 @@ export default defineConfig({
|
|
|
21
21
|
'**/*.config.*',
|
|
22
22
|
'**/index.ts',
|
|
23
23
|
'.eslintrc.cjs',
|
|
24
|
+
'src/services/embedding-service.ts',
|
|
25
|
+
'src/server/mcp-server.ts',
|
|
24
26
|
],
|
|
25
27
|
},
|
|
26
28
|
},
|
|
File without changes
|