@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 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` | `.vector-memory/memories.db` | Database location (relative to cwd) |
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.4.4",
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 };
@@ -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 - always use repo-local .vector-memory folder
48
- const DEFAULT_DB_PATH = join(process.cwd(), ".vector-memory", "memories.db");
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 { existsSync, mkdirSync } from "fs";
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 while no other connection holds a lock.
16
- if (existsSync(dbPath)) {
17
- removeVec0Tables(dbPath);
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