@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.
- package/README.md +42 -1
- package/package.json +3 -1
- package/scripts/lancedb-extract.ts +181 -0
- package/scripts/warmup.ts +63 -0
- package/server/config/index.ts +11 -2
- package/server/core/connection.ts +160 -4
- package/server/core/consolidation.service.ts +815 -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
package/README.md
CHANGED
|
@@ -117,13 +117,54 @@ Assistant: [calls search_memories with history_only: true, history_before/after
|
|
|
117
117
|
|
|
118
118
|
---
|
|
119
119
|
|
|
120
|
+
## Storage Model
|
|
121
|
+
|
|
122
|
+
All memories live in a single global database (`~/.vector-memory/memories.db`)
|
|
123
|
+
shared by every project. Each memory is tagged with the project (working
|
|
124
|
+
directory) it was stored from:
|
|
125
|
+
|
|
126
|
+
- **Searches default to all projects** — results carry their project path, and
|
|
127
|
+
hits from the current project rank slightly higher. Use `scope: "project"`
|
|
128
|
+
to restrict a search to the current repo.
|
|
129
|
+
- **Waypoints are per-project** and resolved automatically from the working
|
|
130
|
+
directory.
|
|
131
|
+
- A repo-local database is still available via `--db-file` or
|
|
132
|
+
`VECTOR_MEMORY_DB_PATH` (note: keep the db on local disk — WAL mode
|
|
133
|
+
misbehaves on network filesystems like NFS home directories).
|
|
134
|
+
|
|
135
|
+
### Migrating repo-local databases
|
|
136
|
+
|
|
137
|
+
Projects that used the old per-repo `.vector-memory/` layout can be imported
|
|
138
|
+
into the global store:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# Import the current repo's .vector-memory/memories.db
|
|
142
|
+
bunx @aeriondyseti/vector-memory-mcp consolidate
|
|
143
|
+
|
|
144
|
+
# Scan a whole directory tree and import every repo-local db found
|
|
145
|
+
bunx @aeriondyseti/vector-memory-mcp consolidate ~/Development --recursive
|
|
146
|
+
|
|
147
|
+
# Preview without writing (prints planned imports and ID re-keys)
|
|
148
|
+
bunx @aeriondyseti/vector-memory-mcp consolidate --dry-run
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Consolidation tags every imported memory with its repo's path, preserves
|
|
152
|
+
embeddings and usefulness stats, deduplicates by ID, re-keys waypoints to
|
|
153
|
+
their per-project IDs (remapping references), and backs up the global db
|
|
154
|
+
first. `--archive` renames the source `.vector-memory/` to
|
|
155
|
+
`.vector-memory.migrated/` after a successful import; `--force` skips the
|
|
156
|
+
live-server check.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
120
160
|
## Configuration
|
|
121
161
|
|
|
122
162
|
CLI flags:
|
|
123
163
|
|
|
124
164
|
| Flag | Alias | Default | Description |
|
|
125
165
|
|------|-------|---------|-------------|
|
|
126
|
-
| `--db-file <path>` | `-d` |
|
|
166
|
+
| `--db-file <path>` | `-d` | `~/.vector-memory/memories.db` | Database location (global store) |
|
|
167
|
+
| `--project <path>` | | *(cwd)* | Project identity used to tag memories |
|
|
127
168
|
| `--port <number>` | `-p` | `3271` | HTTP server port |
|
|
128
169
|
| `--no-http` | | *(HTTP enabled)* | Disable HTTP/SSE transport |
|
|
129
170
|
| `--enable-history` | | *(disabled)* | Enable conversation history indexing |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeriondyseti/vector-memory-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0-dev.2",
|
|
4
4
|
"description": "A zero-configuration RAG memory server for MCP clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/index.ts",
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"server",
|
|
12
|
+
"scripts/lancedb-extract.ts",
|
|
13
|
+
"scripts/warmup.ts",
|
|
12
14
|
"README.md",
|
|
13
15
|
"LICENSE"
|
|
14
16
|
],
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Standalone LanceDB data extractor — runs in a child process so that
|
|
4
|
+
* @lancedb/lancedb native bindings never coexist with bun:sqlite's
|
|
5
|
+
* extension loading in the same process.
|
|
6
|
+
*
|
|
7
|
+
* Usage: bun scripts/lancedb-extract.ts <lance-db-path>
|
|
8
|
+
* Output: JSON on stdout — { memories: Row[], conversations: Row[] }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const source = process.argv[2];
|
|
12
|
+
if (!source) {
|
|
13
|
+
console.error("Usage: bun scripts/lancedb-extract.ts <lance-db-path>");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Arrow TimeUnit enum → divisor to convert to milliseconds.
|
|
18
|
+
// 0=SECOND, 1=MILLISECOND, 2=MICROSECOND, 3=NANOSECOND
|
|
19
|
+
// Negative divisor = multiply (seconds → ms needs ×1000).
|
|
20
|
+
const TIME_UNIT_TO_MS_DIVISOR: Record<number, bigint> = {
|
|
21
|
+
0: -1000n, // seconds → ms (multiply by 1000)
|
|
22
|
+
1: 1n, // ms → no conversion
|
|
23
|
+
2: 1000n, // μs → ms
|
|
24
|
+
3: 1000000n, // ns → ms
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function buildTimestampDivisors(schema: any): Map<string, bigint> {
|
|
28
|
+
const map = new Map<string, bigint>();
|
|
29
|
+
for (const field of schema.fields) {
|
|
30
|
+
if (field.type.typeId === 10) {
|
|
31
|
+
map.set(field.name, TIME_UNIT_TO_MS_DIVISOR[field.type.unit] ?? 1n);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function columnValue(batch: any, colName: string, rowIdx: number): unknown {
|
|
38
|
+
const col = batch.getChild(colName);
|
|
39
|
+
if (!col) return undefined;
|
|
40
|
+
try {
|
|
41
|
+
return col.get(rowIdx);
|
|
42
|
+
} catch {
|
|
43
|
+
// Arrow's getter can throw on BigInt timestamps exceeding MAX_SAFE_INTEGER;
|
|
44
|
+
// fall back to the raw typed array.
|
|
45
|
+
let offset = rowIdx;
|
|
46
|
+
for (const data of col.data) {
|
|
47
|
+
if (offset < data.length) {
|
|
48
|
+
return (data.values instanceof BigInt64Array || data.values instanceof BigUint64Array)
|
|
49
|
+
? data.values[offset]
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
offset -= data.length;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toEpochMs(value: unknown, divisor: bigint = 1n): number {
|
|
59
|
+
if (value == null) return Date.now();
|
|
60
|
+
if (value instanceof Date) return value.getTime();
|
|
61
|
+
if (typeof value === "bigint") {
|
|
62
|
+
if (divisor < 0n) return Number(value * -divisor); // seconds → ms
|
|
63
|
+
if (divisor === 1n) return Number(value);
|
|
64
|
+
return Number(value / divisor);
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === "number") {
|
|
67
|
+
if (divisor < 0n) return value * Number(-divisor);
|
|
68
|
+
if (divisor === 1n) return value;
|
|
69
|
+
return Math.floor(value / Number(divisor));
|
|
70
|
+
}
|
|
71
|
+
return Date.now();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toFloatArray(vec: unknown): number[] {
|
|
75
|
+
if (Array.isArray(vec)) return vec;
|
|
76
|
+
if (vec instanceof Float32Array) return Array.from(vec);
|
|
77
|
+
if (vec && typeof (vec as any).toArray === "function") {
|
|
78
|
+
return Array.from((vec as any).toArray());
|
|
79
|
+
}
|
|
80
|
+
if (ArrayBuffer.isView(vec)) {
|
|
81
|
+
const view = vec as DataView;
|
|
82
|
+
return Array.from(new Float32Array(view.buffer, view.byteOffset, view.byteLength / 4));
|
|
83
|
+
}
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const BATCH_SIZE = 100;
|
|
88
|
+
const lancedb = await import("@lancedb/lancedb");
|
|
89
|
+
const db = await lancedb.connect(source);
|
|
90
|
+
const tableNames = await db.tableNames();
|
|
91
|
+
console.error(`Found tables: ${tableNames.join(", ")}`);
|
|
92
|
+
|
|
93
|
+
const result: { memories: any[]; conversations: any[] } = {
|
|
94
|
+
memories: [],
|
|
95
|
+
conversations: [],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (tableNames.includes("memories")) {
|
|
99
|
+
const table = await db.openTable("memories");
|
|
100
|
+
const total = await table.countRows();
|
|
101
|
+
console.error(`Reading ${total} memories...`);
|
|
102
|
+
|
|
103
|
+
// Paginated scan — query().toArrow() without offset/limit returns
|
|
104
|
+
// non-deterministic results that can duplicate some rows and skip others.
|
|
105
|
+
const schemaSample = await table.query().limit(1).toArrow();
|
|
106
|
+
const tsDivisors = buildTimestampDivisors(schemaSample.schema);
|
|
107
|
+
const seen = new Map<string, any>();
|
|
108
|
+
|
|
109
|
+
for (let offset = 0; offset < total; offset += BATCH_SIZE) {
|
|
110
|
+
const arrowTable = await table.query().offset(offset).limit(BATCH_SIZE).toArrow();
|
|
111
|
+
for (const batch of arrowTable.batches) {
|
|
112
|
+
for (let i = 0; i < batch.numRows; i++) {
|
|
113
|
+
const id = columnValue(batch, "id", i) as string;
|
|
114
|
+
const content = columnValue(batch, "content", i) as string;
|
|
115
|
+
const lastAccessed = columnValue(batch, "last_accessed", i);
|
|
116
|
+
const accessedMs = lastAccessed != null ? toEpochMs(lastAccessed, tsDivisors.get("last_accessed")) : null;
|
|
117
|
+
// Deduplicate by ID: prefer most recently accessed, then longest content.
|
|
118
|
+
const existing = seen.get(id);
|
|
119
|
+
if (existing) {
|
|
120
|
+
const existingAccess = existing.last_accessed ?? 0;
|
|
121
|
+
const newAccess = accessedMs ?? 0;
|
|
122
|
+
if (newAccess < existingAccess) continue;
|
|
123
|
+
if (newAccess === existingAccess && content.length <= existing.content.length) continue;
|
|
124
|
+
}
|
|
125
|
+
seen.set(id, {
|
|
126
|
+
id,
|
|
127
|
+
content,
|
|
128
|
+
metadata: columnValue(batch, "metadata", i) ?? "{}",
|
|
129
|
+
vector: toFloatArray(columnValue(batch, "vector", i)),
|
|
130
|
+
created_at: toEpochMs(columnValue(batch, "created_at", i), tsDivisors.get("created_at")),
|
|
131
|
+
updated_at: toEpochMs(columnValue(batch, "updated_at", i), tsDivisors.get("updated_at")),
|
|
132
|
+
last_accessed: accessedMs,
|
|
133
|
+
superseded_by: columnValue(batch, "superseded_by", i) ?? null,
|
|
134
|
+
usefulness: columnValue(batch, "usefulness", i) ?? 0,
|
|
135
|
+
access_count: columnValue(batch, "access_count", i) ?? 0,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
result.memories = [...seen.values()];
|
|
141
|
+
console.error(` ${result.memories.length} unique memories read (${total} rows scanned)`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (tableNames.includes("conversation_history")) {
|
|
145
|
+
const table = await db.openTable("conversation_history");
|
|
146
|
+
const total = await table.countRows();
|
|
147
|
+
console.error(`Reading ${total} conversation chunks...`);
|
|
148
|
+
|
|
149
|
+
const schemaSample = await table.query().limit(1).toArrow();
|
|
150
|
+
const tsDivisors = buildTimestampDivisors(schemaSample.schema);
|
|
151
|
+
const seen = new Map<string, any>();
|
|
152
|
+
|
|
153
|
+
for (let offset = 0; offset < total; offset += BATCH_SIZE) {
|
|
154
|
+
const arrowTable = await table.query().offset(offset).limit(BATCH_SIZE).toArrow();
|
|
155
|
+
for (const batch of arrowTable.batches) {
|
|
156
|
+
for (let i = 0; i < batch.numRows; i++) {
|
|
157
|
+
const id = columnValue(batch, "id", i) as string;
|
|
158
|
+
const content = columnValue(batch, "content", i) as string;
|
|
159
|
+
const existing = seen.get(id);
|
|
160
|
+
if (existing && existing.content.length >= content.length) continue;
|
|
161
|
+
seen.set(id, {
|
|
162
|
+
id,
|
|
163
|
+
content,
|
|
164
|
+
metadata: columnValue(batch, "metadata", i) ?? "{}",
|
|
165
|
+
vector: toFloatArray(columnValue(batch, "vector", i)),
|
|
166
|
+
created_at: toEpochMs(columnValue(batch, "created_at", i), tsDivisors.get("created_at")),
|
|
167
|
+
session_id: columnValue(batch, "session_id", i),
|
|
168
|
+
role: columnValue(batch, "role", i),
|
|
169
|
+
message_index_start: columnValue(batch, "message_index_start", i) ?? 0,
|
|
170
|
+
message_index_end: columnValue(batch, "message_index_end", i) ?? 0,
|
|
171
|
+
project: columnValue(batch, "project", i) ?? "",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
result.conversations = [...seen.values()];
|
|
177
|
+
console.error(` ${result.conversations.length} unique conversation chunks read (${total} rows scanned)`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await db.close?.();
|
|
181
|
+
process.stdout.write(JSON.stringify(result));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warmup script to pre-download ML models and verify dependencies
|
|
5
|
+
* This runs during installation to ensure everything is ready to use
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { config } from "../server/config/index";
|
|
9
|
+
import { EmbeddingsService } from "../server/core/embeddings.service";
|
|
10
|
+
|
|
11
|
+
async function warmup(): Promise<void> {
|
|
12
|
+
console.log("🔥 Warming up vector-memory-mcp...");
|
|
13
|
+
console.log();
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Check native dependencies
|
|
17
|
+
console.log("✓ Checking native dependencies...");
|
|
18
|
+
try {
|
|
19
|
+
await import("onnxruntime-node");
|
|
20
|
+
console.log(" ✓ onnxruntime-node loaded");
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error(" ✗ onnxruntime-node failed:", (e as Error).message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log();
|
|
27
|
+
|
|
28
|
+
// Initialize embeddings service to download model
|
|
29
|
+
console.log("📥 Downloading ML model (this may take a minute)...");
|
|
30
|
+
console.log(` Model: ${config.embeddingModel}`);
|
|
31
|
+
console.log();
|
|
32
|
+
|
|
33
|
+
const embeddings = new EmbeddingsService(
|
|
34
|
+
config.embeddingModel,
|
|
35
|
+
config.embeddingDimension
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Trigger model download by generating a test embedding
|
|
39
|
+
const startTime = Date.now();
|
|
40
|
+
await embeddings.embed("warmup test");
|
|
41
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
42
|
+
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(`✅ Warmup complete! (${duration}s)`);
|
|
45
|
+
console.log();
|
|
46
|
+
console.log("Ready to use! Configure your MCP client and restart to get started.");
|
|
47
|
+
console.log();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error();
|
|
50
|
+
console.error("❌ Warmup failed:", error);
|
|
51
|
+
console.error();
|
|
52
|
+
console.error("This is not a critical error - the server will download models on first run.");
|
|
53
|
+
console.error("You can try running 'vector-memory-mcp warmup' manually later.");
|
|
54
|
+
process.exit(0); // Exit successfully to not block installation
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Only run if this is the main module
|
|
59
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
60
|
+
warmup();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export { warmup };
|
package/server/config/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import arg from "arg";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { isAbsolute, join } from "path";
|
|
4
|
+
import { normalizeProject } from "../core/project";
|
|
4
5
|
import packageJson from "../../package.json" with { type: "json" };
|
|
5
6
|
|
|
6
7
|
export const VERSION = packageJson.version;
|
|
@@ -23,6 +24,8 @@ export interface ConversationHistoryConfig {
|
|
|
23
24
|
|
|
24
25
|
export interface Config {
|
|
25
26
|
dbPath: string;
|
|
27
|
+
/** Canonical project identifier — normalized absolute path of the project root. */
|
|
28
|
+
project: string;
|
|
26
29
|
embeddingModel: string;
|
|
27
30
|
embeddingDimension: number;
|
|
28
31
|
httpPort: number;
|
|
@@ -35,6 +38,7 @@ export interface Config {
|
|
|
35
38
|
|
|
36
39
|
export interface ConfigOverrides {
|
|
37
40
|
dbPath?: string;
|
|
41
|
+
project?: string;
|
|
38
42
|
httpPort?: number;
|
|
39
43
|
enableHttp?: boolean;
|
|
40
44
|
pluginMode?: boolean;
|
|
@@ -44,8 +48,10 @@ export interface ConfigOverrides {
|
|
|
44
48
|
historyWeight?: number;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
// Defaults
|
|
48
|
-
|
|
51
|
+
// Defaults — single global store shared by all projects. Memories are tagged
|
|
52
|
+
// with the project (cwd) they came from. Use --db-file / VECTOR_MEMORY_DB_PATH
|
|
53
|
+
// for a repo-local database.
|
|
54
|
+
const DEFAULT_DB_PATH = join(homedir(), ".vector-memory", "memories.db");
|
|
49
55
|
const DEFAULT_EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
50
56
|
const DEFAULT_EMBEDDING_DIMENSION = 384;
|
|
51
57
|
const DEFAULT_HTTP_PORT = 3271;
|
|
@@ -69,6 +75,7 @@ export function loadConfig(overrides: ConfigOverrides = {}): Config {
|
|
|
69
75
|
?? process.env.VECTOR_MEMORY_DB_PATH
|
|
70
76
|
?? DEFAULT_DB_PATH
|
|
71
77
|
),
|
|
78
|
+
project: normalizeProject(overrides.project ?? process.cwd()),
|
|
72
79
|
embeddingModel: DEFAULT_EMBEDDING_MODEL,
|
|
73
80
|
embeddingDimension: DEFAULT_EMBEDDING_DIMENSION,
|
|
74
81
|
httpPort:
|
|
@@ -99,6 +106,7 @@ export function parseCliArgs(argv: string[]): ConfigOverrides {
|
|
|
99
106
|
const args = arg(
|
|
100
107
|
{
|
|
101
108
|
"--db-file": String,
|
|
109
|
+
"--project": String,
|
|
102
110
|
"--port": Number,
|
|
103
111
|
"--no-http": Boolean,
|
|
104
112
|
"--plugin": Boolean,
|
|
@@ -115,6 +123,7 @@ export function parseCliArgs(argv: string[]): ConfigOverrides {
|
|
|
115
123
|
|
|
116
124
|
return {
|
|
117
125
|
dbPath: args["--db-file"],
|
|
126
|
+
project: args["--project"],
|
|
118
127
|
httpPort: args["--port"],
|
|
119
128
|
enableHttp: args["--no-http"] ? false : undefined,
|
|
120
129
|
pluginMode: args["--plugin"] ?? undefined,
|
|
@@ -1,24 +1,180 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeSync,
|
|
13
|
+
} from "fs";
|
|
3
14
|
import { dirname } from "path";
|
|
4
15
|
import { removeVec0Tables, runMigrations } from "./migrations";
|
|
5
16
|
|
|
17
|
+
/** How long a starting process will wait for a concurrent vec0 cleanup to finish. */
|
|
18
|
+
const CLEANUP_LOCK_WAIT_MS = 15_000;
|
|
19
|
+
const CLEANUP_LOCK_POLL_MS = 250;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check (read-only) whether the database still contains legacy vec0 virtual
|
|
23
|
+
* table entries. The destructive cleanup must only run when this is true —
|
|
24
|
+
* it rewrites sqlite_master via the sqlite3 CLI, which is unsafe while other
|
|
25
|
+
* connections hold the database open.
|
|
26
|
+
*/
|
|
27
|
+
function hasVec0Tables(dbPath: string): boolean {
|
|
28
|
+
let db: Database | null = null;
|
|
29
|
+
try {
|
|
30
|
+
db = new Database(dbPath, { readonly: true });
|
|
31
|
+
const row = db
|
|
32
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE sql LIKE '%vec0%' LIMIT 1")
|
|
33
|
+
.get();
|
|
34
|
+
return row != null;
|
|
35
|
+
} catch {
|
|
36
|
+
// Unreadable / empty file — let the normal open path surface real errors
|
|
37
|
+
return false;
|
|
38
|
+
} finally {
|
|
39
|
+
db?.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lockPid(lockPath: string): number | null {
|
|
44
|
+
try {
|
|
45
|
+
const pid = parseInt(readFileSync(lockPath, "utf8"), 10);
|
|
46
|
+
return Number.isFinite(pid) ? pid : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isProcessAlive(pid: number): boolean {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the vec0 cleanup under an exclusive advisory lock so that N processes
|
|
63
|
+
* starting against the same database never run the sqlite_master rewrite
|
|
64
|
+
* concurrently. Losers wait for the winner, then re-probe (the winner's
|
|
65
|
+
* cleanup makes the probe false).
|
|
66
|
+
*/
|
|
67
|
+
function guardedVec0Cleanup(dbPath: string): void {
|
|
68
|
+
const lockPath = `${dbPath}.vec0-cleanup.lock`;
|
|
69
|
+
const deadline = Date.now() + CLEANUP_LOCK_WAIT_MS;
|
|
70
|
+
|
|
71
|
+
while (true) {
|
|
72
|
+
try {
|
|
73
|
+
const fd = openSync(lockPath, "wx");
|
|
74
|
+
try {
|
|
75
|
+
writeSync(fd, String(process.pid));
|
|
76
|
+
// Re-probe under the lock — another process may have cleaned up
|
|
77
|
+
// between our first probe and lock acquisition.
|
|
78
|
+
if (hasVec0Tables(dbPath)) {
|
|
79
|
+
removeVec0Tables(dbPath);
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
closeSync(fd);
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(lockPath);
|
|
85
|
+
} catch {
|
|
86
|
+
// already gone — fine
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
92
|
+
|
|
93
|
+
// Lock held — if the holder died, clear the stale lock and retry.
|
|
94
|
+
const pid = lockPid(lockPath);
|
|
95
|
+
if (pid !== null && !isProcessAlive(pid)) {
|
|
96
|
+
try {
|
|
97
|
+
unlinkSync(lockPath);
|
|
98
|
+
} catch {
|
|
99
|
+
// raced with another process clearing it — fine
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (Date.now() > deadline) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`vec0 cleanup lock held too long (${lockPath}) — remove it manually if no other server is starting`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
Bun.sleepSync(CLEANUP_LOCK_POLL_MS);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
6
114
|
/**
|
|
7
115
|
* Open (or create) a SQLite database at the given path
|
|
8
116
|
* and run schema migrations.
|
|
117
|
+
*
|
|
118
|
+
* Safe for multiple concurrent processes sharing one database file:
|
|
119
|
+
* the legacy vec0 cleanup only runs when a read-only probe finds vec0
|
|
120
|
+
* entries (never for healthy databases) and is serialized by an exclusive
|
|
121
|
+
* lock; migrations are user_version-gated inside an immediate transaction.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* Legacy LanceDB installs used the db path as a *directory*
|
|
125
|
+
* (e.g. ~/.vector-memory/memories.db/memories.lance). SQLite needs a file
|
|
126
|
+
* there, so move the directory aside instead of dying with SQLITE_CANTOPEN.
|
|
127
|
+
* Returns the path the directory was moved to, or null if nothing was done.
|
|
9
128
|
*/
|
|
129
|
+
export function relocateLegacyLanceDir(dbPath: string): string | null {
|
|
130
|
+
if (!existsSync(dbPath) || !statSync(dbPath).isDirectory()) return null;
|
|
131
|
+
|
|
132
|
+
const entries = readdirSync(dbPath);
|
|
133
|
+
const isLance = entries.some(
|
|
134
|
+
(e) => e.endsWith(".lance") || e === "_versions" || e === "_indices",
|
|
135
|
+
);
|
|
136
|
+
if (!isLance) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Database path ${dbPath} is a directory, not a SQLite file. ` +
|
|
139
|
+
"Move or remove it, or point --db-file at a different location.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let target = `${dbPath}.lancedb`;
|
|
144
|
+
for (let n = 1; existsSync(target); n++) {
|
|
145
|
+
target = `${dbPath}.lancedb.${n}`;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
renameSync(dbPath, target);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// A concurrently starting process won the rename — nothing left to move.
|
|
151
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
console.error(
|
|
155
|
+
`[vector-memory-mcp] Found a legacy LanceDB store at ${dbPath} — ` +
|
|
156
|
+
`moved it to ${target}. A fresh SQLite database will be created.`,
|
|
157
|
+
);
|
|
158
|
+
return target;
|
|
159
|
+
}
|
|
160
|
+
|
|
10
161
|
export function connectToDatabase(dbPath: string): Database {
|
|
11
162
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
163
|
+
relocateLegacyLanceDir(dbPath);
|
|
12
164
|
|
|
13
165
|
// Remove orphaned vec0 virtual table entries before bun:sqlite opens the
|
|
14
166
|
// database. bun:sqlite cannot modify sqlite_master, so this uses the
|
|
15
|
-
// sqlite3 CLI
|
|
16
|
-
if (existsSync(dbPath)) {
|
|
17
|
-
|
|
167
|
+
// sqlite3 CLI — gated behind a read-only probe and an exclusive lock.
|
|
168
|
+
if (existsSync(dbPath) && hasVec0Tables(dbPath)) {
|
|
169
|
+
guardedVec0Cleanup(dbPath);
|
|
18
170
|
}
|
|
19
171
|
|
|
20
172
|
const db = new Database(dbPath);
|
|
21
173
|
|
|
174
|
+
// busy_timeout FIRST: it is per-connection and needs no lock, while the
|
|
175
|
+
// WAL switch takes an exclusive lock — without the timeout, concurrent
|
|
176
|
+
// processes opening a fresh db race it and fail with SQLITE_BUSY.
|
|
177
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
22
178
|
// WAL mode for concurrent read performance
|
|
23
179
|
db.exec("PRAGMA journal_mode=WAL");
|
|
24
180
|
|