@context-vault/core 2.14.0 → 2.15.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.
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ import { ok } from "../helpers.js";
3
+
4
+ export const name = "list_buckets";
5
+
6
+ export const description =
7
+ "List all registered bucket entities in the vault. Buckets are named scopes used to group entries via 'bucket:' prefixed tags. Returns each bucket's name, description, parent, and optional entry count.";
8
+
9
+ export const inputSchema = {
10
+ include_counts: z
11
+ .boolean()
12
+ .optional()
13
+ .describe(
14
+ "Include count of entries tagged with each bucket (default true). Set false to skip the count queries for faster response.",
15
+ ),
16
+ };
17
+
18
+ /**
19
+ * @param {object} args
20
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
21
+ * @param {import('../types.js').ToolShared} shared
22
+ */
23
+ export async function handler(
24
+ { include_counts = true },
25
+ ctx,
26
+ { ensureIndexed, reindexFailed },
27
+ ) {
28
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
29
+
30
+ await ensureIndexed();
31
+
32
+ const userClause = userId !== undefined ? "AND user_id = ?" : "";
33
+ const userParams = userId !== undefined ? [userId] : [];
34
+
35
+ const buckets = ctx.db
36
+ .prepare(
37
+ `SELECT id, title, identity_key, body, tags, meta, created_at, updated_at
38
+ FROM vault
39
+ WHERE kind = 'bucket'
40
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
41
+ AND superseded_by IS NULL
42
+ ${userClause}
43
+ ORDER BY title ASC`,
44
+ )
45
+ .all(...userParams);
46
+
47
+ if (!buckets.length) {
48
+ return ok(
49
+ "No buckets registered.\n\nCreate one with `save_context(kind: \"bucket\", identity_key: \"bucket:myproject\", title: \"My Project\", body: \"...\")` to register a bucket.",
50
+ );
51
+ }
52
+
53
+ const lines = [];
54
+ if (reindexFailed) {
55
+ lines.push(
56
+ `> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`,
57
+ );
58
+ }
59
+ lines.push(`## Registered Buckets (${buckets.length})\n`);
60
+
61
+ for (const b of buckets) {
62
+ let meta = {};
63
+ if (b.meta) {
64
+ try {
65
+ meta = typeof b.meta === "string" ? JSON.parse(b.meta) : b.meta;
66
+ } catch {
67
+ meta = {};
68
+ }
69
+ }
70
+
71
+ const bucketTags = b.tags ? JSON.parse(b.tags) : [];
72
+ const name = b.identity_key
73
+ ? b.identity_key.replace(/^bucket:/, "")
74
+ : b.title || b.id;
75
+ const parent = meta.parent || null;
76
+
77
+ let entryCount = null;
78
+ if (include_counts && b.identity_key) {
79
+ const countUserClause =
80
+ userId !== undefined ? "AND user_id = ?" : "";
81
+ const countParams = userId !== undefined ? [userId] : [];
82
+ const row = ctx.db
83
+ .prepare(
84
+ `SELECT COUNT(*) as c FROM vault
85
+ WHERE tags LIKE ?
86
+ AND kind != 'bucket'
87
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
88
+ AND superseded_by IS NULL
89
+ ${countUserClause}`,
90
+ )
91
+ .get(`%"${b.identity_key}"%`, ...countParams);
92
+ entryCount = row ? row.c : 0;
93
+ }
94
+
95
+ const titleDisplay = b.title || name;
96
+ const headerParts = [`**${titleDisplay}**`];
97
+ if (b.identity_key) headerParts.push(`\`${b.identity_key}\``);
98
+ if (parent) headerParts.push(`parent: ${parent}`);
99
+ if (entryCount !== null) headerParts.push(`${entryCount} entries`);
100
+ lines.push(`- ${headerParts.join(" — ")}`);
101
+
102
+ if (b.body) {
103
+ const preview = b.body.replace(/\n+/g, " ").trim().slice(0, 120);
104
+ lines.push(` ${preview}${b.body.length > 120 ? "…" : ""}`);
105
+ }
106
+ if (bucketTags.length) {
107
+ lines.push(` tags: ${bucketTags.join(", ")}`);
108
+ }
109
+ }
110
+
111
+ lines.push(
112
+ "\n_Register a new bucket with `save_context(kind: \"bucket\", identity_key: \"bucket:<name>\", title: \"...\", body: \"...\")`_",
113
+ );
114
+
115
+ return ok(lines.join("\n"));
116
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { captureAndIndex, updateEntryFile } from "../../capture/index.js";
3
3
  import { indexEntry } from "../../index/index.js";
4
- import { categoryFor } from "../../core/categories.js";
4
+ import { categoryFor, defaultTierFor } from "../../core/categories.js";
5
5
  import { normalizeKind } from "../../core/files.js";
6
6
  import { ok, err, ensureVaultExists, ensureValidKind } from "../helpers.js";
7
7
  import { maybeShowFeedbackPrompt } from "../../core/telemetry.js";
@@ -17,8 +17,16 @@ import {
17
17
  } from "../../constants.js";
18
18
 
19
19
  const DEFAULT_SIMILARITY_THRESHOLD = 0.85;
20
+ const SKIP_THRESHOLD = 0.95;
21
+ const UPDATE_THRESHOLD = 0.85;
20
22
 
21
- async function findSimilar(ctx, embedding, threshold, userId) {
23
+ async function findSimilar(
24
+ ctx,
25
+ embedding,
26
+ threshold,
27
+ userId,
28
+ { hydrate = false } = {},
29
+ ) {
22
30
  try {
23
31
  const vecCount = ctx.db
24
32
  .prepare("SELECT COUNT(*) as c FROM vault_vec")
@@ -35,14 +43,15 @@ async function findSimilar(ctx, embedding, threshold, userId) {
35
43
 
36
44
  const rowids = vecRows.map((vr) => vr.rowid);
37
45
  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
- )
46
+ const columns = hydrate
47
+ ? "rowid, id, title, body, kind, tags, category, user_id, updated_at"
48
+ : "rowid, id, title, category, user_id";
49
+ const hydratedRows = ctx.db
50
+ .prepare(`SELECT ${columns} FROM vault WHERE rowid IN (${placeholders})`)
42
51
  .all(...rowids);
43
52
 
44
53
  const byRowid = new Map();
45
- for (const row of hydrated) byRowid.set(row.rowid, row);
54
+ for (const row of hydratedRows) byRowid.set(row.rowid, row);
46
55
 
47
56
  const results = [];
48
57
  for (const vr of vecRows) {
@@ -52,7 +61,14 @@ async function findSimilar(ctx, embedding, threshold, userId) {
52
61
  if (!row) continue;
53
62
  if (userId !== undefined && row.user_id !== userId) continue;
54
63
  if (row.category === "entity") continue;
55
- results.push({ id: row.id, title: row.title, score: similarity });
64
+ const entry = { id: row.id, title: row.title, score: similarity };
65
+ if (hydrate) {
66
+ entry.body = row.body;
67
+ entry.kind = row.kind;
68
+ entry.tags = row.tags;
69
+ entry.updated_at = row.updated_at;
70
+ }
71
+ results.push(entry);
56
72
  }
57
73
  return results;
58
74
  } catch {
@@ -73,6 +89,68 @@ function formatSimilarWarning(similar) {
73
89
  return lines.join("\n");
74
90
  }
75
91
 
92
+ export function buildConflictCandidates(similarEntries) {
93
+ return similarEntries.map((entry) => {
94
+ let suggested_action;
95
+ let reasoning_context;
96
+
97
+ if (entry.score >= SKIP_THRESHOLD) {
98
+ suggested_action = "SKIP";
99
+ reasoning_context =
100
+ `Near-duplicate detected (${(entry.score * 100).toFixed(0)}% similarity)` +
101
+ `${entry.title ? ` with "${entry.title}"` : ""}. ` +
102
+ `Content is nearly identical — saving would create a redundant entry. ` +
103
+ `Use save_context with id: "${entry.id}" to update instead, or skip saving entirely.`;
104
+ } else if (entry.score >= UPDATE_THRESHOLD) {
105
+ suggested_action = "UPDATE";
106
+ reasoning_context =
107
+ `High content similarity (${(entry.score * 100).toFixed(0)}%)` +
108
+ `${entry.title ? ` with "${entry.title}"` : ""}. ` +
109
+ `Likely the same knowledge — consider updating this entry via save_context with id: "${entry.id}".`;
110
+ } else {
111
+ suggested_action = "ADD";
112
+ reasoning_context =
113
+ `Moderate similarity (${(entry.score * 100).toFixed(0)}%)` +
114
+ `${entry.title ? ` with "${entry.title}"` : ""}. ` +
115
+ `Content is related but distinct enough to coexist.`;
116
+ }
117
+
118
+ let parsedTags = [];
119
+ if (entry.tags) {
120
+ try {
121
+ parsedTags =
122
+ typeof entry.tags === "string" ? JSON.parse(entry.tags) : entry.tags;
123
+ } catch {
124
+ parsedTags = [];
125
+ }
126
+ }
127
+
128
+ return {
129
+ id: entry.id,
130
+ title: entry.title || null,
131
+ body: entry.body || null,
132
+ kind: entry.kind || null,
133
+ tags: parsedTags,
134
+ score: entry.score,
135
+ updated_at: entry.updated_at || null,
136
+ suggested_action,
137
+ reasoning_context,
138
+ };
139
+ });
140
+ }
141
+
142
+ function formatConflictSuggestions(candidates) {
143
+ const lines = ["", "── Conflict Resolution Suggestions ──"];
144
+ for (const c of candidates) {
145
+ const titleDisplay = c.title ? `"${c.title}"` : "(no title)";
146
+ lines.push(
147
+ ` [${c.suggested_action}] ${titleDisplay} (${(c.score * 100).toFixed(0)}%) — id: ${c.id}`,
148
+ );
149
+ lines.push(` ${c.reasoning_context}`);
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+
76
154
  /**
77
155
  * Validate input fields for save_context. Returns an error response or null.
78
156
  */
@@ -189,7 +267,9 @@ export const inputSchema = {
189
267
  tags: z
190
268
  .array(z.string())
191
269
  .optional()
192
- .describe("Tags for categorization and search"),
270
+ .describe(
271
+ "Tags for categorization and search. Use 'bucket:' prefix for project/domain scoping (e.g., 'bucket:autohub') to enable project-scoped retrieval.",
272
+ ),
193
273
  meta: z
194
274
  .any()
195
275
  .optional()
@@ -214,6 +294,25 @@ export const inputSchema = {
214
294
  .describe(
215
295
  "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
296
  ),
297
+ source_files: z
298
+ .array(
299
+ z.object({
300
+ path: z.string().describe("File path (absolute or relative to cwd)"),
301
+ hash: z
302
+ .string()
303
+ .describe("SHA-256 hash of the file contents at observation time"),
304
+ }),
305
+ )
306
+ .optional()
307
+ .describe(
308
+ "Source code files this entry is derived from. When these files change (hash mismatch), the entry will be flagged as stale in get_context results.",
309
+ ),
310
+ tier: z
311
+ .enum(["ephemeral", "working", "durable"])
312
+ .optional()
313
+ .describe(
314
+ "Memory tier for lifecycle management. 'ephemeral': short-lived session data. 'working': active context (default). 'durable': long-term reference material. Defaults based on kind when not specified.",
315
+ ),
217
316
  dry_run: z
218
317
  .boolean()
219
318
  .optional()
@@ -228,6 +327,18 @@ export const inputSchema = {
228
327
  .describe(
229
328
  "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
329
  ),
330
+ tier: z
331
+ .enum(["ephemeral", "working", "durable"])
332
+ .optional()
333
+ .describe(
334
+ "Memory tier for lifecycle management. 'ephemeral': short-lived session data. 'working': active context (default). 'durable': long-term reference material. Defaults based on kind when not specified.",
335
+ ),
336
+ conflict_resolution: z
337
+ .enum(["suggest", "off"])
338
+ .optional()
339
+ .describe(
340
+ 'Conflict resolution mode. "suggest" (default): when similar entries are found, return structured conflict_candidates with suggested_action (ADD/UPDATE/SKIP) and reasoning_context for the calling agent to decide. Thresholds: score > 0.95 → SKIP (near-duplicate), score > 0.85 → UPDATE (very similar), score < 0.85 → ADD (distinct enough). "off": flag similar entries only (legacy behavior).',
341
+ ),
231
342
  };
232
343
 
233
344
  /**
@@ -248,14 +359,18 @@ export async function handler(
248
359
  identity_key,
249
360
  expires_at,
250
361
  supersedes,
362
+ source_files,
251
363
  dry_run,
252
364
  similarity_threshold,
365
+ tier,
366
+ conflict_resolution,
253
367
  },
254
368
  ctx,
255
369
  { ensureIndexed },
256
370
  ) {
257
371
  const { config } = ctx;
258
372
  const userId = ctx.userId !== undefined ? ctx.userId : undefined;
373
+ const suggestMode = conflict_resolution !== "off";
259
374
 
260
375
  const vaultErr = ensureVaultExists(config);
261
376
  if (vaultErr) return vaultErr;
@@ -313,6 +428,7 @@ export async function handler(
313
428
  source,
314
429
  expires_at,
315
430
  supersedes,
431
+ source_files,
316
432
  });
317
433
  await indexEntry(ctx, entry);
318
434
  const relPath = entry.filePath
@@ -359,6 +475,7 @@ export async function handler(
359
475
  queryEmbedding,
360
476
  threshold,
361
477
  userId,
478
+ { hydrate: suggestMode },
362
479
  );
363
480
  }
364
481
  }
@@ -366,16 +483,31 @@ export async function handler(
366
483
  if (dry_run) {
367
484
  const parts = ["(dry run — nothing saved)"];
368
485
  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}`);
486
+ if (suggestMode) {
487
+ const candidates = buildConflictCandidates(similarEntries);
488
+ parts.push("", "⚠ Similar entries already exist:");
489
+ for (const e of similarEntries) {
490
+ const score = e.score.toFixed(2);
491
+ const titleDisplay = e.title ? `"${e.title}"` : "(no title)";
492
+ parts.push(` - ${titleDisplay} (${score}) — id: ${e.id}`);
493
+ }
494
+ parts.push(formatConflictSuggestions(candidates));
495
+ parts.push(
496
+ "",
497
+ "Use save_context with `id: <existing>` to update one, or omit `dry_run` to save as new.",
498
+ );
499
+ } else {
500
+ parts.push("", "⚠ Similar entries already exist:");
501
+ for (const e of similarEntries) {
502
+ const score = e.score.toFixed(2);
503
+ const titleDisplay = e.title ? `"${e.title}"` : "(no title)";
504
+ parts.push(` - ${titleDisplay} (${score}) — id: ${e.id}`);
505
+ }
506
+ parts.push(
507
+ "",
508
+ "Use save_context with `id: <existing>` to update one, or omit `dry_run` to save as new.",
509
+ );
374
510
  }
375
- parts.push(
376
- "",
377
- "Use save_context with `id: <existing>` to update one, or omit `dry_run` to save as new.",
378
- );
379
511
  } else {
380
512
  parts.push("", "No similar entries found. Safe to save.");
381
513
  }
@@ -386,6 +518,8 @@ export async function handler(
386
518
  if (folder) mergedMeta.folder = folder;
387
519
  const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
388
520
 
521
+ const effectiveTier = tier ?? defaultTierFor(normalizedKind);
522
+
389
523
  const entry = await captureAndIndex(ctx, {
390
524
  kind: normalizedKind,
391
525
  title,
@@ -397,7 +531,9 @@ export async function handler(
397
531
  identity_key,
398
532
  expires_at,
399
533
  supersedes,
534
+ source_files,
400
535
  userId,
536
+ tier: effectiveTier,
401
537
  });
402
538
 
403
539
  if (ctx.config?.dataDir) {
@@ -410,9 +546,44 @@ export async function handler(
410
546
  const parts = [`✓ Saved ${normalizedKind} → ${relPath}`, ` id: ${entry.id}`];
411
547
  if (title) parts.push(` title: ${title}`);
412
548
  if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
549
+ parts.push(` tier: ${effectiveTier}`);
413
550
  parts.push("", "_Use this id to update or delete later._");
551
+ const hasBucketTag = (tags || []).some(
552
+ (t) => typeof t === "string" && t.startsWith("bucket:"),
553
+ );
554
+ if (tags && tags.length > 0 && !hasBucketTag) {
555
+ parts.push(
556
+ "",
557
+ "_Tip: Consider adding a `bucket:` tag (e.g., `bucket:myproject`) for project-scoped retrieval._",
558
+ );
559
+ }
560
+ const bucketTags = (tags || []).filter(
561
+ (t) => typeof t === "string" && t.startsWith("bucket:"),
562
+ );
563
+ for (const bt of bucketTags) {
564
+ const bucketUserClause = userId !== undefined ? "AND user_id = ?" : "";
565
+ const bucketParams =
566
+ userId !== undefined ? [bt, userId] : [bt];
567
+ const exists = ctx.db
568
+ .prepare(
569
+ `SELECT 1 FROM vault WHERE kind = 'bucket' AND identity_key = ? ${bucketUserClause} LIMIT 1`,
570
+ )
571
+ .get(...bucketParams);
572
+ if (!exists) {
573
+ parts.push(
574
+ ``,
575
+ `_Note: bucket '${bt}' is not registered. Use save_context(kind: "bucket", identity_key: "${bt}") to register it._`,
576
+ );
577
+ }
578
+ }
414
579
  if (similarEntries.length) {
415
- parts.push(formatSimilarWarning(similarEntries));
580
+ if (suggestMode) {
581
+ const candidates = buildConflictCandidates(similarEntries);
582
+ parts.push(formatSimilarWarning(similarEntries));
583
+ parts.push(formatConflictSuggestions(candidates));
584
+ } else {
585
+ parts.push(formatSimilarWarning(similarEntries));
586
+ }
416
587
  }
417
588
 
418
589
  const criticalLimit = config.thresholds?.totalEntries?.critical;