@context-vault/core 2.8.3

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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * files.js — Shared file system utilities used across layers
3
+ *
4
+ * ULID generation, slugify, kind/dir mapping, directory walking.
5
+ */
6
+
7
+ import { readdirSync } from "node:fs";
8
+ import { join, resolve, sep } from "node:path";
9
+ import { categoryDirFor } from "./categories.js";
10
+
11
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
12
+
13
+ export function ulid() {
14
+ const now = Date.now();
15
+ let ts = "";
16
+ let t = now;
17
+ for (let i = 0; i < 10; i++) {
18
+ ts = CROCKFORD[t & 31] + ts;
19
+ t = Math.floor(t / 32);
20
+ }
21
+ let rand = "";
22
+ for (let i = 0; i < 16; i++) {
23
+ rand += CROCKFORD[Math.floor(Math.random() * 32)];
24
+ }
25
+ return ts + rand;
26
+ }
27
+
28
+ export function slugify(text, maxLen = 60) {
29
+ let slug = text
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, "-")
32
+ .replace(/^-+|-+$/g, "");
33
+ if (slug.length > maxLen) {
34
+ slug =
35
+ slug.slice(0, maxLen).replace(/-[^-]*$/, "") || slug.slice(0, maxLen);
36
+ }
37
+ return slug;
38
+ }
39
+
40
+ const PLURAL_MAP = {
41
+ insight: "insights",
42
+ decision: "decisions",
43
+ pattern: "patterns",
44
+ status: "statuses",
45
+ analysis: "analyses",
46
+ contact: "contacts",
47
+ project: "projects",
48
+ tool: "tools",
49
+ source: "sources",
50
+ conversation: "conversations",
51
+ message: "messages",
52
+ session: "sessions",
53
+ log: "logs",
54
+ feedback: "feedbacks",
55
+ };
56
+
57
+ const SINGULAR_MAP = Object.fromEntries(
58
+ Object.entries(PLURAL_MAP).map(([k, v]) => [v, k]),
59
+ );
60
+
61
+ export function kindToDir(kind) {
62
+ if (PLURAL_MAP[kind]) return PLURAL_MAP[kind];
63
+ return kind.endsWith("s") ? kind : kind + "s";
64
+ }
65
+
66
+ export function dirToKind(dirName) {
67
+ if (SINGULAR_MAP[dirName]) return SINGULAR_MAP[dirName];
68
+ return dirName.replace(/s$/, "");
69
+ }
70
+
71
+ /** Normalize a kind input (singular or plural) to its canonical singular form. */
72
+ export function normalizeKind(input) {
73
+ if (PLURAL_MAP[input]) return input; // Already a known singular kind
74
+ if (SINGULAR_MAP[input]) return SINGULAR_MAP[input]; // Known plural → singular
75
+ return input; // Unknown — use as-is (don't strip 's')
76
+ }
77
+
78
+ /** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
79
+ export function kindToPath(kind) {
80
+ return `${categoryDirFor(kind)}/${kindToDir(kind)}`;
81
+ }
82
+
83
+ export function safeJoin(base, ...parts) {
84
+ const resolvedBase = resolve(base);
85
+ const result = resolve(join(base, ...parts));
86
+ if (!result.startsWith(resolvedBase + sep) && result !== resolvedBase) {
87
+ throw new Error(
88
+ `Path traversal blocked: resolved path escapes base directory`,
89
+ );
90
+ }
91
+ return result;
92
+ }
93
+
94
+ export function walkDir(dir) {
95
+ const results = [];
96
+ function walk(currentDir, relDir) {
97
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
98
+ const fullPath = join(currentDir, entry.name);
99
+ if (entry.isDirectory() && !entry.name.startsWith("_")) {
100
+ walk(fullPath, relDir ? join(relDir, entry.name) : entry.name);
101
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
102
+ results.push({ filePath: fullPath, relDir });
103
+ }
104
+ }
105
+ }
106
+ walk(dir, "");
107
+ return results;
108
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * frontmatter.js — YAML frontmatter parsing and formatting
3
+ */
4
+
5
+ const NEEDS_QUOTING = /[:#'"{}[\],>|&*?!@`]/;
6
+
7
+ export function formatFrontmatter(meta) {
8
+ const lines = ["---"];
9
+ for (const [k, v] of Object.entries(meta)) {
10
+ if (v === undefined || v === null) continue;
11
+ if (Array.isArray(v)) {
12
+ lines.push(`${k}: [${v.map((i) => JSON.stringify(i)).join(", ")}]`);
13
+ } else {
14
+ const str = String(v);
15
+ lines.push(
16
+ `${k}: ${NEEDS_QUOTING.test(str) ? JSON.stringify(str) : str}`,
17
+ );
18
+ }
19
+ }
20
+ lines.push("---");
21
+ return lines.join("\n");
22
+ }
23
+
24
+ export function parseFrontmatter(text) {
25
+ const normalized = text.replace(/\r\n/g, "\n");
26
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
27
+ if (!match) return { meta: {}, body: normalized.trim() };
28
+ const meta = {};
29
+ for (const line of match[1].split("\n")) {
30
+ const idx = line.indexOf(":");
31
+ if (idx === -1) continue;
32
+ const key = line.slice(0, idx).trim();
33
+ let val = line.slice(idx + 1).trim();
34
+ // Unquote JSON-quoted strings from formatFrontmatter
35
+ if (
36
+ val.length >= 2 &&
37
+ val.startsWith('"') &&
38
+ val.endsWith('"') &&
39
+ !val.startsWith('["')
40
+ ) {
41
+ try {
42
+ val = JSON.parse(val);
43
+ } catch {
44
+ /* keep as-is */
45
+ }
46
+ }
47
+ // Parse arrays: [a, b, c]
48
+ if (val.startsWith("[") && val.endsWith("]")) {
49
+ try {
50
+ val = JSON.parse(val);
51
+ } catch {
52
+ val = val
53
+ .slice(1, -1)
54
+ .split(",")
55
+ .map((s) => s.trim().replace(/^"|"$/g, ""));
56
+ }
57
+ }
58
+ meta[key] = val;
59
+ }
60
+ return { meta, body: match[2].trim() };
61
+ }
62
+
63
+ const RESERVED_FM_KEYS = new Set([
64
+ "id",
65
+ "tags",
66
+ "source",
67
+ "created",
68
+ "identity_key",
69
+ "expires_at",
70
+ ]);
71
+
72
+ export function extractCustomMeta(fmMeta) {
73
+ const custom = {};
74
+ for (const [k, v] of Object.entries(fmMeta)) {
75
+ if (!RESERVED_FM_KEYS.has(k)) custom[k] = v;
76
+ }
77
+ return Object.keys(custom).length ? custom : null;
78
+ }
79
+
80
+ export function parseEntryFromMarkdown(kind, body, fmMeta) {
81
+ if (kind === "insight") {
82
+ return {
83
+ title: null,
84
+ body,
85
+ meta: extractCustomMeta(fmMeta),
86
+ };
87
+ }
88
+
89
+ if (kind === "decision") {
90
+ const titleMatch = body.match(/^## Decision\s*\n+([\s\S]*?)(?=\n## |\n*$)/);
91
+ const rationaleMatch = body.match(/## Rationale\s*\n+([\s\S]*?)$/);
92
+ const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 100);
93
+ const rationale = rationaleMatch ? rationaleMatch[1].trim() : body;
94
+ return {
95
+ title,
96
+ body: rationale,
97
+ meta: extractCustomMeta(fmMeta),
98
+ };
99
+ }
100
+
101
+ if (kind === "pattern") {
102
+ const titleMatch = body.match(/^# (.+)/);
103
+ const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 80);
104
+ const codeMatch = body.match(/```[\w]*\n([\s\S]*?)```/);
105
+ const content = codeMatch ? codeMatch[1].trim() : body;
106
+ return {
107
+ title,
108
+ body: content,
109
+ meta: extractCustomMeta(fmMeta),
110
+ };
111
+ }
112
+
113
+ // Generic: use first heading as title, rest as body
114
+ const headingMatch = body.match(/^#+ (.+)/);
115
+ return {
116
+ title: headingMatch ? headingMatch[1].trim() : null,
117
+ body,
118
+ meta: extractCustomMeta(fmMeta),
119
+ };
120
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * status.js — Vault status/diagnostics data gathering
3
+ */
4
+
5
+ import { existsSync, readdirSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { walkDir } from "./files.js";
8
+ import { isEmbedAvailable } from "../index/embed.js";
9
+
10
+ /**
11
+ * Gather raw vault status data for formatting by consumers.
12
+ *
13
+ * @param {import('../server/types.js').BaseCtx} ctx
14
+ * @param {{ userId?: string }} opts — optional userId for per-user stats
15
+ * @returns {{ fileCount, subdirs, kindCounts, dbSize, stalePaths, resolvedFrom, embeddingStatus, errors }}
16
+ */
17
+ export function gatherVaultStatus(ctx, opts = {}) {
18
+ const { db, config } = ctx;
19
+ const { userId } = opts;
20
+ const errors = [];
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
+
28
+ // Count files in vault subdirs (auto-discover)
29
+ let fileCount = 0;
30
+ const subdirs = [];
31
+ try {
32
+ if (existsSync(config.vaultDir)) {
33
+ for (const d of readdirSync(config.vaultDir, { withFileTypes: true })) {
34
+ if (d.isDirectory()) {
35
+ const dir = join(config.vaultDir, d.name);
36
+ const count = walkDir(dir).length;
37
+ fileCount += count;
38
+ if (count > 0) subdirs.push({ name: d.name, count });
39
+ }
40
+ }
41
+ }
42
+ } catch (e) {
43
+ errors.push(`File scan failed: ${e.message}`);
44
+ }
45
+
46
+ // Count DB rows by kind
47
+ let kindCounts = [];
48
+ try {
49
+ kindCounts = db
50
+ .prepare(
51
+ `SELECT kind, COUNT(*) as c FROM vault ${userWhere} GROUP BY kind`,
52
+ )
53
+ .all(...userParams);
54
+ } catch (e) {
55
+ errors.push(`Kind count query failed: ${e.message}`);
56
+ }
57
+
58
+ // Count DB rows by category
59
+ let categoryCounts = [];
60
+ try {
61
+ categoryCounts = db
62
+ .prepare(
63
+ `SELECT category, COUNT(*) as c FROM vault ${userWhere} GROUP BY category`,
64
+ )
65
+ .all(...userParams);
66
+ } catch (e) {
67
+ errors.push(`Category count query failed: ${e.message}`);
68
+ }
69
+
70
+ // DB file size
71
+ let dbSize = "n/a";
72
+ let dbSizeBytes = 0;
73
+ try {
74
+ if (existsSync(config.dbPath)) {
75
+ dbSizeBytes = statSync(config.dbPath).size;
76
+ dbSize =
77
+ dbSizeBytes > 1024 * 1024
78
+ ? `${(dbSizeBytes / 1024 / 1024).toFixed(1)}MB`
79
+ : `${(dbSizeBytes / 1024).toFixed(1)}KB`;
80
+ }
81
+ } catch (e) {
82
+ errors.push(`DB size check failed: ${e.message}`);
83
+ }
84
+
85
+ // Check for stale paths (count all mismatches, not just a sample)
86
+ let stalePaths = false;
87
+ let staleCount = 0;
88
+ try {
89
+ const result = db
90
+ .prepare(
91
+ `SELECT COUNT(*) as c FROM vault WHERE file_path NOT LIKE ? || '%' ${userAnd}`,
92
+ )
93
+ .get(config.vaultDir, ...userParams);
94
+ staleCount = result.c;
95
+ stalePaths = staleCount > 0;
96
+ } catch (e) {
97
+ errors.push(`Stale path check failed: ${e.message}`);
98
+ }
99
+
100
+ // Count expired entries pending pruning
101
+ let expiredCount = 0;
102
+ try {
103
+ expiredCount = db
104
+ .prepare(
105
+ `SELECT COUNT(*) as c FROM vault WHERE expires_at IS NOT NULL AND expires_at <= datetime('now') ${userAnd}`,
106
+ )
107
+ .get(...userParams).c;
108
+ } catch (e) {
109
+ errors.push(`Expired count failed: ${e.message}`);
110
+ }
111
+
112
+ // Embedding/vector status
113
+ let embeddingStatus = null;
114
+ try {
115
+ const total = db
116
+ .prepare(`SELECT COUNT(*) as c FROM vault ${userWhere}`)
117
+ .get(...userParams).c;
118
+ const indexed = db
119
+ .prepare(
120
+ `SELECT COUNT(*) as c FROM vault WHERE rowid IN (SELECT rowid FROM vault_vec) ${userAnd}`,
121
+ )
122
+ .get(...userParams).c;
123
+ embeddingStatus = { indexed, total, missing: total - indexed };
124
+ } catch (e) {
125
+ errors.push(`Embedding status check failed: ${e.message}`);
126
+ }
127
+
128
+ // Embedding model availability
129
+ const embedModelAvailable = isEmbedAvailable();
130
+
131
+ return {
132
+ fileCount,
133
+ subdirs,
134
+ kindCounts,
135
+ categoryCounts,
136
+ dbSize,
137
+ dbSizeBytes,
138
+ stalePaths,
139
+ staleCount,
140
+ expiredCount,
141
+ embeddingStatus,
142
+ embedModelAvailable,
143
+ resolvedFrom: config.resolvedFrom,
144
+ errors,
145
+ };
146
+ }
@@ -0,0 +1,268 @@
1
+ import { unlinkSync, copyFileSync, existsSync } from "node:fs";
2
+
3
+ export class NativeModuleError extends Error {
4
+ constructor(originalError) {
5
+ const diagnostic = formatNativeModuleError(originalError);
6
+ super(diagnostic);
7
+ this.name = "NativeModuleError";
8
+ this.originalError = originalError;
9
+ }
10
+ }
11
+
12
+ function formatNativeModuleError(err) {
13
+ const msg = err.message || "";
14
+ const versionMatch = msg.match(
15
+ /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+)/,
16
+ );
17
+
18
+ const lines = [
19
+ `Native module failed to load: ${msg}`,
20
+ "",
21
+ ` Running Node.js: ${process.version} (${process.execPath})`,
22
+ ];
23
+
24
+ if (versionMatch) {
25
+ lines.push(` Module compiled for: NODE_MODULE_VERSION ${versionMatch[1]}`);
26
+ lines.push(` Current runtime: NODE_MODULE_VERSION ${versionMatch[2]}`);
27
+ }
28
+
29
+ lines.push(
30
+ "",
31
+ " Fix: Rebuild native modules for your current Node.js:",
32
+ " npm rebuild better-sqlite3 sqlite-vec",
33
+ "",
34
+ " Or reinstall:",
35
+ " npm install -g context-vault",
36
+ );
37
+
38
+ return lines.join("\n");
39
+ }
40
+
41
+ let _Database = null;
42
+ let _sqliteVec = null;
43
+
44
+ async function loadNativeModules() {
45
+ if (_Database && _sqliteVec)
46
+ return { Database: _Database, sqliteVec: _sqliteVec };
47
+
48
+ try {
49
+ const dbMod = await import("better-sqlite3");
50
+ _Database = dbMod.default;
51
+ } catch (e) {
52
+ throw new NativeModuleError(e);
53
+ }
54
+
55
+ try {
56
+ const vecMod = await import("sqlite-vec");
57
+ _sqliteVec = vecMod;
58
+ } catch (e) {
59
+ throw new NativeModuleError(e);
60
+ }
61
+
62
+ return { Database: _Database, sqliteVec: _sqliteVec };
63
+ }
64
+
65
+ export const SCHEMA_DDL = `
66
+ CREATE TABLE IF NOT EXISTS vault (
67
+ id TEXT PRIMARY KEY,
68
+ kind TEXT NOT NULL,
69
+ category TEXT NOT NULL DEFAULT 'knowledge',
70
+ title TEXT,
71
+ body TEXT NOT NULL,
72
+ meta TEXT,
73
+ tags TEXT,
74
+ source TEXT,
75
+ file_path TEXT UNIQUE,
76
+ identity_key TEXT,
77
+ expires_at TEXT,
78
+ created_at TEXT DEFAULT (datetime('now')),
79
+ user_id TEXT,
80
+ team_id TEXT,
81
+ body_encrypted BLOB,
82
+ title_encrypted BLOB,
83
+ meta_encrypted BLOB,
84
+ iv BLOB
85
+ );
86
+
87
+ CREATE INDEX IF NOT EXISTS idx_vault_kind ON vault(kind);
88
+ CREATE INDEX IF NOT EXISTS idx_vault_category ON vault(category);
89
+ CREATE INDEX IF NOT EXISTS idx_vault_category_created ON vault(category, created_at DESC);
90
+ CREATE INDEX IF NOT EXISTS idx_vault_user ON vault(user_id);
91
+ CREATE INDEX IF NOT EXISTS idx_vault_team ON vault(team_id);
92
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(user_id, kind, identity_key) WHERE identity_key IS NOT NULL;
93
+
94
+ -- Single FTS5 table
95
+ CREATE VIRTUAL TABLE IF NOT EXISTS vault_fts USING fts5(
96
+ title, body, tags, kind,
97
+ content='vault', content_rowid='rowid'
98
+ );
99
+
100
+ -- FTS sync triggers
101
+ CREATE TRIGGER IF NOT EXISTS vault_ai AFTER INSERT ON vault BEGIN
102
+ INSERT INTO vault_fts(rowid, title, body, tags, kind)
103
+ VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
104
+ END;
105
+ CREATE TRIGGER IF NOT EXISTS vault_ad AFTER DELETE ON vault BEGIN
106
+ INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
107
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
108
+ END;
109
+ CREATE TRIGGER IF NOT EXISTS vault_au AFTER UPDATE ON vault BEGIN
110
+ INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
111
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
112
+ INSERT INTO vault_fts(rowid, title, body, tags, kind)
113
+ VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
114
+ END;
115
+
116
+ -- Single vec table (384-dim float32 for all-MiniLM-L6-v2)
117
+ CREATE VIRTUAL TABLE IF NOT EXISTS vault_vec USING vec0(embedding float[384]);
118
+ `;
119
+
120
+ export async function initDatabase(dbPath) {
121
+ const { Database, sqliteVec } = await loadNativeModules();
122
+
123
+ function createDb(path) {
124
+ const db = new Database(path);
125
+ db.pragma("journal_mode = WAL");
126
+ db.pragma("foreign_keys = ON");
127
+ try {
128
+ sqliteVec.load(db);
129
+ } catch (e) {
130
+ throw new NativeModuleError(e);
131
+ }
132
+ return db;
133
+ }
134
+
135
+ const db = createDb(dbPath);
136
+ const version = db.pragma("user_version", { simple: true });
137
+
138
+ // Enforce fresh-DB-only — old schemas get a full rebuild (with backup)
139
+ if (version > 0 && version < 5) {
140
+ console.error(
141
+ `[context-vault] Schema v${version} is outdated. Rebuilding database...`,
142
+ );
143
+
144
+ // Backup old DB before destroying it
145
+ const backupPath = `${dbPath}.v${version}.backup`;
146
+ try {
147
+ db.close();
148
+ if (existsSync(dbPath)) {
149
+ copyFileSync(dbPath, backupPath);
150
+ console.error(
151
+ `[context-vault] Backed up old database to: ${backupPath}`,
152
+ );
153
+ }
154
+ } catch (backupErr) {
155
+ console.error(
156
+ `[context-vault] Warning: could not backup old database: ${backupErr.message}`,
157
+ );
158
+ }
159
+
160
+ unlinkSync(dbPath);
161
+ try {
162
+ unlinkSync(dbPath + "-wal");
163
+ } catch {}
164
+ try {
165
+ unlinkSync(dbPath + "-shm");
166
+ } catch {}
167
+
168
+ const freshDb = createDb(dbPath);
169
+ freshDb.exec(SCHEMA_DDL);
170
+ freshDb.pragma("user_version = 7");
171
+ return freshDb;
172
+ }
173
+
174
+ if (version < 5) {
175
+ db.exec(SCHEMA_DDL);
176
+ db.pragma("user_version = 7");
177
+ } else if (version === 5) {
178
+ // v5 -> v6 migration: add multi-tenancy + encryption columns
179
+ // Wrapped in transaction with duplicate-column guards for idempotent retry
180
+ const migrate = db.transaction(() => {
181
+ const addColumnSafe = (sql) => {
182
+ try {
183
+ db.exec(sql);
184
+ } catch (e) {
185
+ if (!e.message.includes("duplicate column")) throw e;
186
+ }
187
+ };
188
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN user_id TEXT`);
189
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN body_encrypted BLOB`);
190
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN title_encrypted BLOB`);
191
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN meta_encrypted BLOB`);
192
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN iv BLOB`);
193
+ addColumnSafe(`ALTER TABLE vault ADD COLUMN team_id TEXT`);
194
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_user ON vault(user_id)`);
195
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_team ON vault(team_id)`);
196
+ db.exec(`DROP INDEX IF EXISTS idx_vault_identity`);
197
+ db.exec(
198
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(user_id, kind, identity_key) WHERE identity_key IS NOT NULL`,
199
+ );
200
+ db.pragma("user_version = 7");
201
+ });
202
+ migrate();
203
+ } else if (version === 6) {
204
+ // v6 -> v7 migration: add team_id column
205
+ const migrate = db.transaction(() => {
206
+ try {
207
+ db.exec(`ALTER TABLE vault ADD COLUMN team_id TEXT`);
208
+ } catch (e) {
209
+ if (!e.message.includes("duplicate column")) throw e;
210
+ }
211
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_team ON vault(team_id)`);
212
+ db.pragma("user_version = 7");
213
+ });
214
+ migrate();
215
+ }
216
+
217
+ return db;
218
+ }
219
+
220
+ export function prepareStatements(db) {
221
+ try {
222
+ return {
223
+ insertEntry: db.prepare(
224
+ `INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
225
+ ),
226
+ insertEntryEncrypted: db.prepare(
227
+ `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
228
+ ),
229
+ updateEntry: db.prepare(
230
+ `UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at = ? WHERE file_path = ?`,
231
+ ),
232
+ deleteEntry: db.prepare(`DELETE FROM vault WHERE id = ?`),
233
+ getRowid: db.prepare(`SELECT rowid FROM vault WHERE id = ?`),
234
+ getRowidByPath: db.prepare(`SELECT rowid FROM vault WHERE file_path = ?`),
235
+ getEntryById: db.prepare(`SELECT * FROM vault WHERE id = ?`),
236
+ getByIdentityKey: db.prepare(
237
+ `SELECT * FROM vault WHERE kind = ? AND identity_key = ? AND user_id IS ?`,
238
+ ),
239
+ upsertByIdentityKey: db.prepare(
240
+ `UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at = ? WHERE kind = ? AND identity_key = ? AND user_id IS ?`,
241
+ ),
242
+ insertVecStmt: db.prepare(
243
+ `INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`,
244
+ ),
245
+ deleteVecStmt: db.prepare(`DELETE FROM vault_vec WHERE rowid = ?`),
246
+ };
247
+ } catch (e) {
248
+ throw new Error(
249
+ `Failed to prepare database statements. The database may be corrupted.\n` +
250
+ `Try deleting and rebuilding: rm "${db.name}" && context-vault reindex\n` +
251
+ `Original error: ${e.message}`,
252
+ );
253
+ }
254
+ }
255
+
256
+ export function insertVec(stmts, rowid, embedding) {
257
+ // sqlite-vec requires BigInt for primary key — better-sqlite3 binds Number as REAL,
258
+ // but vec0 virtual tables only accept INTEGER rowids
259
+ const safeRowid = BigInt(rowid);
260
+ if (safeRowid < 1n) throw new Error(`Invalid rowid: ${rowid}`);
261
+ stmts.insertVecStmt.run(safeRowid, embedding);
262
+ }
263
+
264
+ export function deleteVec(stmts, rowid) {
265
+ const safeRowid = BigInt(rowid);
266
+ if (safeRowid < 1n) throw new Error(`Invalid rowid: ${rowid}`);
267
+ stmts.deleteVecStmt.run(safeRowid);
268
+ }