exovault-mcp-server 1.1.0 → 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.
Files changed (41) hide show
  1. package/dist/db.d.ts +16 -0
  2. package/dist/db.js +34 -0
  3. package/dist/embedding-config.d.ts +1 -2
  4. package/dist/embedding-config.js +11 -10
  5. package/dist/extraction-prompt.js +4 -4
  6. package/dist/gateway-client.d.ts +51 -12
  7. package/dist/gateway-client.js +58 -10
  8. package/dist/index.js +145 -43
  9. package/dist/openai.d.ts +4 -2
  10. package/dist/openai.js +36 -32
  11. package/dist/rlm/actions.js +3 -2
  12. package/dist/rlm/verify.js +6 -6
  13. package/dist/rlm/writeback.js +10 -10
  14. package/dist/scripts/backfill-memory-embeddings.js +13 -30
  15. package/dist/tools/bm25.d.ts +6 -0
  16. package/dist/tools/bm25.js +10 -0
  17. package/dist/tools/confidence-dampen.d.ts +25 -0
  18. package/dist/tools/confidence-dampen.js +29 -0
  19. package/dist/tools/explore-graph.js +1 -1
  20. package/dist/tools/importance-boost.d.ts +25 -0
  21. package/dist/tools/importance-boost.js +29 -0
  22. package/dist/tools/read-memories.js +28 -1
  23. package/dist/tools/read-note.js +17 -1
  24. package/dist/tools/read-notes.js +28 -1
  25. package/dist/tools/recall.d.ts +24 -0
  26. package/dist/tools/recall.js +146 -0
  27. package/dist/tools/relevance.d.ts +13 -0
  28. package/dist/tools/relevance.js +17 -0
  29. package/dist/tools/rrf.d.ts +1 -1
  30. package/dist/tools/rrf.js +16 -1
  31. package/dist/tools/search-and-read.js +28 -1
  32. package/dist/tools/search-memories.d.ts +1 -0
  33. package/dist/tools/search-memories.js +117 -17
  34. package/dist/tools/search-notes.d.ts +10 -1
  35. package/dist/tools/search-notes.js +19 -9
  36. package/dist/tools/search.d.ts +27 -0
  37. package/dist/tools/search.js +78 -0
  38. package/dist/tools/semantic-search.js +29 -2
  39. package/dist/tools/update-memory.js +2 -2
  40. package/dist/tools/write-memory.js +2 -6
  41. package/package.json +2 -1
package/dist/db.d.ts CHANGED
@@ -191,6 +191,22 @@ export declare function matchNotesByBlindTokens(supabase: SupabaseClient, tokenH
191
191
  export declare function getNotesByIds(supabase: SupabaseClient, userId: string, noteIds: string[]): Promise<NoteRow[]>;
192
192
  export declare function getNoteByImportSource(supabase: SupabaseClient, userId: string, vaultId: string, importSource: string, importSourceId: string): Promise<NoteRow | null>;
193
193
  export declare function getMemoriesByIds(supabase: SupabaseClient, userId: string, memoryIds: string[]): Promise<MemoryRow[]>;
194
+ export interface MediaAttachmentRow {
195
+ id: string;
196
+ memory_id: string | null;
197
+ note_id: string | null;
198
+ modality: string;
199
+ mime_type: string;
200
+ file_name: string | null;
201
+ file_size_bytes: number;
202
+ embedding_status: string;
203
+ extracted_text: string | null;
204
+ extracted_text_iv: string | null;
205
+ extraction_status: string;
206
+ }
207
+ export declare function getAttachmentsForMemories(supabase: SupabaseClient, userId: string, memoryIds: string[]): Promise<MediaAttachmentRow[]>;
208
+ export declare function getAttachmentsForNotes(supabase: SupabaseClient, userId: string, noteIds: string[]): Promise<MediaAttachmentRow[]>;
209
+ export declare function formatAttachmentWithExtraction(att: MediaAttachmentRow, decryptedText: string | null, truncate?: number): string;
194
210
  export interface ActiveAgentRow {
195
211
  agentId: string;
196
212
  modelId: string | null;
package/dist/db.js CHANGED
@@ -372,6 +372,40 @@ export async function getMemoriesByIds(supabase, userId, memoryIds) {
372
372
  throw new Error(sanitizeDbError("fetch memories by IDs", error.message));
373
373
  return (data ?? []);
374
374
  }
375
+ export async function getAttachmentsForMemories(supabase, userId, memoryIds) {
376
+ if (memoryIds.length === 0)
377
+ return [];
378
+ const { data, error } = await supabase
379
+ .from("media_attachments")
380
+ .select("id, memory_id, note_id, modality, mime_type, file_name, file_size_bytes, embedding_status, extracted_text, extracted_text_iv, extraction_status")
381
+ .eq("user_id", userId)
382
+ .in("memory_id", memoryIds);
383
+ if (error)
384
+ throw new Error(sanitizeDbError("fetch attachments", error.message));
385
+ return (data ?? []);
386
+ }
387
+ export async function getAttachmentsForNotes(supabase, userId, noteIds) {
388
+ if (noteIds.length === 0)
389
+ return [];
390
+ const { data, error } = await supabase
391
+ .from("media_attachments")
392
+ .select("id, memory_id, note_id, modality, mime_type, file_name, file_size_bytes, embedding_status, extracted_text, extracted_text_iv, extraction_status")
393
+ .eq("user_id", userId)
394
+ .in("note_id", noteIds);
395
+ if (error)
396
+ throw new Error(sanitizeDbError("fetch note attachments", error.message));
397
+ return (data ?? []);
398
+ }
399
+ export function formatAttachmentWithExtraction(att, decryptedText, truncate) {
400
+ let text = decryptedText;
401
+ if (text && truncate)
402
+ text = text.slice(0, truncate);
403
+ return [
404
+ `[${att.modality}] ${att.file_name ?? "unnamed"} (${att.mime_type}, ${att.file_size_bytes}B)`,
405
+ ` embedding: ${att.embedding_status}, extraction: ${att.extraction_status}`,
406
+ text ? ` content: ${text}` : null,
407
+ ].filter(Boolean).join("\n");
408
+ }
375
409
  export async function getActiveAgents(supabase, userId, sinceDays = 30, limit = 20) {
376
410
  const since = new Date();
377
411
  since.setDate(since.getDate() - sinceDays);
@@ -1,11 +1,10 @@
1
1
  import type { McpContext } from "./auth.js";
2
2
  export interface EmbeddingConfig {
3
3
  apiKey: string;
4
- baseUrl: string;
5
4
  model: string;
6
5
  }
7
6
  /**
8
7
  * Resolve embedding API configuration from env vars or MCP context.
9
- * Priority: env vars > ctx.openaiApiKey > null.
8
+ * Priority: env vars > ctx.geminiApiKey > ctx.openaiApiKey > null.
10
9
  */
11
10
  export declare function resolveEmbeddingConfig(ctx: McpContext): EmbeddingConfig | null;
@@ -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;
@@ -74,10 +74,10 @@ function truncate(s, max) {
74
74
  return undefined;
75
75
  return s.length > max ? s.slice(0, max) : s;
76
76
  }
77
- const EXTRACTION_INSTRUCTIONS = `Extract durable knowledge from the activity log above. Return a JSON array, max 8 items.
78
- Each item uses short keys: {c: content, t: type, i: importance(1-5), e: [entities], s: summary}
79
- Types: fact, skill, preference, constraint, task
80
- Only extract knowledge NOT already saved. Skip ephemeral info.
77
+ const EXTRACTION_INSTRUCTIONS = `Extract durable knowledge from the activity log above. Return a JSON array, max 8 items.
78
+ Each item uses short keys: {c: content, t: type, i: importance(1-5), e: [entities], s: summary}
79
+ Types: fact, skill, preference, constraint, task
80
+ Only extract knowledge NOT already saved. Skip ephemeral info.
81
81
  Return [] if nothing worth extracting.`;
82
82
  // ─── parseExtractionResult ────────────────────────────────────────────
83
83
  /**
@@ -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>;
@@ -291,6 +300,16 @@ export declare class GatewayClient {
291
300
  documentType: "soul" | "instructions" | "skills" | "checks";
292
301
  vaultId?: string;
293
302
  }): Promise<string>;
303
+ recall(params: {
304
+ mode: "temporal" | "topic" | "graph";
305
+ range?: string;
306
+ query?: string;
307
+ searchMode?: "auto" | "hybrid" | "bm25" | "semantic";
308
+ topK?: number;
309
+ nodeId?: string;
310
+ maxHops?: number;
311
+ vaultId?: string;
312
+ }): Promise<string>;
294
313
  readDocs(params: {
295
314
  slug?: string;
296
315
  list?: boolean;
@@ -300,4 +319,24 @@ export declare class GatewayClient {
300
319
  documentType: "instructions" | "skills" | "checks";
301
320
  appendContent: string;
302
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>;
303
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);
@@ -274,6 +270,11 @@ export class GatewayClient {
274
270
  const result = await this.request("POST", "/api/agent/read-document", params);
275
271
  return JSON.stringify(result);
276
272
  }
273
+ // ─── Recall ──────────────────────────────────────────────────────────────
274
+ async recall(params) {
275
+ const result = await this.request("POST", "/api/agent/recall", params);
276
+ return JSON.stringify(result);
277
+ }
277
278
  async readDocs(params) {
278
279
  const result = await this.request("POST", "/api/agent/read-docs", params);
279
280
  return JSON.stringify(result);
@@ -282,4 +283,51 @@ export class GatewayClient {
282
283
  const result = await this.request("POST", "/api/agent/update-document", params);
283
284
  return JSON.stringify(result);
284
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
+ }
285
333
  }
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
3
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
7
  import { z } from "zod";
@@ -10,8 +12,6 @@ import { listNotes } from "./tools/list-notes.js";
10
12
  import { readNote } from "./tools/read-note.js";
11
13
  import { readNotes } from "./tools/read-notes.js";
12
14
  import { searchNotes } from "./tools/search-notes.js";
13
- import { semanticSearch } from "./tools/semantic-search.js";
14
- import { searchAndRead } from "./tools/search-and-read.js";
15
15
  import { createNote } from "./tools/create-note.js";
16
16
  import { updateNote } from "./tools/update-note.js";
17
17
  import { deleteNote } from "./tools/delete-note.js";
@@ -27,6 +27,8 @@ import { updateMemoryTool } from "./tools/update-memory.js";
27
27
  import { cleanupMemories } from "./tools/cleanup-memories.js";
28
28
  import { getLinks, addLink, removeLink } from "./tools/knowledge-links.js";
29
29
  import { exploreGraph } from "./tools/explore-graph.js";
30
+ import { recall } from "./tools/recall.js";
31
+ import { search } from "./tools/search.js";
30
32
  import { sendMessage, ackMessage, readMessages } from "./tools/agent-messages.js";
31
33
  // Task tools are thin wrappers around memory tools — no separate agent-tasks import needed
32
34
  import { resolveVaultId } from "./tools/resolve-vault-id.js";
@@ -50,6 +52,30 @@ const MEMORY_TYPES = ["fact", "skill", "preference", "constraint", "task", "epis
50
52
  const memoryTypeEnum = z.enum(MEMORY_TYPES);
51
53
  /** Remind agents to checkpoint every N tool calls. */
52
54
  const CHECKPOINT_REMINDER_INTERVAL = 20;
55
+ /** Max age (ms) of the hook session file to be considered valid. */
56
+ const HOOK_SESSION_MAX_AGE_MS = 120_000;
57
+ /**
58
+ * Read the session ID written by the SessionStart hook.
59
+ * Returns null if the file is missing, stale (>120s), or invalid.
60
+ */
61
+ function readHookSessionId() {
62
+ try {
63
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
64
+ const filePath = join(home, ".exovault-mcp", "current-session.json");
65
+ const raw = readFileSync(filePath, "utf8");
66
+ const data = JSON.parse(raw);
67
+ if (data.sessionId &&
68
+ data.timestamp &&
69
+ Date.now() - data.timestamp < HOOK_SESSION_MAX_AGE_MS) {
70
+ process.stderr.write(`[exovault-mcp] Using hook session ID: ${data.sessionId}\n`);
71
+ return data.sessionId;
72
+ }
73
+ }
74
+ catch {
75
+ // File doesn't exist or is invalid — fall back to random UUID
76
+ }
77
+ return null;
78
+ }
53
79
  async function main() {
54
80
  // ─── Detect mode: gateway (agent key) or direct (Supabase) ─────────────
55
81
  const config = await readConfig();
@@ -60,10 +86,10 @@ async function main() {
60
86
  let allowedVaultIds;
61
87
  let gwAgentType;
62
88
  let gwAgentLabel;
63
- // Generate a unique session ID for this MCP server instance.
64
- // Used as the default agentRunId so all memories from one connection
65
- // are grouped into the same session — even if the agent doesn't pass one.
66
- const sessionRunId = randomUUID();
89
+ // Try to reuse the session ID written by the SessionStart hook so that
90
+ // MCP tool calls and hook-captured turns share the same dashboard session.
91
+ // Falls back to a random UUID if the hook file is missing or stale.
92
+ const sessionRunId = readHookSessionId() ?? randomUUID();
67
93
  if (gwConfig) {
68
94
  gw = new GatewayClient(gwConfig.apiUrl, gwConfig.agentKey, sessionRunId);
69
95
  try {
@@ -344,7 +370,7 @@ async function main() {
344
370
  if (gw) {
345
371
  instructionLines.push("Running in gateway mode. Turn ingestion is automatic.");
346
372
  }
347
- 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).");
348
374
  const instructionsText = instructionLines.join("\n");
349
375
  // ── Guard: keep instructions ≤4,000 chars ─────────────────────────
350
376
  const INSTRUCTION_CHAR_LIMIT = 4_000;
@@ -411,17 +437,22 @@ async function main() {
411
437
  })));
412
438
  // ─── search_notes ─────────────────────────────────────────────────────────
413
439
  server.registerTool("search_notes", {
414
- 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.",
415
441
  inputSchema: {
416
- 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.")),
417
447
  vaultId: s(z.string().uuid().optional().describe("Limit search to a specific vault")),
418
- 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)")),
419
450
  },
420
451
  }, auto.wrap(wrapToolHandler(async (args) => {
421
- const { query, vaultId, limit } = args;
452
+ const input = args;
422
453
  return gw
423
- ? await gw.searchNotes({ query, vaultId: resolveVault(vaultId), limit })
424
- : 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) });
425
456
  })));
426
457
  // ─── create_note ──────────────────────────────────────────────────────────
427
458
  server.registerTool("create_note", {
@@ -522,35 +553,6 @@ async function main() {
522
553
  const { noteIds } = args;
523
554
  return gw ? await gw.readNotes(noteIds) : await readNotes(ctx, noteIds);
524
555
  })));
525
- // ─── semantic_search ────────────────────────────────────────────────────
526
- server.registerTool("semantic_search", {
527
- description: "Search notes by meaning using vector embeddings. Finds conceptually similar content even without exact keyword matches. Requires OpenAI API key in config.",
528
- inputSchema: {
529
- query: s(z.string().min(1).describe("Natural language search query")),
530
- topK: s(z.number().int().min(1).max(50).optional().describe("Max results (default 10)")),
531
- threshold: s(z.number().min(0).max(1).optional().describe("Minimum similarity threshold 0-1 (default 0.5)")),
532
- vaultId: s(z.string().uuid().optional().describe("Vault to search within. Uses default vault if omitted.")),
533
- },
534
- }, auto.wrap(wrapToolHandler(async (args) => {
535
- const { query, topK, threshold, vaultId } = args;
536
- return gw
537
- ? await gw.semanticSearch({ query, topK, threshold, vaultId: resolveVault(vaultId) })
538
- : await semanticSearch(ctx, query, topK, threshold);
539
- })));
540
- // ─── search_and_read ────────────────────────────────────────────────────
541
- server.registerTool("search_and_read", {
542
- 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.",
543
- inputSchema: {
544
- query: s(z.string().min(1).describe("Search query (natural language or keywords)")),
545
- maxNotes: s(z.number().int().min(1).max(20).optional().describe("Max notes to return (default 5)")),
546
- vaultId: s(z.string().uuid().optional().describe("Vault to search within. Uses default vault if omitted.")),
547
- },
548
- }, auto.wrap(wrapToolHandler(async (args) => {
549
- const { query, maxNotes, vaultId } = args;
550
- return gw
551
- ? await gw.searchAndRead({ query, maxNotes, vaultId: resolveVault(vaultId) })
552
- : await searchAndRead(ctx, query, maxNotes);
553
- })));
554
556
  // ─── write_memory ───────────────────────────────────────────────────────────
555
557
  server.registerTool("write_memory", {
556
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.",
@@ -611,6 +613,31 @@ async function main() {
611
613
  }
612
614
  return await searchMemories(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
613
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
+ })));
614
641
  // ─── read_memories ──────────────────────────────────────────────────────────
615
642
  server.registerTool("read_memories", {
616
643
  description: "Read and decrypt full memory entries by IDs.",
@@ -689,7 +716,7 @@ async function main() {
689
716
  })).optional(),
690
717
  sourceNoteIds: z.array(z.string().uuid()).optional(),
691
718
  supersededById: z.string().uuid().optional(),
692
- })).max(50).describe("Array of memories to save (0-50). Can be empty when only sessionSummary is provided.")),
719
+ })).max(50).default([]).describe("Memories to bulk-save (0-50). Defaults to empty array — omit or pass [] when only sessionSummary is needed.")),
693
720
  sessionSummary: s(z.string().min(1).describe("REQUIRED. Narrative summary of what happened this session — what was discussed, decided, and accomplished. Saved as an episodic memory. Do NOT write tool call stats — describe the work in human terms.")),
694
721
  vaultId: s(z.string().uuid().optional().describe("Vault/project scope for all memories. Required unless defaultVaultId is configured.")),
695
722
  agentId: s(z.string().optional().describe("Agent identifier")),
@@ -1168,6 +1195,81 @@ async function main() {
1168
1195
  }
1169
1196
  return await readMessages(ctx, { ...input, agentId, vaultId: resolveVaultId(ctx, input.vaultId) });
1170
1197
  })));
1198
+ // ─── recall ─────────────────────────────────────────────────────────────
1199
+ server.registerTool("recall", {
1200
+ description: "Deep context retrieval with three modes: " +
1201
+ "temporal (session timeline by date range), " +
1202
+ "topic (hybrid BM25+semantic search across memories), " +
1203
+ "graph (knowledge graph expansion from query or node). " +
1204
+ "Use session_start for basic warm-up; use recall for targeted deep retrieval mid-session.",
1205
+ inputSchema: {
1206
+ mode: s(z.enum(["temporal", "topic", "graph"]).describe("temporal: chronological session timeline by date range. " +
1207
+ "topic: hybrid BM25+semantic search across memories. " +
1208
+ "graph: knowledge graph expansion from query or node.")),
1209
+ range: s(z.string().optional().describe("Temporal mode: 'today', 'yesterday', 'last_week', 'last_3_days', 'last_N_days', or ISO date '2026-03-01'")),
1210
+ query: s(z.string().max(2000).optional().describe("Topic/graph mode: search query")),
1211
+ searchMode: s(z.enum(["auto", "hybrid", "bm25", "semantic"]).optional().describe("Topic mode: search strategy. Default: 'hybrid'")),
1212
+ topK: s(z.number().int().min(1).max(50).optional().describe("Topic mode: max results. Default: 10")),
1213
+ nodeId: s(z.string().uuid().optional().describe("Graph mode: start from this node")),
1214
+ maxHops: s(z.number().int().min(1).max(5).optional().describe("Graph mode: traversal depth. Default: 2")),
1215
+ vaultId: s(z.string().uuid().optional().describe("Scope to vault")),
1216
+ },
1217
+ }, auto.wrap(wrapToolHandler(async (args) => {
1218
+ const input = args;
1219
+ if (gw) {
1220
+ return await gw.recall({ ...input, vaultId: resolveVault(input.vaultId) });
1221
+ }
1222
+ return await recall(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
1223
+ })));
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
+ })));
1171
1273
  // ─── Orphan recovery — flush crashed sessions from previous runs ─────────
1172
1274
  try {
1173
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;