exovault-mcp-server 1.0.2 → 1.1.1

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.
@@ -291,6 +291,16 @@ export declare class GatewayClient {
291
291
  documentType: "soul" | "instructions" | "skills" | "checks";
292
292
  vaultId?: string;
293
293
  }): Promise<string>;
294
+ recall(params: {
295
+ mode: "temporal" | "topic" | "graph";
296
+ range?: string;
297
+ query?: string;
298
+ searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
299
+ topK?: number;
300
+ nodeId?: string;
301
+ maxHops?: number;
302
+ vaultId?: string;
303
+ }): Promise<string>;
294
304
  readDocs(params: {
295
305
  slug?: string;
296
306
  list?: boolean;
@@ -274,6 +274,11 @@ export class GatewayClient {
274
274
  const result = await this.request("POST", "/api/agent/read-document", params);
275
275
  return JSON.stringify(result);
276
276
  }
277
+ // ─── Recall ──────────────────────────────────────────────────────────────
278
+ async recall(params) {
279
+ const result = await this.request("POST", "/api/agent/recall", params);
280
+ return JSON.stringify(result);
281
+ }
277
282
  async readDocs(params) {
278
283
  const result = await this.request("POST", "/api/agent/read-docs", params);
279
284
  return JSON.stringify(result);
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
3
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
7
  import { z } from "zod";
@@ -27,6 +29,7 @@ import { updateMemoryTool } from "./tools/update-memory.js";
27
29
  import { cleanupMemories } from "./tools/cleanup-memories.js";
28
30
  import { getLinks, addLink, removeLink } from "./tools/knowledge-links.js";
29
31
  import { exploreGraph } from "./tools/explore-graph.js";
32
+ import { recall } from "./tools/recall.js";
30
33
  import { sendMessage, ackMessage, readMessages } from "./tools/agent-messages.js";
31
34
  // Task tools are thin wrappers around memory tools — no separate agent-tasks import needed
32
35
  import { resolveVaultId } from "./tools/resolve-vault-id.js";
@@ -50,6 +53,30 @@ const MEMORY_TYPES = ["fact", "skill", "preference", "constraint", "task", "epis
50
53
  const memoryTypeEnum = z.enum(MEMORY_TYPES);
51
54
  /** Remind agents to checkpoint every N tool calls. */
52
55
  const CHECKPOINT_REMINDER_INTERVAL = 20;
56
+ /** Max age (ms) of the hook session file to be considered valid. */
57
+ const HOOK_SESSION_MAX_AGE_MS = 120_000;
58
+ /**
59
+ * Read the session ID written by the SessionStart hook.
60
+ * Returns null if the file is missing, stale (>120s), or invalid.
61
+ */
62
+ function readHookSessionId() {
63
+ try {
64
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
65
+ const filePath = join(home, ".exovault-mcp", "current-session.json");
66
+ const raw = readFileSync(filePath, "utf8");
67
+ const data = JSON.parse(raw);
68
+ if (data.sessionId &&
69
+ data.timestamp &&
70
+ Date.now() - data.timestamp < HOOK_SESSION_MAX_AGE_MS) {
71
+ process.stderr.write(`[exovault-mcp] Using hook session ID: ${data.sessionId}\n`);
72
+ return data.sessionId;
73
+ }
74
+ }
75
+ catch {
76
+ // File doesn't exist or is invalid — fall back to random UUID
77
+ }
78
+ return null;
79
+ }
53
80
  async function main() {
54
81
  // ─── Detect mode: gateway (agent key) or direct (Supabase) ─────────────
55
82
  const config = await readConfig();
@@ -60,10 +87,10 @@ async function main() {
60
87
  let allowedVaultIds;
61
88
  let gwAgentType;
62
89
  let gwAgentLabel;
63
- // Generate a unique session ID for this MCP server instance.
64
- // Used as the default agentRunId so all memories from one connection
65
- // are grouped into the same session — even if the agent doesn't pass one.
66
- const sessionRunId = randomUUID();
90
+ // Try to reuse the session ID written by the SessionStart hook so that
91
+ // MCP tool calls and hook-captured turns share the same dashboard session.
92
+ // Falls back to a random UUID if the hook file is missing or stale.
93
+ const sessionRunId = readHookSessionId() ?? randomUUID();
67
94
  if (gwConfig) {
68
95
  gw = new GatewayClient(gwConfig.apiUrl, gwConfig.agentKey, sessionRunId);
69
96
  try {
@@ -689,7 +716,7 @@ async function main() {
689
716
  })).optional(),
690
717
  sourceNoteIds: z.array(z.string().uuid()).optional(),
691
718
  supersededById: z.string().uuid().optional(),
692
- })).max(50).describe("Array of memories to save (0-50). Can be empty when only sessionSummary is provided.")),
719
+ })).max(50).default([]).describe("Memories to bulk-save (0-50). Defaults to empty array — omit or pass [] when only sessionSummary is needed.")),
693
720
  sessionSummary: s(z.string().min(1).describe("REQUIRED. Narrative summary of what happened this session — what was discussed, decided, and accomplished. Saved as an episodic memory. Do NOT write tool call stats — describe the work in human terms.")),
694
721
  vaultId: s(z.string().uuid().optional().describe("Vault/project scope for all memories. Required unless defaultVaultId is configured.")),
695
722
  agentId: s(z.string().optional().describe("Agent identifier")),
@@ -1168,6 +1195,32 @@ async function main() {
1168
1195
  }
1169
1196
  return await readMessages(ctx, { ...input, agentId, vaultId: resolveVaultId(ctx, input.vaultId) });
1170
1197
  })));
1198
+ // ─── recall ─────────────────────────────────────────────────────────────
1199
+ server.registerTool("recall", {
1200
+ description: "Deep context retrieval with three modes: " +
1201
+ "temporal (session timeline by date range), " +
1202
+ "topic (hybrid BM25+semantic search across memories), " +
1203
+ "graph (knowledge graph expansion from query or node). " +
1204
+ "Use session_start for basic warm-up; use recall for targeted deep retrieval mid-session.",
1205
+ inputSchema: {
1206
+ mode: s(z.enum(["temporal", "topic", "graph"]).describe("temporal: chronological session timeline by date range. " +
1207
+ "topic: hybrid BM25+semantic search across memories. " +
1208
+ "graph: knowledge graph expansion from query or node.")),
1209
+ range: s(z.string().optional().describe("Temporal mode: 'today', 'yesterday', 'last_week', 'last_3_days', 'last_N_days', or ISO date '2026-03-01'")),
1210
+ query: s(z.string().max(2000).optional().describe("Topic/graph mode: search query")),
1211
+ searchMode: s(z.enum(["auto", "hybrid", "bm25", "semantic"]).optional().describe("Topic mode: search strategy. Default: 'hybrid'")),
1212
+ topK: s(z.number().int().min(1).max(50).optional().describe("Topic mode: max results. Default: 10")),
1213
+ nodeId: s(z.string().uuid().optional().describe("Graph mode: start from this node")),
1214
+ maxHops: s(z.number().int().min(1).max(5).optional().describe("Graph mode: traversal depth. Default: 2")),
1215
+ vaultId: s(z.string().uuid().optional().describe("Scope to vault")),
1216
+ },
1217
+ }, auto.wrap(wrapToolHandler(async (args) => {
1218
+ const input = args;
1219
+ if (gw) {
1220
+ return await gw.recall({ ...input, vaultId: resolveVault(input.vaultId) });
1221
+ }
1222
+ return await recall(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
1223
+ })));
1171
1224
  // ─── Orphan recovery — flush crashed sessions from previous runs ─────────
1172
1225
  try {
1173
1226
  const orphans = await scanOrphanedBuffers(10);
package/dist/setup.js CHANGED
@@ -1,13 +1,94 @@
1
1
  #!/usr/bin/env node
2
2
  import { createClient } from "@supabase/supabase-js";
3
3
  import { createInterface } from "node:readline";
4
- import { mkdir, writeFile } from "node:fs/promises";
5
- import { join } from "node:path";
6
- import { homedir } from "node:os";
4
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir, platform } from "node:os";
7
7
  import { parseArgs } from "node:util";
8
+ import { fileURLToPath } from "node:url";
8
9
  import { deriveWrappingKey, unwrapMasterKey, importMasterKey, bufferToHex, } from "./crypto.js";
9
10
  const CONFIG_DIR = join(homedir(), ".exovault-mcp");
10
11
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
12
+ const HOOKS_DIR = join(CONFIG_DIR, "hooks");
13
+ const IS_WINDOWS = platform() === "win32";
14
+ /** Returns platform-appropriate MCP server config snippet. */
15
+ function getMcpServerConfig() {
16
+ if (IS_WINDOWS) {
17
+ return {
18
+ mcpServers: {
19
+ exovault: {
20
+ command: "cmd",
21
+ args: ["/c", "npx", "-y", "exovault-mcp-server"],
22
+ },
23
+ },
24
+ };
25
+ }
26
+ return {
27
+ mcpServers: {
28
+ exovault: {
29
+ command: "npx",
30
+ args: ["-y", "exovault-mcp-server"],
31
+ },
32
+ },
33
+ };
34
+ }
35
+ /** Returns hook config snippet for turn capture. */
36
+ function getHooksConfig() {
37
+ const hookPath = "~/.exovault-mcp/hooks/capture-turn.js";
38
+ return {
39
+ hooks: {
40
+ UserPromptSubmit: [{
41
+ matcher: "",
42
+ hooks: [{
43
+ type: "command",
44
+ command: `node ${hookPath} user`,
45
+ timeout: 15,
46
+ }],
47
+ }],
48
+ Stop: [{
49
+ matcher: "",
50
+ hooks: [{
51
+ type: "command",
52
+ command: `node ${hookPath} assistant`,
53
+ timeout: 15,
54
+ }],
55
+ }],
56
+ },
57
+ };
58
+ }
59
+ /** Copies capture-turn.js hook to ~/.exovault-mcp/hooks/ */
60
+ async function installHook() {
61
+ try {
62
+ await mkdir(HOOKS_DIR, { recursive: true, mode: 0o700 });
63
+ // Resolve the bundled hook relative to this file's package
64
+ const thisDir = dirname(fileURLToPath(import.meta.url));
65
+ const srcHook = join(thisDir, "..", "hooks", "capture-turn.js");
66
+ const destHook = join(HOOKS_DIR, "capture-turn.js");
67
+ await copyFile(srcHook, destHook);
68
+ console.log(`Turn capture hook installed to: ${destHook}`);
69
+ return true;
70
+ }
71
+ catch (err) {
72
+ console.warn("Could not install turn capture hook:", err instanceof Error ? err.message : err);
73
+ return false;
74
+ }
75
+ }
76
+ /** Prints Claude Code config snippet + optional hook instructions. */
77
+ function printSetupInstructions(hookInstalled) {
78
+ console.log();
79
+ console.log("Add this to your project's .mcp.json or ~/.claude/settings.json:");
80
+ console.log();
81
+ console.log(JSON.stringify(getMcpServerConfig(), null, 2));
82
+ if (hookInstalled) {
83
+ console.log();
84
+ console.log("Optional: Add turn capture hooks to .claude/settings.json for richer memory:");
85
+ console.log("(Captures full conversation context — not just MCP tool calls)");
86
+ console.log();
87
+ console.log(JSON.stringify(getHooksConfig(), null, 2));
88
+ }
89
+ console.log();
90
+ console.log("Setup complete! Restart Claude Code to connect.");
91
+ }
11
92
  function printUsage() {
12
93
  console.log("Usage:");
13
94
  console.log(" exovault-mcp-server setup --agent-key <key> [--api-url <url>] (gateway mode)");
@@ -90,20 +171,8 @@ async function setupGatewayMode(agentKey, apiUrl) {
90
171
  });
91
172
  console.log();
92
173
  console.log(`Config saved to: ${CONFIG_FILE}`);
93
- // Print Claude Code config snippet
94
- console.log();
95
- console.log("Add this to your ~/.claude/settings.json (mcpServers section):");
96
- console.log();
97
- console.log(JSON.stringify({
98
- mcpServers: {
99
- exovault: {
100
- command: "npx",
101
- args: ["exovault-mcp-server"],
102
- },
103
- },
104
- }, null, 2));
105
- console.log();
106
- console.log("Setup complete! Restart Claude Code to connect.");
174
+ const hookInstalled = await installHook();
175
+ printSetupInstructions(hookInstalled);
107
176
  }
108
177
  async function main() {
109
178
  // Parse CLI args
@@ -237,20 +306,8 @@ async function main() {
237
306
  rl3.close();
238
307
  await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 });
239
308
  console.log(`Config saved to: ${CONFIG_FILE}`);
240
- // Print Claude Code config snippet
241
- console.log();
242
- console.log("Add this to your ~/.claude/settings.json (mcpServers section):");
243
- console.log();
244
- console.log(JSON.stringify({
245
- mcpServers: {
246
- exovault: {
247
- command: "npx",
248
- args: ["exovault-mcp-server"],
249
- },
250
- },
251
- }, null, 2));
252
- console.log();
253
- console.log("Setup complete! Restart Claude Code to connect.");
309
+ const hookInstalled = await installHook();
310
+ printSetupInstructions(hookInstalled);
254
311
  }
255
312
  catch (err) {
256
313
  console.error("Setup failed:", err instanceof Error ? err.message : err);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Normalize a BM25 score (from PostgreSQL ts_rank_cd) to 0-1 range.
3
+ * Formula from QMD: score / (1 + score).
4
+ * Maps: 0→0, 1→0.5, 9→0.9, asymptotically approaches 1.
5
+ */
6
+ export declare function normalizeBm25(raw: number): number;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Normalize a BM25 score (from PostgreSQL ts_rank_cd) to 0-1 range.
3
+ * Formula from QMD: score / (1 + score).
4
+ * Maps: 0→0, 1→0.5, 9→0.9, asymptotically approaches 1.
5
+ */
6
+ export function normalizeBm25(raw) {
7
+ if (raw <= 0)
8
+ return 0;
9
+ return raw / (1 + raw);
10
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Post-RRF confidence dampening for memory search results.
3
+ *
4
+ * Multiplies each candidate's score by a factor derived from its confidence level.
5
+ * Confidence 5 (verified) is neutral (1.0×), confidence 1 (speculative) gets a 40% penalty.
6
+ *
7
+ * Formula: multiplier = 0.5 + 0.5 * (confidence / 5)
8
+ */
9
+ /**
10
+ * Compute the confidence multiplier for a given confidence level (1-5).
11
+ * conf 1 → 0.6, conf 3 → 0.8, conf 5 → 1.0
12
+ */
13
+ export declare function computeConfidenceMultiplier(confidence: number): number;
14
+ export interface DampenedCandidate {
15
+ id: string;
16
+ score: number;
17
+ }
18
+ /**
19
+ * Apply confidence dampening to a batch of scored candidates and re-sort.
20
+ * Candidates missing from confidenceMap default to confidence 3.
21
+ */
22
+ export declare function applyConfidenceDampenBatch(candidates: {
23
+ id: string;
24
+ score: number;
25
+ }[], confidenceMap: Map<string, number>): DampenedCandidate[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Post-RRF confidence dampening for memory search results.
3
+ *
4
+ * Multiplies each candidate's score by a factor derived from its confidence level.
5
+ * Confidence 5 (verified) is neutral (1.0×), confidence 1 (speculative) gets a 40% penalty.
6
+ *
7
+ * Formula: multiplier = 0.5 + 0.5 * (confidence / 5)
8
+ */
9
+ /**
10
+ * Compute the confidence multiplier for a given confidence level (1-5).
11
+ * conf 1 → 0.6, conf 3 → 0.8, conf 5 → 1.0
12
+ */
13
+ export function computeConfidenceMultiplier(confidence) {
14
+ return 0.5 + 0.5 * (confidence / 5);
15
+ }
16
+ /**
17
+ * Apply confidence dampening to a batch of scored candidates and re-sort.
18
+ * Candidates missing from confidenceMap default to confidence 3.
19
+ */
20
+ export function applyConfidenceDampenBatch(candidates, confidenceMap) {
21
+ if (candidates.length === 0)
22
+ return [];
23
+ return candidates
24
+ .map((c) => ({
25
+ id: c.id,
26
+ score: c.score * computeConfidenceMultiplier(confidenceMap.get(c.id) ?? 3),
27
+ }))
28
+ .sort((a, b) => b.score - a.score);
29
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Post-RRF importance boost for memory search results.
3
+ *
4
+ * Multiplies each candidate's score by a factor derived from its importance level.
5
+ * Importance 3 is neutral (1.0×), importance 5 gets a 15% boost, importance 1 gets a 15% penalty.
6
+ *
7
+ * Formula: multiplier = 1 + 0.15 * (importance - 3) / 2
8
+ */
9
+ /**
10
+ * Compute the importance multiplier for a given importance level (1-5).
11
+ * imp 1 → 0.85, imp 3 → 1.0, imp 5 → 1.15
12
+ */
13
+ export declare function computeImportanceMultiplier(importance: number): number;
14
+ export interface BoostedCandidate {
15
+ id: string;
16
+ score: number;
17
+ }
18
+ /**
19
+ * Apply importance boost to a batch of scored candidates and re-sort.
20
+ * Candidates missing from importanceMap default to importance 3 (neutral).
21
+ */
22
+ export declare function applyImportanceBoostBatch(candidates: {
23
+ id: string;
24
+ score: number;
25
+ }[], importanceMap: Map<string, number>): BoostedCandidate[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Post-RRF importance boost for memory search results.
3
+ *
4
+ * Multiplies each candidate's score by a factor derived from its importance level.
5
+ * Importance 3 is neutral (1.0×), importance 5 gets a 15% boost, importance 1 gets a 15% penalty.
6
+ *
7
+ * Formula: multiplier = 1 + 0.15 * (importance - 3) / 2
8
+ */
9
+ /**
10
+ * Compute the importance multiplier for a given importance level (1-5).
11
+ * imp 1 → 0.85, imp 3 → 1.0, imp 5 → 1.15
12
+ */
13
+ export function computeImportanceMultiplier(importance) {
14
+ return 1 + 0.15 * (importance - 3) / 2;
15
+ }
16
+ /**
17
+ * Apply importance boost to a batch of scored candidates and re-sort.
18
+ * Candidates missing from importanceMap default to importance 3 (neutral).
19
+ */
20
+ export function applyImportanceBoostBatch(candidates, importanceMap) {
21
+ if (candidates.length === 0)
22
+ return [];
23
+ return candidates
24
+ .map((c) => ({
25
+ id: c.id,
26
+ score: c.score * computeImportanceMultiplier(importanceMap.get(c.id) ?? 3),
27
+ }))
28
+ .sort((a, b) => b.score - a.score);
29
+ }
@@ -0,0 +1,24 @@
1
+ import type { McpContext } from "../auth.js";
2
+ export interface RecallInput {
3
+ mode: "temporal" | "topic" | "graph";
4
+ range?: string;
5
+ query?: string;
6
+ searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
7
+ topK?: number;
8
+ nodeId?: string;
9
+ maxHops?: number;
10
+ vaultId?: string;
11
+ }
12
+ /** Parse temporal range string to start/end dates. */
13
+ export declare function parseTemporalRange(range: string, now?: Date): {
14
+ start: Date;
15
+ end: Date;
16
+ };
17
+ /** Group memories into morning/afternoon/evening and format as timeline. */
18
+ export declare function formatTimeline(memories: {
19
+ summary?: string | null;
20
+ content?: string;
21
+ createdAt: string;
22
+ }[], dateLabel: string): string;
23
+ /** Main recall function — routes to temporal/topic/graph mode. */
24
+ export declare function recall(ctx: McpContext, input: RecallInput): Promise<string>;
@@ -0,0 +1,146 @@
1
+ import { searchMemories } from "./search-memories.js";
2
+ import { exploreGraph } from "./explore-graph.js";
3
+ import { decryptMemoryFields } from "./decrypt-helpers.js";
4
+ /** Parse temporal range string to start/end dates. */
5
+ export function parseTemporalRange(range, now = new Date()) {
6
+ const todayStart = new Date(now);
7
+ todayStart.setUTCHours(0, 0, 0, 0);
8
+ switch (range) {
9
+ case "today": {
10
+ return { start: todayStart, end: now };
11
+ }
12
+ case "yesterday": {
13
+ const start = new Date(todayStart);
14
+ start.setUTCDate(start.getUTCDate() - 1);
15
+ return { start, end: todayStart };
16
+ }
17
+ case "last_week": {
18
+ const start = new Date(todayStart);
19
+ start.setUTCDate(start.getUTCDate() - 7);
20
+ return { start, end: now };
21
+ }
22
+ default: {
23
+ // Match "last_N_days" pattern
24
+ const match = range.match(/^last_(\d+)_days$/);
25
+ if (match) {
26
+ const days = parseInt(match[1], 10);
27
+ const start = new Date(todayStart);
28
+ start.setUTCDate(start.getUTCDate() - days);
29
+ return { start, end: now };
30
+ }
31
+ // ISO date: "2026-02-15"
32
+ const start = new Date(range + "T00:00:00.000Z");
33
+ const end = new Date(start);
34
+ end.setUTCDate(end.getUTCDate() + 1);
35
+ return { start, end };
36
+ }
37
+ }
38
+ }
39
+ /** Group memories into morning/afternoon/evening and format as timeline. */
40
+ export function formatTimeline(memories, dateLabel) {
41
+ const periods = {
42
+ "Morning (00:00-12:00)": [],
43
+ "Afternoon (12:00-18:00)": [],
44
+ "Evening (18:00-24:00)": [],
45
+ };
46
+ for (const m of memories) {
47
+ const hour = new Date(m.createdAt).getUTCHours();
48
+ if (hour < 12)
49
+ periods["Morning (00:00-12:00)"].push(m);
50
+ else if (hour < 18)
51
+ periods["Afternoon (12:00-18:00)"].push(m);
52
+ else
53
+ periods["Evening (18:00-24:00)"].push(m);
54
+ }
55
+ const lines = [`## Timeline: ${dateLabel}\n`];
56
+ for (const [period, items] of Object.entries(periods)) {
57
+ if (items.length === 0)
58
+ continue;
59
+ lines.push(`### ${period}`);
60
+ for (const item of items) {
61
+ const time = new Date(item.createdAt).toISOString().slice(11, 16);
62
+ const text = item.summary || item.content || "(no summary)";
63
+ lines.push(`- [${time}] ${text}`);
64
+ }
65
+ lines.push("");
66
+ }
67
+ return lines.join("\n");
68
+ }
69
+ /** Main recall function — routes to temporal/topic/graph mode. */
70
+ export async function recall(ctx, input) {
71
+ switch (input.mode) {
72
+ case "temporal":
73
+ return recallTemporal(ctx, input);
74
+ case "topic":
75
+ return recallTopic(ctx, input);
76
+ case "graph":
77
+ return recallGraph(ctx, input);
78
+ default:
79
+ return JSON.stringify({ error: `Unknown mode: ${input.mode}` });
80
+ }
81
+ }
82
+ async function recallTemporal(ctx, input) {
83
+ if (!input.range) {
84
+ return JSON.stringify({ error: "temporal mode requires 'range' parameter" });
85
+ }
86
+ const { start, end } = parseTemporalRange(input.range);
87
+ // Query episodic memories in date range via Supabase
88
+ const { data, error } = await ctx.supabase
89
+ .from("memories")
90
+ .select("id, encrypted_content, content_iv, encrypted_summary, summary_iv, memory_type, importance, created_at")
91
+ .eq("user_id", ctx.userId)
92
+ .eq("memory_type", "episodic")
93
+ .gte("created_at", start.toISOString())
94
+ .lte("created_at", end.toISOString())
95
+ .order("created_at", { ascending: true });
96
+ if (error || !data || data.length === 0) {
97
+ return JSON.stringify({
98
+ mode: "temporal",
99
+ range: input.range,
100
+ sessions: [],
101
+ message: "No sessions found in this range.",
102
+ });
103
+ }
104
+ // Decrypt summaries
105
+ const decrypted = await Promise.all(data.map(async (row) => {
106
+ let summary = "";
107
+ try {
108
+ if (row.encrypted_summary && row.summary_iv) {
109
+ const dec = await decryptMemoryFields({ encrypted_summary: row.encrypted_summary, summary_iv: row.summary_iv }, ctx.masterKey);
110
+ summary = dec.summary || "";
111
+ }
112
+ if (!summary && row.encrypted_content && row.content_iv) {
113
+ const dec = await decryptMemoryFields({ encrypted_content: row.encrypted_content, content_iv: row.content_iv }, ctx.masterKey);
114
+ summary = dec.content.slice(0, 300);
115
+ }
116
+ }
117
+ catch {
118
+ summary = "(decryption failed)";
119
+ }
120
+ return { summary, createdAt: row.created_at };
121
+ }));
122
+ const dateLabel = start.toISOString().slice(0, 10);
123
+ return formatTimeline(decrypted, dateLabel);
124
+ }
125
+ async function recallTopic(ctx, input) {
126
+ if (!input.query) {
127
+ return JSON.stringify({ error: "topic mode requires 'query' parameter" });
128
+ }
129
+ return searchMemories(ctx, {
130
+ query: input.query,
131
+ topK: input.topK ?? 10,
132
+ searchMode: input.searchMode ?? "hybrid",
133
+ vaultId: input.vaultId,
134
+ });
135
+ }
136
+ async function recallGraph(ctx, input) {
137
+ if (!input.query && !input.nodeId) {
138
+ return JSON.stringify({ error: "graph mode requires 'query' or 'nodeId'" });
139
+ }
140
+ return exploreGraph(ctx, {
141
+ query: input.query,
142
+ nodeId: input.nodeId,
143
+ maxHops: input.maxHops ?? 2,
144
+ vaultId: input.vaultId,
145
+ });
146
+ }
@@ -0,0 +1,13 @@
1
+ export interface ScoredResult {
2
+ id: string;
3
+ score: number;
4
+ relevance?: number;
5
+ }
6
+ /**
7
+ * Normalize RRF scores to 0-100 relevance.
8
+ * Top result = 100, others proportional.
9
+ * Input must be sorted by score descending.
10
+ */
11
+ export declare function normalizeToRelevance<T extends ScoredResult>(results: T[]): (T & {
12
+ relevance: number;
13
+ })[];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Normalize RRF scores to 0-100 relevance.
3
+ * Top result = 100, others proportional.
4
+ * Input must be sorted by score descending.
5
+ */
6
+ export function normalizeToRelevance(results) {
7
+ if (results.length === 0)
8
+ return [];
9
+ const maxScore = results[0].score;
10
+ if (maxScore <= 0) {
11
+ return results.map((r) => ({ ...r, relevance: 0 }));
12
+ }
13
+ return results.map((r) => ({
14
+ ...r,
15
+ relevance: Math.round((r.score / maxScore) * 100),
16
+ }));
17
+ }
@@ -25,4 +25,4 @@ export declare function fuseWithRRF(rankedLists: {
25
25
  export declare function fuseWithRRFScored(rankedLists: {
26
26
  ids: string[];
27
27
  weight: number;
28
- }[], k?: number): RRFScoredResult[];
28
+ }[], k?: number, topRankBonus?: boolean): RRFScoredResult[];
package/dist/tools/rrf.js CHANGED
@@ -5,12 +5,27 @@ export function fuseWithRRF(rankedLists, k = 60) {
5
5
  * Like fuseWithRRF but returns { id, score } pairs for downstream re-ranking
6
6
  * (e.g. temporal decay, MMR).
7
7
  */
8
- export function fuseWithRRFScored(rankedLists, k = 60) {
8
+ export function fuseWithRRFScored(rankedLists, k = 60, topRankBonus = false) {
9
9
  const scores = new Map();
10
+ const bestRank = new Map();
10
11
  for (const list of rankedLists) {
11
12
  for (let i = 0; i < list.ids.length; i++) {
12
13
  const id = list.ids[i];
13
14
  scores.set(id, (scores.get(id) ?? 0) + list.weight / (k + i + 1));
15
+ const prev = bestRank.get(id);
16
+ if (prev === undefined || i < prev) {
17
+ bestRank.set(id, i);
18
+ }
19
+ }
20
+ }
21
+ if (topRankBonus) {
22
+ for (const [id, rank] of bestRank) {
23
+ if (rank === 0) {
24
+ scores.set(id, scores.get(id) + 0.05);
25
+ }
26
+ else if (rank <= 2) {
27
+ scores.set(id, scores.get(id) + 0.02);
28
+ }
14
29
  }
15
30
  }
16
31
  return [...scores.entries()]
@@ -10,4 +10,5 @@ export declare function searchMemories(ctx: McpContext, input: {
10
10
  compact?: boolean;
11
11
  decayHalfLife?: number;
12
12
  diversity?: number;
13
+ searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
13
14
  }): Promise<string>;
@@ -7,11 +7,14 @@ import { generateBlindTokens } from "./blind-index.js";
7
7
  import { fuseWithRRFScored } from "./rrf.js";
8
8
  import { applyTemporalDecayBatch, } from "./temporal-decay.js";
9
9
  import { selectMMR } from "./mmr.js";
10
+ import { applyImportanceBoostBatch } from "./importance-boost.js";
11
+ import { applyConfidenceDampenBatch } from "./confidence-dampen.js";
10
12
  const FALLBACK_CONTENT_PREVIEW_CHARS = 500;
11
13
  const COMPACT_CONTENT_CHARS = 200;
12
14
  // RRF signal weights
13
15
  const WEIGHT_SEMANTIC = 1.0;
14
- const WEIGHT_BLIND_INDEX = 0.8;
16
+ const WEIGHT_BM25 = 0.9;
17
+ const WEIGHT_BLIND_INDEX = 0.4;
15
18
  const WEIGHT_GRAPH = 0.6;
16
19
  // Graph expansion: how many initial results to expand, and relation weights
17
20
  const GRAPH_EXPAND_TOP_N = 5;
@@ -31,7 +34,7 @@ function clip(text, maxChars) {
31
34
  }
32
35
  export async function searchMemories(ctx, input) {
33
36
  const topK = input.topK ?? 10;
34
- const threshold = input.threshold ?? 0.4;
37
+ const threshold = input.threshold ?? 0.5;
35
38
  // Entity-based search: use JSONB containment instead of semantic search
36
39
  if (input.entity) {
37
40
  const entityResults = await searchMemoriesByEntity(ctx.supabase, ctx.userId, input.entity, topK);
@@ -80,15 +83,19 @@ export async function searchMemories(ctx, input) {
80
83
  });
81
84
  return payload;
82
85
  }
86
+ const effectiveSearchMode = input.searchMode ?? "auto";
87
+ const useBm25 = effectiveSearchMode !== "semantic";
88
+ const useSemantic = effectiveSearchMode !== "bm25";
83
89
  let embeddingTokens = 0;
84
90
  const semanticIds = [];
85
91
  const semanticScores = new Map();
86
92
  const blindIds = [];
93
+ const bm25Ids = [];
87
94
  let semanticError = null;
88
95
  const embeddingConfig = resolveEmbeddingConfig(ctx);
89
- // ── Signal 1 + Signal 2: run in parallel ──────────────────────────────
96
+ // ── Signal 1 + Signal 2 + Signal 4: run in parallel ──────────────────
90
97
  const semanticPromise = (async () => {
91
- if (!embeddingConfig)
98
+ if (!embeddingConfig || !useSemantic)
92
99
  return;
93
100
  try {
94
101
  embeddingTokens = Math.ceil(input.query.length / 4);
@@ -130,10 +137,31 @@ export async function searchMemories(ctx, input) {
130
137
  process.stderr.write(`[search-memories] Blind index search error: ${err instanceof Error ? err.message : String(err)}\n`);
131
138
  }
132
139
  })();
133
- await Promise.all([semanticPromise, blindPromise]);
134
- // ── Signal 3: Graph expansion on top-N unique results from signals 1+2 ─
140
+ const bm25Promise = (async () => {
141
+ if (!useBm25)
142
+ return;
143
+ try {
144
+ const { data, error } = await ctx.supabase.rpc("match_memories_bm25", {
145
+ p_user_id: ctx.userId,
146
+ p_query: input.query,
147
+ p_vault_id: input.vaultId ?? null,
148
+ p_match_count: topK * 4,
149
+ p_include_archived: input.includeArchived ?? false,
150
+ });
151
+ if (error || !data)
152
+ return;
153
+ for (const r of data) {
154
+ bm25Ids.push(r.memory_id);
155
+ }
156
+ }
157
+ catch (err) {
158
+ process.stderr.write(`[search-memories] BM25 search error: ${err instanceof Error ? err.message : String(err)}\n`);
159
+ }
160
+ })();
161
+ await Promise.all([semanticPromise, blindPromise, bm25Promise]);
162
+ // ── Signal 3: Graph expansion on top-N unique results from all signals ─
135
163
  const graphIds = [];
136
- const initialHits = new Set([...semanticIds, ...blindIds]);
164
+ const initialHits = new Set([...semanticIds, ...blindIds, ...bm25Ids]);
137
165
  const seedIds = [...initialHits].slice(0, GRAPH_EXPAND_TOP_N);
138
166
  if (seedIds.length > 0) {
139
167
  try {
@@ -161,24 +189,34 @@ export async function searchMemories(ctx, input) {
161
189
  let rankedIds = [];
162
190
  let searchMode = "hybrid";
163
191
  const halfLife = input.decayHalfLife ?? 30;
164
- const hasSignals = semanticIds.length > 0 || blindIds.length > 0;
192
+ const hasSignals = semanticIds.length > 0 || blindIds.length > 0 || bm25Ids.length > 0;
165
193
  if (hasSignals) {
166
194
  const lists = [];
167
195
  if (semanticIds.length > 0)
168
196
  lists.push({ ids: semanticIds, weight: WEIGHT_SEMANTIC });
197
+ if (bm25Ids.length > 0)
198
+ lists.push({ ids: bm25Ids, weight: WEIGHT_BM25 });
169
199
  if (blindIds.length > 0)
170
200
  lists.push({ ids: blindIds, weight: WEIGHT_BLIND_INDEX });
171
201
  if (graphIds.length > 0)
172
202
  lists.push({ ids: graphIds, weight: WEIGHT_GRAPH });
173
- // Get scored candidates — fetch topK*2 for decay re-ranking headroom
174
- const rrfScored = fuseWithRRFScored(lists).slice(0, topK * 2);
175
- searchMode = lists.length === 1 && semanticIds.length > 0 ? "semantic" : "hybrid";
176
- // Fetch candidate rows for updatedAt + importance
203
+ // Get scored candidates — fetch topK*2 for decay re-ranking headroom, with top-rank bonus
204
+ const rrfScored = fuseWithRRFScored(lists, 60, true).slice(0, topK * 2);
205
+ searchMode = effectiveSearchMode === "bm25" ? "bm25"
206
+ : lists.length === 1 && semanticIds.length > 0 ? "semantic"
207
+ : "hybrid";
208
+ // Fetch candidate rows for updatedAt + importance + confidence
177
209
  const candidateIds = rrfScored.map((r) => r.id);
178
210
  const candidateRows = await getMemoriesByIds(ctx.supabase, ctx.userId, candidateIds);
179
211
  const candidateMap = new Map(candidateRows.map((r) => [r.id, r]));
212
+ // Build importance and confidence maps for boost/dampen
213
+ const importanceMap = new Map(candidateRows.map((r) => [r.id, r.importance]));
214
+ const confidenceMap = new Map(candidateRows.map((r) => [r.id, r.confidence]));
215
+ // Apply importance boost then confidence dampen (before temporal decay)
216
+ const boosted = applyImportanceBoostBatch(rrfScored, importanceMap);
217
+ const dampened = applyConfidenceDampenBatch(boosted, confidenceMap);
180
218
  // Build scored candidates and apply temporal decay
181
- const scoredCandidates = rrfScored
219
+ const scoredCandidates = dampened
182
220
  .filter((r) => candidateMap.has(r.id))
183
221
  .map((r) => {
184
222
  const row = candidateMap.get(r.id);
@@ -249,15 +287,23 @@ export async function searchMemories(ctx, input) {
249
287
  }));
250
288
  // Track access for returned results (fire-and-forget)
251
289
  touchMemories(ctx.supabase, ctx.userId, results.map((r) => r.id));
290
+ // Add 0-100 relevance scores (top result = 100, proportional by position)
291
+ const withRelevance = results.map((m, i) => ({
292
+ ...m,
293
+ relevance: results.length > 0
294
+ ? Math.round(((results.length - i) / results.length) * 100)
295
+ : 0,
296
+ }));
252
297
  const payload = JSON.stringify({
253
298
  searchMode,
254
299
  compact: input.compact ?? false,
255
300
  threshold,
256
301
  semanticMatchCount: semanticIds.length,
257
302
  blindIndexMatchCount: blindIds.length,
303
+ bm25MatchCount: bm25Ids.length,
258
304
  graphExpansionCount: graphIds.length,
259
305
  semanticError,
260
- memories: results,
306
+ memories: withRelevance,
261
307
  }, null, 2);
262
308
  await logMcpUsageEvent({
263
309
  supabase: ctx.supabase,
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable @typescript-eslint/no-require-imports */
3
+ /**
4
+ * ExoVault turn capture hook for Claude Code.
5
+ * Captures user prompts and assistant responses, POSTs to ingest-turn API.
6
+ *
7
+ * Usage (called by Claude Code hooks, not directly):
8
+ * echo '{"prompt":"hello"}' | node capture-turn.js user
9
+ * echo '{"last_assistant_message":"hi"}' | node capture-turn.js assistant
10
+ *
11
+ * Config resolution (first match wins):
12
+ * 1. EXOVAULT_AGENT_KEY / EXOVAULT_API_URL env vars
13
+ * 2. ~/.exovault-mcp/config.json (agentKey / apiUrl fields)
14
+ */
15
+
16
+ const path = require("path");
17
+ const fs = require("fs");
18
+
19
+ const MAX_CONTENT_LENGTH = 50_000;
20
+ const MIN_CONTENT_LENGTH = 5;
21
+ const FETCH_TIMEOUT_MS = 10_000;
22
+
23
+ /** Default config file path — same as the MCP server. */
24
+ const CONFIG_PATH = path.join(
25
+ process.env.HOME || process.env.USERPROFILE || "~",
26
+ ".exovault-mcp",
27
+ "config.json",
28
+ );
29
+
30
+ /**
31
+ * Resolve agent key and API URL from env vars, falling back to config file.
32
+ * Returns { agentKey, apiUrl } or null if not configured.
33
+ */
34
+ function resolveConfig() {
35
+ // 1. Env vars take priority
36
+ let agentKey = process.env.EXOVAULT_AGENT_KEY || "";
37
+ let apiUrl = process.env.EXOVAULT_API_URL || "";
38
+
39
+ // 2. Fall back to config file
40
+ if (!agentKey) {
41
+ try {
42
+ const raw = fs.readFileSync(CONFIG_PATH, "utf8");
43
+ const config = JSON.parse(raw);
44
+ if (!agentKey && config.agentKey) agentKey = config.agentKey;
45
+ if (!apiUrl && config.apiUrl) apiUrl = config.apiUrl;
46
+ } catch {
47
+ // Config file missing or invalid — that's fine
48
+ }
49
+ }
50
+
51
+ if (!agentKey) return null;
52
+
53
+ return {
54
+ agentKey,
55
+ apiUrl: (apiUrl || "https://exovault.co").replace(/\/+$/, ""),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Extract content from hook input based on role.
61
+ * Returns null if content should be skipped.
62
+ */
63
+ function extractContent(input, role) {
64
+ if (role === "user") {
65
+ return input.prompt || null;
66
+ }
67
+
68
+ if (role === "assistant") {
69
+ // Skip re-entry: stop_hook_active means Stop hook already fired
70
+ if (input.stop_hook_active) return null;
71
+ return input.last_assistant_message || null;
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Prepare the ingest-turn request body.
79
+ */
80
+ function buildRequestBody(content, role, sessionId) {
81
+ // Truncate oversized content
82
+ let trimmed = content;
83
+ if (trimmed.length > MAX_CONTENT_LENGTH) {
84
+ trimmed = trimmed.slice(0, MAX_CONTENT_LENGTH) + "\n[truncated]";
85
+ }
86
+
87
+ const body = {
88
+ content: trimmed,
89
+ role,
90
+ agentId: "claude_code",
91
+ };
92
+
93
+ if (sessionId) {
94
+ body.agentRunId = sessionId;
95
+ }
96
+
97
+ return body;
98
+ }
99
+
100
+ /**
101
+ * POST a turn to the ExoVault ingest-turn API.
102
+ */
103
+ async function postTurn(apiUrl, agentKey, body) {
104
+ const controller = new AbortController();
105
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
106
+
107
+ try {
108
+ await fetch(`${apiUrl}/api/agent/ingest-turn`, {
109
+ method: "POST",
110
+ headers: {
111
+ "Content-Type": "application/json",
112
+ Authorization: `Bearer ${agentKey}`,
113
+ },
114
+ body: JSON.stringify(body),
115
+ signal: controller.signal,
116
+ });
117
+ } finally {
118
+ clearTimeout(timeout);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Main entry point — reads stdin, extracts content, POSTs to API.
124
+ */
125
+ async function main() {
126
+ const role = process.argv[2];
127
+ if (role !== "user" && role !== "assistant") {
128
+ process.exit(0);
129
+ }
130
+
131
+ const config = resolveConfig();
132
+ if (!config) {
133
+ process.exit(0); // Not configured — silently skip
134
+ }
135
+
136
+ // Read JSON from stdin
137
+ let data = "";
138
+ process.stdin.setEncoding("utf8");
139
+
140
+ await new Promise((resolve) => {
141
+ process.stdin.on("data", (chunk) => (data += chunk));
142
+ process.stdin.on("end", resolve);
143
+ });
144
+
145
+ const input = JSON.parse(data);
146
+ const content = extractContent(input, role);
147
+
148
+ if (!content || content.length < MIN_CONTENT_LENGTH) {
149
+ process.exit(0); // Too short or empty — skip
150
+ }
151
+
152
+ const body = buildRequestBody(content, role, input.session_id);
153
+ await postTurn(config.apiUrl, config.agentKey, body);
154
+ }
155
+
156
+ // Export for testing, execute when run directly
157
+ if (typeof module !== "undefined") {
158
+ module.exports = {
159
+ extractContent,
160
+ buildRequestBody,
161
+ postTurn,
162
+ resolveConfig,
163
+ MAX_CONTENT_LENGTH,
164
+ MIN_CONTENT_LENGTH,
165
+ FETCH_TIMEOUT_MS,
166
+ CONFIG_PATH,
167
+ };
168
+ }
169
+
170
+ if (require.main === module) {
171
+ main().then(
172
+ () => process.exit(0),
173
+ () => process.exit(0), // Silently swallow all errors
174
+ );
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exovault-mcp-server",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for ExoVault — read, search, and manage encrypted notes from Claude Code",
6
6
  "main": "dist/index.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/",
12
+ "hooks/",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],