@context-vault/core 2.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+ import { captureAndIndex } from "../../capture/index.js";
3
+ import { ok, err, ensureVaultExists } from "../helpers.js";
4
+ import {
5
+ MAX_KIND_LENGTH,
6
+ MAX_TAG_LENGTH,
7
+ MAX_TAGS_COUNT,
8
+ } from "../../constants.js";
9
+
10
+ const MAX_URL_LENGTH = 2048;
11
+
12
+ export const name = "ingest_url";
13
+
14
+ export const description =
15
+ "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.";
16
+
17
+ export const inputSchema = {
18
+ url: z.string().describe("The URL to fetch and save"),
19
+ kind: z.string().optional().describe("Entry kind (default: reference)"),
20
+ tags: z.array(z.string()).optional().describe("Tags for the entry"),
21
+ };
22
+
23
+ /**
24
+ * @param {object} args
25
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
26
+ * @param {import('../types.js').ToolShared} shared
27
+ */
28
+ export async function handler(
29
+ { url: targetUrl, kind, tags },
30
+ ctx,
31
+ { ensureIndexed },
32
+ ) {
33
+ const { config } = ctx;
34
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
35
+
36
+ const vaultErr = ensureVaultExists(config);
37
+ if (vaultErr) return vaultErr;
38
+
39
+ if (!targetUrl?.trim())
40
+ return err("Required: url (non-empty string)", "INVALID_INPUT");
41
+ if (targetUrl.length > MAX_URL_LENGTH)
42
+ return err(`url must be under ${MAX_URL_LENGTH} chars`, "INVALID_INPUT");
43
+ if (kind !== undefined && kind !== null) {
44
+ if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
45
+ return err(
46
+ `kind must be a string, max ${MAX_KIND_LENGTH} chars`,
47
+ "INVALID_INPUT",
48
+ );
49
+ }
50
+ }
51
+ if (tags !== undefined && tags !== null) {
52
+ if (!Array.isArray(tags))
53
+ return err("tags must be an array of strings", "INVALID_INPUT");
54
+ if (tags.length > MAX_TAGS_COUNT)
55
+ return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
56
+ for (const tag of tags) {
57
+ if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
58
+ return err(
59
+ `each tag must be a string, max ${MAX_TAG_LENGTH} chars`,
60
+ "INVALID_INPUT",
61
+ );
62
+ }
63
+ }
64
+ }
65
+
66
+ await ensureIndexed();
67
+
68
+ // Hosted tier limit enforcement
69
+ if (ctx.checkLimits) {
70
+ const usage = ctx.checkLimits();
71
+ if (usage.entryCount >= usage.maxEntries) {
72
+ return err(
73
+ `Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`,
74
+ "LIMIT_EXCEEDED",
75
+ );
76
+ }
77
+ }
78
+
79
+ try {
80
+ const { ingestUrl } = await import("../../capture/ingest-url.js");
81
+ const entryData = await ingestUrl(targetUrl, { kind, tags });
82
+ const entry = await captureAndIndex(ctx, { ...entryData, userId });
83
+ const relPath = entry.filePath
84
+ ? entry.filePath.replace(config.vaultDir + "/", "")
85
+ : entry.filePath;
86
+ const parts = [
87
+ `✓ Ingested URL → ${relPath}`,
88
+ ` id: ${entry.id}`,
89
+ ` title: ${entry.title || "(untitled)"}`,
90
+ ` source: ${entry.source || targetUrl}`,
91
+ ];
92
+ if (entry.tags?.length) parts.push(` tags: ${entry.tags.join(", ")}`);
93
+ parts.push(` body: ${entry.body?.length || 0} chars`);
94
+ parts.push("", "_Use this id to update or delete later._");
95
+ return ok(parts.join("\n"));
96
+ } catch (e) {
97
+ return err(`Failed to ingest URL: ${e.message}`, "INGEST_FAILED");
98
+ }
99
+ }
@@ -0,0 +1,134 @@
1
+ import { z } from "zod";
2
+ import { normalizeKind } from "../../core/files.js";
3
+ import { ok } from "../helpers.js";
4
+
5
+ export const name = "list_context";
6
+
7
+ export const description =
8
+ "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.";
9
+
10
+ export const inputSchema = {
11
+ kind: z
12
+ .string()
13
+ .optional()
14
+ .describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
15
+ category: z
16
+ .enum(["knowledge", "entity", "event"])
17
+ .optional()
18
+ .describe("Filter by category"),
19
+ tags: z
20
+ .array(z.string())
21
+ .optional()
22
+ .describe("Filter by tags (entries must match at least one)"),
23
+ since: z
24
+ .string()
25
+ .optional()
26
+ .describe("ISO date, return entries created after this"),
27
+ until: z
28
+ .string()
29
+ .optional()
30
+ .describe("ISO date, return entries created before this"),
31
+ limit: z
32
+ .number()
33
+ .optional()
34
+ .describe("Max results to return (default 20, max 100)"),
35
+ offset: z.number().optional().describe("Skip first N results for pagination"),
36
+ };
37
+
38
+ /**
39
+ * @param {object} args
40
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
41
+ * @param {import('../types.js').ToolShared} shared
42
+ */
43
+ export async function handler(
44
+ { kind, category, tags, since, until, limit, offset },
45
+ ctx,
46
+ { ensureIndexed, reindexFailed },
47
+ ) {
48
+ const { config } = ctx;
49
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
50
+
51
+ await ensureIndexed();
52
+
53
+ const clauses = [];
54
+ const params = [];
55
+
56
+ if (userId !== undefined) {
57
+ clauses.push("user_id = ?");
58
+ params.push(userId);
59
+ }
60
+ if (kind) {
61
+ clauses.push("kind = ?");
62
+ params.push(normalizeKind(kind));
63
+ }
64
+ if (category) {
65
+ clauses.push("category = ?");
66
+ params.push(category);
67
+ }
68
+ if (since) {
69
+ clauses.push("created_at >= ?");
70
+ params.push(since);
71
+ }
72
+ if (until) {
73
+ clauses.push("created_at <= ?");
74
+ params.push(until);
75
+ }
76
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
77
+
78
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
79
+ const effectiveLimit = Math.min(limit || 20, 100);
80
+ const effectiveOffset = offset || 0;
81
+ // When tag-filtering, over-fetch to compensate for post-filter reduction
82
+ const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
83
+
84
+ const countParams = [...params];
85
+ const total = ctx.db
86
+ .prepare(`SELECT COUNT(*) as c FROM vault ${where}`)
87
+ .get(...countParams).c;
88
+
89
+ params.push(fetchLimit, effectiveOffset);
90
+ const rows = ctx.db
91
+ .prepare(
92
+ `SELECT id, title, kind, category, tags, created_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
93
+ )
94
+ .all(...params);
95
+
96
+ // Post-filter by tags if provided, then apply requested limit
97
+ const filtered = tags?.length
98
+ ? rows
99
+ .filter((r) => {
100
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
101
+ return tags.some((t) => entryTags.includes(t));
102
+ })
103
+ .slice(0, effectiveLimit)
104
+ : rows;
105
+
106
+ if (!filtered.length)
107
+ return ok("No entries found matching the given filters.");
108
+
109
+ const lines = [];
110
+ if (reindexFailed)
111
+ lines.push(
112
+ `> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`,
113
+ );
114
+ lines.push(`## Vault Entries (${filtered.length} shown, ${total} total)\n`);
115
+ for (const r of filtered) {
116
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
117
+ const tagStr = entryTags.length ? entryTags.join(", ") : "none";
118
+ lines.push(
119
+ `- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${r.created_at} — \`${r.id}\``,
120
+ );
121
+ if (r.preview)
122
+ lines.push(
123
+ ` ${r.preview.replace(/\n+/g, " ").trim()}${r.preview.length >= 120 ? "…" : ""}`,
124
+ );
125
+ }
126
+
127
+ if (effectiveOffset + effectiveLimit < total) {
128
+ lines.push(
129
+ `\n_Page ${Math.floor(effectiveOffset / effectiveLimit) + 1}. Use offset: ${effectiveOffset + effectiveLimit} for next page._`,
130
+ );
131
+ }
132
+
133
+ return ok(lines.join("\n"));
134
+ }
@@ -0,0 +1,297 @@
1
+ import { z } from "zod";
2
+ import { captureAndIndex, updateEntryFile } from "../../capture/index.js";
3
+ import { indexEntry } from "../../index/index.js";
4
+ import { categoryFor } from "../../core/categories.js";
5
+ import { normalizeKind } from "../../core/files.js";
6
+ import { ok, err, ensureVaultExists, ensureValidKind } from "../helpers.js";
7
+ import {
8
+ MAX_BODY_LENGTH,
9
+ MAX_TITLE_LENGTH,
10
+ MAX_KIND_LENGTH,
11
+ MAX_TAG_LENGTH,
12
+ MAX_TAGS_COUNT,
13
+ MAX_META_LENGTH,
14
+ MAX_SOURCE_LENGTH,
15
+ MAX_IDENTITY_KEY_LENGTH,
16
+ } from "../../constants.js";
17
+
18
+ /**
19
+ * Validate input fields for save_context. Returns an error response or null.
20
+ */
21
+ function validateSaveInput({
22
+ kind,
23
+ title,
24
+ body,
25
+ tags,
26
+ meta,
27
+ source,
28
+ identity_key,
29
+ }) {
30
+ if (kind !== undefined && kind !== null) {
31
+ if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
32
+ return err(
33
+ `kind must be a string, max ${MAX_KIND_LENGTH} chars`,
34
+ "INVALID_INPUT",
35
+ );
36
+ }
37
+ }
38
+ if (body !== undefined && body !== null) {
39
+ if (typeof body !== "string" || body.length > MAX_BODY_LENGTH) {
40
+ return err(
41
+ `body must be a string, max ${MAX_BODY_LENGTH / 1024}KB`,
42
+ "INVALID_INPUT",
43
+ );
44
+ }
45
+ }
46
+ if (title !== undefined && title !== null) {
47
+ if (typeof title !== "string" || title.length > MAX_TITLE_LENGTH) {
48
+ return err(
49
+ `title must be a string, max ${MAX_TITLE_LENGTH} chars`,
50
+ "INVALID_INPUT",
51
+ );
52
+ }
53
+ }
54
+ if (tags !== undefined && tags !== null) {
55
+ if (!Array.isArray(tags))
56
+ return err("tags must be an array of strings", "INVALID_INPUT");
57
+ if (tags.length > MAX_TAGS_COUNT)
58
+ return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
59
+ for (const tag of tags) {
60
+ if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
61
+ return err(
62
+ `each tag must be a string, max ${MAX_TAG_LENGTH} chars`,
63
+ "INVALID_INPUT",
64
+ );
65
+ }
66
+ }
67
+ }
68
+ if (meta !== undefined && meta !== null) {
69
+ const metaStr = JSON.stringify(meta);
70
+ if (metaStr.length > MAX_META_LENGTH) {
71
+ return err(
72
+ `meta must be under ${MAX_META_LENGTH / 1024}KB when serialized`,
73
+ "INVALID_INPUT",
74
+ );
75
+ }
76
+ }
77
+ if (source !== undefined && source !== null) {
78
+ if (typeof source !== "string" || source.length > MAX_SOURCE_LENGTH) {
79
+ return err(
80
+ `source must be a string, max ${MAX_SOURCE_LENGTH} chars`,
81
+ "INVALID_INPUT",
82
+ );
83
+ }
84
+ }
85
+ if (identity_key !== undefined && identity_key !== null) {
86
+ if (
87
+ typeof identity_key !== "string" ||
88
+ identity_key.length > MAX_IDENTITY_KEY_LENGTH
89
+ ) {
90
+ return err(
91
+ `identity_key must be a string, max ${MAX_IDENTITY_KEY_LENGTH} chars`,
92
+ "INVALID_INPUT",
93
+ );
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ export const name = "save_context";
100
+
101
+ export const description =
102
+ "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.";
103
+
104
+ export const inputSchema = {
105
+ id: z
106
+ .string()
107
+ .optional()
108
+ .describe(
109
+ "Entry ULID to update. When provided, updates the existing entry instead of creating new. Omitted fields are preserved.",
110
+ ),
111
+ kind: z
112
+ .string()
113
+ .optional()
114
+ .describe(
115
+ "Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind). Required for new entries.",
116
+ ),
117
+ title: z.string().optional().describe("Entry title (optional for insights)"),
118
+ body: z
119
+ .string()
120
+ .optional()
121
+ .describe("Main content. Required for new entries."),
122
+ tags: z
123
+ .array(z.string())
124
+ .optional()
125
+ .describe("Tags for categorization and search"),
126
+ meta: z
127
+ .any()
128
+ .optional()
129
+ .describe(
130
+ "Additional structured metadata (JSON object, e.g. { language: 'js', status: 'accepted' })",
131
+ ),
132
+ folder: z
133
+ .string()
134
+ .optional()
135
+ .describe("Subfolder within the kind directory (e.g. 'react/hooks')"),
136
+ source: z.string().optional().describe("Where this knowledge came from"),
137
+ identity_key: z
138
+ .string()
139
+ .optional()
140
+ .describe(
141
+ "Required for entity kinds (contact, project, tool, source). The unique identifier for this entity.",
142
+ ),
143
+ expires_at: z.string().optional().describe("ISO date for TTL expiry"),
144
+ };
145
+
146
+ /**
147
+ * @param {object} args
148
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
149
+ * @param {import('../types.js').ToolShared} shared
150
+ */
151
+ export async function handler(
152
+ {
153
+ id,
154
+ kind,
155
+ title,
156
+ body,
157
+ tags,
158
+ meta,
159
+ folder,
160
+ source,
161
+ identity_key,
162
+ expires_at,
163
+ },
164
+ ctx,
165
+ { ensureIndexed },
166
+ ) {
167
+ const { config } = ctx;
168
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
169
+
170
+ const vaultErr = ensureVaultExists(config);
171
+ if (vaultErr) return vaultErr;
172
+
173
+ const inputErr = validateSaveInput({
174
+ kind,
175
+ title,
176
+ body,
177
+ tags,
178
+ meta,
179
+ source,
180
+ identity_key,
181
+ });
182
+ if (inputErr) return inputErr;
183
+
184
+ // ── Update mode ──
185
+ if (id) {
186
+ await ensureIndexed();
187
+
188
+ const existing = ctx.stmts.getEntryById.get(id);
189
+ if (!existing) return err(`Entry not found: ${id}`, "NOT_FOUND");
190
+
191
+ // Ownership check: don't leak existence across users
192
+ if (userId !== undefined && existing.user_id !== userId) {
193
+ return err(`Entry not found: ${id}`, "NOT_FOUND");
194
+ }
195
+
196
+ if (kind && normalizeKind(kind) !== existing.kind) {
197
+ return err(
198
+ `Cannot change kind (current: "${existing.kind}"). Delete and re-create instead.`,
199
+ "INVALID_UPDATE",
200
+ );
201
+ }
202
+ if (identity_key && identity_key !== existing.identity_key) {
203
+ return err(
204
+ `Cannot change identity_key (current: "${existing.identity_key}"). Delete and re-create instead.`,
205
+ "INVALID_UPDATE",
206
+ );
207
+ }
208
+
209
+ // Decrypt existing entry before merge if encrypted
210
+ if (ctx.decrypt && existing.body_encrypted) {
211
+ const decrypted = await ctx.decrypt(existing);
212
+ existing.body = decrypted.body;
213
+ if (decrypted.title) existing.title = decrypted.title;
214
+ if (decrypted.meta) existing.meta = JSON.stringify(decrypted.meta);
215
+ }
216
+
217
+ const entry = updateEntryFile(ctx, existing, {
218
+ title,
219
+ body,
220
+ tags,
221
+ meta,
222
+ source,
223
+ expires_at,
224
+ });
225
+ await indexEntry(ctx, entry);
226
+ const relPath = entry.filePath
227
+ ? entry.filePath.replace(config.vaultDir + "/", "")
228
+ : entry.filePath;
229
+ const parts = [`✓ Updated ${entry.kind} → ${relPath}`, ` id: ${entry.id}`];
230
+ if (entry.title) parts.push(` title: ${entry.title}`);
231
+ const entryTags = entry.tags || [];
232
+ if (entryTags.length) parts.push(` tags: ${entryTags.join(", ")}`);
233
+ parts.push("", "_Search with get_context to verify changes._");
234
+ return ok(parts.join("\n"));
235
+ }
236
+
237
+ // ── Create mode ──
238
+ if (!kind) return err("Required: kind (for new entries)", "INVALID_INPUT");
239
+ const kindErr = ensureValidKind(kind);
240
+ if (kindErr) return kindErr;
241
+ if (!body?.trim())
242
+ return err("Required: body (for new entries)", "INVALID_INPUT");
243
+
244
+ // Normalize kind to canonical singular form (e.g. "insights" → "insight")
245
+ const normalizedKind = normalizeKind(kind);
246
+
247
+ if (categoryFor(normalizedKind) === "entity" && !identity_key) {
248
+ return err(
249
+ `Entity kind "${normalizedKind}" requires identity_key`,
250
+ "MISSING_IDENTITY_KEY",
251
+ );
252
+ }
253
+
254
+ // Hosted tier limit enforcement (skipped in local mode — no checkLimits on ctx)
255
+ if (ctx.checkLimits) {
256
+ const usage = ctx.checkLimits();
257
+ if (usage.entryCount >= usage.maxEntries) {
258
+ return err(
259
+ `Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`,
260
+ "LIMIT_EXCEEDED",
261
+ );
262
+ }
263
+ if (usage.storageMb >= usage.maxStorageMb) {
264
+ return err(
265
+ `Storage limit reached (${usage.maxStorageMb} MB). Upgrade to Pro for more storage.`,
266
+ "LIMIT_EXCEEDED",
267
+ );
268
+ }
269
+ }
270
+
271
+ await ensureIndexed();
272
+
273
+ const mergedMeta = { ...(meta || {}) };
274
+ if (folder) mergedMeta.folder = folder;
275
+ const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
276
+
277
+ const entry = await captureAndIndex(ctx, {
278
+ kind: normalizedKind,
279
+ title,
280
+ body,
281
+ meta: finalMeta,
282
+ tags,
283
+ source,
284
+ folder,
285
+ identity_key,
286
+ expires_at,
287
+ userId,
288
+ });
289
+ const relPath = entry.filePath
290
+ ? entry.filePath.replace(config.vaultDir + "/", "")
291
+ : entry.filePath;
292
+ const parts = [`✓ Saved ${normalizedKind} → ${relPath}`, ` id: ${entry.id}`];
293
+ if (title) parts.push(` title: ${title}`);
294
+ if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
295
+ parts.push("", "_Use this id to update or delete later._");
296
+ return ok(parts.join("\n"));
297
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ import { captureAndIndex } from "../../capture/index.js";
3
+ import { ok, ensureVaultExists } from "../helpers.js";
4
+
5
+ export const name = "submit_feedback";
6
+
7
+ export const description =
8
+ "Report a bug, request a feature, or suggest an improvement. Feedback is stored in the vault and triaged by the development pipeline.";
9
+
10
+ export const inputSchema = {
11
+ type: z.enum(["bug", "feature", "improvement"]).describe("Type of feedback"),
12
+ title: z.string().describe("Short summary of the feedback"),
13
+ body: z.string().describe("Detailed description"),
14
+ severity: z
15
+ .enum(["low", "medium", "high"])
16
+ .optional()
17
+ .describe("Severity level (default: medium)"),
18
+ };
19
+
20
+ /**
21
+ * @param {object} args
22
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
23
+ * @param {import('../types.js').ToolShared} shared
24
+ */
25
+ export async function handler(
26
+ { type, title, body, severity },
27
+ ctx,
28
+ { ensureIndexed },
29
+ ) {
30
+ const { config } = ctx;
31
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
32
+
33
+ const vaultErr = ensureVaultExists(config);
34
+ if (vaultErr) return vaultErr;
35
+
36
+ await ensureIndexed();
37
+
38
+ const effectiveSeverity = severity || "medium";
39
+ const entry = await captureAndIndex(ctx, {
40
+ kind: "feedback",
41
+ title,
42
+ body,
43
+ tags: [type, effectiveSeverity],
44
+ source: "submit_feedback",
45
+ meta: { feedback_type: type, severity: effectiveSeverity, status: "new" },
46
+ userId,
47
+ });
48
+
49
+ const relPath = entry.filePath
50
+ ? entry.filePath.replace(config.vaultDir + "/", "")
51
+ : entry.filePath;
52
+ return ok(
53
+ `Feedback submitted: ${type} [${effectiveSeverity}] → ${relPath}\n id: ${entry.id}\n title: ${title}`,
54
+ );
55
+ }
@@ -0,0 +1,111 @@
1
+ import { reindex } from "../index/index.js";
2
+ import { err } from "./helpers.js";
3
+
4
+ import * as getContext from "./tools/get-context.js";
5
+ import * as saveContext from "./tools/save-context.js";
6
+ import * as listContext from "./tools/list-context.js";
7
+ import * as deleteContext from "./tools/delete-context.js";
8
+ import * as submitFeedback from "./tools/submit-feedback.js";
9
+ import * as ingestUrl from "./tools/ingest-url.js";
10
+ import * as contextStatus from "./tools/context-status.js";
11
+
12
+ const toolModules = [
13
+ getContext,
14
+ saveContext,
15
+ listContext,
16
+ deleteContext,
17
+ submitFeedback,
18
+ ingestUrl,
19
+ contextStatus,
20
+ ];
21
+
22
+ const TOOL_TIMEOUT_MS = 60_000;
23
+
24
+ export function registerTools(server, ctx) {
25
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
26
+
27
+ function tracked(handler) {
28
+ return async (...args) => {
29
+ if (ctx.activeOps) ctx.activeOps.count++;
30
+ let timer;
31
+ try {
32
+ return await Promise.race([
33
+ Promise.resolve(handler(...args)),
34
+ new Promise((_, reject) => {
35
+ timer = setTimeout(
36
+ () => reject(new Error("TOOL_TIMEOUT")),
37
+ TOOL_TIMEOUT_MS,
38
+ );
39
+ }),
40
+ ]);
41
+ } catch (e) {
42
+ if (e.message === "TOOL_TIMEOUT") {
43
+ return err(
44
+ "Tool timed out after 60s. Try a simpler query or run `context-vault reindex` first.",
45
+ "TIMEOUT",
46
+ );
47
+ }
48
+ throw e;
49
+ } finally {
50
+ clearTimeout(timer);
51
+ if (ctx.activeOps) ctx.activeOps.count--;
52
+ }
53
+ };
54
+ }
55
+
56
+ // In hosted mode, skip reindex — DB is always in sync via writeEntry→indexEntry
57
+ let reindexDone = userId !== undefined ? true : false;
58
+ let reindexPromise = null;
59
+ let reindexAttempts = 0;
60
+ let reindexFailed = false;
61
+ const MAX_REINDEX_ATTEMPTS = 2;
62
+
63
+ async function ensureIndexed() {
64
+ if (reindexDone) return;
65
+ if (reindexPromise) return reindexPromise;
66
+ // Assign promise synchronously to prevent concurrent calls from both entering reindex()
67
+ const promise = reindex(ctx, { fullSync: true })
68
+ .then((stats) => {
69
+ reindexDone = true;
70
+ const total = stats.added + stats.updated + stats.removed;
71
+ if (total > 0) {
72
+ console.error(
73
+ `[context-vault] Auto-reindex: +${stats.added} ~${stats.updated} -${stats.removed} (${stats.unchanged} unchanged)`,
74
+ );
75
+ }
76
+ })
77
+ .catch((e) => {
78
+ reindexAttempts++;
79
+ console.error(
80
+ `[context-vault] Auto-reindex failed (attempt ${reindexAttempts}/${MAX_REINDEX_ATTEMPTS}): ${e.message}`,
81
+ );
82
+ if (reindexAttempts >= MAX_REINDEX_ATTEMPTS) {
83
+ console.error(
84
+ `[context-vault] Giving up on auto-reindex. Run \`context-vault reindex\` manually to diagnose.`,
85
+ );
86
+ reindexDone = true;
87
+ reindexFailed = true;
88
+ } else {
89
+ reindexPromise = null; // Allow retry on next tool call
90
+ }
91
+ });
92
+ reindexPromise = promise;
93
+ return reindexPromise;
94
+ }
95
+
96
+ const shared = {
97
+ ensureIndexed,
98
+ get reindexFailed() {
99
+ return reindexFailed;
100
+ },
101
+ };
102
+
103
+ for (const mod of toolModules) {
104
+ server.tool(
105
+ mod.name,
106
+ mod.description,
107
+ mod.inputSchema,
108
+ tracked((args) => mod.handler(args, ctx, shared)),
109
+ );
110
+ }
111
+ }