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.
- package/dist/gateway-client.d.ts +10 -0
- package/dist/gateway-client.js +5 -0
- package/dist/index.js +58 -5
- package/dist/setup.js +88 -31
- package/dist/tools/bm25.d.ts +6 -0
- package/dist/tools/bm25.js +10 -0
- package/dist/tools/confidence-dampen.d.ts +25 -0
- package/dist/tools/confidence-dampen.js +29 -0
- package/dist/tools/importance-boost.d.ts +25 -0
- package/dist/tools/importance-boost.js +29 -0
- package/dist/tools/recall.d.ts +24 -0
- package/dist/tools/recall.js +146 -0
- package/dist/tools/relevance.d.ts +13 -0
- package/dist/tools/relevance.js +17 -0
- package/dist/tools/rrf.d.ts +1 -1
- package/dist/tools/rrf.js +16 -1
- package/dist/tools/search-memories.d.ts +1 -0
- package/dist/tools/search-memories.js +60 -14
- package/hooks/capture-turn.js +175 -0
- package/package.json +2 -1
package/dist/gateway-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/gateway-client.js
CHANGED
|
@@ -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
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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("
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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,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
|
+
}
|
package/dist/tools/rrf.d.ts
CHANGED
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()]
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
134
|
-
|
|
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 =
|
|
176
|
-
|
|
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 =
|
|
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:
|
|
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.
|
|
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
|
],
|