@context-vault/core 2.8.19 → 2.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.8.19",
3
+ "version": "2.10.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",
@@ -31,7 +31,7 @@
31
31
  ],
32
32
  "license": "MIT",
33
33
  "engines": {
34
- "node": ">=20"
34
+ "node": ">=24"
35
35
  },
36
36
  "author": "Felix Hellstrom",
37
37
  "repository": {
@@ -45,7 +45,6 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.26.0",
48
- "better-sqlite3": "^12.6.2",
49
48
  "sqlite-vec": "^0.1.0"
50
49
  },
51
50
  "optionalDependencies": {
@@ -0,0 +1,54 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const MAX_LOG_SIZE = 1024 * 1024; // 1MB
12
+
13
+ export function errorLogPath(dataDir) {
14
+ return join(dataDir, "error.log");
15
+ }
16
+
17
+ /**
18
+ * Append a structured JSON entry to the startup error log.
19
+ * Rotates the file if it exceeds MAX_LOG_SIZE.
20
+ * Never throws — logging failures must not mask the original error.
21
+ *
22
+ * @param {string} dataDir
23
+ * @param {object} entry
24
+ */
25
+ export function appendErrorLog(dataDir, entry) {
26
+ try {
27
+ mkdirSync(dataDir, { recursive: true });
28
+ const logPath = errorLogPath(dataDir);
29
+ if (existsSync(logPath) && statSync(logPath).size >= MAX_LOG_SIZE) {
30
+ writeFileSync(logPath, "");
31
+ }
32
+ appendFileSync(logPath, JSON.stringify(entry) + "\n");
33
+ } catch {
34
+ // intentionally swallowed
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Return number of log lines in the error log, or 0 if absent.
40
+ *
41
+ * @param {string} dataDir
42
+ * @returns {number}
43
+ */
44
+ export function errorLogCount(dataDir) {
45
+ try {
46
+ const logPath = errorLogPath(dataDir);
47
+ if (!existsSync(logPath)) return 0;
48
+ return readFileSync(logPath, "utf-8")
49
+ .split("\n")
50
+ .filter((l) => l.trim()).length;
51
+ } catch {
52
+ return 0;
53
+ }
54
+ }
@@ -128,6 +128,18 @@ export function gatherVaultStatus(ctx, opts = {}) {
128
128
  // Embedding model availability
129
129
  const embedModelAvailable = isEmbedAvailable();
130
130
 
131
+ // Count auto-captured feedback entries (written by tracked() on unhandled errors)
132
+ let autoCapturedFeedbackCount = 0;
133
+ try {
134
+ autoCapturedFeedbackCount = db
135
+ .prepare(
136
+ `SELECT COUNT(*) as c FROM vault WHERE kind = 'feedback' AND tags LIKE '%"auto-captured"%' ${userAnd}`,
137
+ )
138
+ .get(...userParams).c;
139
+ } catch (e) {
140
+ errors.push(`Auto-captured feedback count failed: ${e.message}`);
141
+ }
142
+
131
143
  return {
132
144
  fileCount,
133
145
  subdirs,
@@ -140,6 +152,7 @@ export function gatherVaultStatus(ctx, opts = {}) {
140
152
  expiredCount,
141
153
  embeddingStatus,
142
154
  embedModelAvailable,
155
+ autoCapturedFeedbackCount,
143
156
  resolvedFrom: config.resolvedFrom,
144
157
  errors,
145
158
  };
package/src/index/db.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { unlinkSync, copyFileSync, existsSync } from "node:fs";
2
+ import { DatabaseSync } from "node:sqlite";
2
3
 
3
4
  export class NativeModuleError extends Error {
4
5
  constructor(originalError) {
@@ -11,55 +12,34 @@ export class NativeModuleError extends Error {
11
12
 
12
13
  function formatNativeModuleError(err) {
13
14
  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}`,
15
+ return [
16
+ `sqlite-vec extension failed to load: ${msg}`,
20
17
  "",
21
18
  ` 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
19
  "",
34
- " Or reinstall:",
20
+ " Fix: Reinstall context-vault:",
35
21
  " npx -y context-vault@latest setup",
36
- );
37
-
38
- return lines.join("\n");
22
+ ].join("\n");
39
23
  }
40
24
 
41
- let _Database = null;
42
25
  let _sqliteVec = null;
43
26
 
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
- }
27
+ async function loadSqliteVec() {
28
+ if (_sqliteVec) return _sqliteVec;
29
+ const vecMod = await import("sqlite-vec");
30
+ _sqliteVec = vecMod;
31
+ return _sqliteVec;
32
+ }
54
33
 
34
+ function runTransaction(db, fn) {
35
+ db.exec("BEGIN");
55
36
  try {
56
- const vecMod = await import("sqlite-vec");
57
- _sqliteVec = vecMod;
37
+ fn();
38
+ db.exec("COMMIT");
58
39
  } catch (e) {
59
- throw new NativeModuleError(e);
40
+ db.exec("ROLLBACK");
41
+ throw e;
60
42
  }
61
-
62
- return { Database: _Database, sqliteVec: _sqliteVec };
63
43
  }
64
44
 
65
45
  export const SCHEMA_DDL = `
@@ -118,12 +98,12 @@ export const SCHEMA_DDL = `
118
98
  `;
119
99
 
120
100
  export async function initDatabase(dbPath) {
121
- const { Database, sqliteVec } = await loadNativeModules();
101
+ const sqliteVec = await loadSqliteVec();
122
102
 
123
103
  function createDb(path) {
124
- const db = new Database(path);
125
- db.pragma("journal_mode = WAL");
126
- db.pragma("foreign_keys = ON");
104
+ const db = new DatabaseSync(path, { allowExtension: true });
105
+ db.exec("PRAGMA journal_mode = WAL");
106
+ db.exec("PRAGMA foreign_keys = ON");
127
107
  try {
128
108
  sqliteVec.load(db);
129
109
  } catch (e) {
@@ -133,7 +113,7 @@ export async function initDatabase(dbPath) {
133
113
  }
134
114
 
135
115
  const db = createDb(dbPath);
136
- const version = db.pragma("user_version", { simple: true });
116
+ const version = db.prepare("PRAGMA user_version").get().user_version;
137
117
 
138
118
  // Enforce fresh-DB-only — old schemas get a full rebuild (with backup)
139
119
  if (version > 0 && version < 5) {
@@ -167,17 +147,17 @@ export async function initDatabase(dbPath) {
167
147
 
168
148
  const freshDb = createDb(dbPath);
169
149
  freshDb.exec(SCHEMA_DDL);
170
- freshDb.pragma("user_version = 7");
150
+ freshDb.exec("PRAGMA user_version = 7");
171
151
  return freshDb;
172
152
  }
173
153
 
174
154
  if (version < 5) {
175
155
  db.exec(SCHEMA_DDL);
176
- db.pragma("user_version = 7");
156
+ db.exec("PRAGMA user_version = 7");
177
157
  } else if (version === 5) {
178
158
  // v5 -> v6 migration: add multi-tenancy + encryption columns
179
159
  // Wrapped in transaction with duplicate-column guards for idempotent retry
180
- const migrate = db.transaction(() => {
160
+ runTransaction(db, () => {
181
161
  const addColumnSafe = (sql) => {
182
162
  try {
183
163
  db.exec(sql);
@@ -197,21 +177,19 @@ export async function initDatabase(dbPath) {
197
177
  db.exec(
198
178
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(user_id, kind, identity_key) WHERE identity_key IS NOT NULL`,
199
179
  );
200
- db.pragma("user_version = 7");
180
+ db.exec("PRAGMA user_version = 7");
201
181
  });
202
- migrate();
203
182
  } else if (version === 6) {
204
183
  // v6 -> v7 migration: add team_id column
205
- const migrate = db.transaction(() => {
184
+ runTransaction(db, () => {
206
185
  try {
207
186
  db.exec(`ALTER TABLE vault ADD COLUMN team_id TEXT`);
208
187
  } catch (e) {
209
188
  if (!e.message.includes("duplicate column")) throw e;
210
189
  }
211
190
  db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_team ON vault(team_id)`);
212
- db.pragma("user_version = 7");
191
+ db.exec("PRAGMA user_version = 7");
213
192
  });
214
- migrate();
215
193
  }
216
194
 
217
195
  return db;
@@ -247,15 +225,15 @@ export function prepareStatements(db) {
247
225
  } catch (e) {
248
226
  throw new Error(
249
227
  `Failed to prepare database statements. The database may be corrupted.\n` +
250
- `Try deleting and rebuilding: rm "${db.name}" && context-vault reindex\n` +
228
+ `Try deleting and rebuilding: context-vault reindex\n` +
251
229
  `Original error: ${e.message}`,
252
230
  );
253
231
  }
254
232
  }
255
233
 
256
234
  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
235
+ // sqlite-vec requires BigInt for primary key — node:sqlite may bind Number as REAL
236
+ // for vec0 virtual tables which only accept INTEGER rowids
259
237
  const safeRowid = BigInt(rowid);
260
238
  if (safeRowid < 1n) throw new Error(`Invalid rowid: ${rowid}`);
261
239
  stmts.insertVecStmt.run(safeRowid, embedding);
@@ -2,12 +2,25 @@
2
2
  * helpers.js — Shared MCP response helpers and validation
3
3
  */
4
4
 
5
+ import pkg from "../../package.json" with { type: "json" };
6
+
5
7
  export function ok(text) {
6
8
  return { content: [{ type: "text", text }] };
7
9
  }
8
10
 
9
- export function err(text, code = "UNKNOWN") {
10
- return { content: [{ type: "text", text }], isError: true, code };
11
+ export function err(text, code = "UNKNOWN", meta = {}) {
12
+ return {
13
+ content: [{ type: "text", text }],
14
+ isError: true,
15
+ code,
16
+ _meta: {
17
+ cv_version: pkg.version,
18
+ node_version: process.version,
19
+ platform: process.platform,
20
+ arch: process.arch,
21
+ ...meta,
22
+ },
23
+ };
11
24
  }
12
25
 
13
26
  export function ensureVaultExists(config) {
@@ -1,6 +1,16 @@
1
1
  import { gatherVaultStatus } from "../../core/status.js";
2
+ import { errorLogPath, errorLogCount } from "../../core/error-log.js";
2
3
  import { ok } from "../helpers.js";
3
4
 
5
+ function relativeTime(ts) {
6
+ const secs = Math.floor((Date.now() - ts) / 1000);
7
+ if (secs < 60) return `${secs}s ago`;
8
+ const mins = Math.floor(secs / 60);
9
+ if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`;
10
+ const hrs = Math.floor(mins / 60);
11
+ return `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
12
+ }
13
+
4
14
  export const name = "context_status";
5
15
 
6
16
  export const description =
@@ -83,6 +93,33 @@ export function handler(_args, ctx) {
83
93
  lines.push(`Auto-reindex will fix this on next search or save.`);
84
94
  }
85
95
 
96
+ // Error log
97
+ const logPath = errorLogPath(config.dataDir);
98
+ const logCount = errorLogCount(config.dataDir);
99
+ if (logCount > 0) {
100
+ lines.push(``, `### Startup Error Log`);
101
+ lines.push(`- Path: ${logPath}`);
102
+ lines.push(`- Entries: ${logCount} (share this file for support)`);
103
+ }
104
+
105
+ // Health: session-level tool call stats
106
+ const ts = ctx.toolStats;
107
+ if (ts) {
108
+ lines.push(``, `### Health`);
109
+ lines.push(`- Tool calls (session): ${ts.ok} ok, ${ts.errors} errors`);
110
+ if (ts.lastError) {
111
+ const { tool, code, timestamp } = ts.lastError;
112
+ lines.push(
113
+ `- Last error: ${tool ?? "unknown"} — ${code} (${relativeTime(timestamp)})`,
114
+ );
115
+ }
116
+ if (status.autoCapturedFeedbackCount > 0) {
117
+ lines.push(
118
+ `- Auto-captured feedback entries: ${status.autoCapturedFeedbackCount} (run get_context with kind:feedback tags:auto-captured)`,
119
+ );
120
+ }
121
+ }
122
+
86
123
  // Suggested actions
87
124
  const actions = [];
88
125
  if (status.stalePaths)
@@ -182,12 +182,21 @@ export async function handler(
182
182
  for (const r of filtered) r.score = 0;
183
183
  }
184
184
 
185
- if (!filtered.length)
185
+ if (!filtered.length) {
186
+ if (autoWindowed) {
187
+ const days = config.eventDecayDays || 30;
188
+ return ok(
189
+ hasQuery
190
+ ? `No results found for "${query}" in events (last ${days} days).\nTry with \`since: "YYYY-MM-DD"\` to search older events.`
191
+ : `No entries found matching the given filters in events (last ${days} days).\nTry with \`since: "YYYY-MM-DD"\` to search older events.`,
192
+ );
193
+ }
186
194
  return ok(
187
195
  hasQuery
188
196
  ? "No results found for: " + query
189
197
  : "No entries found matching the given filters.",
190
198
  );
199
+ }
191
200
 
192
201
  // Decrypt encrypted entries if ctx.decrypt is available
193
202
  if (ctx.decrypt) {
@@ -212,6 +221,12 @@ export async function handler(
212
221
  );
213
222
  const heading = hasQuery ? `Results for "${query}"` : "Filtered entries";
214
223
  lines.push(`## ${heading} (${filtered.length} matches)\n`);
224
+ if (autoWindowed) {
225
+ const days = config.eventDecayDays || 30;
226
+ lines.push(
227
+ `> ℹ Event search limited to last ${days} days. Use \`since\` parameter for older results.\n`,
228
+ );
229
+ }
215
230
  for (let i = 0; i < filtered.length; i++) {
216
231
  const r = filtered[i];
217
232
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
@@ -229,10 +244,5 @@ export async function handler(
229
244
  lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
230
245
  lines.push("");
231
246
  }
232
- if (autoWindowed) {
233
- lines.push(
234
- `_Showing events from last ${config.eventDecayDays || 30} days. Use since/until for custom range._`,
235
- );
236
- }
237
247
  return ok(lines.join("\n"));
238
248
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { normalizeKind } from "../../core/files.js";
3
+ import { categoryFor } from "../../core/categories.js";
3
4
  import { ok } from "../helpers.js";
4
5
 
5
6
  export const name = "list_context";
@@ -50,6 +51,17 @@ export async function handler(
50
51
 
51
52
  await ensureIndexed();
52
53
 
54
+ const kindFilter = kind ? normalizeKind(kind) : null;
55
+ const effectiveCategory =
56
+ category || (kindFilter ? categoryFor(kindFilter) : null);
57
+ let effectiveSince = since || null;
58
+ let autoWindowed = false;
59
+ if (effectiveCategory === "event" && !since && !until) {
60
+ const decayMs = (config.eventDecayDays || 30) * 86400000;
61
+ effectiveSince = new Date(Date.now() - decayMs).toISOString();
62
+ autoWindowed = true;
63
+ }
64
+
53
65
  const clauses = [];
54
66
  const params = [];
55
67
 
@@ -57,17 +69,17 @@ export async function handler(
57
69
  clauses.push("user_id = ?");
58
70
  params.push(userId);
59
71
  }
60
- if (kind) {
72
+ if (kindFilter) {
61
73
  clauses.push("kind = ?");
62
- params.push(normalizeKind(kind));
74
+ params.push(kindFilter);
63
75
  }
64
76
  if (category) {
65
77
  clauses.push("category = ?");
66
78
  params.push(category);
67
79
  }
68
- if (since) {
80
+ if (effectiveSince) {
69
81
  clauses.push("created_at >= ?");
70
- params.push(since);
82
+ params.push(effectiveSince);
71
83
  }
72
84
  if (until) {
73
85
  clauses.push("created_at <= ?");
@@ -103,8 +115,15 @@ export async function handler(
103
115
  .slice(0, effectiveLimit)
104
116
  : rows;
105
117
 
106
- if (!filtered.length)
118
+ if (!filtered.length) {
119
+ if (autoWindowed) {
120
+ const days = config.eventDecayDays || 30;
121
+ return ok(
122
+ `No entries found matching the given filters in events (last ${days} days).\nTry with \`since: "YYYY-MM-DD"\` to search older events.`,
123
+ );
124
+ }
107
125
  return ok("No entries found matching the given filters.");
126
+ }
108
127
 
109
128
  const lines = [];
110
129
  if (reindexFailed)
@@ -112,6 +131,12 @@ export async function handler(
112
131
  `> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`,
113
132
  );
114
133
  lines.push(`## Vault Entries (${filtered.length} shown, ${total} total)\n`);
134
+ if (autoWindowed) {
135
+ const days = config.eventDecayDays || 30;
136
+ lines.push(
137
+ `> ℹ Event search limited to last ${days} days. Use \`since\` parameter for older results.\n`,
138
+ );
139
+ }
115
140
  for (const r of filtered) {
116
141
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
117
142
  const tagStr = entryTags.length ? entryTags.join(", ") : "none";
@@ -1,5 +1,7 @@
1
1
  import { reindex } from "../index/index.js";
2
+ import { captureAndIndex } from "../capture/index.js";
2
3
  import { err } from "./helpers.js";
4
+ import pkg from "../../package.json" with { type: "json" };
3
5
 
4
6
  import * as getContext from "./tools/get-context.js";
5
7
  import * as saveContext from "./tools/save-context.js";
@@ -24,14 +26,14 @@ const TOOL_TIMEOUT_MS = 60_000;
24
26
  export function registerTools(server, ctx) {
25
27
  const userId = ctx.userId !== undefined ? ctx.userId : undefined;
26
28
 
27
- function tracked(handler) {
29
+ function tracked(handler, toolName) {
28
30
  return async (...args) => {
29
31
  if (ctx.activeOps) ctx.activeOps.count++;
30
32
  let timer;
31
33
  let handlerPromise;
32
34
  try {
33
35
  handlerPromise = Promise.resolve(handler(...args));
34
- return await Promise.race([
36
+ const result = await Promise.race([
35
37
  handlerPromise,
36
38
  new Promise((_, reject) => {
37
39
  timer = setTimeout(
@@ -40,16 +42,49 @@ export function registerTools(server, ctx) {
40
42
  );
41
43
  }),
42
44
  ]);
45
+ if (ctx.toolStats) ctx.toolStats.ok++;
46
+ return result;
43
47
  } catch (e) {
44
48
  if (e.message === "TOOL_TIMEOUT") {
45
49
  // Suppress any late rejection from the still-running handler to
46
50
  // prevent unhandled promise rejection warnings in the host process.
47
51
  handlerPromise?.catch(() => {});
52
+ if (ctx.toolStats) {
53
+ ctx.toolStats.errors++;
54
+ ctx.toolStats.lastError = {
55
+ tool: toolName,
56
+ code: "TIMEOUT",
57
+ timestamp: Date.now(),
58
+ };
59
+ }
48
60
  return err(
49
61
  "Tool timed out after 60s. Try a simpler query or run `context-vault reindex` first.",
50
62
  "TIMEOUT",
51
63
  );
52
64
  }
65
+ if (ctx.toolStats) {
66
+ ctx.toolStats.errors++;
67
+ ctx.toolStats.lastError = {
68
+ tool: toolName,
69
+ code: "UNKNOWN",
70
+ timestamp: Date.now(),
71
+ };
72
+ }
73
+ try {
74
+ await captureAndIndex(ctx, {
75
+ kind: "feedback",
76
+ title: `Unhandled error in ${toolName ?? "tool"} call`,
77
+ body: `${e.message}\n\n${e.stack ?? ""}`,
78
+ tags: ["bug", "auto-captured"],
79
+ source: "auto-capture",
80
+ meta: {
81
+ tool: toolName,
82
+ error_type: e.constructor?.name,
83
+ cv_version: pkg.version,
84
+ auto: true,
85
+ },
86
+ });
87
+ } catch {} // never block on feedback capture
53
88
  throw e;
54
89
  } finally {
55
90
  clearTimeout(timer);
@@ -110,7 +145,7 @@ export function registerTools(server, ctx) {
110
145
  mod.name,
111
146
  mod.description,
112
147
  mod.inputSchema,
113
- tracked((args) => mod.handler(args, ctx, shared)),
148
+ tracked((args) => mod.handler(args, ctx, shared), mod.name),
114
149
  );
115
150
  }
116
151
  }