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.
- package/bin/cli.js +393 -63
- package/node_modules/@context-vault/core/LICENSE +21 -0
- package/node_modules/@context-vault/core/package.json +36 -0
- package/{src → node_modules/@context-vault/core/src}/capture/index.js +3 -2
- package/{src → node_modules/@context-vault/core/src}/core/categories.js +1 -0
- package/{src → node_modules/@context-vault/core/src}/core/config.js +5 -0
- package/{src → node_modules/@context-vault/core/src}/core/files.js +1 -0
- package/{src → node_modules/@context-vault/core/src}/core/status.js +32 -8
- package/node_modules/@context-vault/core/src/index/db.js +245 -0
- package/node_modules/@context-vault/core/src/index/embed.js +87 -0
- package/{src → node_modules/@context-vault/core/src}/index/index.js +51 -12
- package/node_modules/@context-vault/core/src/index.js +29 -0
- package/{src → node_modules/@context-vault/core/src}/retrieve/index.js +52 -43
- package/{src → node_modules/@context-vault/core/src}/server/tools.js +195 -31
- package/package.json +15 -15
- package/scripts/postinstall.js +45 -0
- package/scripts/prepack.js +31 -0
- package/src/server/index.js +127 -68
- package/ui/serve.js +7 -6
- package/README.md +0 -431
- package/smithery.yaml +0 -10
- package/src/capture/README.md +0 -23
- package/src/core/README.md +0 -20
- package/src/index/README.md +0 -28
- package/src/index/db.js +0 -139
- package/src/index/embed.js +0 -57
- package/src/retrieve/README.md +0 -19
- package/src/server/README.md +0 -44
- /package/{src → node_modules/@context-vault/core/src}/capture/file-ops.js +0 -0
- /package/{src → node_modules/@context-vault/core/src}/capture/formatters.js +0 -0
- /package/{src → node_modules/@context-vault/core/src}/core/frontmatter.js +0 -0
- /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 /
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const row
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
results.
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"@
|
|
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");
|