context-vault 2.3.0 → 2.4.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felix Hellstrom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/cli.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  unlinkSync,
21
21
  } from "node:fs";
22
22
  import { join, resolve, dirname } from "node:path";
23
- import { homedir } from "node:os";
23
+ import { homedir, platform } from "node:os";
24
24
  import { execSync, fork } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
26
 
@@ -73,6 +73,36 @@ function prompt(question, defaultVal) {
73
73
  });
74
74
  }
75
75
 
76
+ // ─── Platform Helpers ────────────────────────────────────────────────────────
77
+
78
+ const PLATFORM = platform();
79
+
80
+ /** Get the platform-specific application data directory */
81
+ function appDataDir() {
82
+ switch (PLATFORM) {
83
+ case "win32":
84
+ return process.env.APPDATA || join(HOME, "AppData", "Roaming");
85
+ case "darwin":
86
+ return join(HOME, "Library", "Application Support");
87
+ case "linux":
88
+ default:
89
+ return process.env.XDG_CONFIG_HOME || join(HOME, ".config");
90
+ }
91
+ }
92
+
93
+ /** Get the platform-specific VS Code extensions directory */
94
+ function vscodeDataDir() {
95
+ switch (PLATFORM) {
96
+ case "win32":
97
+ return join(appDataDir(), "Code", "User", "globalStorage");
98
+ case "darwin":
99
+ return join(appDataDir(), "Code", "User", "globalStorage");
100
+ case "linux":
101
+ default:
102
+ return join(HOME, ".config", "Code", "User", "globalStorage");
103
+ }
104
+ }
105
+
76
106
  // ─── Tool Detection ──────────────────────────────────────────────────────────
77
107
 
78
108
  const TOOLS = [
@@ -81,7 +111,8 @@ const TOOLS = [
81
111
  name: "Claude Code",
82
112
  detect: () => {
83
113
  try {
84
- execSync("which claude", { stdio: "pipe" });
114
+ const cmd = PLATFORM === "win32" ? "where claude" : "which claude";
115
+ execSync(cmd, { stdio: "pipe" });
85
116
  return true;
86
117
  } catch {
87
118
  return false;
@@ -92,16 +123,9 @@ const TOOLS = [
92
123
  {
93
124
  id: "claude-desktop",
94
125
  name: "Claude Desktop",
95
- detect: () =>
96
- existsSync(join(HOME, "Library", "Application Support", "Claude")),
126
+ detect: () => existsSync(join(appDataDir(), "Claude")),
97
127
  configType: "json",
98
- configPath: join(
99
- HOME,
100
- "Library",
101
- "Application Support",
102
- "Claude",
103
- "claude_desktop_config.json"
104
- ),
128
+ configPath: join(appDataDir(), "Claude", "claude_desktop_config.json"),
105
129
  configKey: "mcpServers",
106
130
  },
107
131
  {
@@ -124,26 +148,10 @@ const TOOLS = [
124
148
  id: "cline",
125
149
  name: "Cline (VS Code)",
126
150
  detect: () =>
127
- existsSync(
128
- join(
129
- HOME,
130
- "Library",
131
- "Application Support",
132
- "Code",
133
- "User",
134
- "globalStorage",
135
- "saoudrizwan.claude-dev",
136
- "settings"
137
- )
138
- ),
151
+ existsSync(join(vscodeDataDir(), "saoudrizwan.claude-dev", "settings")),
139
152
  configType: "json",
140
153
  configPath: join(
141
- HOME,
142
- "Library",
143
- "Application Support",
144
- "Code",
145
- "User",
146
- "globalStorage",
154
+ vscodeDataDir(),
147
155
  "saoudrizwan.claude-dev",
148
156
  "settings",
149
157
  "cline_mcp_settings.json"
@@ -672,7 +680,7 @@ async function runReindex() {
672
680
  process.exit(1);
673
681
  }
674
682
 
675
- const db = initDatabase(config.dbPath);
683
+ const db = await initDatabase(config.dbPath);
676
684
  const stmts = prepareStatements(db);
677
685
  const ctx = {
678
686
  db,
@@ -701,7 +709,7 @@ async function runStatus() {
701
709
  const { gatherVaultStatus } = await import("@context-vault/core/core/status");
702
710
 
703
711
  const config = resolveConfig();
704
- const db = initDatabase(config.dbPath);
712
+ const db = await initDatabase(config.dbPath);
705
713
 
706
714
  const status = gatherVaultStatus({ db, config });
707
715
 
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felix Hellstrom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -25,7 +25,7 @@
25
25
  "license": "MIT",
26
26
  "engines": { "node": ">=20" },
27
27
  "author": "Felix Hellstrom",
28
- "repository": { "type": "git", "url": "https://github.com/fellanH/context-mcp.git", "directory": "packages/core" },
28
+ "repository": { "type": "git", "url": "git+https://github.com/fellanH/context-mcp.git", "directory": "packages/core" },
29
29
  "homepage": "https://github.com/fellanH/context-mcp",
30
30
  "dependencies": {
31
31
  "@huggingface/transformers": "^3.0.0",
@@ -15,7 +15,7 @@ import { parseFrontmatter, formatFrontmatter } from "../core/frontmatter.js";
15
15
  import { formatBody } from "./formatters.js";
16
16
  import { writeEntryFile } from "./file-ops.js";
17
17
 
18
- export function writeEntry(ctx, { kind, title, body, meta, tags, source, folder, identity_key, expires_at }) {
18
+ export function writeEntry(ctx, { kind, title, body, meta, tags, source, folder, identity_key, expires_at, userId }) {
19
19
  if (!kind || typeof kind !== "string") {
20
20
  throw new Error("writeEntry: kind is required (non-empty string)");
21
21
  }
@@ -59,7 +59,7 @@ export function writeEntry(ctx, { kind, title, body, meta, tags, source, folder,
59
59
  category, identity_key, expires_at,
60
60
  });
61
61
 
62
- return { id, filePath, kind, category, title, body, meta, tags, source, createdAt, identity_key, expires_at };
62
+ return { id, filePath, kind, category, title, body, meta, tags, source, createdAt, identity_key, expires_at, userId: userId || null };
63
63
  }
64
64
 
65
65
  /**
@@ -123,6 +123,7 @@ export function updateEntryFile(ctx, existing, updates) {
123
123
  createdAt: fmMeta.created || existing.created_at,
124
124
  identity_key: existing.identity_key,
125
125
  expires_at,
126
+ userId: existing.user_id || null,
126
127
  };
127
128
  }
128
129
 
@@ -19,6 +19,7 @@ export function parseArgs(argv) {
19
19
  else if (argv[i] === "--data-dir" && argv[i + 1]) args.dataDir = argv[++i];
20
20
  else if (argv[i] === "--db-path" && argv[i + 1]) args.dbPath = argv[++i];
21
21
  else if (argv[i] === "--dev-dir" && argv[i + 1]) args.devDir = argv[++i];
22
+ else if (argv[i] === "--event-decay-days" && argv[i + 1]) args.eventDecayDays = Number(argv[++i]);
22
23
  }
23
24
  return args;
24
25
  }
@@ -34,6 +35,7 @@ export function resolveConfig() {
34
35
  dataDir,
35
36
  dbPath: join(dataDir, "vault.db"),
36
37
  devDir: join(HOME, "dev"),
38
+ eventDecayDays: 30,
37
39
  resolvedFrom: "defaults",
38
40
  };
39
41
 
@@ -46,6 +48,7 @@ export function resolveConfig() {
46
48
  if (fc.dataDir) { config.dataDir = fc.dataDir; config.dbPath = join(resolve(fc.dataDir), "vault.db"); }
47
49
  if (fc.dbPath) config.dbPath = fc.dbPath;
48
50
  if (fc.devDir) config.devDir = fc.devDir;
51
+ if (fc.eventDecayDays) config.eventDecayDays = fc.eventDecayDays;
49
52
  config.resolvedFrom = "config file";
50
53
  } catch (e) {
51
54
  throw new Error(`[context-mcp] Invalid config at ${configPath}: ${e.message}`);
@@ -57,11 +60,13 @@ export function resolveConfig() {
57
60
  if (process.env.CONTEXT_MCP_VAULT_DIR) { config.vaultDir = process.env.CONTEXT_MCP_VAULT_DIR; config.resolvedFrom = "env"; }
58
61
  if (process.env.CONTEXT_MCP_DB_PATH) { config.dbPath = process.env.CONTEXT_MCP_DB_PATH; config.resolvedFrom = "env"; }
59
62
  if (process.env.CONTEXT_MCP_DEV_DIR) { config.devDir = process.env.CONTEXT_MCP_DEV_DIR; config.resolvedFrom = "env"; }
63
+ if (process.env.CONTEXT_MCP_EVENT_DECAY_DAYS) { config.eventDecayDays = Number(process.env.CONTEXT_MCP_EVENT_DECAY_DAYS); config.resolvedFrom = "env"; }
60
64
 
61
65
  // 4. CLI arg overrides (highest priority)
62
66
  if (cliArgs.vaultDir) { config.vaultDir = cliArgs.vaultDir; config.resolvedFrom = "CLI args"; }
63
67
  if (cliArgs.dbPath) { config.dbPath = cliArgs.dbPath; config.resolvedFrom = "CLI args"; }
64
68
  if (cliArgs.devDir) { config.devDir = cliArgs.devDir; config.resolvedFrom = "CLI args"; }
69
+ if (cliArgs.eventDecayDays) { config.eventDecayDays = cliArgs.eventDecayDays; config.resolvedFrom = "CLI args"; }
65
70
 
66
71
  // Resolve all paths to absolute
67
72
  config.vaultDir = resolve(config.vaultDir);
@@ -5,17 +5,26 @@
5
5
  import { existsSync, readdirSync, statSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { walkDir } from "./files.js";
8
+ import { isEmbedAvailable } from "../index/embed.js";
8
9
 
9
10
  /**
10
11
  * Gather raw vault status data for formatting by consumers.
11
12
  *
12
13
  * @param {{ db, config }} ctx
14
+ * @param {{ userId?: string }} opts — optional userId for per-user stats
13
15
  * @returns {{ fileCount, subdirs, kindCounts, dbSize, stalePaths, resolvedFrom, embeddingStatus, errors }}
14
16
  */
15
- export function gatherVaultStatus(ctx) {
17
+ export function gatherVaultStatus(ctx, opts = {}) {
16
18
  const { db, config } = ctx;
19
+ const { userId } = opts;
17
20
  const errors = [];
18
21
 
22
+ // Build user filter clause for DB queries
23
+ const hasUser = userId !== undefined;
24
+ const userWhere = hasUser ? "WHERE user_id = ?" : "";
25
+ const userAnd = hasUser ? "AND user_id = ?" : "";
26
+ const userParams = hasUser ? [userId] : [];
27
+
19
28
  // Count files in vault subdirs (auto-discover)
20
29
  let fileCount = 0;
21
30
  const subdirs = [];
@@ -37,7 +46,7 @@ export function gatherVaultStatus(ctx) {
37
46
  // Count DB rows by kind
38
47
  let kindCounts = [];
39
48
  try {
40
- kindCounts = db.prepare("SELECT kind, COUNT(*) as c FROM vault GROUP BY kind").all();
49
+ kindCounts = db.prepare(`SELECT kind, COUNT(*) as c FROM vault ${userWhere} GROUP BY kind`).all(...userParams);
41
50
  } catch (e) {
42
51
  errors.push(`Kind count query failed: ${e.message}`);
43
52
  }
@@ -45,7 +54,7 @@ export function gatherVaultStatus(ctx) {
45
54
  // Count DB rows by category
46
55
  let categoryCounts = [];
47
56
  try {
48
- categoryCounts = db.prepare("SELECT category, COUNT(*) as c FROM vault GROUP BY category").all();
57
+ categoryCounts = db.prepare(`SELECT category, COUNT(*) as c FROM vault ${userWhere} GROUP BY category`).all(...userParams);
49
58
  } catch (e) {
50
59
  errors.push(`Category count query failed: ${e.message}`);
51
60
  }
@@ -69,26 +78,39 @@ export function gatherVaultStatus(ctx) {
69
78
  let staleCount = 0;
70
79
  try {
71
80
  const result = db.prepare(
72
- "SELECT COUNT(*) as c FROM vault WHERE file_path NOT LIKE ? || '%'"
73
- ).get(config.vaultDir);
81
+ `SELECT COUNT(*) as c FROM vault WHERE file_path NOT LIKE ? || '%' ${userAnd}`
82
+ ).get(config.vaultDir, ...userParams);
74
83
  staleCount = result.c;
75
84
  stalePaths = staleCount > 0;
76
85
  } catch (e) {
77
86
  errors.push(`Stale path check failed: ${e.message}`);
78
87
  }
79
88
 
89
+ // Count expired entries pending pruning
90
+ let expiredCount = 0;
91
+ try {
92
+ expiredCount = db.prepare(
93
+ `SELECT COUNT(*) as c FROM vault WHERE expires_at IS NOT NULL AND expires_at <= datetime('now') ${userAnd}`
94
+ ).get(...userParams).c;
95
+ } catch (e) {
96
+ errors.push(`Expired count failed: ${e.message}`);
97
+ }
98
+
80
99
  // Embedding/vector status
81
100
  let embeddingStatus = null;
82
101
  try {
83
- const total = db.prepare("SELECT COUNT(*) as c FROM vault").get().c;
102
+ const total = db.prepare(`SELECT COUNT(*) as c FROM vault ${userWhere}`).get(...userParams).c;
84
103
  const indexed = db.prepare(
85
- "SELECT COUNT(*) as c FROM vault WHERE rowid IN (SELECT rowid FROM vault_vec)"
86
- ).get().c;
104
+ `SELECT COUNT(*) as c FROM vault WHERE rowid IN (SELECT rowid FROM vault_vec) ${userAnd}`
105
+ ).get(...userParams).c;
87
106
  embeddingStatus = { indexed, total, missing: total - indexed };
88
107
  } catch (e) {
89
108
  errors.push(`Embedding status check failed: ${e.message}`);
90
109
  }
91
110
 
111
+ // Embedding model availability
112
+ const embedModelAvailable = isEmbedAvailable();
113
+
92
114
  return {
93
115
  fileCount,
94
116
  subdirs,
@@ -98,7 +120,9 @@ export function gatherVaultStatus(ctx) {
98
120
  dbSizeBytes,
99
121
  stalePaths,
100
122
  staleCount,
123
+ expiredCount,
101
124
  embeddingStatus,
125
+ embedModelAvailable,
102
126
  resolvedFrom: config.resolvedFrom,
103
127
  errors,
104
128
  };
@@ -2,32 +2,101 @@
2
2
  * db.js — Database schema, initialization, and prepared statements
3
3
  */
4
4
 
5
- import Database from "better-sqlite3";
6
- import * as sqliteVec from "sqlite-vec";
7
- import { unlinkSync } from "node:fs";
5
+ import { unlinkSync, copyFileSync, existsSync } from "node:fs";
8
6
 
9
- // ─── Schema DDL (v5 — categories) ───────────────────────────────────────────
7
+ // ─── Native Module Error ────────────────────────────────────────────────────
8
+
9
+ export class NativeModuleError extends Error {
10
+ constructor(originalError) {
11
+ const diagnostic = formatNativeModuleError(originalError);
12
+ super(diagnostic);
13
+ this.name = "NativeModuleError";
14
+ this.originalError = originalError;
15
+ }
16
+ }
17
+
18
+ function formatNativeModuleError(err) {
19
+ const msg = err.message || "";
20
+ const versionMatch = msg.match(
21
+ /was compiled against a different Node\.js version using\s+NODE_MODULE_VERSION (\d+)\. This version of Node\.js requires\s+NODE_MODULE_VERSION (\d+)/
22
+ );
23
+
24
+ const lines = [
25
+ `Native module failed to load: ${msg}`,
26
+ "",
27
+ ` Running Node.js: ${process.version} (${process.execPath})`,
28
+ ];
29
+
30
+ if (versionMatch) {
31
+ lines.push(` Module compiled for: NODE_MODULE_VERSION ${versionMatch[1]}`);
32
+ lines.push(` Current runtime: NODE_MODULE_VERSION ${versionMatch[2]}`);
33
+ }
34
+
35
+ lines.push(
36
+ "",
37
+ " Fix: Rebuild native modules for your current Node.js:",
38
+ " npm rebuild better-sqlite3 sqlite-vec",
39
+ "",
40
+ " Or reinstall:",
41
+ " npm install -g context-vault",
42
+ );
43
+
44
+ return lines.join("\n");
45
+ }
46
+
47
+ // ─── Lazy Native Module Loading ─────────────────────────────────────────────
48
+
49
+ let _Database = null;
50
+ let _sqliteVec = null;
51
+
52
+ async function loadNativeModules() {
53
+ if (_Database && _sqliteVec) return { Database: _Database, sqliteVec: _sqliteVec };
54
+
55
+ try {
56
+ const dbMod = await import("better-sqlite3");
57
+ _Database = dbMod.default;
58
+ } catch (e) {
59
+ throw new NativeModuleError(e);
60
+ }
61
+
62
+ try {
63
+ const vecMod = await import("sqlite-vec");
64
+ _sqliteVec = vecMod;
65
+ } catch (e) {
66
+ throw new NativeModuleError(e);
67
+ }
68
+
69
+ return { Database: _Database, sqliteVec: _sqliteVec };
70
+ }
71
+
72
+ // ─── Schema DDL (v6 — multi-tenancy + encryption) ──────────────────────────
10
73
 
11
74
  export const SCHEMA_DDL = `
12
75
  CREATE TABLE IF NOT EXISTS vault (
13
- id TEXT PRIMARY KEY,
14
- kind TEXT NOT NULL,
15
- category TEXT NOT NULL DEFAULT 'knowledge',
16
- title TEXT,
17
- body TEXT NOT NULL,
18
- meta TEXT,
19
- tags TEXT,
20
- source TEXT,
21
- file_path TEXT UNIQUE,
22
- identity_key TEXT,
23
- expires_at TEXT,
24
- created_at TEXT DEFAULT (datetime('now'))
76
+ id TEXT PRIMARY KEY,
77
+ kind TEXT NOT NULL,
78
+ category TEXT NOT NULL DEFAULT 'knowledge',
79
+ title TEXT,
80
+ body TEXT NOT NULL,
81
+ meta TEXT,
82
+ tags TEXT,
83
+ source TEXT,
84
+ file_path TEXT UNIQUE,
85
+ identity_key TEXT,
86
+ expires_at TEXT,
87
+ created_at TEXT DEFAULT (datetime('now')),
88
+ user_id TEXT,
89
+ body_encrypted BLOB,
90
+ title_encrypted BLOB,
91
+ meta_encrypted BLOB,
92
+ iv BLOB
25
93
  );
26
94
 
27
95
  CREATE INDEX IF NOT EXISTS idx_vault_kind ON vault(kind);
28
96
  CREATE INDEX IF NOT EXISTS idx_vault_category ON vault(category);
29
97
  CREATE INDEX IF NOT EXISTS idx_vault_category_created ON vault(category, created_at DESC);
30
- CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(kind, identity_key) WHERE identity_key IS NOT NULL;
98
+ CREATE INDEX IF NOT EXISTS idx_vault_user ON vault(user_id);
99
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(user_id, kind, identity_key) WHERE identity_key IS NOT NULL;
31
100
 
32
101
  -- Single FTS5 table
33
102
  CREATE VIRTUAL TABLE IF NOT EXISTS vault_fts USING fts5(
@@ -57,34 +126,15 @@ export const SCHEMA_DDL = `
57
126
 
58
127
  // ─── Database Init ───────────────────────────────────────────────────────────
59
128
 
60
- export function initDatabase(dbPath) {
61
- const db = new Database(dbPath);
62
- db.pragma("journal_mode = WAL");
63
- db.pragma("foreign_keys = ON");
64
- try {
65
- sqliteVec.load(db);
66
- } catch (e) {
67
- console.error(`[context-mcp] Failed to load sqlite-vec native module.`);
68
- console.error(`[context-mcp] This usually means prebuilt binaries aren't available for your platform.`);
69
- console.error(`[context-mcp] Try: npm rebuild sqlite-vec`);
70
- console.error(`[context-mcp] Error: ${e.message}`);
71
- throw e;
72
- }
73
-
74
- const version = db.pragma("user_version", { simple: true });
129
+ export async function initDatabase(dbPath) {
130
+ const { Database, sqliteVec } = await loadNativeModules();
75
131
 
76
- // Enforce fresh-DB-only — old schemas get a full rebuild
77
- if (version > 0 && version < 5) {
78
- console.error(`[context-mcp] Schema v${version} is outdated. Rebuilding database...`);
79
- db.close();
80
- unlinkSync(dbPath);
81
- try { unlinkSync(dbPath + "-wal"); } catch {}
82
- try { unlinkSync(dbPath + "-shm"); } catch {}
83
- const freshDb = new Database(dbPath);
84
- freshDb.pragma("journal_mode = WAL");
85
- freshDb.pragma("foreign_keys = ON");
132
+ function createDb(path) {
133
+ const db = new Database(path);
134
+ db.pragma("journal_mode = WAL");
135
+ db.pragma("foreign_keys = ON");
86
136
  try {
87
- sqliteVec.load(freshDb);
137
+ sqliteVec.load(db);
88
138
  } catch (e) {
89
139
  console.error(`[context-mcp] Failed to load sqlite-vec native module.`);
90
140
  console.error(`[context-mcp] This usually means prebuilt binaries aren't available for your platform.`);
@@ -92,14 +142,61 @@ export function initDatabase(dbPath) {
92
142
  console.error(`[context-mcp] Error: ${e.message}`);
93
143
  throw e;
94
144
  }
145
+ return db;
146
+ }
147
+
148
+ const db = createDb(dbPath);
149
+ const version = db.pragma("user_version", { simple: true });
150
+
151
+ // Enforce fresh-DB-only — old schemas get a full rebuild (with backup)
152
+ if (version > 0 && version < 5) {
153
+ console.error(`[context-mcp] Schema v${version} is outdated. Rebuilding database...`);
154
+
155
+ // Backup old DB before destroying it
156
+ const backupPath = `${dbPath}.v${version}.backup`;
157
+ try {
158
+ db.close();
159
+ if (existsSync(dbPath)) {
160
+ copyFileSync(dbPath, backupPath);
161
+ console.error(`[context-mcp] Backed up old database to: ${backupPath}`);
162
+ }
163
+ } catch (backupErr) {
164
+ console.error(`[context-mcp] Warning: could not backup old database: ${backupErr.message}`);
165
+ }
166
+
167
+ unlinkSync(dbPath);
168
+ try { unlinkSync(dbPath + "-wal"); } catch {}
169
+ try { unlinkSync(dbPath + "-shm"); } catch {}
170
+
171
+ const freshDb = createDb(dbPath);
95
172
  freshDb.exec(SCHEMA_DDL);
96
- freshDb.pragma("user_version = 5");
173
+ freshDb.pragma("user_version = 6");
97
174
  return freshDb;
98
175
  }
99
176
 
100
177
  if (version < 5) {
101
178
  db.exec(SCHEMA_DDL);
102
- db.pragma("user_version = 5");
179
+ db.pragma("user_version = 6");
180
+ } else if (version === 5) {
181
+ // v5 -> v6 migration: add multi-tenancy + encryption columns
182
+ // Wrapped in transaction with duplicate-column guards for idempotent retry
183
+ const migrate = db.transaction(() => {
184
+ const addColumnSafe = (sql) => {
185
+ try { db.exec(sql); } catch (e) {
186
+ if (!e.message.includes("duplicate column")) throw e;
187
+ }
188
+ };
189
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN user_id TEXT`);
190
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN body_encrypted BLOB`);
191
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN title_encrypted BLOB`);
192
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN meta_encrypted BLOB`);
193
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN iv BLOB`);
194
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_user ON vault(user_id)`);
195
+ db.exec(`DROP INDEX IF EXISTS idx_vault_identity`);
196
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(user_id, kind, identity_key) WHERE identity_key IS NOT NULL`);
197
+ db.pragma("user_version = 6");
198
+ });
199
+ migrate();
103
200
  }
104
201
 
105
202
  return db;
@@ -108,18 +205,27 @@ export function initDatabase(dbPath) {
108
205
  // ─── Prepared Statements Factory ─────────────────────────────────────────────
109
206
 
110
207
  export function prepareStatements(db) {
111
- return {
112
- insertEntry: db.prepare(`INSERT INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
113
- updateEntry: db.prepare(`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at = ? WHERE file_path = ?`),
114
- deleteEntry: db.prepare(`DELETE FROM vault WHERE id = ?`),
115
- getRowid: db.prepare(`SELECT rowid FROM vault WHERE id = ?`),
116
- getRowidByPath: db.prepare(`SELECT rowid FROM vault WHERE file_path = ?`),
117
- getEntryById: db.prepare(`SELECT * FROM vault WHERE id = ?`),
118
- getByIdentityKey: db.prepare(`SELECT * FROM vault WHERE kind = ? AND identity_key = ?`),
119
- upsertByIdentityKey: db.prepare(`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at = ? WHERE kind = ? AND identity_key = ?`),
120
- insertVecStmt: db.prepare(`INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`),
121
- deleteVecStmt: db.prepare(`DELETE FROM vault_vec WHERE rowid = ?`),
122
- };
208
+ try {
209
+ return {
210
+ insertEntry: db.prepare(`INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
211
+ insertEntryEncrypted: db.prepare(`INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, body_encrypted, title_encrypted, meta_encrypted, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
212
+ updateEntry: db.prepare(`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at = ? WHERE file_path = ?`),
213
+ deleteEntry: db.prepare(`DELETE FROM vault WHERE id = ?`),
214
+ getRowid: db.prepare(`SELECT rowid FROM vault WHERE id = ?`),
215
+ getRowidByPath: db.prepare(`SELECT rowid FROM vault WHERE file_path = ?`),
216
+ getEntryById: db.prepare(`SELECT * FROM vault WHERE id = ?`),
217
+ getByIdentityKey: db.prepare(`SELECT * FROM vault WHERE kind = ? AND identity_key = ? AND user_id IS ?`),
218
+ upsertByIdentityKey: db.prepare(`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at = ? WHERE kind = ? AND identity_key = ? AND user_id IS ?`),
219
+ insertVecStmt: db.prepare(`INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`),
220
+ deleteVecStmt: db.prepare(`DELETE FROM vault_vec WHERE rowid = ?`),
221
+ };
222
+ } catch (e) {
223
+ throw new Error(
224
+ `Failed to prepare database statements. The database may be corrupted.\n` +
225
+ `Try deleting and rebuilding: rm "${db.name}" && context-mcp reindex\n` +
226
+ `Original error: ${e.message}`
227
+ );
228
+ }
123
229
  }
124
230
 
125
231
  // ─── Vector Helpers (parameterized rowid via cached statements) ──────────────