@context-vault/core 2.10.3 → 2.12.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.
@@ -4,6 +4,7 @@ import { indexEntry } from "../../index/index.js";
4
4
  import { categoryFor } from "../../core/categories.js";
5
5
  import { normalizeKind } from "../../core/files.js";
6
6
  import { ok, err, ensureVaultExists, ensureValidKind } from "../helpers.js";
7
+ import { maybeShowFeedbackPrompt } from "../../core/telemetry.js";
7
8
  import {
8
9
  MAX_BODY_LENGTH,
9
10
  MAX_TITLE_LENGTH,
@@ -15,6 +16,63 @@ import {
15
16
  MAX_IDENTITY_KEY_LENGTH,
16
17
  } from "../../constants.js";
17
18
 
19
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.85;
20
+
21
+ async function findSimilar(ctx, embedding, threshold, userId) {
22
+ try {
23
+ const vecCount = ctx.db
24
+ .prepare("SELECT COUNT(*) as c FROM vault_vec")
25
+ .get().c;
26
+ if (vecCount === 0) return [];
27
+
28
+ const vecRows = ctx.db
29
+ .prepare(
30
+ `SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT ?`,
31
+ )
32
+ .all(embedding, 10);
33
+
34
+ if (!vecRows.length) return [];
35
+
36
+ const rowids = vecRows.map((vr) => vr.rowid);
37
+ const placeholders = rowids.map(() => "?").join(",");
38
+ const hydrated = ctx.db
39
+ .prepare(
40
+ `SELECT rowid, id, title, category, user_id FROM vault WHERE rowid IN (${placeholders})`,
41
+ )
42
+ .all(...rowids);
43
+
44
+ const byRowid = new Map();
45
+ for (const row of hydrated) byRowid.set(row.rowid, row);
46
+
47
+ const results = [];
48
+ for (const vr of vecRows) {
49
+ const similarity = Math.max(0, 1 - vr.distance / 2);
50
+ if (similarity < threshold) continue;
51
+ const row = byRowid.get(vr.rowid);
52
+ if (!row) continue;
53
+ if (userId !== undefined && row.user_id !== userId) continue;
54
+ if (row.category === "entity") continue;
55
+ results.push({ id: row.id, title: row.title, score: similarity });
56
+ }
57
+ return results;
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ function formatSimilarWarning(similar) {
64
+ const lines = ["", "⚠ Similar entries already exist:"];
65
+ for (const e of similar) {
66
+ const score = e.score.toFixed(2);
67
+ const title = e.title ? `"${e.title}"` : "(no title)";
68
+ lines.push(` - ${title} (${score}) — id: ${e.id}`);
69
+ }
70
+ lines.push(
71
+ " Consider using `id: <existing>` in save_context to update instead.",
72
+ );
73
+ return lines.join("\n");
74
+ }
75
+
18
76
  /**
19
77
  * Validate input fields for save_context. Returns an error response or null.
20
78
  */
@@ -150,6 +208,26 @@ export const inputSchema = {
150
208
  "Required for entity kinds (contact, project, tool, source). The unique identifier for this entity.",
151
209
  ),
152
210
  expires_at: z.string().optional().describe("ISO date for TTL expiry"),
211
+ supersedes: z
212
+ .array(z.string())
213
+ .optional()
214
+ .describe(
215
+ "Array of entry IDs that this entry supersedes/replaces. Those entries will be marked with superseded_by pointing to this new entry and excluded from future search results by default.",
216
+ ),
217
+ dry_run: z
218
+ .boolean()
219
+ .optional()
220
+ .describe(
221
+ "If true, check for similar entries without saving. Returns similarity results without creating a new entry. Only applies to knowledge and event categories.",
222
+ ),
223
+ similarity_threshold: z
224
+ .number()
225
+ .min(0)
226
+ .max(1)
227
+ .optional()
228
+ .describe(
229
+ "Cosine similarity threshold for duplicate detection (0–1, default 0.85). Entries above this score are flagged as similar. Only applies to knowledge and event categories.",
230
+ ),
153
231
  };
154
232
 
155
233
  /**
@@ -169,6 +247,9 @@ export async function handler(
169
247
  source,
170
248
  identity_key,
171
249
  expires_at,
250
+ supersedes,
251
+ dry_run,
252
+ similarity_threshold,
172
253
  },
173
254
  ctx,
174
255
  { ensureIndexed },
@@ -231,6 +312,7 @@ export async function handler(
231
312
  meta,
232
313
  source,
233
314
  expires_at,
315
+ supersedes,
234
316
  });
235
317
  await indexEntry(ctx, entry);
236
318
  const relPath = entry.filePath
@@ -261,25 +343,45 @@ export async function handler(
261
343
  );
262
344
  }
263
345
 
264
- // Hosted tier limit enforcement (skipped in local mode — no checkLimits on ctx)
265
- if (ctx.checkLimits) {
266
- const usage = ctx.checkLimits();
267
- if (usage.entryCount >= usage.maxEntries) {
268
- return err(
269
- `Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`,
270
- "LIMIT_EXCEEDED",
346
+ await ensureIndexed();
347
+
348
+ // ── Similarity check (knowledge + event only) ────────────────────────────
349
+ const category = categoryFor(normalizedKind);
350
+ let similarEntries = [];
351
+
352
+ if (category === "knowledge" || category === "event") {
353
+ const threshold = similarity_threshold ?? DEFAULT_SIMILARITY_THRESHOLD;
354
+ const embeddingText = [title, body].filter(Boolean).join(" ");
355
+ const queryEmbedding = await ctx.embed(embeddingText);
356
+ if (queryEmbedding) {
357
+ similarEntries = await findSimilar(
358
+ ctx,
359
+ queryEmbedding,
360
+ threshold,
361
+ userId,
271
362
  );
272
363
  }
273
- if (usage.storageMb >= usage.maxStorageMb) {
274
- return err(
275
- `Storage limit reached (${usage.maxStorageMb} MB). Upgrade to Pro for more storage.`,
276
- "LIMIT_EXCEEDED",
364
+ }
365
+
366
+ if (dry_run) {
367
+ const parts = ["(dry run — nothing saved)"];
368
+ if (similarEntries.length) {
369
+ parts.push("", "⚠ Similar entries already exist:");
370
+ for (const e of similarEntries) {
371
+ const score = e.score.toFixed(2);
372
+ const titleDisplay = e.title ? `"${e.title}"` : "(no title)";
373
+ parts.push(` - ${titleDisplay} (${score}) — id: ${e.id}`);
374
+ }
375
+ parts.push(
376
+ "",
377
+ "Use save_context with `id: <existing>` to update one, or omit `dry_run` to save as new.",
277
378
  );
379
+ } else {
380
+ parts.push("", "No similar entries found. Safe to save.");
278
381
  }
382
+ return ok(parts.join("\n"));
279
383
  }
280
384
 
281
- await ensureIndexed();
282
-
283
385
  const mergedMeta = { ...(meta || {}) };
284
386
  if (folder) mergedMeta.folder = folder;
285
387
  const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
@@ -294,8 +396,14 @@ export async function handler(
294
396
  folder,
295
397
  identity_key,
296
398
  expires_at,
399
+ supersedes,
297
400
  userId,
298
401
  });
402
+
403
+ if (ctx.config?.dataDir) {
404
+ maybeShowFeedbackPrompt(ctx.config.dataDir);
405
+ }
406
+
299
407
  const relPath = entry.filePath
300
408
  ? entry.filePath.replace(config.vaultDir + "/", "")
301
409
  : entry.filePath;
@@ -303,5 +411,28 @@ export async function handler(
303
411
  if (title) parts.push(` title: ${title}`);
304
412
  if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
305
413
  parts.push("", "_Use this id to update or delete later._");
414
+ if (similarEntries.length) {
415
+ parts.push(formatSimilarWarning(similarEntries));
416
+ }
417
+
418
+ const criticalLimit = config.thresholds?.totalEntries?.critical;
419
+ if (criticalLimit != null) {
420
+ try {
421
+ const countRow = ctx.db
422
+ .prepare(
423
+ userId !== undefined
424
+ ? "SELECT COUNT(*) as c FROM vault WHERE user_id = ?"
425
+ : "SELECT COUNT(*) as c FROM vault",
426
+ )
427
+ .get(...(userId !== undefined ? [userId] : []));
428
+ if (countRow.c >= criticalLimit) {
429
+ parts.push(
430
+ ``,
431
+ `ℹ Vault has ${countRow.c.toLocaleString()} entries. Consider running \`context-vault reindex\` or reviewing old entries.`,
432
+ );
433
+ }
434
+ } catch {}
435
+ }
436
+
306
437
  return ok(parts.join("\n"));
307
438
  }
@@ -1,6 +1,7 @@
1
1
  import { reindex } from "../index/index.js";
2
2
  import { captureAndIndex } from "../capture/index.js";
3
3
  import { err } from "./helpers.js";
4
+ import { sendTelemetryEvent } from "../core/telemetry.js";
4
5
  import pkg from "../../package.json" with { type: "json" };
5
6
 
6
7
  import * as getContext from "./tools/get-context.js";
@@ -57,6 +58,12 @@ export function registerTools(server, ctx) {
57
58
  timestamp: Date.now(),
58
59
  };
59
60
  }
61
+ sendTelemetryEvent(ctx.config, {
62
+ event: "tool_error",
63
+ code: "TIMEOUT",
64
+ tool: toolName,
65
+ cv_version: pkg.version,
66
+ });
60
67
  return err(
61
68
  "Tool timed out after 60s. Try a simpler query or run `context-vault reindex` first.",
62
69
  "TIMEOUT",
@@ -70,6 +77,12 @@ export function registerTools(server, ctx) {
70
77
  timestamp: Date.now(),
71
78
  };
72
79
  }
80
+ sendTelemetryEvent(ctx.config, {
81
+ event: "tool_error",
82
+ code: "UNKNOWN",
83
+ tool: toolName,
84
+ cv_version: pkg.version,
85
+ });
73
86
  try {
74
87
  await captureAndIndex(ctx, {
75
88
  kind: "feedback",