exovault-mcp-server 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.d.ts CHANGED
@@ -97,6 +97,8 @@ export declare function getMemories(supabase: SupabaseClient, userId: string, op
97
97
  includeArchived?: boolean;
98
98
  limit?: number;
99
99
  orderBy?: "updated_at" | "importance";
100
+ /** Use lightweight columns (no encrypted content/summary). Default: false. */
101
+ lightweight?: boolean;
100
102
  }): Promise<MemoryRow[]>;
101
103
  export declare function getMemory(supabase: SupabaseClient, userId: string, memoryId: string): Promise<MemoryRow | null>;
102
104
  export declare function getMemoryByExternalWriteId(supabase: SupabaseClient, userId: string, externalWriteId: string): Promise<MemoryRow | null>;
package/dist/db.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { sanitizeDbError } from "./error-sanitizer.js";
2
+ // ─── Column lists (explicit to avoid SELECT * egress overhead) ───────────────
3
+ const VAULT_COLUMNS = "id, user_id, encrypted_name, name_iv, icon, color, sort_order, created_at, updated_at";
4
+ const NOTE_COLUMNS = "id, user_id, vault_id, encrypted_title, title_iv, encrypted_content, content_iv, content_type, encrypted_tags, tags_iv, embedding_status, import_source, import_source_id, is_trashed, created_at, updated_at";
5
+ const LINK_COLUMNS = "id, user_id, source_type, source_id, target_type, target_id, relation_type, encrypted_label, label_iv, is_pending, pending_target_hash, created_at, created_by";
6
+ const MESSAGE_COLUMNS = "id, user_id, vault_id, sender_type, sender_id, target_type, target_id, category, priority, subject, encrypted_content, content_iv, status, delivered_at, acknowledged_at, parent_message_id, thread_id, expires_at, metadata, created_at, updated_at";
2
7
  // ─── Query helpers ────────────────────────────────────────────────────────────
3
8
  export async function getVaults(supabase, userId) {
4
9
  const { data, error } = await supabase
5
10
  .from("vaults")
6
- .select("*")
11
+ .select(VAULT_COLUMNS)
7
12
  .eq("user_id", userId)
8
13
  .order("sort_order", { ascending: true });
9
14
  if (error)
@@ -13,7 +18,7 @@ export async function getVaults(supabase, userId) {
13
18
  export async function getNotes(supabase, userId, vaultId, limit = 20) {
14
19
  let query = supabase
15
20
  .from("notes")
16
- .select("*")
21
+ .select(NOTE_COLUMNS)
17
22
  .eq("user_id", userId)
18
23
  .eq("is_trashed", false)
19
24
  .order("updated_at", { ascending: false })
@@ -29,7 +34,7 @@ export async function getNotes(supabase, userId, vaultId, limit = 20) {
29
34
  export async function getNote(supabase, userId, noteId) {
30
35
  const { data, error } = await supabase
31
36
  .from("notes")
32
- .select("*")
37
+ .select(NOTE_COLUMNS)
33
38
  .eq("id", noteId)
34
39
  .eq("user_id", userId)
35
40
  .single();
@@ -43,7 +48,7 @@ export async function getNote(supabase, userId, noteId) {
43
48
  export async function getVault(supabase, userId, vaultId) {
44
49
  const { data, error } = await supabase
45
50
  .from("vaults")
46
- .select("*")
51
+ .select(VAULT_COLUMNS)
47
52
  .eq("id", vaultId)
48
53
  .eq("user_id", userId)
49
54
  .single();
@@ -102,9 +107,15 @@ export async function getMemories(supabase, userId, options) {
102
107
  const includeArchived = options?.includeArchived ?? false;
103
108
  const limit = options?.limit ?? 50;
104
109
  const orderBy = options?.orderBy ?? "updated_at";
110
+ // Lightweight mode: select only metadata columns (no encrypted content/summary) to reduce egress.
111
+ // Supabase's TS type parser can't handle long literal strings, so we build the query separately.
112
+ const columns = options?.lightweight
113
+ ? "id,user_id,vault_id,memory_type,agent_id,agent_type,model_id,agent_run_id,importance,confidence,access_count,is_archived,entities,superseded_by_id,created_at,updated_at"
114
+ : "*";
105
115
  let query = supabase
106
116
  .from("memories")
107
- .select("*")
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
+ .select(columns)
108
119
  .eq("user_id", userId)
109
120
  .order(orderBy, { ascending: false })
110
121
  .limit(limit);
@@ -339,7 +350,7 @@ export async function getNotesByIds(supabase, userId, noteIds) {
339
350
  return [];
340
351
  const { data, error } = await supabase
341
352
  .from("notes")
342
- .select("*")
353
+ .select(NOTE_COLUMNS)
343
354
  .eq("user_id", userId)
344
355
  .eq("is_trashed", false)
345
356
  .in("id", noteIds);
@@ -350,7 +361,7 @@ export async function getNotesByIds(supabase, userId, noteIds) {
350
361
  export async function getNoteByImportSource(supabase, userId, vaultId, importSource, importSourceId) {
351
362
  const { data, error } = await supabase
352
363
  .from("notes")
353
- .select("*")
364
+ .select(NOTE_COLUMNS)
354
365
  .eq("user_id", userId)
355
366
  .eq("vault_id", vaultId)
356
367
  .eq("import_source", importSource)
@@ -583,7 +594,7 @@ export async function insertKnowledgeLink(supabase, data) {
583
594
  pending_target_hash: data.pending_target_hash ?? null,
584
595
  created_by: data.created_by ?? null,
585
596
  })
586
- .select()
597
+ .select(LINK_COLUMNS)
587
598
  .single();
588
599
  if (error)
589
600
  throw new Error(sanitizeDbError("insert knowledge link", error.message));
@@ -604,7 +615,7 @@ export async function getLinksForNode(supabase, userId, nodeType, nodeId, option
604
615
  if (direction === "outgoing") {
605
616
  const { data, error } = await supabase
606
617
  .from("knowledge_links")
607
- .select()
618
+ .select(LINK_COLUMNS)
608
619
  .eq("user_id", userId)
609
620
  .eq("source_type", nodeType)
610
621
  .eq("source_id", nodeId)
@@ -617,7 +628,7 @@ export async function getLinksForNode(supabase, userId, nodeType, nodeId, option
617
628
  if (direction === "incoming") {
618
629
  const { data, error } = await supabase
619
630
  .from("knowledge_links")
620
- .select()
631
+ .select(LINK_COLUMNS)
621
632
  .eq("user_id", userId)
622
633
  .eq("target_type", nodeType)
623
634
  .eq("target_id", nodeId)
@@ -631,7 +642,7 @@ export async function getLinksForNode(supabase, userId, nodeType, nodeId, option
631
642
  const [outgoing, incoming] = await Promise.all([
632
643
  supabase
633
644
  .from("knowledge_links")
634
- .select()
645
+ .select(LINK_COLUMNS)
635
646
  .eq("user_id", userId)
636
647
  .eq("source_type", nodeType)
637
648
  .eq("source_id", nodeId)
@@ -639,7 +650,7 @@ export async function getLinksForNode(supabase, userId, nodeType, nodeId, option
639
650
  .limit(limit),
640
651
  supabase
641
652
  .from("knowledge_links")
642
- .select()
653
+ .select(LINK_COLUMNS)
643
654
  .eq("user_id", userId)
644
655
  .eq("target_type", nodeType)
645
656
  .eq("target_id", nodeId)
@@ -724,7 +735,7 @@ export async function resolvePendingLinks(supabase, userId, hashToNoteId) {
724
735
  // Fetch all pending links for this user
725
736
  const { data: pending, error } = await supabase
726
737
  .from("knowledge_links")
727
- .select()
738
+ .select(LINK_COLUMNS)
728
739
  .eq("user_id", userId)
729
740
  .eq("is_pending", true)
730
741
  .limit(10_000);
@@ -786,7 +797,7 @@ export async function insertAgentMessage(supabase, data) {
786
797
  parent_message_id: data.parent_message_id ?? null,
787
798
  thread_id: data.thread_id ?? null,
788
799
  })
789
- .select("*")
800
+ .select(MESSAGE_COLUMNS)
790
801
  .single();
791
802
  if (error)
792
803
  throw new Error(sanitizeDbError("insert agent message", error.message));
@@ -799,7 +810,7 @@ export async function insertAgentMessage(supabase, data) {
799
810
  export async function getPendingMessages(supabase, userId, targetId, limit = 5) {
800
811
  const { data, error } = await supabase
801
812
  .from("agent_messages")
802
- .select("*")
813
+ .select(MESSAGE_COLUMNS)
803
814
  .eq("user_id", userId)
804
815
  .eq("status", "pending")
805
816
  .or(`target_id.eq.${targetId},target_id.eq.*`)
@@ -819,7 +830,7 @@ export async function getAgentMessages(supabase, userId, targetId, options) {
819
830
  const includeBroadcast = options?.includeBroadcast ?? true;
820
831
  let query = supabase
821
832
  .from("agent_messages")
822
- .select("*")
833
+ .select(MESSAGE_COLUMNS)
823
834
  .eq("user_id", userId)
824
835
  .order("priority", { ascending: false })
825
836
  .order("created_at", { ascending: false })
@@ -890,7 +901,7 @@ export async function resolveThreadId(supabase, userId, parentMessageId) {
890
901
  export async function getThread(supabase, userId, threadId) {
891
902
  const { data, error } = await supabase
892
903
  .from("agent_messages")
893
- .select("*")
904
+ .select(MESSAGE_COLUMNS)
894
905
  .eq("user_id", userId)
895
906
  .eq("thread_id", threadId)
896
907
  .order("created_at", { ascending: true });
@@ -10,7 +10,7 @@ export function resolveEmbeddingConfig(ctx) {
10
10
  if (envApiKey) {
11
11
  return {
12
12
  apiKey: envApiKey,
13
- model: process.env.EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL,
13
+ model: DEFAULT_EMBEDDING_MODEL, // Hardcoded — EMBEDDING_MODEL is for OpenAI text
14
14
  };
15
15
  }
16
16
  // Fallback to MCP context keys
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Extracts a short headline (max 200 chars) from episodic memory content.
3
- * Strips the "Session: Xm, agent=Y." metadata prefix and returns the
4
- * first 1-2 substantive sentences. Pure function, no LLM calls.
3
+ *
4
+ * New format: first line is the session title, followed by details.
5
+ * Legacy format: "Session: Xm, agent=Y." prefix is stripped.
6
+ *
7
+ * Pure function, no LLM calls.
5
8
  */
6
9
  export declare function generateEpisodicHeadline(content: string): string;
@@ -1,21 +1,30 @@
1
1
  /**
2
2
  * Extracts a short headline (max 200 chars) from episodic memory content.
3
- * Strips the "Session: Xm, agent=Y." metadata prefix and returns the
4
- * first 1-2 substantive sentences. Pure function, no LLM calls.
3
+ *
4
+ * New format: first line is the session title, followed by details.
5
+ * Legacy format: "Session: Xm, agent=Y." prefix is stripped.
6
+ *
7
+ * Pure function, no LLM calls.
5
8
  */
6
9
  const MAX_HEADLINE_CHARS = 200;
7
- // Matches "Session: <duration>, agent=<name>." prefix (with optional newlines after)
10
+ // Legacy prefix — matches "Session: <duration>, agent=<name>." (with optional newlines after)
8
11
  const SESSION_PREFIX_RE = /^Session:\s*\S+,\s*agent=\S+\.\s*/;
9
12
  export function generateEpisodicHeadline(content) {
10
13
  if (!content)
11
14
  return "";
12
- // Strip the metadata prefix
15
+ // Strip legacy metadata prefix if present
13
16
  const body = content.replace(SESSION_PREFIX_RE, "").trim();
14
17
  // If stripping left nothing, return original content
15
18
  if (!body)
16
19
  return content;
17
- // For rule-based format ("Explored: ...\nOutcomes: ..."), join lines
18
- // For LLM narrative, take first 1-2 sentences
20
+ // First line is the session title use it as headline if it fits
21
+ const firstLineEnd = body.indexOf("\n");
22
+ const firstLine = firstLineEnd > 0 ? body.slice(0, firstLineEnd).trim() : body.trim();
23
+ // If first line is short enough and not a "Explored:/Outcomes:" detail line, use it directly
24
+ if (firstLine.length <= MAX_HEADLINE_CHARS && !firstLine.startsWith("Explored:") && !firstLine.startsWith("Outcomes:")) {
25
+ return firstLine;
26
+ }
27
+ // Fallback: take first 1-2 sentences from body
19
28
  const sentences = splitIntoSentences(body);
20
29
  // Take enough sentences to fill ~200 chars
21
30
  let headline = "";
@@ -24,7 +33,6 @@ export function generateEpisodicHeadline(content) {
24
33
  continue;
25
34
  const candidate = headline ? `${headline} ${sentence}` : sentence;
26
35
  if (candidate.length > MAX_HEADLINE_CHARS) {
27
- // If we have nothing yet, take this sentence (will be clipped)
28
36
  if (!headline)
29
37
  headline = sentence;
30
38
  break;
@@ -69,7 +69,7 @@ export declare class GatewayClient {
69
69
  graphSeeds?: number;
70
70
  graphMaxHops?: number;
71
71
  }): Promise<string>;
72
- readMemories(memoryIds: string[]): Promise<string>;
72
+ readMemories(memoryIds: string[], includeMediaContent?: boolean): Promise<string>;
73
73
  updateMemory(params: {
74
74
  memoryId: string;
75
75
  content?: string;
@@ -136,8 +136,8 @@ export declare class GatewayClient {
136
136
  limit?: number;
137
137
  folderId?: string;
138
138
  }): Promise<string>;
139
- readNote(noteId: string): Promise<string>;
140
- readNotes(noteIds: string[]): Promise<string>;
139
+ readNote(noteId: string, includeMediaContent?: boolean): Promise<string>;
140
+ readNotes(noteIds: string[], includeMediaContent?: boolean): Promise<string>;
141
141
  searchNotes(params: {
142
142
  query: string;
143
143
  topK?: number;
@@ -195,6 +195,10 @@ export declare class GatewayClient {
195
195
  icon?: string;
196
196
  color?: string;
197
197
  }): Promise<string>;
198
+ deleteVault(params: {
199
+ vaultId: string;
200
+ confirm: true;
201
+ }): Promise<string>;
198
202
  createTask(params: {
199
203
  title: string;
200
204
  description?: string;
@@ -344,5 +348,21 @@ export declare class GatewayClient {
344
348
  embeddingStatus: string;
345
349
  base64Content: string;
346
350
  }>;
351
+ viewMedia(params: {
352
+ attachmentId: string;
353
+ }): Promise<{
354
+ attachmentId: string;
355
+ memoryId: string | null;
356
+ noteId: string | null;
357
+ modality: string;
358
+ mimeType: string;
359
+ fileName: string | null;
360
+ fileSizeBytes: number;
361
+ embeddingStatus: string;
362
+ extractionStatus: string;
363
+ extractedText: string | null;
364
+ base64Content: string | null;
365
+ contentNote: string | null;
366
+ }>;
347
367
  deleteMedia(attachmentId: string, vaultId?: string): Promise<string>;
348
368
  }
@@ -102,8 +102,8 @@ export class GatewayClient {
102
102
  const result = await this.request("POST", "/api/agent/search-memories", params);
103
103
  return JSON.stringify(result);
104
104
  }
105
- async readMemories(memoryIds) {
106
- const result = await this.request("POST", "/api/agent/read-memories", { memoryIds });
105
+ async readMemories(memoryIds, includeMediaContent) {
106
+ const result = await this.request("POST", "/api/agent/read-memories", { memoryIds, includeMediaContent });
107
107
  return JSON.stringify(result);
108
108
  }
109
109
  async updateMemory(params) {
@@ -145,12 +145,12 @@ export class GatewayClient {
145
145
  const result = await this.request("POST", "/api/agent/list-notes", params);
146
146
  return JSON.stringify(result);
147
147
  }
148
- async readNote(noteId) {
149
- const result = await this.request("POST", "/api/agent/read-note", { noteId });
148
+ async readNote(noteId, includeMediaContent) {
149
+ const result = await this.request("POST", "/api/agent/read-note", { noteId, includeMediaContent });
150
150
  return JSON.stringify(result);
151
151
  }
152
- async readNotes(noteIds) {
153
- const result = await this.request("POST", "/api/agent/read-notes", { noteIds });
152
+ async readNotes(noteIds, includeMediaContent) {
153
+ const result = await this.request("POST", "/api/agent/read-notes", { noteIds, includeMediaContent });
154
154
  return JSON.stringify(result);
155
155
  }
156
156
  async searchNotes(params) {
@@ -191,6 +191,10 @@ export class GatewayClient {
191
191
  const result = await this.request("POST", "/api/agent/create-vault", params);
192
192
  return JSON.stringify(result);
193
193
  }
194
+ async deleteVault(params) {
195
+ const result = await this.request("POST", "/api/agent/delete-vault", params);
196
+ return JSON.stringify(result);
197
+ }
194
198
  // ─── Task operations ─────────────────────────────────────────────────────
195
199
  async createTask(params) {
196
200
  const result = await this.request("POST", "/api/agent/create-task", params);
@@ -323,6 +327,9 @@ export class GatewayClient {
323
327
  async downloadMedia(params) {
324
328
  return this.request("POST", "/api/agent/download-media", params);
325
329
  }
330
+ async viewMedia(params) {
331
+ return this.request("POST", "/api/agent/view-media", params);
332
+ }
326
333
  async deleteMedia(attachmentId, vaultId) {
327
334
  const body = { attachmentId };
328
335
  if (vaultId)
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ import { exploreGraph } from "./tools/explore-graph.js";
30
30
  import { recall } from "./tools/recall.js";
31
31
  import { search } from "./tools/search.js";
32
32
  import { sendMessage, ackMessage, readMessages } from "./tools/agent-messages.js";
33
+ import { viewMedia } from "./tools/view-media.js";
33
34
  // Task tools are thin wrappers around memory tools — no separate agent-tasks import needed
34
35
  import { resolveVaultId } from "./tools/resolve-vault-id.js";
35
36
  import { GatewayClient } from "./gateway-client.js";
@@ -48,7 +49,7 @@ import { scanOrphanedBuffers, deleteBuffer as deleteBufferFile } from "./buffer-
48
49
  import { flushSession } from "./session-flush.js";
49
50
  import { coerceSchema } from "./coerce-params.js";
50
51
  const s = (schema) => coerceSchema(schema);
51
- const MEMORY_TYPES = ["fact", "skill", "preference", "constraint", "task", "episodic", "correction"];
52
+ const MEMORY_TYPES = ["fact", "skill", "preference", "constraint", "task", "episodic", "correction", "decision"];
52
53
  const memoryTypeEnum = z.enum(MEMORY_TYPES);
53
54
  /** Remind agents to checkpoint every N tool calls. */
54
55
  const CHECKPOINT_REMINDER_INTERVAL = 20;
@@ -351,7 +352,7 @@ async function main() {
351
352
  " b. **WRITE on these triggers** — call `write_memory` IMMEDIATELY when:",
352
353
  " - User states a preference, rule, or convention → `preference` or `constraint`",
353
354
  " - You discover a non-obvious fact about the domain/project/topic → `fact` (importance 3-5)",
354
- " - A decision is made (by user or jointly) → `fact` (importance 4-5)",
355
+ " - A decision is made (by user or jointly) → `decision` (importance 4-5, include inputs, reasoning, alternatives, outcome)",
355
356
  " - You solve a problem or learn a procedure → `skill`",
356
357
  " - You hit a surprising gotcha or limitation → `skill` (importance 4)",
357
358
  " - Previous knowledge turns out wrong → `correction` (set supersededById)",
@@ -411,6 +412,20 @@ async function main() {
411
412
  ? await gw.createVault({ name, icon, color })
412
413
  : await createVault(ctx, name, { icon, color });
413
414
  })));
415
+ // ─── delete_vault ──────────────────────────────────────────────────────────
416
+ server.registerTool("delete_vault", {
417
+ description: "Permanently delete a vault and all its notes/folders. Memories are preserved (vault_id set to null). Requires admin scope and confirm: true. Cannot delete the default vault.",
418
+ inputSchema: {
419
+ vaultId: s(z.string().uuid().describe("The vault ID to delete")),
420
+ confirm: s(z.literal(true).describe("Must be true to confirm deletion")),
421
+ },
422
+ }, auto.wrap(wrapToolHandler(async (args) => {
423
+ const { vaultId, confirm } = args;
424
+ if (!gw) {
425
+ return JSON.stringify({ success: false, error: "delete_vault requires gateway mode" });
426
+ }
427
+ return await gw.deleteVault({ vaultId, confirm });
428
+ })));
414
429
  // ─── list_notes ───────────────────────────────────────────────────────────
415
430
  server.registerTool("list_notes", {
416
431
  description: "List notes with titles, tags, and content previews. Optionally filter by vault or folder.",
@@ -430,10 +445,11 @@ async function main() {
430
445
  description: "Read the full decrypted content of a note, including title, tags, and vault name",
431
446
  inputSchema: {
432
447
  noteId: s(z.string().uuid().describe("The note ID to read")),
448
+ includeMediaContent: s(z.boolean().optional().describe("When true, includes base64 image content for images under 500KB. Gateway mode only. Default: false.")),
433
449
  },
434
450
  }, auto.wrap(wrapToolHandler(async (args) => {
435
- const { noteId } = args;
436
- return gw ? await gw.readNote(noteId) : await readNote(ctx, noteId);
451
+ const { noteId, includeMediaContent } = args;
452
+ return gw ? await gw.readNote(noteId, includeMediaContent) : await readNote(ctx, noteId, includeMediaContent);
437
453
  })));
438
454
  // ─── search_notes ─────────────────────────────────────────────────────────
439
455
  server.registerTool("search_notes", {
@@ -548,17 +564,18 @@ async function main() {
548
564
  description: "Read the full decrypted content of multiple notes at once. Returns all notes with their titles, content, tags, and vault names. Reports any IDs that were not found.",
549
565
  inputSchema: {
550
566
  noteIds: s(z.array(z.string().uuid()).min(1).max(20).describe("Array of note IDs to read (max 20)")),
567
+ includeMediaContent: s(z.boolean().optional().describe("When true, includes base64 image content for images under 500KB. Gateway mode only. Default: false.")),
551
568
  },
552
569
  }, auto.wrap(wrapToolHandler(async (args) => {
553
- const { noteIds } = args;
554
- return gw ? await gw.readNotes(noteIds) : await readNotes(ctx, noteIds);
570
+ const { noteIds, includeMediaContent } = args;
571
+ return gw ? await gw.readNotes(noteIds, includeMediaContent) : await readNotes(ctx, noteIds, includeMediaContent);
555
572
  })));
556
573
  // ─── write_memory ───────────────────────────────────────────────────────────
557
574
  server.registerTool("write_memory", {
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.",
575
+ 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), decision (significant choices made — include inputs, reasoning, alternatives considered, outcome; importance 4-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.",
559
576
  inputSchema: {
560
577
  content: s(z.string().min(1).max(1_000_000).describe("Memory content in plain text")),
561
- memoryType: s(memoryTypeEnum.optional().describe("Type: fact, skill, preference, constraint, task, episodic, correction")),
578
+ memoryType: s(memoryTypeEnum.optional().describe("Type: fact, skill, preference, constraint, task, episodic, correction, decision")),
562
579
  summary: s(z.string().max(500).optional().describe("Optional short summary")),
563
580
  vaultId: s(z.string().uuid().optional().describe("Vault/project scope. Required unless defaultVaultId is configured for this MCP session.")),
564
581
  importance: s(z.number().int().min(1).max(5).optional().describe("Importance from 1 to 5")),
@@ -646,10 +663,11 @@ async function main() {
646
663
  description: "Read and decrypt full memory entries by IDs.",
647
664
  inputSchema: {
648
665
  memoryIds: s(z.array(z.string().uuid()).min(1).max(50).describe("Array of memory IDs to read")),
666
+ includeMediaContent: s(z.boolean().optional().describe("When true, includes base64 image content for images under 500KB. Gateway mode only. Default: false.")),
649
667
  },
650
668
  }, auto.wrap(wrapToolHandler(async (args) => {
651
- const { memoryIds } = args;
652
- return gw ? await gw.readMemories(memoryIds) : await readMemories(ctx, memoryIds);
669
+ const { memoryIds, includeMediaContent } = args;
670
+ return gw ? await gw.readMemories(memoryIds, includeMediaContent) : await readMemories(ctx, memoryIds, includeMediaContent);
653
671
  })));
654
672
  // ─── archive_memory ─────────────────────────────────────────────────────────
655
673
  server.registerTool("archive_memory", {
@@ -1260,6 +1278,23 @@ async function main() {
1260
1278
  const result = await gw.downloadMedia({ attachmentId });
1261
1279
  return JSON.stringify(result);
1262
1280
  })));
1281
+ // ─── view_media ─────────────────────────────────────────────────────────
1282
+ server.registerTool("view_media", {
1283
+ description: "View a media attachment intelligently. Returns extracted text for all modalities, " +
1284
+ "plus base64 image content for images under 500KB. For non-image files (PDFs, audio, " +
1285
+ "video), returns the extracted text/transcription instead of raw bytes. " +
1286
+ "Use download_media if you need the actual file bytes regardless of size.",
1287
+ inputSchema: {
1288
+ attachmentId: s(z.string().uuid().describe("Media attachment ID to view")),
1289
+ },
1290
+ }, auto.wrap(wrapToolHandler(async (args) => {
1291
+ const { attachmentId } = args;
1292
+ if (gw) {
1293
+ const result = await gw.viewMedia({ attachmentId });
1294
+ return JSON.stringify(result);
1295
+ }
1296
+ return viewMedia(ctx, attachmentId);
1297
+ })));
1263
1298
  // ─── delete_media ───────────────────────────────────────────────────────
1264
1299
  server.registerTool("delete_media", {
1265
1300
  description: "Delete a media attachment and its embedding. Permanently removes the file from storage.",
@@ -67,7 +67,7 @@ async function main() {
67
67
  }
68
68
  const embeddingConfig = {
69
69
  apiKey,
70
- model: process.env.EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL,
70
+ model: DEFAULT_EMBEDDING_MODEL, // Hardcoded — EMBEDDING_MODEL is for OpenAI text
71
71
  };
72
72
  const candidates = await loadBackfillCandidates(ctx, limit);
73
73
  if (candidates.length === 0) {
@@ -159,11 +159,6 @@ export function buildTurnContent(data) {
159
159
  * using tool call metadata (inputs + outputs).
160
160
  */
161
161
  export function buildRuleBasedEpisodic(data) {
162
- const durationMs = Date.now() - new Date(data.startedAt).getTime();
163
- const durationMin = Math.round(durationMs / 60_000);
164
- const lines = [
165
- `Session: ${durationMin}min, agent=${data.agentId}.`,
166
- ];
167
162
  // Extract search topics
168
163
  const searchTopics = [...new Set(data.activities
169
164
  .filter((a) => a.category === "search" && a.inputSummary)
@@ -173,6 +168,9 @@ export function buildRuleBasedEpisodic(data) {
173
168
  const readTopics = extractReadTopics(data.activities);
174
169
  // Extract write actions with context
175
170
  const writeActions = extractWriteActions(data.activities);
171
+ // Derive a session title from topics/actions
172
+ const title = deriveSessionTitle(searchTopics, readTopics, writeActions);
173
+ const lines = [title];
176
174
  if (searchTopics.length > 0 || readTopics.length > 0) {
177
175
  const explored = [];
178
176
  if (searchTopics.length > 0)
@@ -186,6 +184,28 @@ export function buildRuleBasedEpisodic(data) {
186
184
  }
187
185
  return lines.join("\n");
188
186
  }
187
+ /**
188
+ * Derive a short session title from extracted topics and actions.
189
+ * Returns a concise phrase (no trailing period) summarizing the session focus.
190
+ */
191
+ function deriveSessionTitle(searchTopics, readTopics, writeActions) {
192
+ const allTopics = [...searchTopics, ...readTopics];
193
+ if (allTopics.length > 0) {
194
+ const topicStr = allTopics.slice(0, 2).join(" and ");
195
+ return capitalizeAndClean(topicStr);
196
+ }
197
+ if (writeActions.length > 0) {
198
+ return capitalizeAndClean(writeActions[0]);
199
+ }
200
+ return "Session activity";
201
+ }
202
+ /** Capitalize first letter and strip trailing sentence punctuation. */
203
+ function capitalizeAndClean(phrase) {
204
+ const cleaned = phrase.trim().replace(/[.!?]+$/, "");
205
+ if (!cleaned)
206
+ return "Session activity";
207
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
208
+ }
189
209
  /**
190
210
  * Extract what was read/explored during the session.
191
211
  * Provides context about what the agent was looking at — note titles,
@@ -453,12 +473,10 @@ export function buildTranscriptEpisodic(data) {
453
473
  if (data.transcript.length < MIN_TRANSCRIPT_ENTRIES_FOR_EPISODIC) {
454
474
  return null;
455
475
  }
456
- const durationMs = Date.now() - new Date(data.startedAt).getTime();
457
- const durationMin = Math.round(durationMs / 60_000);
458
- const lines = [
459
- `Session: ${durationMin}min, agent=${data.agentId}.`,
460
- "",
461
- ];
476
+ // Derive title from transcript entries — use first entry as session focus
477
+ const firstContext = data.transcript[0]?.context ?? "Session activity";
478
+ const title = firstContext.charAt(0).toUpperCase() + firstContext.slice(1);
479
+ const lines = [title, ""];
462
480
  for (const entry of data.transcript) {
463
481
  lines.push(`- ${entry.context}`);
464
482
  }
@@ -1,2 +1,2 @@
1
1
  import type { McpContext } from "../auth.js";
2
- export declare function readMemories(ctx: McpContext, memoryIds: string[]): Promise<string>;
2
+ export declare function readMemories(ctx: McpContext, memoryIds: string[], _includeMediaContent?: boolean): Promise<string>;
@@ -2,7 +2,7 @@ import { getMemoriesByIds, touchMemories, getAttachmentsForMemories, formatAttac
2
2
  import { decrypt } from "../crypto.js";
3
3
  import { logMcpUsageEvent } from "../usage.js";
4
4
  import { decryptMemoryFields } from "./decrypt-helpers.js";
5
- export async function readMemories(ctx, memoryIds) {
5
+ export async function readMemories(ctx, memoryIds, _includeMediaContent) {
6
6
  const memories = await getMemoriesByIds(ctx.supabase, ctx.userId, memoryIds);
7
7
  const foundIds = new Set(memories.map((m) => m.id));
8
8
  const notFound = memoryIds.filter((id) => !foundIds.has(id));
@@ -1,2 +1,2 @@
1
1
  import type { McpContext } from "../auth.js";
2
- export declare function readNote(ctx: McpContext, noteId: string): Promise<string>;
2
+ export declare function readNote(ctx: McpContext, noteId: string, _includeMediaContent?: boolean): Promise<string>;
@@ -2,7 +2,7 @@ import { getNote, getVault, getAttachmentsForNotes, formatAttachmentWithExtracti
2
2
  import { decrypt } from "../crypto.js";
3
3
  import { logMcpUsageEvent } from "../usage.js";
4
4
  import { decryptNoteFields } from "./decrypt-helpers.js";
5
- export async function readNote(ctx, noteId) {
5
+ export async function readNote(ctx, noteId, _includeMediaContent) {
6
6
  const note = await getNote(ctx.supabase, ctx.userId, noteId);
7
7
  if (!note) {
8
8
  return JSON.stringify({ error: "Note not found" });
@@ -3,4 +3,4 @@ import type { McpContext } from "../auth.js";
3
3
  * Batch-reads multiple notes by ID. Returns full decrypted content for each,
4
4
  * plus a list of any IDs that were not found.
5
5
  */
6
- export declare function readNotes(ctx: McpContext, noteIds: string[]): Promise<string>;
6
+ export declare function readNotes(ctx: McpContext, noteIds: string[], _includeMediaContent?: boolean): Promise<string>;
@@ -5,7 +5,7 @@ import { decryptNoteFields } from "./decrypt-helpers.js";
5
5
  * Batch-reads multiple notes by ID. Returns full decrypted content for each,
6
6
  * plus a list of any IDs that were not found.
7
7
  */
8
- export async function readNotes(ctx, noteIds) {
8
+ export async function readNotes(ctx, noteIds, _includeMediaContent) {
9
9
  const notes = await getNotesByIds(ctx.supabase, ctx.userId, noteIds);
10
10
  // Build a set of found IDs to report missing ones
11
11
  const foundIds = new Set(notes.map((n) => n.id));
@@ -14,6 +14,7 @@ export declare function updateMemoryTool(ctx: McpContext, input: {
14
14
  relatedMemoryIds?: MemoryRelation[];
15
15
  sourceNoteIds?: string[];
16
16
  isArchived?: boolean;
17
+ supersededById?: string;
17
18
  metadata?: Record<string, unknown>;
18
19
  }): Promise<string>;
19
20
  export {};
@@ -72,6 +72,8 @@ export async function updateMemoryTool(ctx, input) {
72
72
  updates.source_note_ids = input.sourceNoteIds;
73
73
  if (input.isArchived !== undefined)
74
74
  updates.is_archived = input.isArchived;
75
+ if (input.supersededById !== undefined)
76
+ updates.superseded_by_id = input.supersededById;
75
77
  // Merge metadata (task-specific fields like taskStatus, assignedAgentId)
76
78
  if (input.metadata !== undefined) {
77
79
  const existingMeta = (existing.metadata ?? {});
@@ -0,0 +1,6 @@
1
+ import type { McpContext } from "../auth.js";
2
+ /**
3
+ * View a media attachment intelligently.
4
+ * Direct mode: returns extracted text only (no storage access).
5
+ */
6
+ export declare function viewMedia(ctx: McpContext, attachmentId: string): Promise<string>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * View a media attachment intelligently.
3
+ * Direct mode: returns extracted text only (no storage access).
4
+ */
5
+ export async function viewMedia(ctx, attachmentId) {
6
+ const { data, error } = await ctx.supabase
7
+ .from("media_attachments")
8
+ .select("id, memory_id, note_id, vault_id, modality, mime_type, file_name, file_size_bytes, embedding_status, extraction_status, extracted_text, extracted_text_iv")
9
+ .eq("id", attachmentId)
10
+ .eq("user_id", ctx.userId)
11
+ .maybeSingle();
12
+ if (error)
13
+ return JSON.stringify({ error: "Failed to fetch attachment" });
14
+ if (!data)
15
+ return JSON.stringify({ error: "Attachment not found" });
16
+ let extractedText = null;
17
+ if (data.extracted_text && data.extracted_text_iv && ctx.masterKey) {
18
+ const { decrypt } = await import("../crypto.js");
19
+ try {
20
+ extractedText = await decrypt(data.extracted_text, data.extracted_text_iv, ctx.masterKey);
21
+ }
22
+ catch {
23
+ extractedText = null;
24
+ }
25
+ }
26
+ let contentNote = null;
27
+ if (data.modality === "image") {
28
+ contentNote = "base64 unavailable in direct mode — use gateway mode or download_media";
29
+ }
30
+ else {
31
+ const label = data.modality.charAt(0).toUpperCase() + data.modality.slice(1);
32
+ const verb = (data.modality === "audio" || data.modality === "video") ? "transcription" : "extracted text";
33
+ contentNote = `${label} — returning ${verb}. Use download_media for raw bytes.`;
34
+ }
35
+ if (!extractedText && data.extraction_status !== "completed") {
36
+ const note = `Extraction ${data.extraction_status} — no text content available yet.`;
37
+ contentNote = contentNote ? `${contentNote} ${note}` : note;
38
+ }
39
+ return JSON.stringify({
40
+ attachmentId: data.id,
41
+ memoryId: data.memory_id,
42
+ noteId: data.note_id,
43
+ modality: data.modality,
44
+ mimeType: data.mime_type,
45
+ fileName: data.file_name,
46
+ fileSizeBytes: data.file_size_bytes,
47
+ embeddingStatus: data.embedding_status,
48
+ extractionStatus: data.extraction_status,
49
+ extractedText,
50
+ base64Content: null,
51
+ contentNote,
52
+ });
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exovault-mcp-server",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for ExoVault — read, search, and manage encrypted notes from Claude Code",
6
6
  "main": "dist/index.js",