context-vault 2.6.1 → 2.7.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/node_modules/@context-vault/core/package.json +1 -1
- package/node_modules/@context-vault/core/src/core/status.js +1 -1
- package/node_modules/@context-vault/core/src/index/index.js +3 -3
- package/node_modules/@context-vault/core/src/retrieve/index.js +1 -1
- package/node_modules/@context-vault/core/src/server/tools/context-status.js +90 -0
- package/node_modules/@context-vault/core/src/server/tools/delete-context.js +48 -0
- package/node_modules/@context-vault/core/src/server/tools/get-context.js +153 -0
- package/node_modules/@context-vault/core/src/server/tools/ingest-url.js +80 -0
- package/node_modules/@context-vault/core/src/server/tools/list-context.js +93 -0
- package/node_modules/@context-vault/core/src/server/tools/save-context.js +173 -0
- package/node_modules/@context-vault/core/src/server/tools/submit-feedback.js +49 -0
- package/node_modules/@context-vault/core/src/server/tools.js +36 -613
- package/node_modules/@context-vault/core/src/server/types.js +78 -0
- package/node_modules/@context-vault/core/src/sync/sync.js +2 -2
- package/package.json +2 -2
|
@@ -1,91 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tools.js — MCP tool registrations
|
|
2
|
+
* tools.js — MCP tool registrations (orchestrator)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* delete_context (remove), submit_feedback (bug/feature reports),
|
|
4
|
+
* Seven tools: get_context (search), save_context (write/update), list_context (browse),
|
|
5
|
+
* delete_context (remove), submit_feedback (bug/feature reports), ingest_url (fetch+save),
|
|
6
|
+
* context_status (diag).
|
|
6
7
|
* Auto-reindex runs transparently on first tool call per session.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
10
|
+
import { reindex } from "../index/index.js";
|
|
11
|
+
import { err } from "./helpers.js";
|
|
12
|
+
|
|
13
|
+
import * as getContext from "./tools/get-context.js";
|
|
14
|
+
import * as saveContext from "./tools/save-context.js";
|
|
15
|
+
import * as listContext from "./tools/list-context.js";
|
|
16
|
+
import * as deleteContext from "./tools/delete-context.js";
|
|
17
|
+
import * as submitFeedback from "./tools/submit-feedback.js";
|
|
18
|
+
import * as ingestUrl from "./tools/ingest-url.js";
|
|
19
|
+
import * as contextStatus from "./tools/context-status.js";
|
|
20
|
+
|
|
21
|
+
const toolModules = [
|
|
22
|
+
getContext,
|
|
23
|
+
saveContext,
|
|
24
|
+
listContext,
|
|
25
|
+
deleteContext,
|
|
26
|
+
submitFeedback,
|
|
27
|
+
ingestUrl,
|
|
28
|
+
contextStatus,
|
|
29
|
+
];
|
|
11
30
|
|
|
12
|
-
|
|
13
|
-
import { hybridSearch } from "../retrieve/index.js";
|
|
14
|
-
import { reindex, indexEntry } from "../index/index.js";
|
|
15
|
-
import { gatherVaultStatus } from "../core/status.js";
|
|
16
|
-
import { categoryFor } from "../core/categories.js";
|
|
17
|
-
import { normalizeKind } from "../core/files.js";
|
|
18
|
-
import { ok, err, ensureVaultExists, ensureValidKind } from "./helpers.js";
|
|
19
|
-
import { isEmbedAvailable } from "../index/embed.js";
|
|
20
|
-
|
|
21
|
-
// ─── Input size limits (mirrors hosted validation) ────────────────────────────
|
|
22
|
-
const MAX_BODY_LENGTH = 100 * 1024; // 100KB
|
|
23
|
-
const MAX_TITLE_LENGTH = 500;
|
|
24
|
-
const MAX_KIND_LENGTH = 64;
|
|
25
|
-
const MAX_TAG_LENGTH = 100;
|
|
26
|
-
const MAX_TAGS_COUNT = 20;
|
|
27
|
-
const MAX_META_LENGTH = 10 * 1024; // 10KB
|
|
28
|
-
const MAX_SOURCE_LENGTH = 200;
|
|
29
|
-
const MAX_IDENTITY_KEY_LENGTH = 200;
|
|
30
|
-
const MAX_URL_LENGTH = 2048;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Validate input fields for save_context. Returns an error response or null.
|
|
34
|
-
*/
|
|
35
|
-
function validateSaveInput({ kind, title, body, tags, meta, source, identity_key }) {
|
|
36
|
-
if (kind !== undefined && kind !== null) {
|
|
37
|
-
if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
|
|
38
|
-
return err(`kind must be a string, max ${MAX_KIND_LENGTH} chars`, "INVALID_INPUT");
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (body !== undefined && body !== null) {
|
|
42
|
-
if (typeof body !== "string" || body.length > MAX_BODY_LENGTH) {
|
|
43
|
-
return err(`body must be a string, max ${MAX_BODY_LENGTH / 1024}KB`, "INVALID_INPUT");
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
if (title !== undefined && title !== null) {
|
|
47
|
-
if (typeof title !== "string" || title.length > MAX_TITLE_LENGTH) {
|
|
48
|
-
return err(`title must be a string, max ${MAX_TITLE_LENGTH} chars`, "INVALID_INPUT");
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (tags !== undefined && tags !== null) {
|
|
52
|
-
if (!Array.isArray(tags)) return err("tags must be an array of strings", "INVALID_INPUT");
|
|
53
|
-
if (tags.length > MAX_TAGS_COUNT) return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
|
|
54
|
-
for (const tag of tags) {
|
|
55
|
-
if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
|
|
56
|
-
return err(`each tag must be a string, max ${MAX_TAG_LENGTH} chars`, "INVALID_INPUT");
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (meta !== undefined && meta !== null) {
|
|
61
|
-
const metaStr = JSON.stringify(meta);
|
|
62
|
-
if (metaStr.length > MAX_META_LENGTH) {
|
|
63
|
-
return err(`meta must be under ${MAX_META_LENGTH / 1024}KB when serialized`, "INVALID_INPUT");
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
if (source !== undefined && source !== null) {
|
|
67
|
-
if (typeof source !== "string" || source.length > MAX_SOURCE_LENGTH) {
|
|
68
|
-
return err(`source must be a string, max ${MAX_SOURCE_LENGTH} chars`, "INVALID_INPUT");
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
if (identity_key !== undefined && identity_key !== null) {
|
|
72
|
-
if (typeof identity_key !== "string" || identity_key.length > MAX_IDENTITY_KEY_LENGTH) {
|
|
73
|
-
return err(`identity_key must be a string, max ${MAX_IDENTITY_KEY_LENGTH} chars`, "INVALID_INPUT");
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
31
|
+
const TOOL_TIMEOUT_MS = 60_000;
|
|
78
32
|
|
|
79
33
|
/**
|
|
80
34
|
* Register all MCP tools on the server.
|
|
81
35
|
*
|
|
82
36
|
* @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
|
|
83
|
-
* @param {
|
|
37
|
+
* @param {import('./types.js').BaseCtx & Partial<import('./types.js').HostedCtxExtensions>} ctx
|
|
84
38
|
*/
|
|
85
|
-
const TOOL_TIMEOUT_MS = 60_000;
|
|
86
|
-
|
|
87
39
|
export function registerTools(server, ctx) {
|
|
88
|
-
const { config } = ctx;
|
|
89
40
|
const userId = ctx.userId !== undefined ? ctx.userId : undefined;
|
|
90
41
|
|
|
91
42
|
// ─── Tool wrapper: tracks in-flight ops for graceful shutdown + timeout ────
|
|
@@ -149,544 +100,16 @@ export function registerTools(server, ctx) {
|
|
|
149
100
|
return reindexPromise;
|
|
150
101
|
}
|
|
151
102
|
|
|
152
|
-
// ───
|
|
153
|
-
|
|
154
|
-
server.tool(
|
|
155
|
-
"get_context",
|
|
156
|
-
"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.",
|
|
157
|
-
{
|
|
158
|
-
query: z.string().optional().describe("Search query (natural language or keywords). Optional if filters (tags, kind, category) are provided."),
|
|
159
|
-
kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
|
|
160
|
-
category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
|
|
161
|
-
identity_key: z.string().optional().describe("For entity lookup: exact match on identity key. Requires kind."),
|
|
162
|
-
tags: z.array(z.string()).optional().describe("Filter by tags (entries must match at least one)"),
|
|
163
|
-
since: z.string().optional().describe("ISO date, return entries created after this"),
|
|
164
|
-
until: z.string().optional().describe("ISO date, return entries created before this"),
|
|
165
|
-
limit: z.number().optional().describe("Max results to return (default 10)"),
|
|
166
|
-
},
|
|
167
|
-
tracked(async ({ query, kind, category, identity_key, tags, since, until, limit }) => {
|
|
168
|
-
const hasQuery = query?.trim();
|
|
169
|
-
const hasFilters = kind || category || tags?.length || since || until || identity_key;
|
|
170
|
-
if (!hasQuery && !hasFilters) return err("Required: query or at least one filter (kind, category, tags, since, until, identity_key)", "INVALID_INPUT");
|
|
171
|
-
await ensureIndexed();
|
|
172
|
-
|
|
173
|
-
const kindFilter = kind ? normalizeKind(kind) : null;
|
|
174
|
-
|
|
175
|
-
// Gap 1: Entity exact-match by identity_key
|
|
176
|
-
if (identity_key) {
|
|
177
|
-
if (!kindFilter) return err("identity_key requires kind to be specified", "INVALID_INPUT");
|
|
178
|
-
const match = ctx.stmts.getByIdentityKey.get(kindFilter, identity_key, userId !== undefined ? userId : null);
|
|
179
|
-
if (match) {
|
|
180
|
-
const entryTags = match.tags ? JSON.parse(match.tags) : [];
|
|
181
|
-
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
182
|
-
const relPath = match.file_path && config.vaultDir ? match.file_path.replace(config.vaultDir + "/", "") : match.file_path || "n/a";
|
|
183
|
-
const lines = [
|
|
184
|
-
`## Entity Match (exact)\n`,
|
|
185
|
-
`### ${match.title || "(untitled)"} [${match.kind}/${match.category}]`,
|
|
186
|
-
`1.000 · ${tagStr} · ${relPath} · id: \`${match.id}\``,
|
|
187
|
-
match.body?.slice(0, 300) + (match.body?.length > 300 ? "..." : ""),
|
|
188
|
-
];
|
|
189
|
-
return ok(lines.join("\n"));
|
|
190
|
-
}
|
|
191
|
-
// Fall through to semantic search as fallback
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Gap 2: Event default time-window
|
|
195
|
-
const effectiveCategory = category || (kindFilter ? categoryFor(kindFilter) : null);
|
|
196
|
-
let effectiveSince = since || null;
|
|
197
|
-
let effectiveUntil = until || null;
|
|
198
|
-
let autoWindowed = false;
|
|
199
|
-
if (effectiveCategory === "event" && !since && !until) {
|
|
200
|
-
const decayMs = (config.eventDecayDays || 30) * 86400000;
|
|
201
|
-
effectiveSince = new Date(Date.now() - decayMs).toISOString();
|
|
202
|
-
autoWindowed = true;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const effectiveLimit = limit || 10;
|
|
206
|
-
// When tag-filtering, over-fetch to compensate for post-filter reduction
|
|
207
|
-
const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
|
|
208
|
-
|
|
209
|
-
let filtered;
|
|
210
|
-
if (hasQuery) {
|
|
211
|
-
// Hybrid search mode
|
|
212
|
-
const sorted = await hybridSearch(ctx, query, {
|
|
213
|
-
kindFilter,
|
|
214
|
-
categoryFilter: category || null,
|
|
215
|
-
since: effectiveSince,
|
|
216
|
-
until: effectiveUntil,
|
|
217
|
-
limit: fetchLimit,
|
|
218
|
-
decayDays: config.eventDecayDays || 30,
|
|
219
|
-
userIdFilter: userId,
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// Post-filter by tags if provided, then apply requested limit
|
|
223
|
-
filtered = tags?.length
|
|
224
|
-
? sorted.filter((r) => {
|
|
225
|
-
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
226
|
-
return tags.some((t) => entryTags.includes(t));
|
|
227
|
-
}).slice(0, effectiveLimit)
|
|
228
|
-
: sorted;
|
|
229
|
-
} else {
|
|
230
|
-
// Filter-only mode (no query, use SQL directly)
|
|
231
|
-
const clauses = [];
|
|
232
|
-
const params = [];
|
|
233
|
-
if (userId !== undefined) { clauses.push("user_id = ?"); params.push(userId); }
|
|
234
|
-
if (kindFilter) { clauses.push("kind = ?"); params.push(kindFilter); }
|
|
235
|
-
if (category) { clauses.push("category = ?"); params.push(category); }
|
|
236
|
-
if (effectiveSince) { clauses.push("created_at >= ?"); params.push(effectiveSince); }
|
|
237
|
-
if (effectiveUntil) { clauses.push("created_at <= ?"); params.push(effectiveUntil); }
|
|
238
|
-
clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
|
|
239
|
-
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
240
|
-
params.push(fetchLimit);
|
|
241
|
-
const rows = ctx.db.prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT ?`).all(...params);
|
|
242
|
-
|
|
243
|
-
// Post-filter by tags if provided, then apply requested limit
|
|
244
|
-
filtered = tags?.length
|
|
245
|
-
? rows.filter((r) => {
|
|
246
|
-
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
247
|
-
return tags.some((t) => entryTags.includes(t));
|
|
248
|
-
}).slice(0, effectiveLimit)
|
|
249
|
-
: rows;
|
|
250
|
-
|
|
251
|
-
// Add score field for consistent output
|
|
252
|
-
for (const r of filtered) r.score = 0;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (!filtered.length) return ok(hasQuery ? "No results found for: " + query : "No entries found matching the given filters.");
|
|
256
|
-
|
|
257
|
-
// Decrypt encrypted entries if ctx.decrypt is available
|
|
258
|
-
if (ctx.decrypt) {
|
|
259
|
-
for (const r of filtered) {
|
|
260
|
-
if (r.body_encrypted) {
|
|
261
|
-
const decrypted = await ctx.decrypt(r);
|
|
262
|
-
r.body = decrypted.body;
|
|
263
|
-
if (decrypted.title) r.title = decrypted.title;
|
|
264
|
-
if (decrypted.meta) r.meta = JSON.stringify(decrypted.meta);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const lines = [];
|
|
270
|
-
if (reindexFailed) lines.push(`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`);
|
|
271
|
-
if (hasQuery && isEmbedAvailable() === false) lines.push(`> **Note:** Semantic search unavailable — results ranked by keyword match only. Run \`context-vault setup\` to download the embedding model.\n`);
|
|
272
|
-
const heading = hasQuery ? `Results for "${query}"` : "Filtered entries";
|
|
273
|
-
lines.push(`## ${heading} (${filtered.length} matches)\n`);
|
|
274
|
-
for (let i = 0; i < filtered.length; i++) {
|
|
275
|
-
const r = filtered[i];
|
|
276
|
-
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
277
|
-
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
278
|
-
const relPath = r.file_path && config.vaultDir ? r.file_path.replace(config.vaultDir + "/", "") : r.file_path || "n/a";
|
|
279
|
-
lines.push(`### [${i + 1}/${filtered.length}] ${r.title || "(untitled)"} [${r.kind}/${r.category}]`);
|
|
280
|
-
lines.push(`${r.score.toFixed(3)} · ${tagStr} · ${relPath} · id: \`${r.id}\``);
|
|
281
|
-
lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
|
|
282
|
-
lines.push("");
|
|
283
|
-
}
|
|
284
|
-
if (autoWindowed) {
|
|
285
|
-
lines.push(`_Showing events from last ${config.eventDecayDays || 30} days. Use since/until for custom range._`);
|
|
286
|
-
}
|
|
287
|
-
return ok(lines.join("\n"));
|
|
288
|
-
})
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
// ─── save_context (write / update) ────────────────────────────────────────
|
|
292
|
-
|
|
293
|
-
server.tool(
|
|
294
|
-
"save_context",
|
|
295
|
-
"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.",
|
|
296
|
-
{
|
|
297
|
-
id: z.string().optional().describe("Entry ULID to update. When provided, updates the existing entry instead of creating new. Omitted fields are preserved."),
|
|
298
|
-
kind: z.string().optional().describe("Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind). Required for new entries."),
|
|
299
|
-
title: z.string().optional().describe("Entry title (optional for insights)"),
|
|
300
|
-
body: z.string().optional().describe("Main content. Required for new entries."),
|
|
301
|
-
tags: z.array(z.string()).optional().describe("Tags for categorization and search"),
|
|
302
|
-
meta: z.any().optional().describe("Additional structured metadata (JSON object, e.g. { language: 'js', status: 'accepted' })"),
|
|
303
|
-
folder: z.string().optional().describe("Subfolder within the kind directory (e.g. 'react/hooks')"),
|
|
304
|
-
source: z.string().optional().describe("Where this knowledge came from"),
|
|
305
|
-
identity_key: z.string().optional().describe("Required for entity kinds (contact, project, tool, source). The unique identifier for this entity."),
|
|
306
|
-
expires_at: z.string().optional().describe("ISO date for TTL expiry"),
|
|
307
|
-
},
|
|
308
|
-
tracked(async ({ id, kind, title, body, tags, meta, folder, source, identity_key, expires_at }) => {
|
|
309
|
-
const vaultErr = ensureVaultExists(config);
|
|
310
|
-
if (vaultErr) return vaultErr;
|
|
311
|
-
|
|
312
|
-
const inputErr = validateSaveInput({ kind, title, body, tags, meta, source, identity_key });
|
|
313
|
-
if (inputErr) return inputErr;
|
|
314
|
-
|
|
315
|
-
// ── Update mode ──
|
|
316
|
-
if (id) {
|
|
317
|
-
await ensureIndexed();
|
|
318
|
-
|
|
319
|
-
const existing = ctx.stmts.getEntryById.get(id);
|
|
320
|
-
if (!existing) return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
321
|
-
|
|
322
|
-
// Ownership check: don't leak existence across users
|
|
323
|
-
if (userId !== undefined && existing.user_id !== userId) {
|
|
324
|
-
return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (kind && normalizeKind(kind) !== existing.kind) {
|
|
328
|
-
return err(`Cannot change kind (current: "${existing.kind}"). Delete and re-create instead.`, "INVALID_UPDATE");
|
|
329
|
-
}
|
|
330
|
-
if (identity_key && identity_key !== existing.identity_key) {
|
|
331
|
-
return err(`Cannot change identity_key (current: "${existing.identity_key}"). Delete and re-create instead.`, "INVALID_UPDATE");
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Decrypt existing entry before merge if encrypted
|
|
335
|
-
if (ctx.decrypt && existing.body_encrypted) {
|
|
336
|
-
const decrypted = await ctx.decrypt(existing);
|
|
337
|
-
existing.body = decrypted.body;
|
|
338
|
-
if (decrypted.title) existing.title = decrypted.title;
|
|
339
|
-
if (decrypted.meta) existing.meta = JSON.stringify(decrypted.meta);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const entry = updateEntryFile(ctx, existing, { title, body, tags, meta, source, expires_at });
|
|
343
|
-
await indexEntry(ctx, entry);
|
|
344
|
-
const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
|
|
345
|
-
const parts = [`✓ Updated ${entry.kind} → ${relPath}`, ` id: ${entry.id}`];
|
|
346
|
-
if (entry.title) parts.push(` title: ${entry.title}`);
|
|
347
|
-
const entryTags = entry.tags || [];
|
|
348
|
-
if (entryTags.length) parts.push(` tags: ${entryTags.join(", ")}`);
|
|
349
|
-
parts.push("", "_Search with get_context to verify changes._");
|
|
350
|
-
return ok(parts.join("\n"));
|
|
351
|
-
}
|
|
103
|
+
// ─── Register all tool handlers ─────────────────────────────────────────────
|
|
352
104
|
|
|
353
|
-
|
|
354
|
-
if (!kind) return err("Required: kind (for new entries)", "INVALID_INPUT");
|
|
355
|
-
const kindErr = ensureValidKind(kind);
|
|
356
|
-
if (kindErr) return kindErr;
|
|
357
|
-
if (!body?.trim()) return err("Required: body (for new entries)", "INVALID_INPUT");
|
|
105
|
+
const shared = { ensureIndexed, get reindexFailed() { return reindexFailed; } };
|
|
358
106
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (ctx.checkLimits) {
|
|
368
|
-
const usage = ctx.checkLimits();
|
|
369
|
-
if (usage.entryCount >= usage.maxEntries) {
|
|
370
|
-
return err(`Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`, "LIMIT_EXCEEDED");
|
|
371
|
-
}
|
|
372
|
-
if (usage.storageMb >= usage.maxStorageMb) {
|
|
373
|
-
return err(`Storage limit reached (${usage.maxStorageMb} MB). Upgrade to Pro for more storage.`, "LIMIT_EXCEEDED");
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
await ensureIndexed();
|
|
378
|
-
|
|
379
|
-
const mergedMeta = { ...(meta || {}) };
|
|
380
|
-
if (folder) mergedMeta.folder = folder;
|
|
381
|
-
const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
|
|
382
|
-
|
|
383
|
-
const entry = await captureAndIndex(ctx, { kind: normalizedKind, title, body, meta: finalMeta, tags, source, folder, identity_key, expires_at, userId }, indexEntry);
|
|
384
|
-
const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
|
|
385
|
-
const parts = [`✓ Saved ${normalizedKind} → ${relPath}`, ` id: ${entry.id}`];
|
|
386
|
-
if (title) parts.push(` title: ${title}`);
|
|
387
|
-
if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
|
|
388
|
-
parts.push("", "_Use this id to update or delete later._");
|
|
389
|
-
return ok(parts.join("\n"));
|
|
390
|
-
})
|
|
391
|
-
);
|
|
392
|
-
|
|
393
|
-
// ─── list_context (browse) ────────────────────────────────────────────────
|
|
394
|
-
|
|
395
|
-
server.tool(
|
|
396
|
-
"list_context",
|
|
397
|
-
"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.",
|
|
398
|
-
{
|
|
399
|
-
kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
|
|
400
|
-
category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
|
|
401
|
-
tags: z.array(z.string()).optional().describe("Filter by tags (entries must match at least one)"),
|
|
402
|
-
since: z.string().optional().describe("ISO date, return entries created after this"),
|
|
403
|
-
until: z.string().optional().describe("ISO date, return entries created before this"),
|
|
404
|
-
limit: z.number().optional().describe("Max results to return (default 20, max 100)"),
|
|
405
|
-
offset: z.number().optional().describe("Skip first N results for pagination"),
|
|
406
|
-
},
|
|
407
|
-
tracked(async ({ kind, category, tags, since, until, limit, offset }) => {
|
|
408
|
-
await ensureIndexed();
|
|
409
|
-
|
|
410
|
-
const clauses = [];
|
|
411
|
-
const params = [];
|
|
412
|
-
|
|
413
|
-
if (userId !== undefined) {
|
|
414
|
-
clauses.push("user_id = ?");
|
|
415
|
-
params.push(userId);
|
|
416
|
-
}
|
|
417
|
-
if (kind) {
|
|
418
|
-
clauses.push("kind = ?");
|
|
419
|
-
params.push(normalizeKind(kind));
|
|
420
|
-
}
|
|
421
|
-
if (category) {
|
|
422
|
-
clauses.push("category = ?");
|
|
423
|
-
params.push(category);
|
|
424
|
-
}
|
|
425
|
-
if (since) {
|
|
426
|
-
clauses.push("created_at >= ?");
|
|
427
|
-
params.push(since);
|
|
428
|
-
}
|
|
429
|
-
if (until) {
|
|
430
|
-
clauses.push("created_at <= ?");
|
|
431
|
-
params.push(until);
|
|
432
|
-
}
|
|
433
|
-
clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
|
|
434
|
-
|
|
435
|
-
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
436
|
-
const effectiveLimit = Math.min(limit || 20, 100);
|
|
437
|
-
const effectiveOffset = offset || 0;
|
|
438
|
-
// When tag-filtering, over-fetch to compensate for post-filter reduction
|
|
439
|
-
const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
|
|
440
|
-
|
|
441
|
-
const countParams = [...params];
|
|
442
|
-
const total = ctx.db.prepare(`SELECT COUNT(*) as c FROM vault ${where}`).get(...countParams).c;
|
|
443
|
-
|
|
444
|
-
params.push(fetchLimit, effectiveOffset);
|
|
445
|
-
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);
|
|
446
|
-
|
|
447
|
-
// Post-filter by tags if provided, then apply requested limit
|
|
448
|
-
const filtered = tags?.length
|
|
449
|
-
? rows.filter((r) => {
|
|
450
|
-
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
451
|
-
return tags.some((t) => entryTags.includes(t));
|
|
452
|
-
}).slice(0, effectiveLimit)
|
|
453
|
-
: rows;
|
|
454
|
-
|
|
455
|
-
if (!filtered.length) return ok("No entries found matching the given filters.");
|
|
456
|
-
|
|
457
|
-
const lines = [];
|
|
458
|
-
if (reindexFailed) lines.push(`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`);
|
|
459
|
-
lines.push(`## Vault Entries (${filtered.length} shown, ${total} total)\n`);
|
|
460
|
-
for (const r of filtered) {
|
|
461
|
-
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
462
|
-
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
463
|
-
lines.push(`- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${r.created_at} — \`${r.id}\``);
|
|
464
|
-
if (r.preview) lines.push(` ${r.preview.replace(/\n+/g, " ").trim()}${r.preview.length >= 120 ? "…" : ""}`);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
if (effectiveOffset + effectiveLimit < total) {
|
|
468
|
-
lines.push(`\n_Page ${Math.floor(effectiveOffset / effectiveLimit) + 1}. Use offset: ${effectiveOffset + effectiveLimit} for next page._`);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return ok(lines.join("\n"));
|
|
472
|
-
})
|
|
473
|
-
);
|
|
474
|
-
|
|
475
|
-
// ─── delete_context (remove) ──────────────────────────────────────────────
|
|
476
|
-
|
|
477
|
-
server.tool(
|
|
478
|
-
"delete_context",
|
|
479
|
-
"Delete an entry from your vault by its ULID id. Removes the file from disk and cleans up the search index.",
|
|
480
|
-
{
|
|
481
|
-
id: z.string().describe("The entry ULID to delete"),
|
|
482
|
-
},
|
|
483
|
-
tracked(async ({ id }) => {
|
|
484
|
-
if (!id?.trim()) return err("Required: id (non-empty string)", "INVALID_INPUT");
|
|
485
|
-
await ensureIndexed();
|
|
486
|
-
|
|
487
|
-
const entry = ctx.stmts.getEntryById.get(id);
|
|
488
|
-
if (!entry) return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
489
|
-
|
|
490
|
-
// Ownership check: don't leak existence across users
|
|
491
|
-
if (userId !== undefined && entry.user_id !== userId) {
|
|
492
|
-
return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Delete file from disk first (source of truth)
|
|
496
|
-
if (entry.file_path) {
|
|
497
|
-
try { unlinkSync(entry.file_path); } catch {}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Delete vector embedding
|
|
501
|
-
const rowidResult = ctx.stmts.getRowid.get(id);
|
|
502
|
-
if (rowidResult?.rowid) {
|
|
503
|
-
try { ctx.deleteVec(Number(rowidResult.rowid)); } catch {}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Delete DB row (FTS trigger handles FTS cleanup)
|
|
507
|
-
ctx.stmts.deleteEntry.run(id);
|
|
508
|
-
|
|
509
|
-
return ok(`Deleted ${entry.kind}: ${entry.title || "(untitled)"} [${id}]`);
|
|
510
|
-
})
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
// ─── submit_feedback (bug/feature reports) ────────────────────────────────
|
|
514
|
-
|
|
515
|
-
server.tool(
|
|
516
|
-
"submit_feedback",
|
|
517
|
-
"Report a bug, request a feature, or suggest an improvement. Feedback is stored in the vault and triaged by the development pipeline.",
|
|
518
|
-
{
|
|
519
|
-
type: z.enum(["bug", "feature", "improvement"]).describe("Type of feedback"),
|
|
520
|
-
title: z.string().describe("Short summary of the feedback"),
|
|
521
|
-
body: z.string().describe("Detailed description"),
|
|
522
|
-
severity: z.enum(["low", "medium", "high"]).optional().describe("Severity level (default: medium)"),
|
|
523
|
-
},
|
|
524
|
-
tracked(async ({ type, title, body, severity }) => {
|
|
525
|
-
const vaultErr = ensureVaultExists(config);
|
|
526
|
-
if (vaultErr) return vaultErr;
|
|
527
|
-
|
|
528
|
-
await ensureIndexed();
|
|
529
|
-
|
|
530
|
-
const effectiveSeverity = severity || "medium";
|
|
531
|
-
const entry = await captureAndIndex(
|
|
532
|
-
ctx,
|
|
533
|
-
{
|
|
534
|
-
kind: "feedback",
|
|
535
|
-
title,
|
|
536
|
-
body,
|
|
537
|
-
tags: [type, effectiveSeverity],
|
|
538
|
-
source: "submit_feedback",
|
|
539
|
-
meta: { feedback_type: type, severity: effectiveSeverity, status: "new" },
|
|
540
|
-
userId,
|
|
541
|
-
},
|
|
542
|
-
indexEntry
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
|
|
546
|
-
return ok(`Feedback submitted: ${type} [${effectiveSeverity}] → ${relPath}\n id: ${entry.id}\n title: ${title}`);
|
|
547
|
-
})
|
|
548
|
-
);
|
|
549
|
-
|
|
550
|
-
// ─── ingest_url (fetch URL and save) ────────────────────────────────────────
|
|
551
|
-
|
|
552
|
-
server.tool(
|
|
553
|
-
"ingest_url",
|
|
554
|
-
"Fetch a URL, extract its readable content, and save it as a vault entry. Useful for saving articles, documentation, or web pages to your knowledge vault.",
|
|
555
|
-
{
|
|
556
|
-
url: z.string().describe("The URL to fetch and save"),
|
|
557
|
-
kind: z.string().optional().describe("Entry kind (default: reference)"),
|
|
558
|
-
tags: z.array(z.string()).optional().describe("Tags for the entry"),
|
|
559
|
-
},
|
|
560
|
-
tracked(async ({ url: targetUrl, kind, tags }) => {
|
|
561
|
-
const vaultErr = ensureVaultExists(config);
|
|
562
|
-
if (vaultErr) return vaultErr;
|
|
563
|
-
|
|
564
|
-
if (!targetUrl?.trim()) return err("Required: url (non-empty string)", "INVALID_INPUT");
|
|
565
|
-
if (targetUrl.length > MAX_URL_LENGTH) return err(`url must be under ${MAX_URL_LENGTH} chars`, "INVALID_INPUT");
|
|
566
|
-
if (kind !== undefined && kind !== null) {
|
|
567
|
-
if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
|
|
568
|
-
return err(`kind must be a string, max ${MAX_KIND_LENGTH} chars`, "INVALID_INPUT");
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
if (tags !== undefined && tags !== null) {
|
|
572
|
-
if (!Array.isArray(tags)) return err("tags must be an array of strings", "INVALID_INPUT");
|
|
573
|
-
if (tags.length > MAX_TAGS_COUNT) return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
|
|
574
|
-
for (const tag of tags) {
|
|
575
|
-
if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
|
|
576
|
-
return err(`each tag must be a string, max ${MAX_TAG_LENGTH} chars`, "INVALID_INPUT");
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
await ensureIndexed();
|
|
582
|
-
|
|
583
|
-
// Hosted tier limit enforcement
|
|
584
|
-
if (ctx.checkLimits) {
|
|
585
|
-
const usage = ctx.checkLimits();
|
|
586
|
-
if (usage.entryCount >= usage.maxEntries) {
|
|
587
|
-
return err(`Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`, "LIMIT_EXCEEDED");
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
try {
|
|
592
|
-
const { ingestUrl } = await import("../capture/ingest-url.js");
|
|
593
|
-
const entryData = await ingestUrl(targetUrl, { kind, tags });
|
|
594
|
-
const entry = await captureAndIndex(ctx, { ...entryData, userId }, indexEntry);
|
|
595
|
-
const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
|
|
596
|
-
const parts = [
|
|
597
|
-
`✓ Ingested URL → ${relPath}`,
|
|
598
|
-
` id: ${entry.id}`,
|
|
599
|
-
` title: ${entry.title || "(untitled)"}`,
|
|
600
|
-
` source: ${entry.source || targetUrl}`,
|
|
601
|
-
];
|
|
602
|
-
if (entry.tags?.length) parts.push(` tags: ${entry.tags.join(", ")}`);
|
|
603
|
-
parts.push(` body: ${entry.body?.length || 0} chars`);
|
|
604
|
-
parts.push("", "_Use this id to update or delete later._");
|
|
605
|
-
return ok(parts.join("\n"));
|
|
606
|
-
} catch (e) {
|
|
607
|
-
return err(`Failed to ingest URL: ${e.message}`, "INGEST_FAILED");
|
|
608
|
-
}
|
|
609
|
-
})
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
// ─── context_status (diagnostics) ──────────────────────────────────────────
|
|
613
|
-
|
|
614
|
-
server.tool(
|
|
615
|
-
"context_status",
|
|
616
|
-
"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.",
|
|
617
|
-
{},
|
|
618
|
-
() => {
|
|
619
|
-
const status = gatherVaultStatus(ctx, { userId });
|
|
620
|
-
|
|
621
|
-
const hasIssues = status.stalePaths || (status.embeddingStatus?.missing > 0);
|
|
622
|
-
const healthIcon = hasIssues ? "⚠" : "✓";
|
|
623
|
-
|
|
624
|
-
const lines = [
|
|
625
|
-
`## ${healthIcon} Vault Status (connected)`,
|
|
626
|
-
``,
|
|
627
|
-
`Vault: ${config.vaultDir} (${config.vaultDirExists ? status.fileCount + " files" : "missing"})`,
|
|
628
|
-
`Database: ${config.dbPath} (${status.dbSize})`,
|
|
629
|
-
`Dev dir: ${config.devDir}`,
|
|
630
|
-
`Data dir: ${config.dataDir}`,
|
|
631
|
-
`Config: ${config.configPath}`,
|
|
632
|
-
`Resolved via: ${status.resolvedFrom}`,
|
|
633
|
-
`Schema: v7 (teams)`,
|
|
634
|
-
];
|
|
635
|
-
|
|
636
|
-
if (status.embeddingStatus) {
|
|
637
|
-
const { indexed, total, missing } = status.embeddingStatus;
|
|
638
|
-
const pct = total > 0 ? Math.round((indexed / total) * 100) : 100;
|
|
639
|
-
lines.push(`Embeddings: ${indexed}/${total} (${pct}%)`);
|
|
640
|
-
}
|
|
641
|
-
if (status.embedModelAvailable === false) {
|
|
642
|
-
lines.push(`Embed model: unavailable (semantic search disabled, FTS still works)`);
|
|
643
|
-
} else if (status.embedModelAvailable === true) {
|
|
644
|
-
lines.push(`Embed model: loaded`);
|
|
645
|
-
}
|
|
646
|
-
lines.push(`Decay: ${config.eventDecayDays} days (event recency window)`);
|
|
647
|
-
if (status.expiredCount > 0) {
|
|
648
|
-
lines.push(`Expired: ${status.expiredCount} entries (pruned on next reindex)`);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
lines.push(``, `### Indexed`);
|
|
652
|
-
|
|
653
|
-
if (status.kindCounts.length) {
|
|
654
|
-
for (const { kind, c } of status.kindCounts) lines.push(`- ${c} ${kind}s`);
|
|
655
|
-
} else {
|
|
656
|
-
lines.push(`- (empty)`);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
if (status.categoryCounts.length) {
|
|
660
|
-
lines.push(``);
|
|
661
|
-
lines.push(`### Categories`);
|
|
662
|
-
for (const { category, c } of status.categoryCounts) lines.push(`- ${category}: ${c}`);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
if (status.subdirs.length) {
|
|
666
|
-
lines.push(``);
|
|
667
|
-
lines.push(`### Disk Directories`);
|
|
668
|
-
for (const { name, count } of status.subdirs) lines.push(`- ${name}/: ${count} files`);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (status.stalePaths) {
|
|
672
|
-
lines.push(``);
|
|
673
|
-
lines.push(`### ⚠ Stale Paths`);
|
|
674
|
-
lines.push(`DB contains ${status.staleCount} paths not matching current vault dir.`);
|
|
675
|
-
lines.push(`Auto-reindex will fix this on next search or save.`);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Suggested actions
|
|
679
|
-
const actions = [];
|
|
680
|
-
if (status.stalePaths) actions.push("- Run `context-vault reindex` to fix stale paths");
|
|
681
|
-
if (status.embeddingStatus?.missing > 0) actions.push("- Run `context-vault reindex` to generate missing embeddings");
|
|
682
|
-
if (!config.vaultDirExists) actions.push("- Run `context-vault setup` to create the vault directory");
|
|
683
|
-
if (status.kindCounts.length === 0 && config.vaultDirExists) actions.push("- Use `save_context` to add your first entry");
|
|
684
|
-
|
|
685
|
-
if (actions.length) {
|
|
686
|
-
lines.push("", "### Suggested Actions", ...actions);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
return ok(lines.join("\n"));
|
|
690
|
-
}
|
|
691
|
-
);
|
|
107
|
+
for (const mod of toolModules) {
|
|
108
|
+
server.tool(
|
|
109
|
+
mod.name,
|
|
110
|
+
mod.description,
|
|
111
|
+
mod.inputSchema,
|
|
112
|
+
tracked((args) => mod.handler(args, ctx, shared))
|
|
113
|
+
);
|
|
114
|
+
}
|
|
692
115
|
}
|