@context-vault/core 2.12.0 → 2.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -36,6 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
+ "@anthropic-ai/sdk": "^0.78.0",
39
40
  "@modelcontextprotocol/sdk": "^1.26.0",
40
41
  "sqlite-vec": "^0.1.0"
41
42
  },
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { ok } from "../helpers.js";
3
+
4
+ export const name = "clear_context";
5
+
6
+ export const description =
7
+ "Reset active in-memory session context without deleting vault entries. Call this when switching projects or topics mid-session. With `scope`, all subsequent get_context calls should filter to that tag/project. Vault data is never modified.";
8
+
9
+ export const inputSchema = {
10
+ scope: z
11
+ .string()
12
+ .optional()
13
+ .describe(
14
+ "Optional tag or project name to focus on going forward. When provided, treat subsequent get_context calls as if filtered to this tag.",
15
+ ),
16
+ };
17
+
18
+ /**
19
+ * @param {object} args
20
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} _ctx
21
+ */
22
+ export function handler({ scope } = {}) {
23
+ const lines = [
24
+ "## Context Reset",
25
+ "",
26
+ "Active session context has been cleared. All previous context from this session should be disregarded.",
27
+ "",
28
+ "Vault entries are unchanged — no data was deleted.",
29
+ ];
30
+
31
+ if (scope?.trim()) {
32
+ const trimmed = scope.trim();
33
+ lines.push(
34
+ "",
35
+ `### Active Scope: \`${trimmed}\``,
36
+ "",
37
+ `Going forward, treat \`get_context\` calls as scoped to the tag or project **"${trimmed}"** unless the user explicitly requests a different scope or passes their own tag filters.`,
38
+ );
39
+ } else {
40
+ lines.push(
41
+ "",
42
+ "No scope set. Use `get_context` normally — all vault entries are accessible.",
43
+ );
44
+ }
45
+
46
+ return ok(lines.join("\n"));
47
+ }
@@ -0,0 +1,222 @@
1
+ import { z } from "zod";
2
+ import { hybridSearch } from "../../retrieve/index.js";
3
+ import { captureAndIndex } from "../../capture/index.js";
4
+ import { normalizeKind } from "../../core/files.js";
5
+ import { ok, err, ensureVaultExists } from "../helpers.js";
6
+
7
+ const NOISE_KINDS = new Set(["prompt-history", "task-notification"]);
8
+ const SYNTHESIS_MODEL = "claude-haiku-4-5-20251001";
9
+ const MAX_ENTRIES_FOR_SYNTHESIS = 40;
10
+ const MAX_BODY_PER_ENTRY = 600;
11
+
12
+ export const name = "create_snapshot";
13
+
14
+ export const description =
15
+ "Pull all relevant vault entries matching a topic, run an LLM synthesis pass to deduplicate and structure them into a context brief, then save and return the brief's ULID. The brief is saved as kind: 'brief' with a deterministic identity_key for retrieval.";
16
+
17
+ export const inputSchema = {
18
+ topic: z.string().describe("The topic or project name to snapshot"),
19
+ tags: z
20
+ .array(z.string())
21
+ .optional()
22
+ .describe("Optional tag filters — entries must match at least one"),
23
+ kinds: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe("Optional kind filters to restrict which entry types are pulled"),
27
+ identity_key: z
28
+ .string()
29
+ .optional()
30
+ .describe(
31
+ "Deterministic key for the saved brief (defaults to slugified topic). Use the same key to overwrite a previous snapshot.",
32
+ ),
33
+ };
34
+
35
+ function buildSynthesisPrompt(topic, entries) {
36
+ const entriesBlock = entries
37
+ .map((e, i) => {
38
+ const tags = e.tags ? JSON.parse(e.tags) : [];
39
+ const tagStr = tags.length ? tags.join(", ") : "none";
40
+ const body = e.body
41
+ ? e.body.slice(0, MAX_BODY_PER_ENTRY) +
42
+ (e.body.length > MAX_BODY_PER_ENTRY ? "…" : "")
43
+ : "(no body)";
44
+ return [
45
+ `### Entry ${i + 1} [${e.kind}] id: ${e.id}`,
46
+ `tags: ${tagStr}`,
47
+ `updated: ${e.updated_at || e.created_at || "unknown"}`,
48
+ body,
49
+ ].join("\n");
50
+ })
51
+ .join("\n\n");
52
+
53
+ return `You are a knowledge synthesis assistant. Given the following vault entries about "${topic}", produce a structured context brief.
54
+
55
+ Deduplicate overlapping information, resolve any contradictions (note them in Audit Notes), and organise the content into the sections below. Keep each section concise and actionable. Omit sections that have no relevant content.
56
+
57
+ Output ONLY the markdown document — no preamble, no explanation.
58
+
59
+ Required format:
60
+ # ${topic} — Context Brief
61
+ ## Status
62
+ (current state of the topic)
63
+ ## Key Decisions
64
+ (architectural or strategic decisions made)
65
+ ## Patterns & Conventions
66
+ (recurring patterns, coding conventions, standards)
67
+ ## Active Constraints
68
+ (known limitations, hard requirements, deadlines)
69
+ ## Open Questions
70
+ (unresolved questions or areas needing investigation)
71
+ ## Audit Notes
72
+ (contradictions detected, stale entries flagged with their ids)
73
+
74
+ ---
75
+ VAULT ENTRIES:
76
+
77
+ ${entriesBlock}`;
78
+ }
79
+
80
+ async function callLlm(prompt) {
81
+ const { Anthropic } = await import("@anthropic-ai/sdk");
82
+ const client = new Anthropic();
83
+ const message = await client.messages.create({
84
+ model: SYNTHESIS_MODEL,
85
+ max_tokens: 2048,
86
+ messages: [{ role: "user", content: prompt }],
87
+ });
88
+ const block = message.content.find((b) => b.type === "text");
89
+ if (!block) throw new Error("LLM returned no text content");
90
+ return block.text;
91
+ }
92
+
93
+ function slugifyTopic(topic) {
94
+ return topic
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9]+/g, "-")
97
+ .replace(/^-+|-+$/g, "")
98
+ .slice(0, 120);
99
+ }
100
+
101
+ export async function handler(
102
+ { topic, tags, kinds, identity_key },
103
+ ctx,
104
+ { ensureIndexed },
105
+ ) {
106
+ const { config } = ctx;
107
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
108
+
109
+ const vaultErr = ensureVaultExists(config);
110
+ if (vaultErr) return vaultErr;
111
+
112
+ if (!topic?.trim()) {
113
+ return err("Required: topic (non-empty string)", "INVALID_INPUT");
114
+ }
115
+
116
+ await ensureIndexed();
117
+
118
+ const normalizedKinds = kinds?.map(normalizeKind) ?? [];
119
+
120
+ let candidates = [];
121
+
122
+ if (normalizedKinds.length > 0) {
123
+ for (const kindFilter of normalizedKinds) {
124
+ const rows = await hybridSearch(ctx, topic, {
125
+ kindFilter,
126
+ limit: Math.ceil(MAX_ENTRIES_FOR_SYNTHESIS / normalizedKinds.length),
127
+ userIdFilter: userId,
128
+ includeSuperseeded: false,
129
+ });
130
+ candidates.push(...rows);
131
+ }
132
+ const seen = new Set();
133
+ candidates = candidates.filter((r) => {
134
+ if (seen.has(r.id)) return false;
135
+ seen.add(r.id);
136
+ return true;
137
+ });
138
+ } else {
139
+ candidates = await hybridSearch(ctx, topic, {
140
+ limit: MAX_ENTRIES_FOR_SYNTHESIS,
141
+ userIdFilter: userId,
142
+ includeSuperseeded: false,
143
+ });
144
+ }
145
+
146
+ if (tags?.length) {
147
+ candidates = candidates.filter((r) => {
148
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
149
+ return tags.some((t) => entryTags.includes(t));
150
+ });
151
+ }
152
+
153
+ const noiseIds = candidates
154
+ .filter((r) => NOISE_KINDS.has(r.kind))
155
+ .map((r) => r.id);
156
+
157
+ const synthesisEntries = candidates.filter((r) => !NOISE_KINDS.has(r.kind));
158
+
159
+ if (synthesisEntries.length === 0) {
160
+ return err(
161
+ `No entries found for topic "${topic}" to synthesize. Try a broader topic or different tags.`,
162
+ "NO_ENTRIES",
163
+ );
164
+ }
165
+
166
+ let briefBody;
167
+ try {
168
+ const prompt = buildSynthesisPrompt(topic, synthesisEntries);
169
+ briefBody = await callLlm(prompt);
170
+ } catch (e) {
171
+ return err(
172
+ `LLM synthesis failed: ${e.message}. Ensure ANTHROPIC_API_KEY is set.`,
173
+ "LLM_ERROR",
174
+ );
175
+ }
176
+
177
+ const effectiveIdentityKey =
178
+ identity_key ?? `snapshot-${slugifyTopic(topic)}`;
179
+
180
+ const briefTags = [
181
+ "snapshot",
182
+ ...(tags ?? []),
183
+ ...(normalizedKinds.length > 0 ? [] : []),
184
+ ];
185
+
186
+ const supersedes = noiseIds.length > 0 ? noiseIds : undefined;
187
+
188
+ const entry = await captureAndIndex(ctx, {
189
+ kind: "brief",
190
+ title: `${topic} — Context Brief`,
191
+ body: briefBody,
192
+ tags: briefTags,
193
+ source: "create_snapshot",
194
+ identity_key: effectiveIdentityKey,
195
+ supersedes,
196
+ userId,
197
+ meta: {
198
+ topic,
199
+ entry_count: synthesisEntries.length,
200
+ noise_superseded: noiseIds.length,
201
+ synthesized_from: synthesisEntries.map((e) => e.id),
202
+ },
203
+ });
204
+
205
+ const parts = [
206
+ `✓ Snapshot created → id: ${entry.id}`,
207
+ ` title: ${entry.title}`,
208
+ ` identity_key: ${effectiveIdentityKey}`,
209
+ ` synthesized from: ${synthesisEntries.length} entries`,
210
+ noiseIds.length > 0
211
+ ? ` noise superseded: ${noiseIds.length} entries`
212
+ : null,
213
+ "",
214
+ "_Retrieve with: get_context(kind: 'brief', identity_key: '" +
215
+ effectiveIdentityKey +
216
+ "')_",
217
+ ]
218
+ .filter((l) => l !== null)
219
+ .join("\n");
220
+
221
+ return ok(parts);
222
+ }
@@ -5,6 +5,87 @@ import { normalizeKind } from "../../core/files.js";
5
5
  import { ok, err } from "../helpers.js";
6
6
  import { isEmbedAvailable } from "../../index/embed.js";
7
7
 
8
+ const STALE_DUPLICATE_DAYS = 7;
9
+
10
+ /**
11
+ * Detect conflicts among a set of search result entries.
12
+ *
13
+ * Two checks are performed:
14
+ * 1. Supersession: if entry A's `superseded_by` points to any entry B in the
15
+ * result set, A is stale and should be discarded in favour of B.
16
+ * 2. Stale duplicate: two entries share the same kind and at least one common
17
+ * tag, but their `updated_at` timestamps differ by more than
18
+ * STALE_DUPLICATE_DAYS days — suggesting the older one may be outdated.
19
+ *
20
+ * No LLM calls, no new dependencies — pure in-memory set operations on the
21
+ * rows already fetched from the DB.
22
+ *
23
+ * @param {Array} entries - Result rows (as returned by hybridSearch / filter-only mode)
24
+ * @param {import('../types.js').BaseCtx} _ctx - Unused for now; reserved for future DB look-ups
25
+ * @returns {Array<{entry_a_id: string, entry_b_id: string, reason: string, recommendation: string}>}
26
+ */
27
+ export function detectConflicts(entries, _ctx) {
28
+ const conflicts = [];
29
+ const idSet = new Set(entries.map((e) => e.id));
30
+
31
+ for (const entry of entries) {
32
+ if (entry.superseded_by && idSet.has(entry.superseded_by)) {
33
+ conflicts.push({
34
+ entry_a_id: entry.id,
35
+ entry_b_id: entry.superseded_by,
36
+ reason: "superseded",
37
+ recommendation: `Discard \`${entry.id}\` — it has been explicitly superseded by \`${entry.superseded_by}\`.`,
38
+ });
39
+ }
40
+ }
41
+
42
+ const supersededConflictPairs = new Set(
43
+ conflicts.map((c) => `${c.entry_a_id}|${c.entry_b_id}`),
44
+ );
45
+
46
+ for (let i = 0; i < entries.length; i++) {
47
+ for (let j = i + 1; j < entries.length; j++) {
48
+ const a = entries[i];
49
+ const b = entries[j];
50
+
51
+ if (
52
+ supersededConflictPairs.has(`${a.id}|${b.id}`) ||
53
+ supersededConflictPairs.has(`${b.id}|${a.id}`)
54
+ ) {
55
+ continue;
56
+ }
57
+
58
+ if (a.kind !== b.kind) continue;
59
+
60
+ const tagsA = a.tags ? JSON.parse(a.tags) : [];
61
+ const tagsB = b.tags ? JSON.parse(b.tags) : [];
62
+
63
+ if (!tagsA.length || !tagsB.length) continue;
64
+
65
+ const tagsSetA = new Set(tagsA);
66
+ const sharedTag = tagsB.some((t) => tagsSetA.has(t));
67
+ if (!sharedTag) continue;
68
+
69
+ const dateA = new Date(a.updated_at || a.created_at);
70
+ const dateB = new Date(b.updated_at || b.created_at);
71
+ if (isNaN(dateA.getTime()) || isNaN(dateB.getTime())) continue;
72
+
73
+ const diffDays = Math.abs(dateA - dateB) / 86400000;
74
+ if (diffDays <= STALE_DUPLICATE_DAYS) continue;
75
+
76
+ const [older, newer] = dateA < dateB ? [a, b] : [b, a];
77
+ conflicts.push({
78
+ entry_a_id: older.id,
79
+ entry_b_id: newer.id,
80
+ reason: "stale_duplicate",
81
+ recommendation: `Verify \`${older.id}\` is still accurate — it shares kind "${older.kind}" and tags with \`${newer.id}\` but was last updated ${Math.round(diffDays)} days earlier.`,
82
+ });
83
+ }
84
+ }
85
+
86
+ return conflicts;
87
+ }
88
+
8
89
  export const name = "get_context";
9
90
 
10
91
  export const description =
@@ -48,6 +129,12 @@ export const inputSchema = {
48
129
  .describe(
49
130
  "If true, include entries that have been superseded by newer ones. Default: false.",
50
131
  ),
132
+ detect_conflicts: z
133
+ .boolean()
134
+ .optional()
135
+ .describe(
136
+ "If true, compare results for contradicting entries and append a conflicts array. Flags superseded entries still in results and stale duplicates (same kind+tags, updated_at >7 days apart). No LLM calls — pure DB logic.",
137
+ ),
51
138
  };
52
139
 
53
140
  /**
@@ -66,6 +153,7 @@ export async function handler(
66
153
  until,
67
154
  limit,
68
155
  include_superseded,
156
+ detect_conflicts,
69
157
  },
70
158
  ctx,
71
159
  { ensureIndexed, reindexFailed },
@@ -227,6 +315,9 @@ export async function handler(
227
315
  }
228
316
  }
229
317
 
318
+ // Conflict detection
319
+ const conflicts = detect_conflicts ? detectConflicts(filtered, ctx) : [];
320
+
230
321
  const lines = [];
231
322
  if (reindexFailed)
232
323
  lines.push(
@@ -265,5 +356,23 @@ export async function handler(
265
356
  lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
266
357
  lines.push("");
267
358
  }
359
+
360
+ if (detect_conflicts) {
361
+ if (conflicts.length === 0) {
362
+ lines.push(
363
+ `## Conflict Detection\n\nNo conflicts detected among results.\n`,
364
+ );
365
+ } else {
366
+ lines.push(`## Conflict Detection (${conflicts.length} flagged)\n`);
367
+ for (const c of conflicts) {
368
+ lines.push(
369
+ `- **${c.reason}**: \`${c.entry_a_id}\` vs \`${c.entry_b_id}\``,
370
+ );
371
+ lines.push(` Recommendation: ${c.recommendation}`);
372
+ }
373
+ lines.push("");
374
+ }
375
+ }
376
+
268
377
  return ok(lines.join("\n"));
269
378
  }
@@ -11,6 +11,8 @@ import * as deleteContext from "./tools/delete-context.js";
11
11
  import * as submitFeedback from "./tools/submit-feedback.js";
12
12
  import * as ingestUrl from "./tools/ingest-url.js";
13
13
  import * as contextStatus from "./tools/context-status.js";
14
+ import * as clearContext from "./tools/clear-context.js";
15
+ import * as createSnapshot from "./tools/create-snapshot.js";
14
16
 
15
17
  const toolModules = [
16
18
  getContext,
@@ -20,6 +22,8 @@ const toolModules = [
20
22
  submitFeedback,
21
23
  ingestUrl,
22
24
  contextStatus,
25
+ clearContext,
26
+ createSnapshot,
23
27
  ];
24
28
 
25
29
  const TOOL_TIMEOUT_MS = 60_000;