exovault-mcp-server 1.1.1 → 1.2.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/dist/db.d.ts +16 -0
- package/dist/db.js +34 -0
- package/dist/embedding-config.d.ts +1 -2
- package/dist/embedding-config.js +11 -10
- package/dist/gateway-client.d.ts +41 -12
- package/dist/gateway-client.js +53 -10
- package/dist/index.js +87 -38
- package/dist/openai.d.ts +4 -2
- package/dist/openai.js +36 -32
- package/dist/rlm/actions.js +3 -2
- package/dist/scripts/backfill-memory-embeddings.js +13 -30
- package/dist/tools/explore-graph.js +1 -1
- package/dist/tools/read-memories.js +28 -1
- package/dist/tools/read-note.js +17 -1
- package/dist/tools/read-notes.js +28 -1
- package/dist/tools/search-and-read.js +28 -1
- package/dist/tools/search-memories.js +57 -3
- package/dist/tools/search-notes.d.ts +10 -1
- package/dist/tools/search-notes.js +19 -9
- package/dist/tools/search.d.ts +27 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/semantic-search.js +29 -2
- package/dist/tools/update-memory.js +2 -2
- package/dist/tools/write-memory.js +2 -6
- package/package.json +2 -1
package/dist/db.d.ts
CHANGED
|
@@ -191,6 +191,22 @@ export declare function matchNotesByBlindTokens(supabase: SupabaseClient, tokenH
|
|
|
191
191
|
export declare function getNotesByIds(supabase: SupabaseClient, userId: string, noteIds: string[]): Promise<NoteRow[]>;
|
|
192
192
|
export declare function getNoteByImportSource(supabase: SupabaseClient, userId: string, vaultId: string, importSource: string, importSourceId: string): Promise<NoteRow | null>;
|
|
193
193
|
export declare function getMemoriesByIds(supabase: SupabaseClient, userId: string, memoryIds: string[]): Promise<MemoryRow[]>;
|
|
194
|
+
export interface MediaAttachmentRow {
|
|
195
|
+
id: string;
|
|
196
|
+
memory_id: string | null;
|
|
197
|
+
note_id: string | null;
|
|
198
|
+
modality: string;
|
|
199
|
+
mime_type: string;
|
|
200
|
+
file_name: string | null;
|
|
201
|
+
file_size_bytes: number;
|
|
202
|
+
embedding_status: string;
|
|
203
|
+
extracted_text: string | null;
|
|
204
|
+
extracted_text_iv: string | null;
|
|
205
|
+
extraction_status: string;
|
|
206
|
+
}
|
|
207
|
+
export declare function getAttachmentsForMemories(supabase: SupabaseClient, userId: string, memoryIds: string[]): Promise<MediaAttachmentRow[]>;
|
|
208
|
+
export declare function getAttachmentsForNotes(supabase: SupabaseClient, userId: string, noteIds: string[]): Promise<MediaAttachmentRow[]>;
|
|
209
|
+
export declare function formatAttachmentWithExtraction(att: MediaAttachmentRow, decryptedText: string | null, truncate?: number): string;
|
|
194
210
|
export interface ActiveAgentRow {
|
|
195
211
|
agentId: string;
|
|
196
212
|
modelId: string | null;
|
package/dist/db.js
CHANGED
|
@@ -372,6 +372,40 @@ export async function getMemoriesByIds(supabase, userId, memoryIds) {
|
|
|
372
372
|
throw new Error(sanitizeDbError("fetch memories by IDs", error.message));
|
|
373
373
|
return (data ?? []);
|
|
374
374
|
}
|
|
375
|
+
export async function getAttachmentsForMemories(supabase, userId, memoryIds) {
|
|
376
|
+
if (memoryIds.length === 0)
|
|
377
|
+
return [];
|
|
378
|
+
const { data, error } = await supabase
|
|
379
|
+
.from("media_attachments")
|
|
380
|
+
.select("id, memory_id, note_id, modality, mime_type, file_name, file_size_bytes, embedding_status, extracted_text, extracted_text_iv, extraction_status")
|
|
381
|
+
.eq("user_id", userId)
|
|
382
|
+
.in("memory_id", memoryIds);
|
|
383
|
+
if (error)
|
|
384
|
+
throw new Error(sanitizeDbError("fetch attachments", error.message));
|
|
385
|
+
return (data ?? []);
|
|
386
|
+
}
|
|
387
|
+
export async function getAttachmentsForNotes(supabase, userId, noteIds) {
|
|
388
|
+
if (noteIds.length === 0)
|
|
389
|
+
return [];
|
|
390
|
+
const { data, error } = await supabase
|
|
391
|
+
.from("media_attachments")
|
|
392
|
+
.select("id, memory_id, note_id, modality, mime_type, file_name, file_size_bytes, embedding_status, extracted_text, extracted_text_iv, extraction_status")
|
|
393
|
+
.eq("user_id", userId)
|
|
394
|
+
.in("note_id", noteIds);
|
|
395
|
+
if (error)
|
|
396
|
+
throw new Error(sanitizeDbError("fetch note attachments", error.message));
|
|
397
|
+
return (data ?? []);
|
|
398
|
+
}
|
|
399
|
+
export function formatAttachmentWithExtraction(att, decryptedText, truncate) {
|
|
400
|
+
let text = decryptedText;
|
|
401
|
+
if (text && truncate)
|
|
402
|
+
text = text.slice(0, truncate);
|
|
403
|
+
return [
|
|
404
|
+
`[${att.modality}] ${att.file_name ?? "unnamed"} (${att.mime_type}, ${att.file_size_bytes}B)`,
|
|
405
|
+
` embedding: ${att.embedding_status}, extraction: ${att.extraction_status}`,
|
|
406
|
+
text ? ` content: ${text}` : null,
|
|
407
|
+
].filter(Boolean).join("\n");
|
|
408
|
+
}
|
|
375
409
|
export async function getActiveAgents(supabase, userId, sinceDays = 30, limit = 20) {
|
|
376
410
|
const since = new Date();
|
|
377
411
|
since.setDate(since.getDate() - sinceDays);
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { McpContext } from "./auth.js";
|
|
2
2
|
export interface EmbeddingConfig {
|
|
3
3
|
apiKey: string;
|
|
4
|
-
baseUrl: string;
|
|
5
4
|
model: string;
|
|
6
5
|
}
|
|
7
6
|
/**
|
|
8
7
|
* Resolve embedding API configuration from env vars or MCP context.
|
|
9
|
-
* Priority: env vars > ctx.openaiApiKey > null.
|
|
8
|
+
* Priority: env vars > ctx.geminiApiKey > ctx.openaiApiKey > null.
|
|
10
9
|
*/
|
|
11
10
|
export declare function resolveEmbeddingConfig(ctx: McpContext): EmbeddingConfig | null;
|
package/dist/embedding-config.js
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
const
|
|
1
|
+
const DEFAULT_EMBEDDING_MODEL = "gemini-embedding-2-preview";
|
|
2
2
|
/**
|
|
3
3
|
* Resolve embedding API configuration from env vars or MCP context.
|
|
4
|
-
* Priority: env vars > ctx.openaiApiKey > null.
|
|
4
|
+
* Priority: env vars > ctx.geminiApiKey > ctx.openaiApiKey > null.
|
|
5
5
|
*/
|
|
6
6
|
export function resolveEmbeddingConfig(ctx) {
|
|
7
|
-
const envApiKey = process.env.
|
|
8
|
-
process.env.
|
|
7
|
+
const envApiKey = process.env.EXO_GEMINI_KEY ??
|
|
8
|
+
process.env.GEMINI_API_KEY ??
|
|
9
|
+
process.env.EMBEDDING_API_KEY;
|
|
9
10
|
if (envApiKey) {
|
|
10
11
|
return {
|
|
11
12
|
apiKey: envApiKey,
|
|
12
|
-
|
|
13
|
-
model: process.env.EMBEDDING_MODEL ?? OPENAI_EMBEDDING_MODEL,
|
|
13
|
+
model: process.env.EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL,
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
// Fallback to MCP context keys
|
|
17
|
+
const ctxKey = ctx.geminiApiKey ?? ctx.openaiApiKey;
|
|
18
|
+
if (ctxKey && typeof ctxKey === "string") {
|
|
17
19
|
return {
|
|
18
|
-
apiKey:
|
|
19
|
-
|
|
20
|
-
model: OPENAI_EMBEDDING_MODEL,
|
|
20
|
+
apiKey: ctxKey,
|
|
21
|
+
model: DEFAULT_EMBEDDING_MODEL,
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
return null;
|
package/dist/gateway-client.d.ts
CHANGED
|
@@ -134,8 +134,28 @@ export declare class GatewayClient {
|
|
|
134
134
|
readNotes(noteIds: string[]): Promise<string>;
|
|
135
135
|
searchNotes(params: {
|
|
136
136
|
query: string;
|
|
137
|
+
topK?: number;
|
|
138
|
+
threshold?: number;
|
|
139
|
+
searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
|
|
140
|
+
diversity?: number;
|
|
137
141
|
vaultId?: string;
|
|
138
|
-
|
|
142
|
+
includeContent?: boolean;
|
|
143
|
+
compact?: boolean;
|
|
144
|
+
}): Promise<string>;
|
|
145
|
+
search(params: {
|
|
146
|
+
query: string;
|
|
147
|
+
topK?: number;
|
|
148
|
+
threshold?: number;
|
|
149
|
+
searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
|
|
150
|
+
diversity?: number;
|
|
151
|
+
vaultId?: string;
|
|
152
|
+
includeContent?: boolean;
|
|
153
|
+
compact?: boolean;
|
|
154
|
+
scope?: "all" | "memories" | "notes";
|
|
155
|
+
memoryType?: string;
|
|
156
|
+
entity?: string;
|
|
157
|
+
includeArchived?: boolean;
|
|
158
|
+
decayHalfLife?: number;
|
|
139
159
|
}): Promise<string>;
|
|
140
160
|
createNote(params: {
|
|
141
161
|
vaultId?: string;
|
|
@@ -151,17 +171,6 @@ export declare class GatewayClient {
|
|
|
151
171
|
tags?: string[];
|
|
152
172
|
}): Promise<string>;
|
|
153
173
|
deleteNote(noteId: string): Promise<string>;
|
|
154
|
-
semanticSearch(params: {
|
|
155
|
-
query: string;
|
|
156
|
-
topK?: number;
|
|
157
|
-
threshold?: number;
|
|
158
|
-
vaultId?: string;
|
|
159
|
-
}): Promise<string>;
|
|
160
|
-
searchAndRead(params: {
|
|
161
|
-
query: string;
|
|
162
|
-
maxNotes?: number;
|
|
163
|
-
vaultId?: string;
|
|
164
|
-
}): Promise<string>;
|
|
165
174
|
listFolders(params: {
|
|
166
175
|
vaultId?: string;
|
|
167
176
|
}): Promise<string>;
|
|
@@ -310,4 +319,24 @@ export declare class GatewayClient {
|
|
|
310
319
|
documentType: "instructions" | "skills" | "checks";
|
|
311
320
|
appendContent: string;
|
|
312
321
|
}): Promise<string>;
|
|
322
|
+
attachMedia(params: {
|
|
323
|
+
fileBytes: Uint8Array;
|
|
324
|
+
fileName: string;
|
|
325
|
+
mimeType: string;
|
|
326
|
+
memoryId?: string;
|
|
327
|
+
vaultId?: string;
|
|
328
|
+
}): Promise<string>;
|
|
329
|
+
downloadMedia(params: {
|
|
330
|
+
attachmentId: string;
|
|
331
|
+
}): Promise<{
|
|
332
|
+
attachmentId: string;
|
|
333
|
+
memoryId: string | null;
|
|
334
|
+
modality: string;
|
|
335
|
+
mimeType: string;
|
|
336
|
+
fileName: string | null;
|
|
337
|
+
fileSizeBytes: number;
|
|
338
|
+
embeddingStatus: string;
|
|
339
|
+
base64Content: string;
|
|
340
|
+
}>;
|
|
341
|
+
deleteMedia(attachmentId: string, vaultId?: string): Promise<string>;
|
|
313
342
|
}
|
package/dist/gateway-client.js
CHANGED
|
@@ -154,8 +154,12 @@ export class GatewayClient {
|
|
|
154
154
|
return JSON.stringify(result);
|
|
155
155
|
}
|
|
156
156
|
async searchNotes(params) {
|
|
157
|
-
const
|
|
158
|
-
return JSON.stringify(
|
|
157
|
+
const data = await this.request("POST", "/api/agent/search-notes", params);
|
|
158
|
+
return JSON.stringify(data, null, 2);
|
|
159
|
+
}
|
|
160
|
+
async search(params) {
|
|
161
|
+
const data = await this.request("POST", "/api/agent/search", params);
|
|
162
|
+
return JSON.stringify(data, null, 2);
|
|
159
163
|
}
|
|
160
164
|
async createNote(params) {
|
|
161
165
|
const result = await this.request("POST", "/api/agent/create-note", params);
|
|
@@ -169,14 +173,6 @@ export class GatewayClient {
|
|
|
169
173
|
const result = await this.request("POST", "/api/agent/delete-note", { noteId });
|
|
170
174
|
return JSON.stringify(result);
|
|
171
175
|
}
|
|
172
|
-
async semanticSearch(params) {
|
|
173
|
-
const result = await this.request("POST", "/api/agent/semantic-search", params);
|
|
174
|
-
return JSON.stringify(result);
|
|
175
|
-
}
|
|
176
|
-
async searchAndRead(params) {
|
|
177
|
-
const result = await this.request("POST", "/api/agent/search-and-read", params);
|
|
178
|
-
return JSON.stringify(result);
|
|
179
|
-
}
|
|
180
176
|
// ─── Folder operations ──────────────────────────────────────────────────
|
|
181
177
|
async listFolders(params) {
|
|
182
178
|
const result = await this.request("POST", "/api/agent/list-folders", params);
|
|
@@ -287,4 +283,51 @@ export class GatewayClient {
|
|
|
287
283
|
const result = await this.request("POST", "/api/agent/update-document", params);
|
|
288
284
|
return JSON.stringify(result);
|
|
289
285
|
}
|
|
286
|
+
// ─── Media operations ───────────────────────────────────────────────────────
|
|
287
|
+
async attachMedia(params) {
|
|
288
|
+
const url = `${this.baseUrl}/api/agent/attach-media`;
|
|
289
|
+
const headers = {
|
|
290
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
291
|
+
};
|
|
292
|
+
if (this.sessionRunId) {
|
|
293
|
+
headers["X-Agent-Run-Id"] = this.sessionRunId;
|
|
294
|
+
}
|
|
295
|
+
// Build multipart form data
|
|
296
|
+
const formData = new FormData();
|
|
297
|
+
const ab = params.fileBytes.buffer.slice(params.fileBytes.byteOffset, params.fileBytes.byteOffset + params.fileBytes.byteLength);
|
|
298
|
+
const blob = new Blob([ab], { type: params.mimeType });
|
|
299
|
+
formData.append("file", blob, params.fileName);
|
|
300
|
+
if (params.memoryId)
|
|
301
|
+
formData.append("memoryId", params.memoryId);
|
|
302
|
+
if (params.vaultId)
|
|
303
|
+
formData.append("vaultId", params.vaultId);
|
|
304
|
+
const response = await fetch(url, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers,
|
|
307
|
+
body: formData,
|
|
308
|
+
});
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
let errorBody;
|
|
311
|
+
try {
|
|
312
|
+
errorBody = await response.json();
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
errorBody = null;
|
|
316
|
+
}
|
|
317
|
+
const message = errorBody?.error ?? `Gateway returned ${response.status}`;
|
|
318
|
+
throw new GatewayError(message, response.status, errorBody);
|
|
319
|
+
}
|
|
320
|
+
const result = await response.json();
|
|
321
|
+
return JSON.stringify(result);
|
|
322
|
+
}
|
|
323
|
+
async downloadMedia(params) {
|
|
324
|
+
return this.request("POST", "/api/agent/download-media", params);
|
|
325
|
+
}
|
|
326
|
+
async deleteMedia(attachmentId, vaultId) {
|
|
327
|
+
const body = { attachmentId };
|
|
328
|
+
if (vaultId)
|
|
329
|
+
body.vaultId = vaultId;
|
|
330
|
+
const res = await this.request("POST", "/api/agent/delete-media", body);
|
|
331
|
+
return JSON.stringify(res);
|
|
332
|
+
}
|
|
290
333
|
}
|
package/dist/index.js
CHANGED
|
@@ -12,8 +12,6 @@ import { listNotes } from "./tools/list-notes.js";
|
|
|
12
12
|
import { readNote } from "./tools/read-note.js";
|
|
13
13
|
import { readNotes } from "./tools/read-notes.js";
|
|
14
14
|
import { searchNotes } from "./tools/search-notes.js";
|
|
15
|
-
import { semanticSearch } from "./tools/semantic-search.js";
|
|
16
|
-
import { searchAndRead } from "./tools/search-and-read.js";
|
|
17
15
|
import { createNote } from "./tools/create-note.js";
|
|
18
16
|
import { updateNote } from "./tools/update-note.js";
|
|
19
17
|
import { deleteNote } from "./tools/delete-note.js";
|
|
@@ -30,6 +28,7 @@ import { cleanupMemories } from "./tools/cleanup-memories.js";
|
|
|
30
28
|
import { getLinks, addLink, removeLink } from "./tools/knowledge-links.js";
|
|
31
29
|
import { exploreGraph } from "./tools/explore-graph.js";
|
|
32
30
|
import { recall } from "./tools/recall.js";
|
|
31
|
+
import { search } from "./tools/search.js";
|
|
33
32
|
import { sendMessage, ackMessage, readMessages } from "./tools/agent-messages.js";
|
|
34
33
|
// Task tools are thin wrappers around memory tools — no separate agent-tasks import needed
|
|
35
34
|
import { resolveVaultId } from "./tools/resolve-vault-id.js";
|
|
@@ -371,7 +370,7 @@ async function main() {
|
|
|
371
370
|
if (gw) {
|
|
372
371
|
instructionLines.push("Running in gateway mode. Turn ingestion is automatic.");
|
|
373
372
|
}
|
|
374
|
-
instructionLines.push("", "## Tasks", "`create_task` (title, description, status, priority, assignedAgentId, doneWhen). `update_task` to change status. `list_tasks` to view.", "Set `doneWhen` for auto-detect completion. Tasks are memories with memoryType='task'.", "assignedAgentId: null=unassigned, 'any'=any agent, '<type>'=specific. Check assigned tasks at session start.", "", "## Messages", "Pending messages from users/agents appear in `session_start` and `context_checkpoint` responses under `pendingMessages`.", "**When you receive a message**: respond with `send_message(targetId: 'user', content: '...', parentMessageId: '<message.id>')` to reply in-thread.", "`ack_message(messageId)` to acknowledge without replying. `read_messages(agentId)` to fetch messages on demand.", "", "## Memory Protocol", "1. Scope to vaultId. 2. SEARCH FIRST before writing/answering. 3. externalWriteId for idempotency.", "4. Set importance+confidence (1-5). 5. Extract entities. 6. relatedMemoryIds for links. 7. supersededById for corrections.", "Types: fact, skill, preference, constraint, task, episodic, correction. Always set dedup:true, agentId, agentRunId.", "", "## Retrieval Tools (pick the right one)", "- `search_memories` — hybrid search. Use compact:true, then read_memories for full content.", "- `explore_graph` — **PREFERRED for deep retrieval**. query/nodeId → multi-hop graph map (nodes+edges). Then read_memories/read_note for full content. Zero LLM cost.", "- `
|
|
373
|
+
instructionLines.push("", "## Tasks", "`create_task` (title, description, status, priority, assignedAgentId, doneWhen). `update_task` to change status. `list_tasks` to view.", "Set `doneWhen` for auto-detect completion. Tasks are memories with memoryType='task'.", "assignedAgentId: null=unassigned, 'any'=any agent, '<type>'=specific. Check assigned tasks at session start.", "", "## Messages", "Pending messages from users/agents appear in `session_start` and `context_checkpoint` responses under `pendingMessages`.", "**When you receive a message**: respond with `send_message(targetId: 'user', content: '...', parentMessageId: '<message.id>')` to reply in-thread.", "`ack_message(messageId)` to acknowledge without replying. `read_messages(agentId)` to fetch messages on demand.", "", "## Memory Protocol", "1. Scope to vaultId. 2. SEARCH FIRST before writing/answering. 3. externalWriteId for idempotency.", "4. Set importance+confidence (1-5). 5. Extract entities. 6. relatedMemoryIds for links. 7. supersededById for corrections.", "Types: fact, skill, preference, constraint, task, episodic, correction. Always set dedup:true, agentId, agentRunId.", "", "## Retrieval Tools (pick the right one)", "- `search` — **universal search** across memories+notes with cross-type MMR. Set scope='all' (default), 'memories', or 'notes'.", "- `search_memories` — hybrid search memories only. Use compact:true, then read_memories for full content.", "- `search_notes` — hybrid search notes only. Use includeContent:true for full content, or read_notes for specific IDs.", "- `explore_graph` — **PREFERRED for deep retrieval**. query/nodeId → multi-hop graph map (nodes+edges). Then read_memories/read_note for full content. Zero LLM cost.", "- `get_links` / `get_related_memories` — single-hop link traversal.", "- `read_document` — vault docs (instructions, skills, checks).");
|
|
375
374
|
const instructionsText = instructionLines.join("\n");
|
|
376
375
|
// ── Guard: keep instructions ≤4,000 chars ─────────────────────────
|
|
377
376
|
const INSTRUCTION_CHAR_LIMIT = 4_000;
|
|
@@ -438,17 +437,22 @@ async function main() {
|
|
|
438
437
|
})));
|
|
439
438
|
// ─── search_notes ─────────────────────────────────────────────────────────
|
|
440
439
|
server.registerTool("search_notes", {
|
|
441
|
-
description: "Search notes
|
|
440
|
+
description: "Search notes using 4-signal hybrid scoring (semantic + BM25 + blind index + graph) with RRF fusion and MMR diversity. Gateway mode uses server-side pipeline; direct mode uses local Supabase queries. Use includeContent:true to get full note content inline, or follow up with read_notes for specific IDs. Consider `search` tool (scope='notes') for unified cross-type results.",
|
|
442
441
|
inputSchema: {
|
|
443
|
-
query: s(z.string().min(1).describe("Search query")),
|
|
442
|
+
query: s(z.string().min(1).describe("Search query (natural language or keywords)")),
|
|
443
|
+
topK: s(z.number().int().min(1).max(50).optional().describe("Max results (default 10)")),
|
|
444
|
+
threshold: s(z.number().min(0).max(1).optional().describe("Minimum similarity threshold 0-1 (default 0.5)")),
|
|
445
|
+
searchMode: s(z.enum(["auto", "hybrid", "bm25", "semantic"]).optional().describe("Search mode: auto (default), hybrid, bm25, or semantic")),
|
|
446
|
+
diversity: s(z.number().min(0).max(1).optional().describe("MMR diversity balance 0-1 (default 0.7). Higher = more relevance, lower = more diversity.")),
|
|
444
447
|
vaultId: s(z.string().uuid().optional().describe("Limit search to a specific vault")),
|
|
445
|
-
|
|
448
|
+
includeContent: s(z.boolean().optional().describe("Include full note content in results (default false, returns preview only)")),
|
|
449
|
+
compact: s(z.boolean().optional().describe("Return shorter previews (200 chars instead of 500)")),
|
|
446
450
|
},
|
|
447
451
|
}, auto.wrap(wrapToolHandler(async (args) => {
|
|
448
|
-
const
|
|
452
|
+
const input = args;
|
|
449
453
|
return gw
|
|
450
|
-
? await gw.searchNotes({
|
|
451
|
-
: await searchNotes(ctx,
|
|
454
|
+
? await gw.searchNotes({ ...input, vaultId: resolveVault(input.vaultId) })
|
|
455
|
+
: await searchNotes(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
|
|
452
456
|
})));
|
|
453
457
|
// ─── create_note ──────────────────────────────────────────────────────────
|
|
454
458
|
server.registerTool("create_note", {
|
|
@@ -549,35 +553,6 @@ async function main() {
|
|
|
549
553
|
const { noteIds } = args;
|
|
550
554
|
return gw ? await gw.readNotes(noteIds) : await readNotes(ctx, noteIds);
|
|
551
555
|
})));
|
|
552
|
-
// ─── semantic_search ────────────────────────────────────────────────────
|
|
553
|
-
server.registerTool("semantic_search", {
|
|
554
|
-
description: "Search notes by meaning using vector embeddings. Finds conceptually similar content even without exact keyword matches. Requires OpenAI API key in config.",
|
|
555
|
-
inputSchema: {
|
|
556
|
-
query: s(z.string().min(1).describe("Natural language search query")),
|
|
557
|
-
topK: s(z.number().int().min(1).max(50).optional().describe("Max results (default 10)")),
|
|
558
|
-
threshold: s(z.number().min(0).max(1).optional().describe("Minimum similarity threshold 0-1 (default 0.5)")),
|
|
559
|
-
vaultId: s(z.string().uuid().optional().describe("Vault to search within. Uses default vault if omitted.")),
|
|
560
|
-
},
|
|
561
|
-
}, auto.wrap(wrapToolHandler(async (args) => {
|
|
562
|
-
const { query, topK, threshold, vaultId } = args;
|
|
563
|
-
return gw
|
|
564
|
-
? await gw.semanticSearch({ query, topK, threshold, vaultId: resolveVault(vaultId) })
|
|
565
|
-
: await semanticSearch(ctx, query, topK, threshold);
|
|
566
|
-
})));
|
|
567
|
-
// ─── search_and_read ────────────────────────────────────────────────────
|
|
568
|
-
server.registerTool("search_and_read", {
|
|
569
|
-
description: "Search notes using hybrid scoring (70% semantic + 30% keyword) and return the full content of top matches. Falls back to keyword-only if no OpenAI key, and to recency if no matches found. Best for gathering all relevant content on a topic.",
|
|
570
|
-
inputSchema: {
|
|
571
|
-
query: s(z.string().min(1).describe("Search query (natural language or keywords)")),
|
|
572
|
-
maxNotes: s(z.number().int().min(1).max(20).optional().describe("Max notes to return (default 5)")),
|
|
573
|
-
vaultId: s(z.string().uuid().optional().describe("Vault to search within. Uses default vault if omitted.")),
|
|
574
|
-
},
|
|
575
|
-
}, auto.wrap(wrapToolHandler(async (args) => {
|
|
576
|
-
const { query, maxNotes, vaultId } = args;
|
|
577
|
-
return gw
|
|
578
|
-
? await gw.searchAndRead({ query, maxNotes, vaultId: resolveVault(vaultId) })
|
|
579
|
-
: await searchAndRead(ctx, query, maxNotes);
|
|
580
|
-
})));
|
|
581
556
|
// ─── write_memory ───────────────────────────────────────────────────────────
|
|
582
557
|
server.registerTool("write_memory", {
|
|
583
558
|
description: "Create or upsert a durable memory entry.\n\nMemory types: fact (durable knowledge, importance 3-5), skill (procedures/how-tos, 3-5), preference (user style/choices, 2-4), constraint (hard rules/limits, 4-5), task (active work items, 2-4), episodic (session summaries, 1-3), correction (superseded knowledge — always set supersededById, 3-5).\n\nImportance scale: 5=critical, 4=important, 3=standard (default), 2=supplementary, 1=low-value. Confidence scale: 5=verified, 4=observed multiple times, 3=reasonable inference (default), 2=uncertain, 1=speculative.\n\nRelationship fields: relatedMemoryIds links to related memories (derived_from, contradicts, refines, part_of, supersedes). sourceNoteIds links to source notes. supersededById points to the memory this one replaces. entities array enables cross-linking.\n\nServer dedup (dedup: true): >92% similarity = skip, >80% = supersede old, <80% = create new.",
|
|
@@ -638,6 +613,31 @@ async function main() {
|
|
|
638
613
|
}
|
|
639
614
|
return await searchMemories(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
|
|
640
615
|
})));
|
|
616
|
+
// ─── search (universal) ─────────────────────────────────────────────────────
|
|
617
|
+
server.registerTool("search", {
|
|
618
|
+
description: "Universal search across both memories and notes. Set scope to 'all' (default) for cross-type MMR-ranked results, 'memories' for memories only, or 'notes' for notes only. In gateway mode, delegates to the server for full cross-type MMR re-ranking. In direct mode, best-effort interleave.",
|
|
619
|
+
inputSchema: {
|
|
620
|
+
query: s(z.string().min(1).describe("Natural language search query")),
|
|
621
|
+
topK: s(z.number().int().min(1).max(50).optional().describe("Max results (default 10)")),
|
|
622
|
+
threshold: s(z.number().min(0).max(1).optional().describe("Similarity threshold 0-1 (default 0.5)")),
|
|
623
|
+
searchMode: s(z.enum(["auto", "hybrid", "bm25", "semantic"]).optional().describe("Search mode (default: auto)")),
|
|
624
|
+
diversity: s(z.number().min(0).max(1).optional().describe("MMR diversity balance 0-1 (default 0.7)")),
|
|
625
|
+
vaultId: s(z.string().uuid().optional().describe("Vault filter")),
|
|
626
|
+
includeContent: s(z.boolean().optional().describe("Include full note content (default false)")),
|
|
627
|
+
compact: s(z.boolean().optional().describe("Return shorter previews")),
|
|
628
|
+
scope: s(z.enum(["all", "memories", "notes"]).optional().describe("Search scope: all (default), memories, or notes")),
|
|
629
|
+
memoryType: s(z.string().optional().describe("Filter by memory type (memories scope only)")),
|
|
630
|
+
entity: s(z.string().optional().describe("Search by entity name (memories scope only)")),
|
|
631
|
+
includeArchived: s(z.boolean().optional().describe("Include archived memories (default false)")),
|
|
632
|
+
decayHalfLife: s(z.number().int().min(1).max(365).optional().describe("Temporal decay half-life in days (default 30)")),
|
|
633
|
+
},
|
|
634
|
+
}, auto.wrap(wrapToolHandler(async (args) => {
|
|
635
|
+
const input = args;
|
|
636
|
+
if (gw) {
|
|
637
|
+
return await gw.search({ ...input, vaultId: resolveVault(input.vaultId) });
|
|
638
|
+
}
|
|
639
|
+
return await search(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
|
|
640
|
+
})));
|
|
641
641
|
// ─── read_memories ──────────────────────────────────────────────────────────
|
|
642
642
|
server.registerTool("read_memories", {
|
|
643
643
|
description: "Read and decrypt full memory entries by IDs.",
|
|
@@ -1221,6 +1221,55 @@ async function main() {
|
|
|
1221
1221
|
}
|
|
1222
1222
|
return await recall(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
|
|
1223
1223
|
})));
|
|
1224
|
+
// ─── attach_media ──────────────────────────────────────────────────────────
|
|
1225
|
+
server.registerTool("attach_media", {
|
|
1226
|
+
description: "Attach a media file (image, PDF, audio, video) to a memory for multimodal embedding. The file is encrypted at rest and embedded via Gemini for cross-modal search. Accepts base64-encoded file content.\n\nSupported types: images (PNG, JPEG, WebP, GIF — 20MB), documents (PDF — 50MB), audio (MP3, WAV, OGG, WebM — 50MB), video (MP4, WebM, QuickTime — 20MB). SVG is not supported.\n\nPer-user storage quota applies (free tier: 100MB).",
|
|
1227
|
+
inputSchema: {
|
|
1228
|
+
fileBase64: s(z.string().min(1).describe("Base64-encoded file content")),
|
|
1229
|
+
fileName: s(z.string().min(1).describe("Original file name with extension")),
|
|
1230
|
+
mimeType: s(z.string().min(1).describe("MIME type (e.g. image/png, application/pdf, audio/mpeg, video/mp4)")),
|
|
1231
|
+
memoryId: s(z.string().uuid().optional().describe("Memory to attach this media to")),
|
|
1232
|
+
vaultId: s(z.string().uuid().optional().describe("Vault scope")),
|
|
1233
|
+
},
|
|
1234
|
+
}, auto.wrap(wrapToolHandler(async (args) => {
|
|
1235
|
+
const input = args;
|
|
1236
|
+
if (!gw)
|
|
1237
|
+
throw new Error("attach_media requires gateway mode (agent key)");
|
|
1238
|
+
const fileBytes = Buffer.from(input.fileBase64, "base64");
|
|
1239
|
+
return await gw.attachMedia({
|
|
1240
|
+
fileBytes: new Uint8Array(fileBytes),
|
|
1241
|
+
fileName: input.fileName,
|
|
1242
|
+
mimeType: input.mimeType,
|
|
1243
|
+
memoryId: input.memoryId,
|
|
1244
|
+
vaultId: resolveVault(input.vaultId),
|
|
1245
|
+
});
|
|
1246
|
+
})));
|
|
1247
|
+
// ─── download_media ───────────────────────────────────────────────────────
|
|
1248
|
+
server.registerTool("download_media", {
|
|
1249
|
+
description: "Download and decrypt a media attachment by ID. Returns base64-encoded file content with metadata.",
|
|
1250
|
+
inputSchema: {
|
|
1251
|
+
attachmentId: s(z.string().uuid().describe("Media attachment ID to download")),
|
|
1252
|
+
},
|
|
1253
|
+
}, auto.wrap(wrapToolHandler(async (args) => {
|
|
1254
|
+
const { attachmentId } = args;
|
|
1255
|
+
if (!gw)
|
|
1256
|
+
throw new Error("download_media requires gateway mode (agent key)");
|
|
1257
|
+
const result = await gw.downloadMedia({ attachmentId });
|
|
1258
|
+
return JSON.stringify(result);
|
|
1259
|
+
})));
|
|
1260
|
+
// ─── delete_media ───────────────────────────────────────────────────────
|
|
1261
|
+
server.registerTool("delete_media", {
|
|
1262
|
+
description: "Delete a media attachment and its embedding. Permanently removes the file from storage.",
|
|
1263
|
+
inputSchema: {
|
|
1264
|
+
attachmentId: s(z.string().uuid().describe("The attachment ID to delete")),
|
|
1265
|
+
vaultId: s(z.string().uuid().optional().describe("Optional vault scope")),
|
|
1266
|
+
},
|
|
1267
|
+
}, auto.wrap(wrapToolHandler(async (args) => {
|
|
1268
|
+
const { attachmentId, vaultId } = args;
|
|
1269
|
+
if (!gw)
|
|
1270
|
+
throw new Error("delete_media requires gateway mode (agent key)");
|
|
1271
|
+
return await gw.deleteMedia(attachmentId, vaultId);
|
|
1272
|
+
})));
|
|
1224
1273
|
// ─── Orphan recovery — flush crashed sessions from previous runs ─────────
|
|
1225
1274
|
try {
|
|
1226
1275
|
const orphans = await scanOrphanedBuffers(10);
|
package/dist/openai.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generates one query embedding using
|
|
2
|
+
* Generates one query embedding using Gemini.
|
|
3
|
+
* Used in direct mode for semantic search queries.
|
|
3
4
|
*/
|
|
4
5
|
export declare function generateQueryEmbedding(text: string, apiKey: string, options?: {
|
|
5
6
|
model?: string;
|
|
6
7
|
baseUrl?: string;
|
|
7
8
|
}): Promise<number[]>;
|
|
8
9
|
/**
|
|
9
|
-
* Generates multiple embeddings in one request
|
|
10
|
+
* Generates multiple embeddings in one request.
|
|
11
|
+
* Used in direct mode for bulk embedding operations.
|
|
10
12
|
*/
|
|
11
13
|
export declare function generateEmbeddings(values: string[], apiKey: string, options?: {
|
|
12
14
|
model?: string;
|
package/dist/openai.js
CHANGED
|
@@ -1,43 +1,47 @@
|
|
|
1
|
-
import {
|
|
2
|
-
const DEFAULT_EMBEDDING_MODEL = "
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { GoogleGenAI } from "@google/genai";
|
|
2
|
+
const DEFAULT_EMBEDDING_MODEL = "gemini-embedding-2-preview";
|
|
3
|
+
const DEFAULT_DIMS = 3072;
|
|
4
|
+
/**
|
|
5
|
+
* Generates one query embedding using Gemini.
|
|
6
|
+
* Used in direct mode for semantic search queries.
|
|
7
|
+
*/
|
|
8
|
+
export async function generateQueryEmbedding(text, apiKey, options) {
|
|
9
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
6
10
|
const model = options?.model ?? DEFAULT_EMBEDDING_MODEL;
|
|
7
|
-
const response = await
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
const response = await ai.models.embedContent({
|
|
12
|
+
model,
|
|
13
|
+
contents: [text],
|
|
14
|
+
config: {
|
|
15
|
+
outputDimensionality: DEFAULT_DIMS,
|
|
16
|
+
taskType: "RETRIEVAL_QUERY",
|
|
12
17
|
},
|
|
13
|
-
body: JSON.stringify({
|
|
14
|
-
model,
|
|
15
|
-
input,
|
|
16
|
-
}),
|
|
17
18
|
});
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
throw new Error(
|
|
19
|
+
const embeddings = response.embeddings ?? [];
|
|
20
|
+
if (embeddings.length === 0) {
|
|
21
|
+
throw new Error("Gemini returned unexpected response: no embedding data");
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
const vectors = json.data?.map((d) => d.embedding).filter(Boolean) ?? [];
|
|
24
|
-
if (vectors.length === 0) {
|
|
25
|
-
throw new Error("OpenAI returned unexpected response: no embedding data");
|
|
26
|
-
}
|
|
27
|
-
return vectors;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Generates one query embedding using OpenAI text-embedding-3-small.
|
|
31
|
-
*/
|
|
32
|
-
export async function generateQueryEmbedding(text, apiKey, options) {
|
|
33
|
-
const vectors = await requestEmbeddings(text, apiKey, options);
|
|
34
|
-
return vectors[0];
|
|
23
|
+
return embeddings[0].values;
|
|
35
24
|
}
|
|
36
25
|
/**
|
|
37
|
-
* Generates multiple embeddings in one request
|
|
26
|
+
* Generates multiple embeddings in one request.
|
|
27
|
+
* Used in direct mode for bulk embedding operations.
|
|
38
28
|
*/
|
|
39
29
|
export async function generateEmbeddings(values, apiKey, options) {
|
|
40
30
|
if (values.length === 0)
|
|
41
31
|
return [];
|
|
42
|
-
|
|
32
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
33
|
+
const model = options?.model ?? DEFAULT_EMBEDDING_MODEL;
|
|
34
|
+
const response = await ai.models.embedContent({
|
|
35
|
+
model,
|
|
36
|
+
contents: values,
|
|
37
|
+
config: {
|
|
38
|
+
outputDimensionality: DEFAULT_DIMS,
|
|
39
|
+
taskType: "RETRIEVAL_DOCUMENT",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const embeddings = response.embeddings ?? [];
|
|
43
|
+
if (embeddings.length === 0) {
|
|
44
|
+
throw new Error("Gemini returned unexpected response: no embedding data");
|
|
45
|
+
}
|
|
46
|
+
return embeddings.map((e) => e.values);
|
|
43
47
|
}
|
package/dist/rlm/actions.js
CHANGED
|
@@ -61,7 +61,8 @@ function compactReadMemories(raw) {
|
|
|
61
61
|
function compactSearchNotes(raw) {
|
|
62
62
|
try {
|
|
63
63
|
const data = JSON.parse(raw);
|
|
64
|
-
|
|
64
|
+
// searchNotes returns { searchMode, blindIndexMatchCount, notes: [...] }
|
|
65
|
+
const results = Array.isArray(data.notes) ? data.notes : (Array.isArray(data) ? data : []);
|
|
65
66
|
return JSON.stringify(results.map((n) => ({
|
|
66
67
|
id: n.id,
|
|
67
68
|
title: n.title,
|
|
@@ -194,7 +195,7 @@ export function buildToolDefinitions(ctx, state, vaultId) {
|
|
|
194
195
|
advice: "Read existing note hits or call finish().",
|
|
195
196
|
});
|
|
196
197
|
}
|
|
197
|
-
const raw = await searchNotes(ctx, query, vaultId, limit ?? state.tuning.noteSearchTopK);
|
|
198
|
+
const raw = await searchNotes(ctx, { query, vaultId, topK: limit ?? state.tuning.noteSearchTopK });
|
|
198
199
|
return compactSearchNotes(raw);
|
|
199
200
|
},
|
|
200
201
|
}),
|
|
@@ -4,9 +4,8 @@ import { initialize } from "../auth.js";
|
|
|
4
4
|
import { decrypt } from "../crypto.js";
|
|
5
5
|
import { replaceMemoryEmbeddings, updateMemory } from "../db.js";
|
|
6
6
|
import { generateEmbeddings } from "../openai.js";
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const VECTOR_DIMS = 1536;
|
|
7
|
+
const DEFAULT_EMBEDDING_MODEL = "gemini-embedding-2-preview";
|
|
8
|
+
const VECTOR_DIMS = 3072;
|
|
10
9
|
const CHARS_PER_TOKEN = 4;
|
|
11
10
|
const CHUNK_TARGET_CHARS = 2400;
|
|
12
11
|
const CHUNK_OVERLAP_CHARS = 400;
|
|
@@ -58,31 +57,18 @@ async function loadBackfillCandidates(ctx, limit) {
|
|
|
58
57
|
async function main() {
|
|
59
58
|
const ctx = await initialize();
|
|
60
59
|
const limit = parseLimitArg();
|
|
61
|
-
const
|
|
60
|
+
const apiKey = process.env.EXO_GEMINI_KEY ??
|
|
61
|
+
process.env.GEMINI_API_KEY ??
|
|
62
62
|
process.env.EMBEDDING_API_KEY ??
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
baseUrl: process.env.RLM_EMBEDDING_BASE_URL ?? process.env.EMBEDDING_BASE_URL ?? "https://api.openai.com/v1",
|
|
68
|
-
model: process.env.RLM_EMBEDDING_MODEL ?? process.env.EMBEDDING_MODEL ?? OPENAI_EMBEDDING_MODEL,
|
|
69
|
-
}
|
|
70
|
-
: ctx.openaiApiKey
|
|
71
|
-
? {
|
|
72
|
-
apiKey: ctx.openaiApiKey,
|
|
73
|
-
baseUrl: "https://api.openai.com/v1",
|
|
74
|
-
model: OPENAI_EMBEDDING_MODEL,
|
|
75
|
-
}
|
|
76
|
-
: ctx.llmConfig
|
|
77
|
-
? {
|
|
78
|
-
apiKey: ctx.llmConfig.apiKey,
|
|
79
|
-
baseUrl: ctx.llmConfig.baseUrl,
|
|
80
|
-
model: process.env.RLM_EMBEDDING_MODEL ?? DEEPSEEK_EMBEDDING_MODEL,
|
|
81
|
-
}
|
|
82
|
-
: null;
|
|
83
|
-
if (!embeddingConfig) {
|
|
84
|
-
throw new Error("No embedding-capable key found in MCP config (openaiApiKey or llmApiKey).");
|
|
63
|
+
ctx.geminiApiKey ??
|
|
64
|
+
ctx.openaiApiKey;
|
|
65
|
+
if (!apiKey) {
|
|
66
|
+
throw new Error("No EXO_GEMINI_KEY or GEMINI_API_KEY found.");
|
|
85
67
|
}
|
|
68
|
+
const embeddingConfig = {
|
|
69
|
+
apiKey,
|
|
70
|
+
model: process.env.EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL,
|
|
71
|
+
};
|
|
86
72
|
const candidates = await loadBackfillCandidates(ctx, limit);
|
|
87
73
|
if (candidates.length === 0) {
|
|
88
74
|
process.stdout.write("No candidate memories found for backfill.\n");
|
|
@@ -110,10 +96,7 @@ async function main() {
|
|
|
110
96
|
indexing_status: "embedding",
|
|
111
97
|
indexing_error: null,
|
|
112
98
|
});
|
|
113
|
-
const vectors = await generateEmbeddings(chunks.map((c) => c.text), embeddingConfig.apiKey, {
|
|
114
|
-
baseUrl: embeddingConfig.baseUrl,
|
|
115
|
-
model: embeddingConfig.model,
|
|
116
|
-
});
|
|
99
|
+
const vectors = await generateEmbeddings(chunks.map((c) => c.text), embeddingConfig.apiKey, { model: embeddingConfig.model });
|
|
117
100
|
const badVector = vectors.find((v) => v.length !== VECTOR_DIMS);
|
|
118
101
|
if (badVector) {
|
|
119
102
|
throw new Error(`embedding_dimensions_mismatch expected=${VECTOR_DIMS} got=${badVector.length} model=${embeddingConfig.model}`);
|
|
@@ -63,7 +63,7 @@ export async function exploreGraph(ctx, input) {
|
|
|
63
63
|
error: "No embedding configuration available. Set openaiApiKey in config or EMBEDDING_API_KEY env var.",
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
|
-
const embedding = await generateQueryEmbedding(query, embeddingConfig.apiKey, {
|
|
66
|
+
const embedding = await generateQueryEmbedding(query, embeddingConfig.apiKey, { model: embeddingConfig.model });
|
|
67
67
|
// Search both memory and note embeddings in parallel
|
|
68
68
|
const [memoryMatches, noteMatches] = await Promise.all([
|
|
69
69
|
matchMemoryEmbeddings(ctx.supabase, embedding, ctx.userId, SEMANTIC_THRESHOLD, SEMANTIC_TOP_K),
|
|
@@ -1,12 +1,38 @@
|
|
|
1
|
-
import { getMemoriesByIds, touchMemories } from "../db.js";
|
|
1
|
+
import { getMemoriesByIds, touchMemories, getAttachmentsForMemories, formatAttachmentWithExtraction } from "../db.js";
|
|
2
|
+
import { decrypt } from "../crypto.js";
|
|
2
3
|
import { logMcpUsageEvent } from "../usage.js";
|
|
3
4
|
import { decryptMemoryFields } from "./decrypt-helpers.js";
|
|
4
5
|
export async function readMemories(ctx, memoryIds) {
|
|
5
6
|
const memories = await getMemoriesByIds(ctx.supabase, ctx.userId, memoryIds);
|
|
6
7
|
const foundIds = new Set(memories.map((m) => m.id));
|
|
7
8
|
const notFound = memoryIds.filter((id) => !foundIds.has(id));
|
|
9
|
+
const attachmentRows = await getAttachmentsForMemories(ctx.supabase, ctx.userId, memories.map((m) => m.id));
|
|
10
|
+
const attachByMem = new Map();
|
|
11
|
+
for (const a of attachmentRows) {
|
|
12
|
+
if (!a.memory_id)
|
|
13
|
+
continue;
|
|
14
|
+
const list = attachByMem.get(a.memory_id) ?? [];
|
|
15
|
+
list.push(a);
|
|
16
|
+
attachByMem.set(a.memory_id, list);
|
|
17
|
+
}
|
|
8
18
|
const decrypted = await Promise.all(memories.map(async (m) => {
|
|
9
19
|
const { content, summary } = await decryptMemoryFields(m, ctx.masterKey);
|
|
20
|
+
const ma = attachByMem.get(m.id);
|
|
21
|
+
const attachmentLines = [];
|
|
22
|
+
if (ma && ma.length > 0) {
|
|
23
|
+
for (const a of ma) {
|
|
24
|
+
let decryptedText = null;
|
|
25
|
+
if (a.extracted_text && a.extracted_text_iv && ctx.masterKey) {
|
|
26
|
+
try {
|
|
27
|
+
decryptedText = await decrypt(a.extracted_text, a.extracted_text_iv, ctx.masterKey);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore decryption failures for extracted text
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
attachmentLines.push(formatAttachmentWithExtraction(a, decryptedText));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
10
36
|
return {
|
|
11
37
|
id: m.id,
|
|
12
38
|
memoryType: m.memory_type,
|
|
@@ -30,6 +56,7 @@ export async function readMemories(ctx, memoryIds) {
|
|
|
30
56
|
lastAccessedAt: m.last_accessed_at,
|
|
31
57
|
createdAt: m.created_at,
|
|
32
58
|
updatedAt: m.updated_at,
|
|
59
|
+
...(attachmentLines.length > 0 ? { attachments: attachmentLines } : {}),
|
|
33
60
|
};
|
|
34
61
|
}));
|
|
35
62
|
// Track access (fire-and-forget)
|
package/dist/tools/read-note.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getNote, getVault } from "../db.js";
|
|
1
|
+
import { getNote, getVault, getAttachmentsForNotes, formatAttachmentWithExtraction } from "../db.js";
|
|
2
2
|
import { decrypt } from "../crypto.js";
|
|
3
3
|
import { logMcpUsageEvent } from "../usage.js";
|
|
4
4
|
import { decryptNoteFields } from "./decrypt-helpers.js";
|
|
@@ -14,6 +14,21 @@ export async function readNote(ctx, noteId) {
|
|
|
14
14
|
if (vault) {
|
|
15
15
|
vaultName = await decrypt(vault.encrypted_name, vault.name_iv, ctx.masterKey);
|
|
16
16
|
}
|
|
17
|
+
// Fetch and format attachments with extracted text
|
|
18
|
+
const attachmentRows = await getAttachmentsForNotes(ctx.supabase, ctx.userId, [note.id]);
|
|
19
|
+
const attachmentLines = [];
|
|
20
|
+
for (const a of attachmentRows) {
|
|
21
|
+
let decryptedText = null;
|
|
22
|
+
if (a.extracted_text && a.extracted_text_iv && ctx.masterKey) {
|
|
23
|
+
try {
|
|
24
|
+
decryptedText = await decrypt(a.extracted_text, a.extracted_text_iv, ctx.masterKey);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Ignore decryption failures for extracted text
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
attachmentLines.push(formatAttachmentWithExtraction(a, decryptedText));
|
|
31
|
+
}
|
|
17
32
|
const payload = JSON.stringify({
|
|
18
33
|
id: note.id,
|
|
19
34
|
title,
|
|
@@ -23,6 +38,7 @@ export async function readNote(ctx, noteId) {
|
|
|
23
38
|
vaultId: note.vault_id,
|
|
24
39
|
createdAt: note.created_at,
|
|
25
40
|
updatedAt: note.updated_at,
|
|
41
|
+
...(attachmentLines.length > 0 ? { attachments: attachmentLines } : {}),
|
|
26
42
|
}, null, 2);
|
|
27
43
|
await logMcpUsageEvent({
|
|
28
44
|
supabase: ctx.supabase,
|
package/dist/tools/read-notes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getNotesByIds, getVault } from "../db.js";
|
|
1
|
+
import { getNotesByIds, getVault, getAttachmentsForNotes, formatAttachmentWithExtraction } from "../db.js";
|
|
2
2
|
import { decrypt } from "../crypto.js";
|
|
3
3
|
import { decryptNoteFields } from "./decrypt-helpers.js";
|
|
4
4
|
/**
|
|
@@ -24,11 +24,37 @@ export async function readNotes(ctx, noteIds) {
|
|
|
24
24
|
vaultNameCache.set(vaultId, name);
|
|
25
25
|
return name;
|
|
26
26
|
}
|
|
27
|
+
// Fetch attachments for all notes at once
|
|
28
|
+
const attachmentRows = await getAttachmentsForNotes(ctx.supabase, ctx.userId, notes.map((n) => n.id));
|
|
29
|
+
const attachByNote = new Map();
|
|
30
|
+
for (const a of attachmentRows) {
|
|
31
|
+
if (!a.note_id)
|
|
32
|
+
continue;
|
|
33
|
+
const list = attachByNote.get(a.note_id) ?? [];
|
|
34
|
+
list.push(a);
|
|
35
|
+
attachByNote.set(a.note_id, list);
|
|
36
|
+
}
|
|
27
37
|
async function decryptNote(note) {
|
|
28
38
|
const [{ title, content, tags }, vaultName] = await Promise.all([
|
|
29
39
|
decryptNoteFields(note, ctx.masterKey),
|
|
30
40
|
getVaultName(note.vault_id),
|
|
31
41
|
]);
|
|
42
|
+
const na = attachByNote.get(note.id);
|
|
43
|
+
const attachmentLines = [];
|
|
44
|
+
if (na && na.length > 0) {
|
|
45
|
+
for (const a of na) {
|
|
46
|
+
let decryptedText = null;
|
|
47
|
+
if (a.extracted_text && a.extracted_text_iv && ctx.masterKey) {
|
|
48
|
+
try {
|
|
49
|
+
decryptedText = await decrypt(a.extracted_text, a.extracted_text_iv, ctx.masterKey);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Ignore decryption failures for extracted text
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
attachmentLines.push(formatAttachmentWithExtraction(a, decryptedText));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
32
58
|
return {
|
|
33
59
|
id: note.id,
|
|
34
60
|
title,
|
|
@@ -38,6 +64,7 @@ export async function readNotes(ctx, noteIds) {
|
|
|
38
64
|
vaultId: note.vault_id,
|
|
39
65
|
createdAt: note.created_at,
|
|
40
66
|
updatedAt: note.updated_at,
|
|
67
|
+
...(attachmentLines.length > 0 ? { attachments: attachmentLines } : {}),
|
|
41
68
|
};
|
|
42
69
|
}
|
|
43
70
|
const results = await Promise.all(notes.map(decryptNote));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getNotes, getNotesByIds, getVault, matchNoteEmbeddings } from "../db.js";
|
|
1
|
+
import { getNotes, getNotesByIds, getVault, matchNoteEmbeddings, getAttachmentsForNotes, formatAttachmentWithExtraction } from "../db.js";
|
|
2
2
|
import { decrypt } from "../crypto.js";
|
|
3
3
|
import { stripHtml } from "../strip-html.js";
|
|
4
4
|
import { STOPWORDS } from "../stopwords.js";
|
|
@@ -166,11 +166,37 @@ export async function searchAndRead(ctx, query, maxNotes = 5) {
|
|
|
166
166
|
vaultNameCache.set(vaultId, name);
|
|
167
167
|
return name;
|
|
168
168
|
}
|
|
169
|
+
// Fetch attachments for top notes
|
|
170
|
+
const attachmentRows = await getAttachmentsForNotes(ctx.supabase, ctx.userId, topIds);
|
|
171
|
+
const attachByNote = new Map();
|
|
172
|
+
for (const a of attachmentRows) {
|
|
173
|
+
if (!a.note_id)
|
|
174
|
+
continue;
|
|
175
|
+
const list = attachByNote.get(a.note_id) ?? [];
|
|
176
|
+
list.push(a);
|
|
177
|
+
attachByNote.set(a.note_id, list);
|
|
178
|
+
}
|
|
169
179
|
const results = await Promise.all(topIds.map(async (noteId) => {
|
|
170
180
|
const dec = decryptedMap.get(noteId);
|
|
171
181
|
if (!dec)
|
|
172
182
|
return null;
|
|
173
183
|
const vaultName = await getVaultName(dec.note.vault_id);
|
|
184
|
+
const na = attachByNote.get(noteId);
|
|
185
|
+
const attachmentLines = [];
|
|
186
|
+
if (na && na.length > 0) {
|
|
187
|
+
for (const a of na) {
|
|
188
|
+
let decryptedText = null;
|
|
189
|
+
if (a.extracted_text && a.extracted_text_iv && ctx.masterKey) {
|
|
190
|
+
try {
|
|
191
|
+
decryptedText = await decrypt(a.extracted_text, a.extracted_text_iv, ctx.masterKey);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Ignore decryption failures for extracted text
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
attachmentLines.push(formatAttachmentWithExtraction(a, decryptedText));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
174
200
|
return {
|
|
175
201
|
id: noteId,
|
|
176
202
|
title: dec.title,
|
|
@@ -181,6 +207,7 @@ export async function searchAndRead(ctx, query, maxNotes = 5) {
|
|
|
181
207
|
score: combinedScores.get(noteId) ?? 0,
|
|
182
208
|
createdAt: dec.note.created_at,
|
|
183
209
|
updatedAt: dec.note.updated_at,
|
|
210
|
+
...(attachmentLines.length > 0 ? { attachments: attachmentLines } : {}),
|
|
184
211
|
};
|
|
185
212
|
}));
|
|
186
213
|
const searchMode = semanticScores.size > 0 && keywordScores.size > 0
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getMemoriesByIds, getMemories, matchMemoryEmbeddings, matchMemoriesByBlindTokens, searchMemoriesByEntity, getKnowledgeGraph, touchMemories, getEmbeddingsForMemories, } from "../db.js";
|
|
1
|
+
import { getMemoriesByIds, getMemories, matchMemoryEmbeddings, matchMemoriesByBlindTokens, searchMemoriesByEntity, getKnowledgeGraph, touchMemories, getEmbeddingsForMemories, getAttachmentsForMemories, formatAttachmentWithExtraction, } from "../db.js";
|
|
2
|
+
import { decrypt } from "../crypto.js";
|
|
2
3
|
import { decryptMemoryFields } from "./decrypt-helpers.js";
|
|
3
4
|
import { generateQueryEmbedding } from "../openai.js";
|
|
4
5
|
import { logMcpUsageEvent } from "../usage.js";
|
|
@@ -42,11 +43,36 @@ export async function searchMemories(ctx, input) {
|
|
|
42
43
|
.filter((m) => (input.vaultId ? m.vault_id === input.vaultId : true))
|
|
43
44
|
.filter((m) => (input.includeArchived ? true : !m.is_archived))
|
|
44
45
|
.filter((m) => (input.memoryType ? m.memory_type === input.memoryType : true));
|
|
46
|
+
const entityAttachments = await getAttachmentsForMemories(ctx.supabase, ctx.userId, filtered.map((m) => m.id));
|
|
47
|
+
const entityAttachByMem = new Map();
|
|
48
|
+
for (const a of entityAttachments) {
|
|
49
|
+
if (!a.memory_id)
|
|
50
|
+
continue;
|
|
51
|
+
const list = entityAttachByMem.get(a.memory_id) ?? [];
|
|
52
|
+
list.push(a);
|
|
53
|
+
entityAttachByMem.set(a.memory_id, list);
|
|
54
|
+
}
|
|
45
55
|
const results = await Promise.all(filtered.map(async (m) => {
|
|
46
56
|
const { content, summary } = await decryptMemoryFields(m, ctx.masterKey);
|
|
47
57
|
const displayContent = input.compact
|
|
48
58
|
? clip(content, COMPACT_CONTENT_CHARS)
|
|
49
59
|
: content;
|
|
60
|
+
const ma = entityAttachByMem.get(m.id);
|
|
61
|
+
const attachmentLines = [];
|
|
62
|
+
if (ma && ma.length > 0) {
|
|
63
|
+
for (const a of ma) {
|
|
64
|
+
let decryptedText = null;
|
|
65
|
+
if (a.extracted_text && a.extracted_text_iv && ctx.masterKey) {
|
|
66
|
+
try {
|
|
67
|
+
decryptedText = await decrypt(a.extracted_text, a.extracted_text_iv, ctx.masterKey);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Ignore decryption failures for extracted text
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
attachmentLines.push(formatAttachmentWithExtraction(a, decryptedText, 500));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
50
76
|
return {
|
|
51
77
|
id: m.id,
|
|
52
78
|
memoryType: m.memory_type,
|
|
@@ -64,6 +90,7 @@ export async function searchMemories(ctx, input) {
|
|
|
64
90
|
supersededById: m.superseded_by_id,
|
|
65
91
|
entities: m.entities,
|
|
66
92
|
updatedAt: m.updated_at,
|
|
93
|
+
...(attachmentLines.length > 0 ? { attachments: attachmentLines } : {}),
|
|
67
94
|
};
|
|
68
95
|
}));
|
|
69
96
|
// Track access for returned results (fire-and-forget)
|
|
@@ -100,7 +127,6 @@ export async function searchMemories(ctx, input) {
|
|
|
100
127
|
try {
|
|
101
128
|
embeddingTokens = Math.ceil(input.query.length / 4);
|
|
102
129
|
const queryEmbedding = await generateQueryEmbedding(input.query, embeddingConfig.apiKey, {
|
|
103
|
-
baseUrl: embeddingConfig.baseUrl,
|
|
104
130
|
model: embeddingConfig.model,
|
|
105
131
|
});
|
|
106
132
|
const matches = await matchMemoryEmbeddings(ctx.supabase, queryEmbedding, ctx.userId, threshold, topK * 4);
|
|
@@ -254,8 +280,19 @@ export async function searchMemories(ctx, input) {
|
|
|
254
280
|
.filter((m) => (input.memoryType ? m.memory_type === input.memoryType : true))
|
|
255
281
|
.map((m) => m.id);
|
|
256
282
|
}
|
|
257
|
-
const memories = await
|
|
283
|
+
const [memories, attachmentRows] = await Promise.all([
|
|
284
|
+
getMemoriesByIds(ctx.supabase, ctx.userId, rankedIds),
|
|
285
|
+
getAttachmentsForMemories(ctx.supabase, ctx.userId, rankedIds),
|
|
286
|
+
]);
|
|
258
287
|
const byId = new Map(memories.map((m) => [m.id, m]));
|
|
288
|
+
const attachByMem = new Map();
|
|
289
|
+
for (const a of attachmentRows) {
|
|
290
|
+
if (!a.memory_id)
|
|
291
|
+
continue;
|
|
292
|
+
const list = attachByMem.get(a.memory_id) ?? [];
|
|
293
|
+
list.push(a);
|
|
294
|
+
attachByMem.set(a.memory_id, list);
|
|
295
|
+
}
|
|
259
296
|
const ordered = rankedIds
|
|
260
297
|
.map((id) => byId.get(id))
|
|
261
298
|
.filter((m) => !!m)
|
|
@@ -266,6 +303,22 @@ export async function searchMemories(ctx, input) {
|
|
|
266
303
|
const { content, summary } = await decryptMemoryFields(m, ctx.masterKey);
|
|
267
304
|
const shouldClip = input.compact || searchMode === "fallback-recency";
|
|
268
305
|
const clipLimit = input.compact ? COMPACT_CONTENT_CHARS : FALLBACK_CONTENT_PREVIEW_CHARS;
|
|
306
|
+
const ma = attachByMem.get(m.id);
|
|
307
|
+
const attachmentLines = [];
|
|
308
|
+
if (ma && ma.length > 0) {
|
|
309
|
+
for (const a of ma) {
|
|
310
|
+
let decryptedText = null;
|
|
311
|
+
if (a.extracted_text && a.extracted_text_iv && ctx.masterKey) {
|
|
312
|
+
try {
|
|
313
|
+
decryptedText = await decrypt(a.extracted_text, a.extracted_text_iv, ctx.masterKey);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Ignore decryption failures for extracted text
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
attachmentLines.push(formatAttachmentWithExtraction(a, decryptedText, 500));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
269
322
|
return {
|
|
270
323
|
id: m.id,
|
|
271
324
|
memoryType: m.memory_type,
|
|
@@ -283,6 +336,7 @@ export async function searchMemories(ctx, input) {
|
|
|
283
336
|
supersededById: m.superseded_by_id,
|
|
284
337
|
entities: m.entities,
|
|
285
338
|
updatedAt: m.updated_at,
|
|
339
|
+
...(attachmentLines.length > 0 ? { attachments: attachmentLines } : {}),
|
|
286
340
|
};
|
|
287
341
|
}));
|
|
288
342
|
// Track access for returned results (fire-and-forget)
|
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
import type { McpContext } from "../auth.js";
|
|
2
|
-
export declare function searchNotes(ctx: McpContext,
|
|
2
|
+
export declare function searchNotes(ctx: McpContext, input: {
|
|
3
|
+
query: string;
|
|
4
|
+
topK?: number;
|
|
5
|
+
threshold?: number;
|
|
6
|
+
searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
|
|
7
|
+
diversity?: number;
|
|
8
|
+
vaultId?: string;
|
|
9
|
+
includeContent?: boolean;
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
}): Promise<string>;
|
|
@@ -40,19 +40,23 @@ function scoreNote(terms, title, tags, content) {
|
|
|
40
40
|
}
|
|
41
41
|
return { score, matchedFields: Array.from(matchedFields) };
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const COMPACT_PREVIEW_CHARS = 200;
|
|
44
|
+
const FULL_PREVIEW_CHARS = 500;
|
|
45
|
+
export async function searchNotes(ctx, input) {
|
|
46
|
+
const topK = input.topK ?? 10;
|
|
47
|
+
const vaultId = input.vaultId;
|
|
48
|
+
const terms = parseSearchTerms(input.query);
|
|
45
49
|
if (terms.length === 0) {
|
|
46
|
-
return JSON.stringify({ searchMode: "no_terms", blindIndexMatchCount: 0, notes: [] });
|
|
50
|
+
return JSON.stringify({ searchMode: "no_terms", blindIndexMatchCount: 0, notes: [] }, null, 2);
|
|
47
51
|
}
|
|
48
52
|
let candidateNotes;
|
|
49
53
|
let searchMode = "full_scan";
|
|
50
54
|
let blindIndexMatchCount = 0;
|
|
51
55
|
if (ctx.mekHex) {
|
|
52
56
|
try {
|
|
53
|
-
const queryTokenHashes = generateBlindTokens(query, ctx.mekHex);
|
|
57
|
+
const queryTokenHashes = generateBlindTokens(input.query, ctx.mekHex);
|
|
54
58
|
if (queryTokenHashes.length > 0) {
|
|
55
|
-
const blindMatches = await matchNotesByBlindTokens(ctx.supabase, queryTokenHashes, ctx.userId, vaultId,
|
|
59
|
+
const blindMatches = await matchNotesByBlindTokens(ctx.supabase, queryTokenHashes, ctx.userId, vaultId, topK * 3);
|
|
56
60
|
blindIndexMatchCount = blindMatches.length;
|
|
57
61
|
if (blindMatches.length > 0) {
|
|
58
62
|
searchMode = "blind_index";
|
|
@@ -69,26 +73,32 @@ export async function searchNotes(ctx, query, vaultId, limit = 10) {
|
|
|
69
73
|
if (!candidateNotes) {
|
|
70
74
|
candidateNotes = await getNotes(ctx.supabase, ctx.userId, vaultId, 500);
|
|
71
75
|
}
|
|
76
|
+
const previewChars = input.compact ? COMPACT_PREVIEW_CHARS : FULL_PREVIEW_CHARS;
|
|
72
77
|
const scored = [];
|
|
73
78
|
await Promise.all(candidateNotes.map(async (n) => {
|
|
74
79
|
const { title, content, tags } = await decryptNoteFields(n, ctx.masterKey);
|
|
75
80
|
const { score, matchedFields } = scoreNote(terms, title, tags, content);
|
|
76
81
|
if (score > 0) {
|
|
77
|
-
|
|
82
|
+
const plainContent = stripHtml(content);
|
|
83
|
+
const entry = {
|
|
78
84
|
id: n.id,
|
|
79
85
|
title,
|
|
80
86
|
tags,
|
|
81
|
-
preview:
|
|
87
|
+
preview: plainContent.slice(0, previewChars),
|
|
82
88
|
score,
|
|
83
89
|
vaultId: n.vault_id,
|
|
84
90
|
matchedFields,
|
|
85
|
-
}
|
|
91
|
+
};
|
|
92
|
+
if (input.includeContent) {
|
|
93
|
+
entry.content = content;
|
|
94
|
+
}
|
|
95
|
+
scored.push(entry);
|
|
86
96
|
}
|
|
87
97
|
}));
|
|
88
98
|
scored.sort((a, b) => b.score - a.score);
|
|
89
99
|
return JSON.stringify({
|
|
90
100
|
searchMode,
|
|
91
101
|
blindIndexMatchCount,
|
|
92
|
-
notes: scored.slice(0,
|
|
102
|
+
notes: scored.slice(0, topK),
|
|
93
103
|
}, null, 2);
|
|
94
104
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { McpContext } from "../auth.js";
|
|
2
|
+
interface SearchParams {
|
|
3
|
+
query: string;
|
|
4
|
+
topK?: number;
|
|
5
|
+
threshold?: number;
|
|
6
|
+
searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
|
|
7
|
+
diversity?: number;
|
|
8
|
+
vaultId?: string;
|
|
9
|
+
includeContent?: boolean;
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
scope?: "all" | "memories" | "notes";
|
|
12
|
+
memoryType?: string;
|
|
13
|
+
includeArchived?: boolean;
|
|
14
|
+
decayHalfLife?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Universal search tool — searches memories, notes, or both.
|
|
18
|
+
*
|
|
19
|
+
* In direct mode: best-effort — calls searchMemories and/or searchNotes
|
|
20
|
+
* independently and interleaves results (no cross-type MMR).
|
|
21
|
+
*
|
|
22
|
+
* Note: In gateway mode, the MCP server's `search` tool registration in index.ts
|
|
23
|
+
* delegates to the gateway `/api/agent/search` endpoint which performs full
|
|
24
|
+
* cross-type MMR re-ranking server-side. This function is only used in direct mode.
|
|
25
|
+
*/
|
|
26
|
+
export declare function search(ctx: McpContext, params: SearchParams): Promise<string>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { searchMemories } from "./search-memories.js";
|
|
2
|
+
import { searchNotes } from "./search-notes.js";
|
|
3
|
+
/**
|
|
4
|
+
* Universal search tool — searches memories, notes, or both.
|
|
5
|
+
*
|
|
6
|
+
* In direct mode: best-effort — calls searchMemories and/or searchNotes
|
|
7
|
+
* independently and interleaves results (no cross-type MMR).
|
|
8
|
+
*
|
|
9
|
+
* Note: In gateway mode, the MCP server's `search` tool registration in index.ts
|
|
10
|
+
* delegates to the gateway `/api/agent/search` endpoint which performs full
|
|
11
|
+
* cross-type MMR re-ranking server-side. This function is only used in direct mode.
|
|
12
|
+
*/
|
|
13
|
+
export async function search(ctx, params) {
|
|
14
|
+
const scope = params.scope ?? "all";
|
|
15
|
+
const topK = params.topK ?? 10;
|
|
16
|
+
const searchMemoriesParams = {
|
|
17
|
+
query: params.query,
|
|
18
|
+
topK,
|
|
19
|
+
threshold: params.threshold,
|
|
20
|
+
vaultId: params.vaultId,
|
|
21
|
+
memoryType: params.memoryType,
|
|
22
|
+
includeArchived: params.includeArchived,
|
|
23
|
+
compact: params.compact,
|
|
24
|
+
decayHalfLife: params.decayHalfLife,
|
|
25
|
+
diversity: params.diversity,
|
|
26
|
+
searchMode: params.searchMode,
|
|
27
|
+
};
|
|
28
|
+
const searchNotesParams = {
|
|
29
|
+
query: params.query,
|
|
30
|
+
topK,
|
|
31
|
+
threshold: params.threshold,
|
|
32
|
+
searchMode: params.searchMode,
|
|
33
|
+
diversity: params.diversity,
|
|
34
|
+
vaultId: params.vaultId,
|
|
35
|
+
includeContent: params.includeContent,
|
|
36
|
+
compact: params.compact,
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
if (scope === "memories") {
|
|
40
|
+
const memoriesResult = await searchMemories(ctx, searchMemoriesParams);
|
|
41
|
+
const parsed = JSON.parse(memoriesResult);
|
|
42
|
+
return JSON.stringify({ scope: "memories", ...parsed }, null, 2);
|
|
43
|
+
}
|
|
44
|
+
if (scope === "notes") {
|
|
45
|
+
const notesResult = await searchNotes(ctx, searchNotesParams);
|
|
46
|
+
const parsed = JSON.parse(notesResult);
|
|
47
|
+
return JSON.stringify({ scope: "notes", ...parsed }, null, 2);
|
|
48
|
+
}
|
|
49
|
+
// scope === "all": run both in parallel and interleave results
|
|
50
|
+
const [memoriesResult, notesResult] = await Promise.all([
|
|
51
|
+
searchMemories(ctx, { ...searchMemoriesParams, topK: Math.ceil(topK / 2) }),
|
|
52
|
+
searchNotes(ctx, { ...searchNotesParams, topK: Math.ceil(topK / 2) }),
|
|
53
|
+
]);
|
|
54
|
+
const memoriesParsed = JSON.parse(memoriesResult);
|
|
55
|
+
const notesParsed = JSON.parse(notesResult);
|
|
56
|
+
const memories = (memoriesParsed.memories ?? []);
|
|
57
|
+
const notes = (notesParsed.notes ?? []);
|
|
58
|
+
// Interleave: memory, note, memory, note, ...
|
|
59
|
+
const combined = [];
|
|
60
|
+
const maxLen = Math.max(memories.length, notes.length);
|
|
61
|
+
for (let i = 0; i < maxLen; i++) {
|
|
62
|
+
if (i < memories.length)
|
|
63
|
+
combined.push({ _type: "memory", ...memories[i] });
|
|
64
|
+
if (i < notes.length)
|
|
65
|
+
combined.push({ _type: "note", ...notes[i] });
|
|
66
|
+
}
|
|
67
|
+
return JSON.stringify({
|
|
68
|
+
scope: "all",
|
|
69
|
+
note: "Direct mode: best-effort interleave (no cross-type MMR). Use gateway mode for ranked unified results.",
|
|
70
|
+
memoriesSearchMode: memoriesParsed.searchMode,
|
|
71
|
+
notesSearchMode: notesParsed.searchMode,
|
|
72
|
+
results: combined,
|
|
73
|
+
}, null, 2);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }, null, 2);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { matchNoteEmbeddings, getNotesByIds } from "../db.js";
|
|
1
|
+
import { matchNoteEmbeddings, getNotesByIds, getAttachmentsForNotes, formatAttachmentWithExtraction } from "../db.js";
|
|
2
2
|
import { generateQueryEmbedding } from "../openai.js";
|
|
3
3
|
import { decrypt } from "../crypto.js";
|
|
4
4
|
import { stripHtml } from "../strip-html.js";
|
|
@@ -43,7 +43,17 @@ export async function semanticSearch(ctx, query, topK = 10, threshold = 0.5) {
|
|
|
43
43
|
// 4. Fetch the actual notes
|
|
44
44
|
const noteIds = Array.from(bestByNote.keys());
|
|
45
45
|
const notes = await getNotesByIds(ctx.supabase, ctx.userId, noteIds);
|
|
46
|
-
// 5.
|
|
46
|
+
// 5. Fetch attachments for all matched notes
|
|
47
|
+
const attachmentRows = await getAttachmentsForNotes(ctx.supabase, ctx.userId, noteIds);
|
|
48
|
+
const attachByNote = new Map();
|
|
49
|
+
for (const a of attachmentRows) {
|
|
50
|
+
if (!a.note_id)
|
|
51
|
+
continue;
|
|
52
|
+
const list = attachByNote.get(a.note_id) ?? [];
|
|
53
|
+
list.push(a);
|
|
54
|
+
attachByNote.set(a.note_id, list);
|
|
55
|
+
}
|
|
56
|
+
// 6. Decrypt and build results
|
|
47
57
|
const results = await Promise.all(notes.map(async (note) => {
|
|
48
58
|
const [title, content] = await Promise.all([
|
|
49
59
|
decrypt(note.encrypted_title, note.title_iv, ctx.masterKey),
|
|
@@ -59,6 +69,22 @@ export async function semanticSearch(ctx, query, topK = 10, threshold = 0.5) {
|
|
|
59
69
|
// ignore
|
|
60
70
|
}
|
|
61
71
|
}
|
|
72
|
+
const na = attachByNote.get(note.id);
|
|
73
|
+
const attachmentLines = [];
|
|
74
|
+
if (na && na.length > 0) {
|
|
75
|
+
for (const a of na) {
|
|
76
|
+
let decryptedText = null;
|
|
77
|
+
if (a.extracted_text && a.extracted_text_iv && ctx.masterKey) {
|
|
78
|
+
try {
|
|
79
|
+
decryptedText = await decrypt(a.extracted_text, a.extracted_text_iv, ctx.masterKey);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Ignore decryption failures for extracted text
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
attachmentLines.push(formatAttachmentWithExtraction(a, decryptedText, 500));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
62
88
|
return {
|
|
63
89
|
id: note.id,
|
|
64
90
|
title,
|
|
@@ -67,6 +93,7 @@ export async function semanticSearch(ctx, query, topK = 10, threshold = 0.5) {
|
|
|
67
93
|
similarity: bestByNote.get(note.id) ?? 0,
|
|
68
94
|
vaultId: note.vault_id,
|
|
69
95
|
updatedAt: note.updated_at,
|
|
96
|
+
...(attachmentLines.length > 0 ? { attachments: attachmentLines } : {}),
|
|
70
97
|
};
|
|
71
98
|
}));
|
|
72
99
|
// Sort by similarity descending and limit to topK
|
|
@@ -6,7 +6,7 @@ import { generateEmbeddings } from "../openai.js";
|
|
|
6
6
|
import { logMcpUsageEvent } from "../usage.js";
|
|
7
7
|
import { resolveEmbeddingConfig } from "../embedding-config.js";
|
|
8
8
|
import { syncWikiLinks, deleteWikiLinksForNode } from "./wiki-link-sync.js";
|
|
9
|
-
const VECTOR_DIMS =
|
|
9
|
+
const VECTOR_DIMS = 3072;
|
|
10
10
|
const CHARS_PER_TOKEN = 4;
|
|
11
11
|
const CHUNK_TARGET_CHARS = 2400;
|
|
12
12
|
const CHUNK_OVERLAP_CHARS = 400;
|
|
@@ -166,7 +166,7 @@ export async function updateMemoryTool(ctx, input) {
|
|
|
166
166
|
const chunks = chunkText(base);
|
|
167
167
|
if (chunks.length > 0) {
|
|
168
168
|
try {
|
|
169
|
-
const vectors = await generateEmbeddings(chunks.map((c) => c.text), embeddingConfig.apiKey, {
|
|
169
|
+
const vectors = await generateEmbeddings(chunks.map((c) => c.text), embeddingConfig.apiKey, { model: embeddingConfig.model });
|
|
170
170
|
const badVector = vectors.find((v) => v.length !== VECTOR_DIMS);
|
|
171
171
|
if (badVector) {
|
|
172
172
|
throw new Error(`embedding_dimensions_mismatch expected=${VECTOR_DIMS} got=${badVector.length}`);
|
|
@@ -8,7 +8,7 @@ import { generateEmbeddings, generateQueryEmbedding } from "../openai.js";
|
|
|
8
8
|
import { logMcpUsageEvent } from "../usage.js";
|
|
9
9
|
import { resolveEmbeddingConfig } from "../embedding-config.js";
|
|
10
10
|
import { syncWikiLinks } from "./wiki-link-sync.js";
|
|
11
|
-
const VECTOR_DIMS =
|
|
11
|
+
const VECTOR_DIMS = 3072;
|
|
12
12
|
const CHARS_PER_TOKEN = 4;
|
|
13
13
|
const CHUNK_TARGET_CHARS = 2400; // ~600 tokens
|
|
14
14
|
const CHUNK_OVERLAP_CHARS = 400; // ~100 tokens
|
|
@@ -55,7 +55,6 @@ export async function checkSemanticDedup(ctx, content, summary) {
|
|
|
55
55
|
? `${summary.trim()}\n\n${content.trim()}`
|
|
56
56
|
: content.trim();
|
|
57
57
|
const embedding = await generateQueryEmbedding(textToEmbed, embeddingConfig.apiKey, {
|
|
58
|
-
baseUrl: embeddingConfig.baseUrl,
|
|
59
58
|
model: embeddingConfig.model,
|
|
60
59
|
});
|
|
61
60
|
const matches = await matchMemoryEmbeddings(ctx.supabase, embedding, ctx.userId, DEDUP_SUPERSEDE_THRESHOLD, 5);
|
|
@@ -162,10 +161,7 @@ async function indexMemoryEmbeddings(ctx, memoryId, content, summary) {
|
|
|
162
161
|
indexing_error: null,
|
|
163
162
|
});
|
|
164
163
|
try {
|
|
165
|
-
const vectors = await generateEmbeddings(chunks.map((c) => c.text), embeddingConfig.apiKey, {
|
|
166
|
-
baseUrl: embeddingConfig.baseUrl,
|
|
167
|
-
model: embeddingConfig.model,
|
|
168
|
-
});
|
|
164
|
+
const vectors = await generateEmbeddings(chunks.map((c) => c.text), embeddingConfig.apiKey, { model: embeddingConfig.model });
|
|
169
165
|
const badVector = vectors.find((v) => v.length !== VECTOR_DIMS);
|
|
170
166
|
if (badVector) {
|
|
171
167
|
throw new Error(`embedding_dimensions_mismatch expected=${VECTOR_DIMS} got=${badVector.length} model=${embeddingConfig.model}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "exovault-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@ai-sdk/openai": "^3.0.27",
|
|
28
|
+
"@google/genai": "^1.44.0",
|
|
28
29
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
29
30
|
"@supabase/supabase-js": "^2.49.4",
|
|
30
31
|
"ai": "^6.0.82",
|