exovault-mcp-server 1.1.1 → 1.3.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;
@@ -62,6 +62,12 @@ export declare class GatewayClient {
62
62
  includeArchived?: boolean;
63
63
  entity?: string;
64
64
  compact?: boolean;
65
+ decayHalfLife?: number;
66
+ diversity?: number;
67
+ searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
68
+ graphWeight?: number;
69
+ graphSeeds?: number;
70
+ graphMaxHops?: number;
65
71
  }): Promise<string>;
66
72
  readMemories(memoryIds: string[]): Promise<string>;
67
73
  updateMemory(params: {
@@ -134,8 +140,28 @@ export declare class GatewayClient {
134
140
  readNotes(noteIds: string[]): Promise<string>;
135
141
  searchNotes(params: {
136
142
  query: string;
143
+ topK?: number;
144
+ threshold?: number;
145
+ searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
146
+ diversity?: number;
137
147
  vaultId?: string;
138
- limit?: number;
148
+ includeContent?: boolean;
149
+ compact?: boolean;
150
+ }): Promise<string>;
151
+ search(params: {
152
+ query: string;
153
+ topK?: number;
154
+ threshold?: number;
155
+ searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
156
+ diversity?: number;
157
+ vaultId?: string;
158
+ includeContent?: boolean;
159
+ compact?: boolean;
160
+ scope?: "all" | "memories" | "notes";
161
+ memoryType?: string;
162
+ entity?: string;
163
+ includeArchived?: boolean;
164
+ decayHalfLife?: number;
139
165
  }): Promise<string>;
140
166
  createNote(params: {
141
167
  vaultId?: string;
@@ -151,17 +177,6 @@ export declare class GatewayClient {
151
177
  tags?: string[];
152
178
  }): Promise<string>;
153
179
  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
180
  listFolders(params: {
166
181
  vaultId?: string;
167
182
  }): Promise<string>;
@@ -310,4 +325,24 @@ export declare class GatewayClient {
310
325
  documentType: "instructions" | "skills" | "checks";
311
326
  appendContent: string;
312
327
  }): Promise<string>;
328
+ attachMedia(params: {
329
+ fileBytes: Uint8Array;
330
+ fileName: string;
331
+ mimeType: string;
332
+ memoryId?: string;
333
+ vaultId?: string;
334
+ }): Promise<string>;
335
+ downloadMedia(params: {
336
+ attachmentId: string;
337
+ }): Promise<{
338
+ attachmentId: string;
339
+ memoryId: string | null;
340
+ modality: string;
341
+ mimeType: string;
342
+ fileName: string | null;
343
+ fileSizeBytes: number;
344
+ embeddingStatus: string;
345
+ base64Content: string;
346
+ }>;
347
+ deleteMedia(attachmentId: string, vaultId?: string): Promise<string>;
313
348
  }
@@ -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.",
@@ -630,6 +605,9 @@ async function main() {
630
605
  compact: s(z.boolean().optional().describe("Return truncated content previews (200 chars) instead of full content. Use read_memories for full content on specific IDs.")),
631
606
  decayHalfLife: s(z.number().int().min(1).max(365).optional().describe("Temporal decay half-life in days (default 30). Older memories score lower unless importance >= 4.")),
632
607
  diversity: s(z.number().min(0).max(1).optional().describe("MMR diversity balance 0-1 (default 0.7). Higher = more relevance, lower = more diversity.")),
608
+ graphWeight: s(z.number().min(0).max(1.5).optional().describe("RRF weight for graph signal (default 0.6). Set 0 to disable graph expansion.")),
609
+ graphSeeds: s(z.number().int().min(0).max(15).optional().describe("How many top results to use as graph expansion seeds (default 5)")),
610
+ graphMaxHops: s(z.number().int().min(1).max(2).optional().describe("Max hops for graph traversal (default 1)")),
633
611
  },
634
612
  }, auto.wrap(wrapToolHandler(async (args) => {
635
613
  const input = args;
@@ -638,6 +616,31 @@ async function main() {
638
616
  }
639
617
  return await searchMemories(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
640
618
  })));
619
+ // ─── search (universal) ─────────────────────────────────────────────────────
620
+ server.registerTool("search", {
621
+ 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.",
622
+ inputSchema: {
623
+ query: s(z.string().min(1).describe("Natural language search query")),
624
+ topK: s(z.number().int().min(1).max(50).optional().describe("Max results (default 10)")),
625
+ threshold: s(z.number().min(0).max(1).optional().describe("Similarity threshold 0-1 (default 0.5)")),
626
+ searchMode: s(z.enum(["auto", "hybrid", "bm25", "semantic"]).optional().describe("Search mode (default: auto)")),
627
+ diversity: s(z.number().min(0).max(1).optional().describe("MMR diversity balance 0-1 (default 0.7)")),
628
+ vaultId: s(z.string().uuid().optional().describe("Vault filter")),
629
+ includeContent: s(z.boolean().optional().describe("Include full note content (default false)")),
630
+ compact: s(z.boolean().optional().describe("Return shorter previews")),
631
+ scope: s(z.enum(["all", "memories", "notes"]).optional().describe("Search scope: all (default), memories, or notes")),
632
+ memoryType: s(z.string().optional().describe("Filter by memory type (memories scope only)")),
633
+ entity: s(z.string().optional().describe("Search by entity name (memories scope only)")),
634
+ includeArchived: s(z.boolean().optional().describe("Include archived memories (default false)")),
635
+ decayHalfLife: s(z.number().int().min(1).max(365).optional().describe("Temporal decay half-life in days (default 30)")),
636
+ },
637
+ }, auto.wrap(wrapToolHandler(async (args) => {
638
+ const input = args;
639
+ if (gw) {
640
+ return await gw.search({ ...input, vaultId: resolveVault(input.vaultId) });
641
+ }
642
+ return await search(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
643
+ })));
641
644
  // ─── read_memories ──────────────────────────────────────────────────────────
642
645
  server.registerTool("read_memories", {
643
646
  description: "Read and decrypt full memory entries by IDs.",
@@ -1221,6 +1224,55 @@ async function main() {
1221
1224
  }
1222
1225
  return await recall(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
1223
1226
  })));
1227
+ // ─── attach_media ──────────────────────────────────────────────────────────
1228
+ server.registerTool("attach_media", {
1229
+ 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).",
1230
+ inputSchema: {
1231
+ fileBase64: s(z.string().min(1).describe("Base64-encoded file content")),
1232
+ fileName: s(z.string().min(1).describe("Original file name with extension")),
1233
+ mimeType: s(z.string().min(1).describe("MIME type (e.g. image/png, application/pdf, audio/mpeg, video/mp4)")),
1234
+ memoryId: s(z.string().uuid().optional().describe("Memory to attach this media to")),
1235
+ vaultId: s(z.string().uuid().optional().describe("Vault scope")),
1236
+ },
1237
+ }, auto.wrap(wrapToolHandler(async (args) => {
1238
+ const input = args;
1239
+ if (!gw)
1240
+ throw new Error("attach_media requires gateway mode (agent key)");
1241
+ const fileBytes = Buffer.from(input.fileBase64, "base64");
1242
+ return await gw.attachMedia({
1243
+ fileBytes: new Uint8Array(fileBytes),
1244
+ fileName: input.fileName,
1245
+ mimeType: input.mimeType,
1246
+ memoryId: input.memoryId,
1247
+ vaultId: resolveVault(input.vaultId),
1248
+ });
1249
+ })));
1250
+ // ─── download_media ───────────────────────────────────────────────────────
1251
+ server.registerTool("download_media", {
1252
+ description: "Download and decrypt a media attachment by ID. Returns base64-encoded file content with metadata.",
1253
+ inputSchema: {
1254
+ attachmentId: s(z.string().uuid().describe("Media attachment ID to download")),
1255
+ },
1256
+ }, auto.wrap(wrapToolHandler(async (args) => {
1257
+ const { attachmentId } = args;
1258
+ if (!gw)
1259
+ throw new Error("download_media requires gateway mode (agent key)");
1260
+ const result = await gw.downloadMedia({ attachmentId });
1261
+ return JSON.stringify(result);
1262
+ })));
1263
+ // ─── delete_media ───────────────────────────────────────────────────────
1264
+ server.registerTool("delete_media", {
1265
+ description: "Delete a media attachment and its embedding. Permanently removes the file from storage.",
1266
+ inputSchema: {
1267
+ attachmentId: s(z.string().uuid().describe("The attachment ID to delete")),
1268
+ vaultId: s(z.string().uuid().optional().describe("Optional vault scope")),
1269
+ },
1270
+ }, auto.wrap(wrapToolHandler(async (args) => {
1271
+ const { attachmentId, vaultId } = args;
1272
+ if (!gw)
1273
+ throw new Error("delete_media requires gateway mode (agent key)");
1274
+ return await gw.deleteMedia(attachmentId, vaultId);
1275
+ })));
1224
1276
  // ─── Orphan recovery — flush crashed sessions from previous runs ─────────
1225
1277
  try {
1226
1278
  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
  }),