context-vault 2.2.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.
Files changed (32) hide show
  1. package/bin/cli.js +393 -63
  2. package/node_modules/@context-vault/core/LICENSE +21 -0
  3. package/node_modules/@context-vault/core/package.json +36 -0
  4. package/{src → node_modules/@context-vault/core/src}/capture/index.js +3 -2
  5. package/{src → node_modules/@context-vault/core/src}/core/categories.js +1 -0
  6. package/{src → node_modules/@context-vault/core/src}/core/config.js +5 -0
  7. package/{src → node_modules/@context-vault/core/src}/core/files.js +1 -0
  8. package/{src → node_modules/@context-vault/core/src}/core/status.js +32 -8
  9. package/node_modules/@context-vault/core/src/index/db.js +245 -0
  10. package/node_modules/@context-vault/core/src/index/embed.js +87 -0
  11. package/{src → node_modules/@context-vault/core/src}/index/index.js +51 -12
  12. package/node_modules/@context-vault/core/src/index.js +29 -0
  13. package/{src → node_modules/@context-vault/core/src}/retrieve/index.js +52 -43
  14. package/{src → node_modules/@context-vault/core/src}/server/tools.js +195 -31
  15. package/package.json +15 -15
  16. package/scripts/postinstall.js +45 -0
  17. package/scripts/prepack.js +31 -0
  18. package/src/server/index.js +127 -68
  19. package/ui/serve.js +7 -6
  20. package/README.md +0 -431
  21. package/smithery.yaml +0 -10
  22. package/src/capture/README.md +0 -23
  23. package/src/core/README.md +0 -20
  24. package/src/index/README.md +0 -28
  25. package/src/index/db.js +0 -139
  26. package/src/index/embed.js +0 -57
  27. package/src/retrieve/README.md +0 -19
  28. package/src/server/README.md +0 -44
  29. /package/{src → node_modules/@context-vault/core/src}/capture/file-ops.js +0 -0
  30. /package/{src → node_modules/@context-vault/core/src}/capture/formatters.js +0 -0
  31. /package/{src → node_modules/@context-vault/core/src}/core/frontmatter.js +0 -0
  32. /package/{src → node_modules/@context-vault/core/src}/server/helpers.js +0 -0
@@ -28,19 +28,23 @@ function buildFtsQuery(query) {
28
28
  * knowledge + entity: no decay (enduring)
29
29
  * event: steeper decay (~0.5 at 30 days)
30
30
  */
31
- function recencyBoost(createdAt, category) {
31
+ function recencyBoost(createdAt, category, decayDays = 30) {
32
32
  if (category !== "event") return 1.0;
33
33
  const ageDays = (Date.now() - new Date(createdAt).getTime()) / 86400000;
34
- return 1 / (1 + ageDays / 30);
34
+ return 1 / (1 + ageDays / decayDays);
35
35
  }
36
36
 
37
37
  /**
38
38
  * Build additional WHERE clauses for category/time filtering.
39
39
  * Returns { clauses: string[], params: any[] }
40
40
  */
41
- function buildFilterClauses({ categoryFilter, since, until }) {
41
+ function buildFilterClauses({ categoryFilter, since, until, userIdFilter }) {
42
42
  const clauses = [];
43
43
  const params = [];
44
+ if (userIdFilter !== undefined) {
45
+ clauses.push("e.user_id = ?");
46
+ params.push(userIdFilter);
47
+ }
44
48
  if (categoryFilter) {
45
49
  clauses.push("e.category = ?");
46
50
  params.push(categoryFilter);
@@ -68,10 +72,10 @@ function buildFilterClauses({ categoryFilter, since, until }) {
68
72
  export async function hybridSearch(
69
73
  ctx,
70
74
  query,
71
- { kindFilter = null, categoryFilter = null, since = null, until = null, limit = 20, offset = 0 } = {}
75
+ { kindFilter = null, categoryFilter = null, since = null, until = null, limit = 20, offset = 0, decayDays = 30, userIdFilter } = {}
72
76
  ) {
73
77
  const results = new Map();
74
- const extraFilters = buildFilterClauses({ categoryFilter, since, until });
78
+ const extraFilters = buildFilterClauses({ categoryFilter, since, until, userIdFilter });
75
79
 
76
80
  // FTS5 search
77
81
  const ftsQuery = buildFtsQuery(query);
@@ -108,49 +112,54 @@ export async function hybridSearch(
108
112
  }
109
113
  }
110
114
 
111
- // Vector similarity search
115
+ // Vector similarity search (skipped if embedding unavailable)
112
116
  try {
113
117
  const vecCount = ctx.db
114
118
  .prepare("SELECT COUNT(*) as c FROM vault_vec")
115
119
  .get().c;
116
120
  if (vecCount > 0) {
117
121
  const queryVec = await ctx.embed(query);
118
- const vecLimit = kindFilter ? 30 : 15;
119
- const vecRows = ctx.db
120
- .prepare(
121
- `SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT ${vecLimit}`
122
- )
123
- .all(queryVec);
124
-
125
- if (vecRows.length) {
126
- // Batch hydration: single query instead of N+1
127
- const rowids = vecRows.map((vr) => vr.rowid);
128
- const placeholders = rowids.map(() => "?").join(",");
129
- const hydrated = ctx.db
130
- .prepare(`SELECT rowid, * FROM vault WHERE rowid IN (${placeholders})`)
131
- .all(...rowids);
132
-
133
- const byRowid = new Map();
134
- for (const row of hydrated) byRowid.set(row.rowid, row);
135
-
136
- for (const vr of vecRows) {
137
- const row = byRowid.get(vr.rowid);
138
- if (!row) continue;
139
- if (kindFilter && row.kind !== kindFilter) continue;
140
- if (categoryFilter && row.category !== categoryFilter) continue;
141
- if (since && row.created_at < since) continue;
142
- if (until && row.created_at > until) continue;
143
- if (row.expires_at && new Date(row.expires_at) <= new Date()) continue;
144
-
145
- const { rowid: _rowid, ...cleanRow } = row;
146
- // sqlite-vec returns L2 distance [0, 2] for normalized vectors.
147
- // Convert to similarity [1, 0] with: 1 - distance/2
148
- const vecScore = Math.max(0, 1 - vr.distance / 2) * VEC_WEIGHT;
149
- const existing = results.get(cleanRow.id);
150
- if (existing) {
151
- existing.score += vecScore;
152
- } else {
153
- results.set(cleanRow.id, { ...cleanRow, score: vecScore });
122
+ if (queryVec) {
123
+ // Increase limits in hosted mode to compensate for post-filtering
124
+ const hasUserFilter = userIdFilter !== undefined;
125
+ const vecLimit = hasUserFilter ? (kindFilter ? 60 : 30) : (kindFilter ? 30 : 15);
126
+ const vecRows = ctx.db
127
+ .prepare(
128
+ `SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT ${vecLimit}`
129
+ )
130
+ .all(queryVec);
131
+
132
+ if (vecRows.length) {
133
+ // Batch hydration: single query instead of N+1
134
+ const rowids = vecRows.map((vr) => vr.rowid);
135
+ const placeholders = rowids.map(() => "?").join(",");
136
+ const hydrated = ctx.db
137
+ .prepare(`SELECT rowid, * FROM vault WHERE rowid IN (${placeholders})`)
138
+ .all(...rowids);
139
+
140
+ const byRowid = new Map();
141
+ for (const row of hydrated) byRowid.set(row.rowid, row);
142
+
143
+ for (const vr of vecRows) {
144
+ const row = byRowid.get(vr.rowid);
145
+ if (!row) continue;
146
+ if (userIdFilter !== undefined && row.user_id !== userIdFilter) continue;
147
+ if (kindFilter && row.kind !== kindFilter) continue;
148
+ if (categoryFilter && row.category !== categoryFilter) continue;
149
+ if (since && row.created_at < since) continue;
150
+ if (until && row.created_at > until) continue;
151
+ if (row.expires_at && new Date(row.expires_at) <= new Date()) continue;
152
+
153
+ const { rowid: _rowid, ...cleanRow } = row;
154
+ // sqlite-vec returns L2 distance [0, 2] for normalized vectors.
155
+ // Convert to similarity [1, 0] with: 1 - distance/2
156
+ const vecScore = Math.max(0, 1 - vr.distance / 2) * VEC_WEIGHT;
157
+ const existing = results.get(cleanRow.id);
158
+ if (existing) {
159
+ existing.score += vecScore;
160
+ } else {
161
+ results.set(cleanRow.id, { ...cleanRow, score: vecScore });
162
+ }
154
163
  }
155
164
  }
156
165
  }
@@ -165,7 +174,7 @@ export async function hybridSearch(
165
174
 
166
175
  // Apply category-aware recency boost
167
176
  for (const [, entry] of results) {
168
- entry.score *= recencyBoost(entry.created_at, entry.category);
177
+ entry.score *= recencyBoost(entry.created_at, entry.category, decayDays);
169
178
  }
170
179
 
171
180
  const sorted = [...results.values()].sort((a, b) => b.score - a.score);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * tools.js — MCP tool registrations
3
3
  *
4
- * Five tools: save_context (write/update), get_context (search), list_context (browse),
5
- * delete_context (remove), context_status (diag).
4
+ * Six tools: save_context (write/update), get_context (search), list_context (browse),
5
+ * delete_context (remove), submit_feedback (bug/feature reports), context_status (diag).
6
6
  * Auto-reindex runs transparently on first tool call per session.
7
7
  */
8
8
 
@@ -25,10 +25,12 @@ import { ok, err, ensureVaultExists, ensureValidKind } from "./helpers.js";
25
25
  */
26
26
  export function registerTools(server, ctx) {
27
27
  const { config } = ctx;
28
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
28
29
 
29
30
  // ─── Auto-Reindex (runs once per session, on first tool call) ──────────────
30
31
 
31
- let reindexDone = false;
32
+ // In hosted mode, skip reindex — DB is always in sync via writeEntry→indexEntry
33
+ let reindexDone = userId !== undefined ? true : false;
32
34
  let reindexPromise = null;
33
35
  let reindexAttempts = 0;
34
36
  let reindexFailed = false;
@@ -63,52 +65,132 @@ export function registerTools(server, ctx) {
63
65
 
64
66
  server.tool(
65
67
  "get_context",
66
- "Search your knowledge vault. Returns entries ranked by relevance using hybrid full-text + semantic search. Use this to find insights, decisions, patterns, or any saved context.",
68
+ "Search your knowledge vault. Returns entries ranked by relevance using hybrid full-text + semantic search. Use this to find insights, decisions, patterns, or any saved context. Each result includes an `id` you can use with save_context or delete_context.",
67
69
  {
68
- query: z.string().describe("Search query (natural language or keywords)"),
70
+ query: z.string().optional().describe("Search query (natural language or keywords). Optional if filters (tags, kind, category) are provided."),
69
71
  kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
70
72
  category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
73
+ identity_key: z.string().optional().describe("For entity lookup: exact match on identity key. Requires kind."),
71
74
  tags: z.array(z.string()).optional().describe("Filter by tags (entries must match at least one)"),
72
75
  since: z.string().optional().describe("ISO date, return entries created after this"),
73
76
  until: z.string().optional().describe("ISO date, return entries created before this"),
74
77
  limit: z.number().optional().describe("Max results to return (default 10)"),
75
78
  },
76
- async ({ query, kind, category, tags, since, until, limit }) => {
77
- if (!query?.trim()) return err("Required: query (non-empty string)", "INVALID_INPUT");
79
+ async ({ query, kind, category, identity_key, tags, since, until, limit }) => {
80
+ const hasQuery = query?.trim();
81
+ const hasFilters = kind || category || tags?.length || since || until || identity_key;
82
+ if (!hasQuery && !hasFilters) return err("Required: query or at least one filter (kind, category, tags, since, until, identity_key)", "INVALID_INPUT");
78
83
  await ensureIndexed();
79
84
 
80
85
  const kindFilter = kind ? normalizeKind(kind) : null;
81
- const sorted = await hybridSearch(ctx, query, {
82
- kindFilter,
83
- categoryFilter: category || null,
84
- since: since || null,
85
- until: until || null,
86
- limit: limit || 10,
87
- });
88
86
 
89
- // Post-filter by tags if provided
90
- const filtered = tags?.length
91
- ? sorted.filter((r) => {
92
- const entryTags = r.tags ? JSON.parse(r.tags) : [];
93
- return tags.some((t) => entryTags.includes(t));
94
- })
95
- : sorted;
87
+ // Gap 1: Entity exact-match by identity_key
88
+ if (identity_key) {
89
+ if (!kindFilter) return err("identity_key requires kind to be specified", "INVALID_INPUT");
90
+ const match = ctx.stmts.getByIdentityKey.get(kindFilter, identity_key, userId !== undefined ? userId : null);
91
+ if (match) {
92
+ const entryTags = match.tags ? JSON.parse(match.tags) : [];
93
+ const tagStr = entryTags.length ? entryTags.join(", ") : "none";
94
+ const relPath = match.file_path && config.vaultDir ? match.file_path.replace(config.vaultDir + "/", "") : match.file_path || "n/a";
95
+ const lines = [
96
+ `## Entity Match (exact)\n`,
97
+ `### ${match.title || "(untitled)"} [${match.kind}/${match.category}]`,
98
+ `1.000 · ${tagStr} · ${relPath} · id: \`${match.id}\``,
99
+ match.body?.slice(0, 300) + (match.body?.length > 300 ? "..." : ""),
100
+ ];
101
+ return ok(lines.join("\n"));
102
+ }
103
+ // Fall through to semantic search as fallback
104
+ }
96
105
 
97
- if (!filtered.length) return ok("No results found for: " + query);
106
+ // Gap 2: Event default time-window
107
+ const effectiveCategory = category || (kindFilter ? categoryFor(kindFilter) : null);
108
+ let effectiveSince = since || null;
109
+ let effectiveUntil = until || null;
110
+ let autoWindowed = false;
111
+ if (effectiveCategory === "event" && !since && !until) {
112
+ const decayMs = (config.eventDecayDays || 30) * 86400000;
113
+ effectiveSince = new Date(Date.now() - decayMs).toISOString();
114
+ autoWindowed = true;
115
+ }
116
+
117
+ let filtered;
118
+ if (hasQuery) {
119
+ // Hybrid search mode
120
+ const sorted = await hybridSearch(ctx, query, {
121
+ kindFilter,
122
+ categoryFilter: category || null,
123
+ since: effectiveSince,
124
+ until: effectiveUntil,
125
+ limit: limit || 10,
126
+ decayDays: config.eventDecayDays || 30,
127
+ userIdFilter: userId,
128
+ });
129
+
130
+ // Post-filter by tags if provided
131
+ filtered = tags?.length
132
+ ? sorted.filter((r) => {
133
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
134
+ return tags.some((t) => entryTags.includes(t));
135
+ })
136
+ : sorted;
137
+ } else {
138
+ // Filter-only mode (no query, use SQL directly)
139
+ const clauses = [];
140
+ const params = [];
141
+ if (userId !== undefined) { clauses.push("user_id = ?"); params.push(userId); }
142
+ if (kindFilter) { clauses.push("kind = ?"); params.push(kindFilter); }
143
+ if (category) { clauses.push("category = ?"); params.push(category); }
144
+ if (effectiveSince) { clauses.push("created_at >= ?"); params.push(effectiveSince); }
145
+ if (effectiveUntil) { clauses.push("created_at <= ?"); params.push(effectiveUntil); }
146
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
147
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
148
+ const effectiveLimit = limit || 10;
149
+ params.push(effectiveLimit);
150
+ const rows = ctx.db.prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT ?`).all(...params);
151
+
152
+ filtered = tags?.length
153
+ ? rows.filter((r) => {
154
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
155
+ return tags.some((t) => entryTags.includes(t));
156
+ })
157
+ : rows;
158
+
159
+ // Add score field for consistent output
160
+ for (const r of filtered) r.score = 0;
161
+ }
162
+
163
+ if (!filtered.length) return ok(hasQuery ? "No results found for: " + query : "No entries found matching the given filters.");
164
+
165
+ // Decrypt encrypted entries if ctx.decrypt is available
166
+ if (ctx.decrypt) {
167
+ for (const r of filtered) {
168
+ if (r.body_encrypted) {
169
+ const decrypted = await ctx.decrypt(r);
170
+ r.body = decrypted.body;
171
+ if (decrypted.title) r.title = decrypted.title;
172
+ if (decrypted.meta) r.meta = JSON.stringify(decrypted.meta);
173
+ }
174
+ }
175
+ }
98
176
 
99
177
  const lines = [];
100
178
  if (reindexFailed) lines.push(`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-mcp reindex\` to fix.\n`);
101
- lines.push(`## Results for "${query}" (${filtered.length} matches)\n`);
179
+ const heading = hasQuery ? `Results for "${query}"` : "Filtered entries";
180
+ lines.push(`## ${heading} (${filtered.length} matches)\n`);
102
181
  for (let i = 0; i < filtered.length; i++) {
103
182
  const r = filtered[i];
104
183
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
105
184
  const tagStr = entryTags.length ? entryTags.join(", ") : "none";
106
185
  const relPath = r.file_path && config.vaultDir ? r.file_path.replace(config.vaultDir + "/", "") : r.file_path || "n/a";
107
186
  lines.push(`### [${i + 1}/${filtered.length}] ${r.title || "(untitled)"} [${r.kind}/${r.category}]`);
108
- lines.push(`${r.score.toFixed(3)} · ${tagStr} · ${relPath}`);
187
+ lines.push(`${r.score.toFixed(3)} · ${tagStr} · ${relPath} · id: \`${r.id}\``);
109
188
  lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
110
189
  lines.push("");
111
190
  }
191
+ if (autoWindowed) {
192
+ lines.push(`_Showing events from last ${config.eventDecayDays || 30} days. Use since/until for custom range._`);
193
+ }
112
194
  return ok(lines.join("\n"));
113
195
  }
114
196
  );
@@ -117,7 +199,7 @@ export function registerTools(server, ctx) {
117
199
 
118
200
  server.tool(
119
201
  "save_context",
120
- "Save knowledge to your vault. Creates a .md file and indexes it for search. Use for any kind of context: insights, decisions, patterns, references, or any custom kind.",
202
+ "Save knowledge to your vault. Creates a .md file and indexes it for search. Use for any kind of context: insights, decisions, patterns, references, or any custom kind. To update an existing entry, pass its `id` — omitted fields are preserved.",
121
203
  {
122
204
  id: z.string().optional().describe("Entry ULID to update. When provided, updates the existing entry instead of creating new. Omitted fields are preserved."),
123
205
  kind: z.string().optional().describe("Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind). Required for new entries."),
@@ -141,6 +223,11 @@ export function registerTools(server, ctx) {
141
223
  const existing = ctx.stmts.getEntryById.get(id);
142
224
  if (!existing) return err(`Entry not found: ${id}`, "NOT_FOUND");
143
225
 
226
+ // Ownership check: don't leak existence across users
227
+ if (userId !== undefined && existing.user_id !== userId) {
228
+ return err(`Entry not found: ${id}`, "NOT_FOUND");
229
+ }
230
+
144
231
  if (kind && normalizeKind(kind) !== existing.kind) {
145
232
  return err(`Cannot change kind (current: "${existing.kind}"). Delete and re-create instead.`, "INVALID_UPDATE");
146
233
  }
@@ -148,6 +235,14 @@ export function registerTools(server, ctx) {
148
235
  return err(`Cannot change identity_key (current: "${existing.identity_key}"). Delete and re-create instead.`, "INVALID_UPDATE");
149
236
  }
150
237
 
238
+ // Decrypt existing entry before merge if encrypted
239
+ if (ctx.decrypt && existing.body_encrypted) {
240
+ const decrypted = await ctx.decrypt(existing);
241
+ existing.body = decrypted.body;
242
+ if (decrypted.title) existing.title = decrypted.title;
243
+ if (decrypted.meta) existing.meta = JSON.stringify(decrypted.meta);
244
+ }
245
+
151
246
  const entry = updateEntryFile(ctx, existing, { title, body, tags, meta, source, expires_at });
152
247
  await indexEntry(ctx, entry);
153
248
  const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
@@ -155,6 +250,7 @@ export function registerTools(server, ctx) {
155
250
  if (entry.title) parts.push(` title: ${entry.title}`);
156
251
  const entryTags = entry.tags || [];
157
252
  if (entryTags.length) parts.push(` tags: ${entryTags.join(", ")}`);
253
+ parts.push("", "_Search with get_context to verify changes._");
158
254
  return ok(parts.join("\n"));
159
255
  }
160
256
 
@@ -174,11 +270,12 @@ export function registerTools(server, ctx) {
174
270
  if (folder) mergedMeta.folder = folder;
175
271
  const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
176
272
 
177
- const entry = await captureAndIndex(ctx, { kind, title, body, meta: finalMeta, tags, source, folder, identity_key, expires_at }, indexEntry);
273
+ const entry = await captureAndIndex(ctx, { kind, title, body, meta: finalMeta, tags, source, folder, identity_key, expires_at, userId }, indexEntry);
178
274
  const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
179
275
  const parts = [`✓ Saved ${kind} → ${relPath}`, ` id: ${entry.id}`];
180
276
  if (title) parts.push(` title: ${title}`);
181
277
  if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
278
+ parts.push("", "_Use this id to update or delete later._");
182
279
  return ok(parts.join("\n"));
183
280
  }
184
281
  );
@@ -187,7 +284,7 @@ export function registerTools(server, ctx) {
187
284
 
188
285
  server.tool(
189
286
  "list_context",
190
- "Browse vault entries without a search query. Returns id, title, kind, category, tags, created_at. Use get_context with a query for semantic search.",
287
+ "Browse vault entries without a search query. Returns id, title, kind, category, tags, created_at. Use get_context with a query for semantic search. Use this to browse by tags or find recent entries.",
191
288
  {
192
289
  kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
193
290
  category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
@@ -203,6 +300,10 @@ export function registerTools(server, ctx) {
203
300
  const clauses = [];
204
301
  const params = [];
205
302
 
303
+ if (userId !== undefined) {
304
+ clauses.push("user_id = ?");
305
+ params.push(userId);
306
+ }
206
307
  if (kind) {
207
308
  clauses.push("kind = ?");
208
309
  params.push(normalizeKind(kind));
@@ -229,7 +330,7 @@ export function registerTools(server, ctx) {
229
330
  const total = ctx.db.prepare(`SELECT COUNT(*) as c FROM vault ${where}`).get(...countParams).c;
230
331
 
231
332
  params.push(effectiveLimit, effectiveOffset);
232
- const rows = ctx.db.prepare(`SELECT id, title, kind, category, tags, created_at FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params);
333
+ const rows = ctx.db.prepare(`SELECT id, title, kind, category, tags, created_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params);
233
334
 
234
335
  // Post-filter by tags if provided
235
336
  const filtered = tags?.length
@@ -246,6 +347,7 @@ export function registerTools(server, ctx) {
246
347
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
247
348
  const tagStr = entryTags.length ? entryTags.join(", ") : "none";
248
349
  lines.push(`- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${r.created_at} — \`${r.id}\``);
350
+ if (r.preview) lines.push(` ${r.preview.replace(/\n+/g, " ").trim()}${r.preview.length >= 120 ? "…" : ""}`);
249
351
  }
250
352
 
251
353
  if (effectiveOffset + effectiveLimit < total) {
@@ -271,6 +373,11 @@ export function registerTools(server, ctx) {
271
373
  const entry = ctx.stmts.getEntryById.get(id);
272
374
  if (!entry) return err(`Entry not found: ${id}`, "NOT_FOUND");
273
375
 
376
+ // Ownership check: don't leak existence across users
377
+ if (userId !== undefined && entry.user_id !== userId) {
378
+ return err(`Entry not found: ${id}`, "NOT_FOUND");
379
+ }
380
+
274
381
  // Delete file from disk first (source of truth)
275
382
  if (entry.file_path) {
276
383
  try { unlinkSync(entry.file_path); } catch {}
@@ -289,14 +396,51 @@ export function registerTools(server, ctx) {
289
396
  }
290
397
  );
291
398
 
399
+ // ─── submit_feedback (bug/feature reports) ────────────────────────────────
400
+
401
+ server.tool(
402
+ "submit_feedback",
403
+ "Report a bug, request a feature, or suggest an improvement. Feedback is stored in the vault and triaged by the development pipeline.",
404
+ {
405
+ type: z.enum(["bug", "feature", "improvement"]).describe("Type of feedback"),
406
+ title: z.string().describe("Short summary of the feedback"),
407
+ body: z.string().describe("Detailed description"),
408
+ severity: z.enum(["low", "medium", "high"]).optional().describe("Severity level (default: medium)"),
409
+ },
410
+ async ({ type, title, body, severity }) => {
411
+ const vaultErr = ensureVaultExists(config);
412
+ if (vaultErr) return vaultErr;
413
+
414
+ await ensureIndexed();
415
+
416
+ const effectiveSeverity = severity || "medium";
417
+ const entry = await captureAndIndex(
418
+ ctx,
419
+ {
420
+ kind: "feedback",
421
+ title,
422
+ body,
423
+ tags: [type, effectiveSeverity],
424
+ source: "submit_feedback",
425
+ meta: { feedback_type: type, severity: effectiveSeverity, status: "new" },
426
+ userId,
427
+ },
428
+ indexEntry
429
+ );
430
+
431
+ const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
432
+ return ok(`Feedback submitted: ${type} [${effectiveSeverity}] → ${relPath}\n id: ${entry.id}\n title: ${title}`);
433
+ }
434
+ );
435
+
292
436
  // ─── context_status (diagnostics) ──────────────────────────────────────────
293
437
 
294
438
  server.tool(
295
439
  "context_status",
296
- "Show vault health: resolved config, file counts per kind, database size, and any issues. Use to verify setup or troubleshoot.",
440
+ "Show vault health: resolved config, file counts per kind, database size, and any issues. Use to verify setup or troubleshoot. Call this when a user asks about their vault or to debug search issues.",
297
441
  {},
298
442
  () => {
299
- const status = gatherVaultStatus(ctx);
443
+ const status = gatherVaultStatus(ctx, { userId });
300
444
 
301
445
  const hasIssues = status.stalePaths || (status.embeddingStatus?.missing > 0);
302
446
  const healthIcon = hasIssues ? "⚠" : "✓";
@@ -310,7 +454,7 @@ export function registerTools(server, ctx) {
310
454
  `Data dir: ${config.dataDir}`,
311
455
  `Config: ${config.configPath}`,
312
456
  `Resolved via: ${status.resolvedFrom}`,
313
- `Schema: v5 (categories)`,
457
+ `Schema: v6 (multi-tenancy)`,
314
458
  ];
315
459
 
316
460
  if (status.embeddingStatus) {
@@ -318,6 +462,15 @@ export function registerTools(server, ctx) {
318
462
  const pct = total > 0 ? Math.round((indexed / total) * 100) : 100;
319
463
  lines.push(`Embeddings: ${indexed}/${total} (${pct}%)`);
320
464
  }
465
+ if (status.embedModelAvailable === false) {
466
+ lines.push(`Embed model: unavailable (semantic search disabled, FTS still works)`);
467
+ } else if (status.embedModelAvailable === true) {
468
+ lines.push(`Embed model: loaded`);
469
+ }
470
+ lines.push(`Decay: ${config.eventDecayDays} days (event recency window)`);
471
+ if (status.expiredCount > 0) {
472
+ lines.push(`Expired: ${status.expiredCount} entries (pruned on next reindex)`);
473
+ }
321
474
 
322
475
  lines.push(``, `### Indexed`);
323
476
 
@@ -346,6 +499,17 @@ export function registerTools(server, ctx) {
346
499
  lines.push(`Auto-reindex will fix this on next search or save.`);
347
500
  }
348
501
 
502
+ // Suggested actions
503
+ const actions = [];
504
+ if (status.stalePaths) actions.push("- Run `context-mcp reindex` to fix stale paths");
505
+ if (status.embeddingStatus?.missing > 0) actions.push("- Run `context-mcp reindex` to generate missing embeddings");
506
+ if (!config.vaultDirExists) actions.push("- Run `context-mcp setup` to create the vault directory");
507
+ if (status.kindCounts.length === 0 && config.vaultDirExists) actions.push("- Use `save_context` to add your first entry");
508
+
509
+ if (actions.length) {
510
+ lines.push("", "### Suggested Actions", ...actions);
511
+ }
512
+
349
513
  return ok(lines.join("\n"));
350
514
  }
351
515
  );
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
1
  {
2
2
  "name": "context-vault",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "Persistent memory for AI agents — saves and searches knowledge across sessions",
6
6
  "bin": {
7
7
  "context-mcp": "bin/cli.js"
8
8
  },
9
9
  "main": "src/server/index.js",
10
+ "exports": {
11
+ ".": "./src/server/index.js",
12
+ "./cli": "./bin/cli.js"
13
+ },
14
+ "scripts": {
15
+ "postinstall": "node scripts/postinstall.js",
16
+ "prepack": "node scripts/prepack.js"
17
+ },
10
18
  "files": [
11
19
  "bin/",
12
20
  "src/",
21
+ "scripts/",
13
22
  "ui/",
14
23
  "README.md",
15
- "LICENSE",
16
- "smithery.yaml"
24
+ "LICENSE"
17
25
  ],
18
26
  "license": "MIT",
19
27
  "engines": { "node": ">=20" },
20
28
  "author": "Felix Hellstrom",
21
- "repository": { "type": "git", "url": "https://github.com/fellanH/context-mcp.git" },
29
+ "repository": { "type": "git", "url": "git+https://github.com/fellanH/context-mcp.git" },
22
30
  "homepage": "https://github.com/fellanH/context-mcp",
31
+ "bugs": { "url": "https://github.com/fellanH/context-mcp/issues" },
23
32
  "keywords": ["mcp", "model-context-protocol", "ai", "knowledge-base", "knowledge-management", "vault", "rag", "sqlite", "embeddings", "claude", "cursor", "cline", "windsurf"],
24
- "scripts": {
25
- "test": "vitest run",
26
- "test:watch": "vitest"
27
- },
28
- "devDependencies": {
29
- "vitest": "^3.0.0"
30
- },
33
+ "bundledDependencies": ["@context-vault/core"],
31
34
  "dependencies": {
32
- "@huggingface/transformers": "^3.0.0",
33
- "@modelcontextprotocol/sdk": "^1.26.0",
34
- "better-sqlite3": "^12.6.2",
35
- "sqlite-vec": "^0.1.0"
35
+ "@context-vault/core": "^2.4.0"
36
36
  }
37
37
  }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * postinstall.js — Auto-rebuild native modules on install
5
+ *
6
+ * Detects NODE_MODULE_VERSION mismatches and attempts a rebuild.
7
+ */
8
+
9
+ import { execSync } from "node:child_process";
10
+
11
+ async function main() {
12
+ let needsRebuild = false;
13
+
14
+ try {
15
+ await import("better-sqlite3");
16
+ } catch (e) {
17
+ if (e.message?.includes("NODE_MODULE_VERSION")) {
18
+ needsRebuild = true;
19
+ }
20
+ }
21
+
22
+ try {
23
+ await import("sqlite-vec");
24
+ } catch (e) {
25
+ if (e.message?.includes("NODE_MODULE_VERSION")) {
26
+ needsRebuild = true;
27
+ }
28
+ }
29
+
30
+ if (needsRebuild) {
31
+ console.log("[context-vault] Rebuilding native modules for Node.js " + process.version + "...");
32
+ try {
33
+ execSync("npm rebuild better-sqlite3 sqlite-vec", {
34
+ stdio: "inherit",
35
+ timeout: 60000,
36
+ });
37
+ console.log("[context-vault] Native modules rebuilt successfully.");
38
+ } catch {
39
+ console.error("[context-vault] Warning: native module rebuild failed.");
40
+ console.error("[context-vault] Try manually: npm rebuild better-sqlite3 sqlite-vec");
41
+ }
42
+ }
43
+ }
44
+
45
+ main().catch(() => {});
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * prepack.js — Cross-platform bundle preparation
5
+ *
6
+ * Copies @context-vault/core into node_modules for npm pack bundling.
7
+ * Replaces the Unix shell script in package.json "prepack".
8
+ */
9
+
10
+ import { cpSync, rmSync, mkdirSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const LOCAL_ROOT = join(__dirname, "..");
16
+ const CORE_SRC = join(LOCAL_ROOT, "..", "core");
17
+ const CORE_DEST = join(LOCAL_ROOT, "node_modules", "@context-vault", "core");
18
+
19
+ // Ensure target directory exists
20
+ mkdirSync(join(LOCAL_ROOT, "node_modules", "@context-vault"), { recursive: true });
21
+
22
+ // Remove old copy if present
23
+ rmSync(CORE_DEST, { recursive: true, force: true });
24
+
25
+ // Copy core package (dereference symlinks)
26
+ cpSync(CORE_SRC, CORE_DEST, { recursive: true, dereference: true });
27
+
28
+ // Remove nested node_modules from the copy
29
+ rmSync(join(CORE_DEST, "node_modules"), { recursive: true, force: true });
30
+
31
+ console.log("[prepack] Bundled @context-vault/core into node_modules");