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 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;
@@ -1,23 +1,24 @@
1
- const OPENAI_EMBEDDING_MODEL = "text-embedding-3-small";
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.EMBEDDING_API_KEY ??
8
- process.env.OPENAI_API_KEY;
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
- baseUrl: process.env.EMBEDDING_BASE_URL ?? "https://api.openai.com/v1",
13
- model: process.env.EMBEDDING_MODEL ?? OPENAI_EMBEDDING_MODEL,
13
+ model: process.env.EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL,
14
14
  };
15
15
  }
16
- if (ctx.openaiApiKey) {
16
+ // Fallback to MCP context keys
17
+ const ctxKey = ctx.geminiApiKey ?? ctx.openaiApiKey;
18
+ if (ctxKey && typeof ctxKey === "string") {
17
19
  return {
18
- apiKey: ctx.openaiApiKey,
19
- baseUrl: "https://api.openai.com/v1",
20
- model: OPENAI_EMBEDDING_MODEL,
20
+ apiKey: ctxKey,
21
+ model: DEFAULT_EMBEDDING_MODEL,
21
22
  };
22
23
  }
23
24
  return null;
@@ -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
- limit?: number;
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
  }
@@ -154,8 +154,12 @@ export class GatewayClient {
154
154
  return JSON.stringify(result);
155
155
  }
156
156
  async searchNotes(params) {
157
- const result = await this.request("POST", "/api/agent/search-notes", params);
158
- return JSON.stringify(result);
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.", "- `semantic_search` — vector similarity across notes+memories.", "- `search_and_read` — search + auto-read in one call.", "- `get_links` / `get_related_memories` — single-hop link traversal.", "- `read_document` — vault docs (instructions, skills, checks).");
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 by keyword. Weighted: 3x title, 2x tags, 1x content. Returns scored results.",
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
- limit: s(z.number().int().min(1).max(50).optional().describe("Max results (default 10)")),
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 { query, vaultId, limit } = args;
452
+ const input = args;
449
453
  return gw
450
- ? await gw.searchNotes({ query, vaultId: resolveVault(vaultId), limit })
451
- : await searchNotes(ctx, query, resolveVaultId(ctx, vaultId), limit);
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 OpenAI text-embedding-3-small.
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 when possible.
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 { sanitizeApiError } from "./error-sanitizer.js";
2
- const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
3
- const DEFAULT_EMBEDDING_BASE_URL = "https://api.openai.com/v1";
4
- async function requestEmbeddings(input, apiKey, options) {
5
- const baseUrl = (options?.baseUrl ?? DEFAULT_EMBEDDING_BASE_URL).replace(/\/$/, "");
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 fetch(`${baseUrl}/embeddings`, {
8
- method: "POST",
9
- headers: {
10
- "Content-Type": "application/json",
11
- Authorization: `Bearer ${apiKey}`,
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
- if (!response.ok) {
19
- const body = await response.text();
20
- throw new Error(sanitizeApiError(response.status, body));
19
+ const embeddings = response.embeddings ?? [];
20
+ if (embeddings.length === 0) {
21
+ throw new Error("Gemini returned unexpected response: no embedding data");
21
22
  }
22
- const json = (await response.json());
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 when possible.
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
- return requestEmbeddings(values, apiKey, options);
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
  }
@@ -61,7 +61,8 @@ function compactReadMemories(raw) {
61
61
  function compactSearchNotes(raw) {
62
62
  try {
63
63
  const data = JSON.parse(raw);
64
- const results = Array.isArray(data) ? data : [];
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 OPENAI_EMBEDDING_MODEL = "text-embedding-3-small";
8
- const DEEPSEEK_EMBEDDING_MODEL = "deepseek-embedding";
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 envApiKey = process.env.RLM_EMBEDDING_API_KEY ??
60
+ const apiKey = process.env.EXO_GEMINI_KEY ??
61
+ process.env.GEMINI_API_KEY ??
62
62
  process.env.EMBEDDING_API_KEY ??
63
- process.env.OPENAI_API_KEY;
64
- const embeddingConfig = envApiKey
65
- ? {
66
- apiKey: envApiKey,
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, { baseUrl: embeddingConfig.baseUrl, model: embeddingConfig.model });
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)
@@ -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,
@@ -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 getMemoriesByIds(ctx.supabase, ctx.userId, rankedIds);
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, query: string, vaultId?: string, limit?: number): Promise<string>;
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
- export async function searchNotes(ctx, query, vaultId, limit = 10) {
44
- const terms = parseSearchTerms(query);
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, limit * 3);
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
- scored.push({
82
+ const plainContent = stripHtml(content);
83
+ const entry = {
78
84
  id: n.id,
79
85
  title,
80
86
  tags,
81
- preview: stripHtml(content).slice(0, 200),
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, limit),
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. Decrypt and build results
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 = 1536;
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, { baseUrl: embeddingConfig.baseUrl, model: embeddingConfig.model });
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 = 1536;
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.1.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",