exovault-mcp-server 1.0.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 (305) hide show
  1. package/dist/auth.d.ts +41 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +236 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/auto-session.d.ts +39 -0
  6. package/dist/auto-session.d.ts.map +1 -0
  7. package/dist/auto-session.js +128 -0
  8. package/dist/auto-session.js.map +1 -0
  9. package/dist/buffer-persistence.d.ts +35 -0
  10. package/dist/buffer-persistence.d.ts.map +1 -0
  11. package/dist/buffer-persistence.js +110 -0
  12. package/dist/buffer-persistence.js.map +1 -0
  13. package/dist/coerce-params.d.ts +36 -0
  14. package/dist/coerce-params.d.ts.map +1 -0
  15. package/dist/coerce-params.js +120 -0
  16. package/dist/coerce-params.js.map +1 -0
  17. package/dist/crypto.d.ts +39 -0
  18. package/dist/crypto.d.ts.map +1 -0
  19. package/dist/crypto.js +119 -0
  20. package/dist/crypto.js.map +1 -0
  21. package/dist/db.d.ts +350 -0
  22. package/dist/db.d.ts.map +1 -0
  23. package/dist/db.js +866 -0
  24. package/dist/db.js.map +1 -0
  25. package/dist/embedding-config.d.ts +11 -0
  26. package/dist/embedding-config.d.ts.map +1 -0
  27. package/dist/embedding-config.js +24 -0
  28. package/dist/embedding-config.js.map +1 -0
  29. package/dist/entity-extraction.d.ts +22 -0
  30. package/dist/entity-extraction.d.ts.map +1 -0
  31. package/dist/entity-extraction.js +140 -0
  32. package/dist/entity-extraction.js.map +1 -0
  33. package/dist/episodic-headline.d.ts +6 -0
  34. package/dist/episodic-headline.d.ts.map +1 -0
  35. package/dist/episodic-headline.js +62 -0
  36. package/dist/episodic-headline.js.map +1 -0
  37. package/dist/error-sanitizer.d.ts +20 -0
  38. package/dist/error-sanitizer.d.ts.map +1 -0
  39. package/dist/error-sanitizer.js +54 -0
  40. package/dist/error-sanitizer.js.map +1 -0
  41. package/dist/extraction-budget.d.ts +39 -0
  42. package/dist/extraction-budget.d.ts.map +1 -0
  43. package/dist/extraction-budget.js +122 -0
  44. package/dist/extraction-budget.js.map +1 -0
  45. package/dist/extraction-llm.d.ts +22 -0
  46. package/dist/extraction-llm.d.ts.map +1 -0
  47. package/dist/extraction-llm.js +32 -0
  48. package/dist/extraction-llm.js.map +1 -0
  49. package/dist/extraction-prompt.d.ts +40 -0
  50. package/dist/extraction-prompt.d.ts.map +1 -0
  51. package/dist/extraction-prompt.js +176 -0
  52. package/dist/extraction-prompt.js.map +1 -0
  53. package/dist/gateway-client.d.ts +303 -0
  54. package/dist/gateway-client.d.ts.map +1 -0
  55. package/dist/gateway-client.js +285 -0
  56. package/dist/gateway-client.js.map +1 -0
  57. package/dist/gateway-init.d.ts +32 -0
  58. package/dist/gateway-init.d.ts.map +1 -0
  59. package/dist/gateway-init.js +71 -0
  60. package/dist/gateway-init.js.map +1 -0
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +1242 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/infer-task-status.d.ts +7 -0
  66. package/dist/infer-task-status.d.ts.map +1 -0
  67. package/dist/infer-task-status.js +23 -0
  68. package/dist/infer-task-status.js.map +1 -0
  69. package/dist/normalize-agent-id.d.ts +21 -0
  70. package/dist/normalize-agent-id.d.ts.map +1 -0
  71. package/dist/normalize-agent-id.js +54 -0
  72. package/dist/normalize-agent-id.js.map +1 -0
  73. package/dist/openai.d.ts +14 -0
  74. package/dist/openai.d.ts.map +1 -0
  75. package/dist/openai.js +43 -0
  76. package/dist/openai.js.map +1 -0
  77. package/dist/rlm/actions.d.ts +31 -0
  78. package/dist/rlm/actions.d.ts.map +1 -0
  79. package/dist/rlm/actions.js +241 -0
  80. package/dist/rlm/actions.js.map +1 -0
  81. package/dist/rlm/benchmark.d.ts +2 -0
  82. package/dist/rlm/benchmark.d.ts.map +1 -0
  83. package/dist/rlm/benchmark.js +215 -0
  84. package/dist/rlm/benchmark.js.map +1 -0
  85. package/dist/rlm/execute.d.ts +13 -0
  86. package/dist/rlm/execute.d.ts.map +1 -0
  87. package/dist/rlm/execute.js +366 -0
  88. package/dist/rlm/execute.js.map +1 -0
  89. package/dist/rlm/index.d.ts +6 -0
  90. package/dist/rlm/index.d.ts.map +1 -0
  91. package/dist/rlm/index.js +147 -0
  92. package/dist/rlm/index.js.map +1 -0
  93. package/dist/rlm/profiles.d.ts +9 -0
  94. package/dist/rlm/profiles.d.ts.map +1 -0
  95. package/dist/rlm/profiles.js +46 -0
  96. package/dist/rlm/profiles.js.map +1 -0
  97. package/dist/rlm/types.d.ts +98 -0
  98. package/dist/rlm/types.d.ts.map +1 -0
  99. package/dist/rlm/types.js +6 -0
  100. package/dist/rlm/types.js.map +1 -0
  101. package/dist/rlm/verify.d.ts +13 -0
  102. package/dist/rlm/verify.d.ts.map +1 -0
  103. package/dist/rlm/verify.js +58 -0
  104. package/dist/rlm/verify.js.map +1 -0
  105. package/dist/rlm/writeback.d.ts +7 -0
  106. package/dist/rlm/writeback.d.ts.map +1 -0
  107. package/dist/rlm/writeback.js +77 -0
  108. package/dist/rlm/writeback.js.map +1 -0
  109. package/dist/scripts/backfill-memory-embeddings.d.ts +2 -0
  110. package/dist/scripts/backfill-memory-embeddings.d.ts.map +1 -0
  111. package/dist/scripts/backfill-memory-embeddings.js +153 -0
  112. package/dist/scripts/backfill-memory-embeddings.js.map +1 -0
  113. package/dist/session-buffer.d.ts +104 -0
  114. package/dist/session-buffer.d.ts.map +1 -0
  115. package/dist/session-buffer.js +466 -0
  116. package/dist/session-buffer.js.map +1 -0
  117. package/dist/session-dedup.d.ts +30 -0
  118. package/dist/session-dedup.d.ts.map +1 -0
  119. package/dist/session-dedup.js +67 -0
  120. package/dist/session-dedup.js.map +1 -0
  121. package/dist/session-flush.d.ts +81 -0
  122. package/dist/session-flush.d.ts.map +1 -0
  123. package/dist/session-flush.js +169 -0
  124. package/dist/session-flush.js.map +1 -0
  125. package/dist/session-lifecycle.d.ts +72 -0
  126. package/dist/session-lifecycle.d.ts.map +1 -0
  127. package/dist/session-lifecycle.js +247 -0
  128. package/dist/session-lifecycle.js.map +1 -0
  129. package/dist/setup.d.ts +2 -0
  130. package/dist/setup.d.ts.map +1 -0
  131. package/dist/setup.js +260 -0
  132. package/dist/setup.js.map +1 -0
  133. package/dist/stopwords.d.ts +2 -0
  134. package/dist/stopwords.d.ts.map +1 -0
  135. package/dist/stopwords.js +20 -0
  136. package/dist/stopwords.js.map +1 -0
  137. package/dist/strip-html.d.ts +5 -0
  138. package/dist/strip-html.d.ts.map +1 -0
  139. package/dist/strip-html.js +35 -0
  140. package/dist/strip-html.js.map +1 -0
  141. package/dist/task-completion-flush.d.ts +36 -0
  142. package/dist/task-completion-flush.d.ts.map +1 -0
  143. package/dist/task-completion-flush.js +97 -0
  144. package/dist/task-completion-flush.js.map +1 -0
  145. package/dist/task-lifecycle-types.d.ts +13 -0
  146. package/dist/task-lifecycle-types.d.ts.map +1 -0
  147. package/dist/task-lifecycle-types.js +12 -0
  148. package/dist/task-lifecycle-types.js.map +1 -0
  149. package/dist/task-lifecycle.d.ts +78 -0
  150. package/dist/task-lifecycle.d.ts.map +1 -0
  151. package/dist/task-lifecycle.js +256 -0
  152. package/dist/task-lifecycle.js.map +1 -0
  153. package/dist/tools/agent-messages.d.ts +26 -0
  154. package/dist/tools/agent-messages.d.ts.map +1 -0
  155. package/dist/tools/agent-messages.js +123 -0
  156. package/dist/tools/agent-messages.js.map +1 -0
  157. package/dist/tools/agent-tasks.d.ts +24 -0
  158. package/dist/tools/agent-tasks.d.ts.map +1 -0
  159. package/dist/tools/agent-tasks.js +162 -0
  160. package/dist/tools/agent-tasks.js.map +1 -0
  161. package/dist/tools/archive-memory.d.ts +2 -0
  162. package/dist/tools/archive-memory.d.ts.map +1 -0
  163. package/dist/tools/archive-memory.js +19 -0
  164. package/dist/tools/archive-memory.js.map +1 -0
  165. package/dist/tools/blind-index.d.ts +29 -0
  166. package/dist/tools/blind-index.d.ts.map +1 -0
  167. package/dist/tools/blind-index.js +53 -0
  168. package/dist/tools/blind-index.js.map +1 -0
  169. package/dist/tools/cleanup-memories.d.ts +44 -0
  170. package/dist/tools/cleanup-memories.d.ts.map +1 -0
  171. package/dist/tools/cleanup-memories.js +126 -0
  172. package/dist/tools/cleanup-memories.js.map +1 -0
  173. package/dist/tools/context-checkpoint.d.ts +28 -0
  174. package/dist/tools/context-checkpoint.d.ts.map +1 -0
  175. package/dist/tools/context-checkpoint.js +140 -0
  176. package/dist/tools/context-checkpoint.js.map +1 -0
  177. package/dist/tools/context-profiles.d.ts +67 -0
  178. package/dist/tools/context-profiles.d.ts.map +1 -0
  179. package/dist/tools/context-profiles.js +30 -0
  180. package/dist/tools/context-profiles.js.map +1 -0
  181. package/dist/tools/create-note.d.ts +2 -0
  182. package/dist/tools/create-note.d.ts.map +1 -0
  183. package/dist/tools/create-note.js +60 -0
  184. package/dist/tools/create-note.js.map +1 -0
  185. package/dist/tools/create-vault.d.ts +5 -0
  186. package/dist/tools/create-vault.d.ts.map +1 -0
  187. package/dist/tools/create-vault.js +121 -0
  188. package/dist/tools/create-vault.js.map +1 -0
  189. package/dist/tools/decrypt-helpers.d.ts +31 -0
  190. package/dist/tools/decrypt-helpers.d.ts.map +1 -0
  191. package/dist/tools/decrypt-helpers.js +33 -0
  192. package/dist/tools/decrypt-helpers.js.map +1 -0
  193. package/dist/tools/delete-note.d.ts +2 -0
  194. package/dist/tools/delete-note.d.ts.map +1 -0
  195. package/dist/tools/delete-note.js +21 -0
  196. package/dist/tools/delete-note.js.map +1 -0
  197. package/dist/tools/explore-graph.d.ts +11 -0
  198. package/dist/tools/explore-graph.d.ts.map +1 -0
  199. package/dist/tools/explore-graph.js +169 -0
  200. package/dist/tools/explore-graph.js.map +1 -0
  201. package/dist/tools/get-related-memories.d.ts +2 -0
  202. package/dist/tools/get-related-memories.d.ts.map +1 -0
  203. package/dist/tools/get-related-memories.js +59 -0
  204. package/dist/tools/get-related-memories.js.map +1 -0
  205. package/dist/tools/knowledge-links.d.ts +17 -0
  206. package/dist/tools/knowledge-links.d.ts.map +1 -0
  207. package/dist/tools/knowledge-links.js +102 -0
  208. package/dist/tools/knowledge-links.js.map +1 -0
  209. package/dist/tools/list-active-agents.d.ts +5 -0
  210. package/dist/tools/list-active-agents.d.ts.map +1 -0
  211. package/dist/tools/list-active-agents.js +15 -0
  212. package/dist/tools/list-active-agents.js.map +1 -0
  213. package/dist/tools/list-notes.d.ts +2 -0
  214. package/dist/tools/list-notes.d.ts.map +1 -0
  215. package/dist/tools/list-notes.js +19 -0
  216. package/dist/tools/list-notes.js.map +1 -0
  217. package/dist/tools/list-vaults.d.ts +2 -0
  218. package/dist/tools/list-vaults.d.ts.map +1 -0
  219. package/dist/tools/list-vaults.js +19 -0
  220. package/dist/tools/list-vaults.js.map +1 -0
  221. package/dist/tools/mmr.d.ts +18 -0
  222. package/dist/tools/mmr.d.ts.map +1 -0
  223. package/dist/tools/mmr.js +67 -0
  224. package/dist/tools/mmr.js.map +1 -0
  225. package/dist/tools/read-memories.d.ts +2 -0
  226. package/dist/tools/read-memories.d.ts.map +1 -0
  227. package/dist/tools/read-memories.js +46 -0
  228. package/dist/tools/read-memories.js.map +1 -0
  229. package/dist/tools/read-note.d.ts +2 -0
  230. package/dist/tools/read-note.d.ts.map +1 -0
  231. package/dist/tools/read-note.js +35 -0
  232. package/dist/tools/read-note.js.map +1 -0
  233. package/dist/tools/read-notes.d.ts +6 -0
  234. package/dist/tools/read-notes.d.ts.map +1 -0
  235. package/dist/tools/read-notes.js +45 -0
  236. package/dist/tools/read-notes.js.map +1 -0
  237. package/dist/tools/resolve-vault-id.d.ts +6 -0
  238. package/dist/tools/resolve-vault-id.d.ts.map +1 -0
  239. package/dist/tools/resolve-vault-id.js +7 -0
  240. package/dist/tools/resolve-vault-id.js.map +1 -0
  241. package/dist/tools/rrf.d.ts +28 -0
  242. package/dist/tools/rrf.d.ts.map +1 -0
  243. package/dist/tools/rrf.js +19 -0
  244. package/dist/tools/rrf.js.map +1 -0
  245. package/dist/tools/search-and-read.d.ts +11 -0
  246. package/dist/tools/search-and-read.d.ts.map +1 -0
  247. package/dist/tools/search-and-read.js +208 -0
  248. package/dist/tools/search-and-read.js.map +1 -0
  249. package/dist/tools/search-memories.d.ts +13 -0
  250. package/dist/tools/search-memories.d.ts.map +1 -0
  251. package/dist/tools/search-memories.js +272 -0
  252. package/dist/tools/search-memories.js.map +1 -0
  253. package/dist/tools/search-notes.d.ts +2 -0
  254. package/dist/tools/search-notes.d.ts.map +1 -0
  255. package/dist/tools/search-notes.js +94 -0
  256. package/dist/tools/search-notes.js.map +1 -0
  257. package/dist/tools/semantic-search.d.ts +7 -0
  258. package/dist/tools/semantic-search.d.ts.map +1 -0
  259. package/dist/tools/semantic-search.js +85 -0
  260. package/dist/tools/semantic-search.js.map +1 -0
  261. package/dist/tools/session-start.d.ts +24 -0
  262. package/dist/tools/session-start.d.ts.map +1 -0
  263. package/dist/tools/session-start.js +256 -0
  264. package/dist/tools/session-start.js.map +1 -0
  265. package/dist/tools/stale-tasks.d.ts +22 -0
  266. package/dist/tools/stale-tasks.d.ts.map +1 -0
  267. package/dist/tools/stale-tasks.js +39 -0
  268. package/dist/tools/stale-tasks.js.map +1 -0
  269. package/dist/tools/temporal-decay.d.ts +21 -0
  270. package/dist/tools/temporal-decay.d.ts.map +1 -0
  271. package/dist/tools/temporal-decay.js +32 -0
  272. package/dist/tools/temporal-decay.js.map +1 -0
  273. package/dist/tools/update-memory.d.ts +19 -0
  274. package/dist/tools/update-memory.d.ts.map +1 -0
  275. package/dist/tools/update-memory.js +230 -0
  276. package/dist/tools/update-memory.js.map +1 -0
  277. package/dist/tools/update-note.d.ts +2 -0
  278. package/dist/tools/update-note.d.ts.map +1 -0
  279. package/dist/tools/update-note.js +79 -0
  280. package/dist/tools/update-note.js.map +1 -0
  281. package/dist/tools/vault-instruction-template.d.ts +17 -0
  282. package/dist/tools/vault-instruction-template.d.ts.map +1 -0
  283. package/dist/tools/vault-instruction-template.js +77 -0
  284. package/dist/tools/vault-instruction-template.js.map +1 -0
  285. package/dist/tools/wiki-link-sync.d.ts +34 -0
  286. package/dist/tools/wiki-link-sync.d.ts.map +1 -0
  287. package/dist/tools/wiki-link-sync.js +132 -0
  288. package/dist/tools/wiki-link-sync.js.map +1 -0
  289. package/dist/tools/wrap-tool-handler.d.ts +8 -0
  290. package/dist/tools/wrap-tool-handler.d.ts.map +1 -0
  291. package/dist/tools/wrap-tool-handler.js +32 -0
  292. package/dist/tools/wrap-tool-handler.js.map +1 -0
  293. package/dist/tools/write-memory.d.ts +34 -0
  294. package/dist/tools/write-memory.d.ts.map +1 -0
  295. package/dist/tools/write-memory.js +359 -0
  296. package/dist/tools/write-memory.js.map +1 -0
  297. package/dist/usage.d.ts +11 -0
  298. package/dist/usage.d.ts.map +1 -0
  299. package/dist/usage.js +38 -0
  300. package/dist/usage.js.map +1 -0
  301. package/dist/wiki-link-parser.d.ts +27 -0
  302. package/dist/wiki-link-parser.d.ts.map +1 -0
  303. package/dist/wiki-link-parser.js +93 -0
  304. package/dist/wiki-link-parser.js.map +1 -0
  305. package/package.json +38 -0
package/dist/index.js ADDED
@@ -0,0 +1,1242 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { initialize, readConfig } from "./auth.js";
7
+ import { listVaults } from "./tools/list-vaults.js";
8
+ import { createVault } from "./tools/create-vault.js";
9
+ import { listNotes } from "./tools/list-notes.js";
10
+ import { readNote } from "./tools/read-note.js";
11
+ import { readNotes } from "./tools/read-notes.js";
12
+ 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
+ import { createNote } from "./tools/create-note.js";
16
+ import { updateNote } from "./tools/update-note.js";
17
+ import { deleteNote } from "./tools/delete-note.js";
18
+ import { writeMemory } from "./tools/write-memory.js";
19
+ import { searchMemories } from "./tools/search-memories.js";
20
+ import { readMemories } from "./tools/read-memories.js";
21
+ import { archiveMemory } from "./tools/archive-memory.js";
22
+ import { getRelatedMemories } from "./tools/get-related-memories.js";
23
+ import { contextCheckpoint } from "./tools/context-checkpoint.js";
24
+ import { listActiveAgents } from "./tools/list-active-agents.js";
25
+ import { sessionStart } from "./tools/session-start.js";
26
+ import { updateMemoryTool } from "./tools/update-memory.js";
27
+ import { cleanupMemories } from "./tools/cleanup-memories.js";
28
+ import { getLinks, addLink, removeLink } from "./tools/knowledge-links.js";
29
+ import { exploreGraph } from "./tools/explore-graph.js";
30
+ import { sendMessage, ackMessage, readMessages } from "./tools/agent-messages.js";
31
+ // Task tools are thin wrappers around memory tools — no separate agent-tasks import needed
32
+ import { resolveVaultId } from "./tools/resolve-vault-id.js";
33
+ import { GatewayClient } from "./gateway-client.js";
34
+ import { resolveGatewayConfig } from "./gateway-init.js";
35
+ import { normalizeAgentId } from "./normalize-agent-id.js";
36
+ import { inferTaskStatus } from "./infer-task-status.js";
37
+ import { createAutoSession } from "./auto-session.js";
38
+ import { wrapToolHandler } from "./tools/wrap-tool-handler.js";
39
+ import { createSessionLifecycle } from "./session-lifecycle.js";
40
+ import { extractToolContext } from "./session-buffer.js";
41
+ import { createExtractionClient } from "./extraction-llm.js";
42
+ import { readBudget, writeBudget } from "./extraction-budget.js";
43
+ import { DEFAULT_TASK_LIFECYCLE_SETTINGS } from "./task-lifecycle-types.js";
44
+ import { buildPlanTasksPrompt, parsePlanTasksResult } from "./task-lifecycle.js";
45
+ import { scanOrphanedBuffers, deleteBuffer as deleteBufferFile } from "./buffer-persistence.js";
46
+ import { flushSession } from "./session-flush.js";
47
+ import { coerceSchema } from "./coerce-params.js";
48
+ const s = (schema) => coerceSchema(schema);
49
+ const MEMORY_TYPES = ["fact", "skill", "preference", "constraint", "task", "episodic", "correction"];
50
+ const memoryTypeEnum = z.enum(MEMORY_TYPES);
51
+ /** Remind agents to checkpoint every N tool calls. */
52
+ const CHECKPOINT_REMINDER_INTERVAL = 20;
53
+ async function main() {
54
+ // ─── Detect mode: gateway (agent key) or direct (Supabase) ─────────────
55
+ const config = await readConfig();
56
+ const gwConfig = resolveGatewayConfig(config);
57
+ let ctx = null;
58
+ let gw = null;
59
+ let defaultVaultId = config.defaultVaultId;
60
+ let allowedVaultIds;
61
+ let gwAgentType;
62
+ 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();
67
+ if (gwConfig) {
68
+ gw = new GatewayClient(gwConfig.apiUrl, gwConfig.agentKey, sessionRunId);
69
+ try {
70
+ const info = await gw.info();
71
+ gwAgentType = info.agentType;
72
+ gwAgentLabel = info.label;
73
+ process.stderr.write(`[exovault-mcp] Gateway mode: connected as "${gwAgentLabel}" (${gwAgentType}) to ${gwConfig.apiUrl}\n`);
74
+ // Set allowed vaults and determine default
75
+ allowedVaultIds = info.allowedVaultIds ?? undefined;
76
+ if (!defaultVaultId) {
77
+ if (info.restrictToVault && allowedVaultIds?.length === 1) {
78
+ defaultVaultId = allowedVaultIds[0];
79
+ }
80
+ else if (info.restrictToVault && info.vaultId) {
81
+ defaultVaultId = info.vaultId;
82
+ }
83
+ }
84
+ if (allowedVaultIds && allowedVaultIds.length > 1) {
85
+ process.stderr.write(`[exovault-mcp] Multi-vault access: ${allowedVaultIds.length} vaults. Specify vaultId per tool call.\n`);
86
+ }
87
+ // Auto-register session on connection — creates an active session
88
+ // immediately so the dashboard shows the agent even before any tools are called.
89
+ void gw.trackSession({
90
+ toolName: "_connection_start",
91
+ agentRunId: sessionRunId,
92
+ agentId: gwAgentLabel ?? "mcp_stdio",
93
+ agentType: gwAgentType,
94
+ agentLabel: gwAgentLabel,
95
+ vaultId: defaultVaultId,
96
+ });
97
+ }
98
+ catch (e) {
99
+ // Agent key is configured — gateway failure is fatal.
100
+ // Falling back to direct mode would bypass vault scope restrictions
101
+ // enforced by the gateway, which is a security risk.
102
+ throw new Error(`Gateway connection failed: ${e.message}. ` +
103
+ `Cannot fall back to direct mode when agent key is configured (vault scope restrictions would be bypassed). ` +
104
+ `Check your EXOVAULT_API_URL and EXOVAULT_AGENT_KEY settings.`);
105
+ }
106
+ }
107
+ if (!gw) {
108
+ ctx = await initialize();
109
+ defaultVaultId = ctx.defaultVaultId;
110
+ process.stderr.write("[exovault-mcp] Direct mode: connected to Supabase\n");
111
+ }
112
+ process.stderr.write(`[exovault-mcp] Session ID: ${sessionRunId}\n`);
113
+ // Gateway-mode vault resolver: uses defaultVaultId from config
114
+ function resolveVault(requestedVaultId) {
115
+ return requestedVaultId ?? defaultVaultId;
116
+ }
117
+ // ─── Auto-session inject ──────────────────────────────────────────────────
118
+ // Ensures agents get session context on their first tool call, even if they
119
+ // don't explicitly call session_start. This is the market-standard pattern:
120
+ // pre-inject context rather than relying on the LLM to remember to fetch it.
121
+ const auto = createAutoSession(async () => {
122
+ const result = gw
123
+ ? await gw.sessionStart({ vaultId: resolveVault() })
124
+ : await sessionStart(ctx, { vaultId: resolveVaultId(ctx) });
125
+ return result;
126
+ }, (msg) => process.stderr.write(`${msg}\n`));
127
+ // ─── LLM extraction client (optional — only when LLM config is present) ──
128
+ const extractionClient = config.llmApiKey && config.llmBaseUrl && config.llmModelId
129
+ ? createExtractionClient({
130
+ apiKey: config.llmApiKey,
131
+ baseUrl: config.llmBaseUrl,
132
+ modelId: config.llmModelId,
133
+ })
134
+ : undefined;
135
+ if (extractionClient) {
136
+ process.stderr.write("[exovault-mcp] LLM extraction enabled for session auto-flush\n");
137
+ }
138
+ // ─── Session lifecycle (auto-flush on idle / disconnect / signal) ─────────
139
+ const lifecycleAgentId = gwAgentLabel ?? normalizeAgentId(gwAgentType) ?? "mcp_stdio";
140
+ const lifecycle = createSessionLifecycle({
141
+ agentRunId: sessionRunId,
142
+ agentId: lifecycleAgentId,
143
+ vaultId: defaultVaultId ?? "",
144
+ idleTimeoutMs: 5 * 60 * 1000, // 5 minutes
145
+ minToolCalls: 5,
146
+ skipIfMemoriesWritten: 3,
147
+ // In gateway mode, the Inngest cron (session-auto-checkpoint) owns episodic
148
+ // writing — it has access to conversation turns for LLM summarization.
149
+ // The MCP server only sees tool call metadata, not actual conversation context,
150
+ // so its episodic summaries are thin and duplicative. Skip here, let the cron handle it.
151
+ checkpointFn: gw
152
+ ? async () => "skipped — gateway cron handles episodic"
153
+ : async (params) => {
154
+ return await contextCheckpoint(ctx, {
155
+ ...params,
156
+ vaultId: resolveVaultId(ctx, params.vaultId),
157
+ });
158
+ },
159
+ log: (msg) => process.stderr.write(`${msg}\n`),
160
+ onIdle: () => {
161
+ lifecycle.flush("idle").then(() => {
162
+ process.stderr.write("[exovault-mcp] Idle flush complete — process remains alive\n");
163
+ }).catch((e) => {
164
+ process.stderr.write(`[exovault-mcp] Idle flush failed: ${e.message}\n`);
165
+ });
166
+ },
167
+ // In gateway mode, skip LLM extraction — the Inngest cron handles episodic
168
+ // writing and task completion server-side. No point burning DeepSeek tokens
169
+ // here when checkpointFn is a no-op.
170
+ extractionClient: gw ? undefined : extractionClient,
171
+ readBudget: gw ? undefined : readBudget,
172
+ writeBudget: gw ? undefined : writeBudget,
173
+ // Task lifecycle: fetch open tasks + auto-complete at flush time
174
+ taskLifecycleSettings: DEFAULT_TASK_LIFECYCLE_SETTINGS,
175
+ fetchOpenTasks: async () => {
176
+ try {
177
+ if (gw) {
178
+ const raw = await gw.listTasks({
179
+ vaultId: resolveVault(),
180
+ status: "in_progress",
181
+ });
182
+ const parsed = JSON.parse(raw);
183
+ return (parsed.tasks ?? []).map((t) => ({
184
+ id: t.id,
185
+ title: t.title,
186
+ doneWhen: t.doneWhen,
187
+ status: t.status,
188
+ }));
189
+ }
190
+ // Direct mode: tasks require decryption which we can't do here
191
+ return [];
192
+ }
193
+ catch {
194
+ return [];
195
+ }
196
+ },
197
+ updateTaskFn: async (taskId, updates) => {
198
+ const metaUpdate = {
199
+ taskStatus: updates.status,
200
+ completionSource: updates.completionSource,
201
+ completionEvidence: updates.completionEvidence,
202
+ };
203
+ if (gw) {
204
+ await gw.updateTask({ taskId, status: updates.status });
205
+ return;
206
+ }
207
+ await updateMemoryTool(ctx, { memoryId: taskId, metadata: metaUpdate });
208
+ },
209
+ createCompletedTaskFn: async (task) => {
210
+ if (gw) {
211
+ try {
212
+ const result = await gw.createTask({
213
+ title: task.title,
214
+ description: task.evidence,
215
+ status: "done",
216
+ priority: 3,
217
+ vaultId: resolveVault(),
218
+ doneWhen: task.doneWhen,
219
+ agentId: "task-lifecycle",
220
+ agentRunId: sessionRunId,
221
+ });
222
+ const parsed = JSON.parse(result);
223
+ return parsed.taskId ?? null;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ return null;
230
+ },
231
+ writeTaskSuggestionFn: async (content) => {
232
+ if (gw) {
233
+ await gw.writeMemory({
234
+ content,
235
+ memoryType: "fact",
236
+ importance: 2,
237
+ vaultId: resolveVault(),
238
+ agentId: "task-lifecycle",
239
+ agentRunId: sessionRunId,
240
+ });
241
+ }
242
+ },
243
+ // Auto-ingest session activity as a conversation turn for extraction pipeline.
244
+ // Use signalThreshold: 0 so ALL auto-ingested turns get flagged for extraction.
245
+ // Session lifecycle already filters to meaningful activity (min tool calls, non-passive),
246
+ // so signal detection is redundant here — let the LLM decide what's worth extracting.
247
+ ingestTurnFn: gw
248
+ ? async (content) => {
249
+ await gw.ingestTurn({
250
+ content,
251
+ role: "assistant",
252
+ vaultId: defaultVaultId,
253
+ agentId: lifecycleAgentId,
254
+ agentRunId: sessionRunId,
255
+ signalThreshold: 0,
256
+ });
257
+ }
258
+ : undefined,
259
+ ingestIntervalMs: 5 * 60 * 1000, // 5 minutes — aligned with extraction cron interval
260
+ });
261
+ /**
262
+ * Lifecycle tracking wrapper — records each tool call in the session buffer.
263
+ * Chains as the outermost layer: ltrack("name", auto.wrap(wrapToolHandler(fn)))
264
+ */
265
+ function ltrack(toolName, handler) {
266
+ return async (args) => {
267
+ const inputStr = typeof args === "object" && args !== null
268
+ ? JSON.stringify(args).slice(0, 200)
269
+ : undefined;
270
+ const result = await handler(args);
271
+ lifecycle.onToolCall(toolName, inputStr, result.content?.[0]?.text?.slice(0, 200));
272
+ // Record rich transcript entry for auto-episodic generation
273
+ if (!result.isError && typeof args === "object" && args !== null) {
274
+ const ctx = extractToolContext(toolName, args, result.content?.[0]?.text);
275
+ if (ctx)
276
+ lifecycle.onTranscript(toolName, ctx);
277
+ }
278
+ // Auto-detect memory writes from write_memory / create_task results
279
+ if (!result.isError && (toolName === "write_memory" || toolName === "create_task")) {
280
+ try {
281
+ const parsed = JSON.parse(result.content[0].text);
282
+ if (parsed.memoryId)
283
+ lifecycle.onMemoryWritten(parsed.memoryId);
284
+ }
285
+ catch { /* ignore parse errors */ }
286
+ }
287
+ // Gateway session tracking — fire-and-forget for dashboard visibility
288
+ if (gw) {
289
+ const outputStr = result.content?.[0]?.text?.slice(0, 200);
290
+ void gw.trackSession({
291
+ toolName,
292
+ agentRunId: sessionRunId,
293
+ agentId: gwAgentLabel ?? "mcp_stdio",
294
+ agentType: gwAgentType,
295
+ agentLabel: gwAgentLabel,
296
+ vaultId: defaultVaultId,
297
+ inputSummary: inputStr,
298
+ outputSummary: outputStr,
299
+ });
300
+ }
301
+ // Periodic checkpoint reminder — nudge agent every CHECKPOINT_REMINDER_INTERVAL calls
302
+ const count = lifecycle.getToolCallCount();
303
+ if (count > 0 &&
304
+ count % CHECKPOINT_REMINDER_INTERVAL === 0 &&
305
+ !lifecycle.wasCheckpointed() &&
306
+ toolName !== "context_checkpoint" &&
307
+ toolName !== "session_start") {
308
+ const reminder = `\n\n---\nNote: You have made ${count} tool calls this session. Call \`context_checkpoint({ sessionSummary: "..." })\` to save your progress.`;
309
+ if (result.content?.[0]?.text) {
310
+ result.content[0].text += reminder;
311
+ }
312
+ }
313
+ return result;
314
+ };
315
+ }
316
+ // Build dynamic instructions — target ≤3,500 chars (v5).
317
+ // Full reference in instructions.md, returned once per session via session_start.
318
+ const instructionLines = [
319
+ "ExoVault — encrypted notes and durable agent memory.",
320
+ "",
321
+ "## Session Lifecycle (MANDATORY)",
322
+ "1. ON START: Call `session_start` immediately. If `isFirstConnection`, follow soul.md first-connection instructions.",
323
+ "2. DURING SESSION — MANDATORY, do these without being asked:",
324
+ " a. **SEARCH before acting**: `search_memories` before answering questions or making decisions. `explore_graph` for deep context.",
325
+ " b. **WRITE on these triggers** — call `write_memory` IMMEDIATELY when:",
326
+ " - User states a preference, rule, or convention → `preference` or `constraint`",
327
+ " - You discover a non-obvious fact about the domain/project/topic → `fact` (importance 3-5)",
328
+ " - A decision is made (by user or jointly) → `fact` (importance 4-5)",
329
+ " - You solve a problem or learn a procedure → `skill`",
330
+ " - You hit a surprising gotcha or limitation → `skill` (importance 4)",
331
+ " - Previous knowledge turns out wrong → `correction` (set supersededById)",
332
+ " - Follow-up work surfaces → `create_task` with doneWhen",
333
+ " c. **Tasks**: `update_task(status='done')` IMMEDIATELY when complete. `create_task` for follow-ups.",
334
+ " d. **PERIODIC CHECKPOINT**: Call `context_checkpoint` with a `sessionSummary` every ~20 tool calls or after completing a significant milestone. This saves your progress incrementally — do not wait until the end.",
335
+ "3. ON END: Update all tasks → call `context_checkpoint` with a final `sessionSummary` describing what happened, decisions made, and open threads. Do NOT write a separate episodic memory — the checkpoint creates it from your summary.",
336
+ "",
337
+ ];
338
+ if (defaultVaultId) {
339
+ instructionLines.push(`Default vault: ${defaultVaultId} (auto-applied to all tools).`);
340
+ }
341
+ if (allowedVaultIds && allowedVaultIds.length > 1) {
342
+ instructionLines.push(`Allowed vaults: ${allowedVaultIds.join(", ")}. Specify vaultId in tool calls to target a specific vault.`);
343
+ }
344
+ if (gw) {
345
+ instructionLines.push("Running in gateway mode. Turn ingestion is automatic.");
346
+ }
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).");
348
+ const instructionsText = instructionLines.join("\n");
349
+ // ── Guard: keep instructions ≤4,000 chars ─────────────────────────
350
+ const INSTRUCTION_CHAR_LIMIT = 4_000;
351
+ if (instructionsText.length > INSTRUCTION_CHAR_LIMIT) {
352
+ console.error(`[exovault] WARNING: Server instructions are ${instructionsText.length} chars ` +
353
+ `(limit ${INSTRUCTION_CHAR_LIMIT}). Slim them down to avoid token bloat.`);
354
+ }
355
+ const server = new McpServer({
356
+ name: "exovault",
357
+ version: "1.0.0",
358
+ }, {
359
+ instructions: instructionsText,
360
+ });
361
+ // Intercept registerTool to auto-track all tool calls via session lifecycle.
362
+ // This avoids wrapping each of the 30+ tool registrations individually.
363
+ const _origRegisterTool = server.registerTool.bind(server);
364
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
365
+ server.registerTool = (name, opts, handler) => {
366
+ return _origRegisterTool(name, opts, ltrack(name, handler));
367
+ };
368
+ // ─── list_vaults ──────────────────────────────────────────────────────────
369
+ server.registerTool("list_vaults", {
370
+ description: "List all vaults with their names, icons, colors, and note counts",
371
+ }, auto.wrap(wrapToolHandler(async () => {
372
+ return gw ? await gw.listVaults() : await listVaults(ctx);
373
+ })));
374
+ // ─── create_vault ──────────────────────────────────────────────────────────
375
+ server.registerTool("create_vault", {
376
+ description: "Create a new encrypted vault (idempotent by vault name).",
377
+ inputSchema: {
378
+ name: s(z.string().min(1).describe("Vault name")),
379
+ icon: s(z.string().optional().describe("Optional icon identifier")),
380
+ color: s(z.string().optional().describe("Optional color hex")),
381
+ },
382
+ }, auto.wrap(wrapToolHandler(async (args) => {
383
+ const { name, icon, color } = args;
384
+ return gw
385
+ ? await gw.createVault({ name, icon, color })
386
+ : await createVault(ctx, name, { icon, color });
387
+ })));
388
+ // ─── list_notes ───────────────────────────────────────────────────────────
389
+ server.registerTool("list_notes", {
390
+ description: "List notes with titles, tags, and content previews. Optionally filter by vault or folder.",
391
+ inputSchema: {
392
+ vaultId: s(z.string().uuid().optional().describe("Filter by vault ID")),
393
+ folderId: s(z.string().uuid().optional().describe("Filter by folder ID")),
394
+ limit: s(z.number().int().min(1).max(100).optional().describe("Max notes to return (default 20)")),
395
+ },
396
+ }, auto.wrap(wrapToolHandler(async (args) => {
397
+ const { vaultId, folderId, limit } = args;
398
+ return gw
399
+ ? await gw.listNotes({ vaultId: resolveVault(vaultId), folderId, limit })
400
+ : await listNotes(ctx, resolveVaultId(ctx, vaultId), limit);
401
+ })));
402
+ // ─── read_note ────────────────────────────────────────────────────────────
403
+ server.registerTool("read_note", {
404
+ description: "Read the full decrypted content of a note, including title, tags, and vault name",
405
+ inputSchema: {
406
+ noteId: s(z.string().uuid().describe("The note ID to read")),
407
+ },
408
+ }, auto.wrap(wrapToolHandler(async (args) => {
409
+ const { noteId } = args;
410
+ return gw ? await gw.readNote(noteId) : await readNote(ctx, noteId);
411
+ })));
412
+ // ─── search_notes ─────────────────────────────────────────────────────────
413
+ server.registerTool("search_notes", {
414
+ description: "Search notes by keyword. Weighted: 3x title, 2x tags, 1x content. Returns scored results.",
415
+ inputSchema: {
416
+ query: s(z.string().min(1).describe("Search query")),
417
+ 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)")),
419
+ },
420
+ }, auto.wrap(wrapToolHandler(async (args) => {
421
+ const { query, vaultId, limit } = args;
422
+ return gw
423
+ ? await gw.searchNotes({ query, vaultId: resolveVault(vaultId), limit })
424
+ : await searchNotes(ctx, query, resolveVaultId(ctx, vaultId), limit);
425
+ })));
426
+ // ─── create_note ──────────────────────────────────────────────────────────
427
+ server.registerTool("create_note", {
428
+ description: "Create a new encrypted note in a vault, optionally inside a folder",
429
+ inputSchema: {
430
+ vaultId: s(z.string().uuid().optional().describe("Vault ID to create the note in. Defaults to defaultVaultId if configured.")),
431
+ title: s(z.string().min(1).max(1000).describe("Note title")),
432
+ content: s(z.string().max(1_000_000).describe("Note content (plain text or HTML)")),
433
+ tags: s(z.array(z.string().max(100)).max(50).optional().describe("Optional tags")),
434
+ folderId: s(z.string().uuid().optional().describe("Optional folder ID to place the note in")),
435
+ },
436
+ }, auto.wrap(wrapToolHandler(async (args) => {
437
+ const { vaultId, title, content, tags, folderId } = args;
438
+ if (gw) {
439
+ return await gw.createNote({ vaultId: resolveVault(vaultId), title, content, tags, folderId });
440
+ }
441
+ const effectiveVaultId = resolveVaultId(ctx, vaultId);
442
+ if (!effectiveVaultId) {
443
+ throw new Error("vaultId is required for create_note. Pass vaultId or configure defaultVaultId.");
444
+ }
445
+ return await createNote(ctx, effectiveVaultId, title, content, tags);
446
+ })));
447
+ // ─── update_note ──────────────────────────────────────────────────────────
448
+ server.registerTool("update_note", {
449
+ description: "Update an existing note's title, content, or tags",
450
+ inputSchema: {
451
+ noteId: s(z.string().uuid().describe("The note ID to update")),
452
+ title: s(z.string().max(1000).optional().describe("New title")),
453
+ content: s(z.string().max(1_000_000).optional().describe("New content (plain text or HTML)")),
454
+ tags: s(z.array(z.string().max(100)).max(50).optional().describe("New tags (replaces existing)")),
455
+ },
456
+ }, auto.wrap(wrapToolHandler(async (args) => {
457
+ const { noteId, title, content, tags } = args;
458
+ return gw
459
+ ? await gw.updateNote({ noteId, title, content, tags })
460
+ : await updateNote(ctx, noteId, title, content, tags);
461
+ })));
462
+ // ─── delete_note ─────────────────────────────────────────────────────────
463
+ server.registerTool("delete_note", {
464
+ description: "Permanently delete a note by ID",
465
+ inputSchema: {
466
+ noteId: s(z.string().uuid().describe("The note ID to delete")),
467
+ },
468
+ }, auto.wrap(wrapToolHandler(async (args) => {
469
+ const { noteId } = args;
470
+ return gw ? await gw.deleteNote(noteId) : await deleteNote(ctx, noteId);
471
+ })));
472
+ // ─── list_folders ──────────────────────────────────────────────────────────
473
+ server.registerTool("list_folders", {
474
+ description: "List folders in a vault. Returns a flat list of folders with names, parent IDs, and sort order. Client can build the tree structure.",
475
+ inputSchema: {
476
+ vaultId: s(z.string().uuid().optional().describe("Vault ID. Defaults to defaultVaultId.")),
477
+ },
478
+ }, auto.wrap(wrapToolHandler(async (args) => {
479
+ const { vaultId } = args;
480
+ if (gw) {
481
+ return await gw.listFolders({ vaultId: resolveVault(vaultId) });
482
+ }
483
+ throw new Error("list_folders is only available in gateway mode.");
484
+ })));
485
+ // ─── create_folder ────────────────────────────────────────────────────────
486
+ server.registerTool("create_folder", {
487
+ description: "Create a new folder in a vault. Folders organize notes hierarchically (like Obsidian). Use parentId to create subfolders.",
488
+ inputSchema: {
489
+ vaultId: s(z.string().uuid().optional().describe("Vault ID. Defaults to defaultVaultId.")),
490
+ name: s(z.string().min(1).max(500).describe("Folder name")),
491
+ parentId: s(z.string().uuid().optional().describe("Parent folder ID for nesting. Omit for root-level folder.")),
492
+ icon: s(z.string().max(8).optional().describe("Emoji icon for the folder (e.g. '📁'). Omit for default icon.")),
493
+ },
494
+ }, auto.wrap(wrapToolHandler(async (args) => {
495
+ const { vaultId, name, parentId, icon } = args;
496
+ if (gw) {
497
+ return await gw.createFolder({ vaultId: resolveVault(vaultId), name, parentId, icon });
498
+ }
499
+ throw new Error("create_folder is only available in gateway mode.");
500
+ })));
501
+ // ─── move_note ────────────────────────────────────────────────────────────
502
+ server.registerTool("move_note", {
503
+ description: "Move a note to a different folder. Set folderId to null to move to the root (unfiled).",
504
+ inputSchema: {
505
+ noteId: s(z.string().uuid().describe("The note ID to move")),
506
+ folderId: s(z.string().uuid().optional().describe("Destination folder ID. Omit or null to move to root (unfiled).")),
507
+ },
508
+ }, auto.wrap(wrapToolHandler(async (args) => {
509
+ const { noteId, folderId } = args;
510
+ if (gw) {
511
+ return await gw.moveNote({ noteId, folderId: folderId ?? null });
512
+ }
513
+ throw new Error("move_note is only available in gateway mode.");
514
+ })));
515
+ // ─── read_notes (batch) ─────────────────────────────────────────────────
516
+ server.registerTool("read_notes", {
517
+ 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.",
518
+ inputSchema: {
519
+ noteIds: s(z.array(z.string().uuid()).min(1).max(20).describe("Array of note IDs to read (max 20)")),
520
+ },
521
+ }, auto.wrap(wrapToolHandler(async (args) => {
522
+ const { noteIds } = args;
523
+ return gw ? await gw.readNotes(noteIds) : await readNotes(ctx, noteIds);
524
+ })));
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
+ // ─── write_memory ───────────────────────────────────────────────────────────
555
+ server.registerTool("write_memory", {
556
+ 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.",
557
+ inputSchema: {
558
+ content: s(z.string().min(1).max(1_000_000).describe("Memory content in plain text")),
559
+ memoryType: s(memoryTypeEnum.optional().describe("Type: fact, skill, preference, constraint, task, episodic, correction")),
560
+ summary: s(z.string().max(500).optional().describe("Optional short summary")),
561
+ vaultId: s(z.string().uuid().optional().describe("Vault/project scope. Required unless defaultVaultId is configured for this MCP session.")),
562
+ importance: s(z.number().int().min(1).max(5).optional().describe("Importance from 1 to 5")),
563
+ confidence: s(z.number().int().min(1).max(5).optional().describe("Confidence from 1 to 5")),
564
+ agentId: s(z.string().optional().describe("Agent identifier")),
565
+ agentType: s(z.string().optional().describe("Agent type")),
566
+ modelId: s(z.string().optional().describe("Model used to create this memory")),
567
+ agentRunId: s(z.string().optional().describe("Agent run identifier")),
568
+ sourceMessageId: s(z.string().uuid().optional().describe("Source message UUID")),
569
+ writeReason: s(z.string().optional().describe("Why this memory should be stored")),
570
+ externalWriteId: s(z.string().optional().describe("Idempotency key for upsert behavior")),
571
+ relatedMemoryIds: s(z.array(z.object({
572
+ memoryId: z.string().uuid(),
573
+ relationType: z.enum(["derived_from", "contradicts", "refines", "part_of", "supersedes"]),
574
+ })).optional().describe("Links to related memories with relation type")),
575
+ sourceNoteIds: s(z.array(z.string().uuid()).optional().describe("Note UUIDs this memory was extracted from")),
576
+ supersededById: s(z.string().uuid().optional().describe("Memory ID that supersedes this one (for corrections)")),
577
+ entities: s(z.array(z.string()).optional().describe("Extracted entity names (people, projects, tools, concepts) for cross-linking")),
578
+ dedup: s(z.boolean().optional().describe("Run semantic dedup before writing. >92% match = skip, >80% = supersede old.")),
579
+ },
580
+ }, auto.wrap(wrapToolHandler(async (args) => {
581
+ const input = args;
582
+ const normalized = {
583
+ ...input,
584
+ agentId: gwAgentLabel ?? normalizeAgentId(input.agentId) ?? input.agentId,
585
+ agentRunId: input.agentRunId ?? sessionRunId,
586
+ };
587
+ if (gw) {
588
+ return await gw.writeMemory({ ...normalized, vaultId: resolveVault(input.vaultId) });
589
+ }
590
+ return await writeMemory(ctx, normalized);
591
+ })));
592
+ // ─── search_memories ────────────────────────────────────────────────────────
593
+ server.registerTool("search_memories", {
594
+ description: "Search durable memories semantically with optional filters and recency fallback. Use 'entity' for exact entity-based search instead of semantic search.",
595
+ inputSchema: {
596
+ query: s(z.string().min(1).describe("Natural language query (ignored when entity is provided)")),
597
+ topK: s(z.number().int().min(1).max(50).optional().describe("Max results (default 10)")),
598
+ threshold: s(z.number().min(0).max(1).optional().describe("Similarity threshold 0-1 (default 0.4)")),
599
+ vaultId: s(z.string().uuid().optional().describe("Optional vault/project filter")),
600
+ memoryType: s(memoryTypeEnum.optional().describe("Optional memory type filter")),
601
+ includeArchived: s(z.boolean().optional().describe("Include archived memories")),
602
+ entity: s(z.string().optional().describe("Search by entity name using JSONB containment instead of semantic search")),
603
+ 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.")),
604
+ 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.")),
605
+ diversity: s(z.number().min(0).max(1).optional().describe("MMR diversity balance 0-1 (default 0.7). Higher = more relevance, lower = more diversity.")),
606
+ },
607
+ }, auto.wrap(wrapToolHandler(async (args) => {
608
+ const input = args;
609
+ if (gw) {
610
+ return await gw.searchMemories({ ...input, vaultId: resolveVault(input.vaultId) });
611
+ }
612
+ return await searchMemories(ctx, { ...input, vaultId: resolveVaultId(ctx, input.vaultId) });
613
+ })));
614
+ // ─── read_memories ──────────────────────────────────────────────────────────
615
+ server.registerTool("read_memories", {
616
+ description: "Read and decrypt full memory entries by IDs.",
617
+ inputSchema: {
618
+ memoryIds: s(z.array(z.string().uuid()).min(1).max(50).describe("Array of memory IDs to read")),
619
+ },
620
+ }, auto.wrap(wrapToolHandler(async (args) => {
621
+ const { memoryIds } = args;
622
+ return gw ? await gw.readMemories(memoryIds) : await readMemories(ctx, memoryIds);
623
+ })));
624
+ // ─── archive_memory ─────────────────────────────────────────────────────────
625
+ server.registerTool("archive_memory", {
626
+ description: "Archive or unarchive a memory entry by ID.",
627
+ inputSchema: {
628
+ memoryId: s(z.string().uuid().describe("Memory ID")),
629
+ archived: s(z.boolean().optional().describe("True to archive, false to unarchive (default true)")),
630
+ },
631
+ }, auto.wrap(wrapToolHandler(async (args) => {
632
+ const { memoryId, archived } = args;
633
+ return gw
634
+ ? await gw.archiveMemory(memoryId, archived ?? true)
635
+ : await archiveMemory(ctx, memoryId, archived ?? true);
636
+ })));
637
+ // ─── update_memory ─────────────────────────────────────────────────────────
638
+ server.registerTool("update_memory", {
639
+ description: "Update an existing memory's content, summary, type, importance, confidence, entities, or other fields. Re-encrypts and re-indexes embeddings when content changes.",
640
+ inputSchema: {
641
+ memoryId: s(z.string().uuid().describe("The memory ID to update")),
642
+ content: s(z.string().min(1).max(1_000_000).optional().describe("New content (replaces existing)")),
643
+ summary: s(z.string().max(500).optional().describe("New summary (empty string to clear)")),
644
+ memoryType: s(memoryTypeEnum.optional().describe("New memory type")),
645
+ importance: s(z.number().int().min(1).max(5).optional().describe("New importance (1-5)")),
646
+ confidence: s(z.number().int().min(1).max(5).optional().describe("New confidence (1-5)")),
647
+ entities: s(z.array(z.string()).optional().describe("New entities (replaces existing)")),
648
+ relatedMemoryIds: s(z.array(z.object({
649
+ memoryId: z.string().uuid(),
650
+ relationType: z.enum(["derived_from", "contradicts", "refines", "part_of", "supersedes"]),
651
+ })).optional().describe("New related memories (replaces existing)")),
652
+ sourceNoteIds: s(z.array(z.string().uuid()).optional().describe("New source note IDs (replaces existing)")),
653
+ isArchived: s(z.boolean().optional().describe("Archive or unarchive")),
654
+ metadata: s(z.record(z.string(), z.unknown()).optional().describe("Metadata fields to merge (not replace). For tasks: { taskStatus: 'in_progress', assignedAgentId: 'claude_code' }")),
655
+ },
656
+ }, auto.wrap(wrapToolHandler(async (args) => {
657
+ const input = args;
658
+ return gw ? await gw.updateMemory(input) : await updateMemoryTool(ctx, input);
659
+ })));
660
+ // ─── get_related_memories ────────────────────────────────────────────────────
661
+ server.registerTool("get_related_memories", {
662
+ description: "Given a memory ID, returns related memories: those listed in relatedMemoryIds, memories sharing entities, and the supersession chain.",
663
+ inputSchema: {
664
+ memoryId: s(z.string().uuid().describe("The memory ID to find relations for")),
665
+ limit: s(z.number().int().min(1).max(50).optional().describe("Max related memories to return (default 20)")),
666
+ vaultId: s(z.string().uuid().optional().describe("Vault scope for related memory lookup. Uses default vault if omitted.")),
667
+ },
668
+ }, auto.wrap(wrapToolHandler(async (args) => {
669
+ const { memoryId, limit, vaultId } = args;
670
+ return gw
671
+ ? await gw.getRelatedMemories(memoryId, limit, resolveVault(vaultId))
672
+ : await getRelatedMemories(ctx, memoryId, limit);
673
+ })));
674
+ // ─── context_checkpoint ──────────────────────────────────────────────────
675
+ server.registerTool("context_checkpoint", {
676
+ description: "End-of-session checkpoint. Provide a sessionSummary describing what happened, decisions made, and open threads. Optionally include memories to bulk-save. The summary is stored as an episodic memory for future sessions.",
677
+ inputSchema: {
678
+ memories: s(z.array(z.object({
679
+ content: z.string().min(1).max(1_000_000),
680
+ memoryType: memoryTypeEnum.optional(),
681
+ summary: z.string().max(500).optional(),
682
+ importance: z.number().int().min(1).max(5).optional(),
683
+ confidence: z.number().int().min(1).max(5).optional(),
684
+ entities: z.array(z.string()).optional(),
685
+ writeReason: z.string().optional(),
686
+ relatedMemoryIds: z.array(z.object({
687
+ memoryId: z.string().uuid(),
688
+ relationType: z.enum(["derived_from", "contradicts", "refines", "part_of", "supersedes"]),
689
+ })).optional(),
690
+ sourceNoteIds: z.array(z.string().uuid()).optional(),
691
+ supersededById: z.string().uuid().optional(),
692
+ })).max(50).describe("Array of memories to save (0-50). Can be empty when only sessionSummary is provided.")),
693
+ 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
+ vaultId: s(z.string().uuid().optional().describe("Vault/project scope for all memories. Required unless defaultVaultId is configured.")),
695
+ agentId: s(z.string().optional().describe("Agent identifier")),
696
+ modelId: s(z.string().optional().describe("Model used")),
697
+ agentRunId: s(z.string().optional().describe("Run ID for checkpoint idempotency")),
698
+ dedup: s(z.boolean().optional().describe("Run semantic dedup per memory (default true)")),
699
+ },
700
+ }, auto.wrap(wrapToolHandler(async (args) => {
701
+ const input = args;
702
+ const normalized = {
703
+ ...input,
704
+ agentId: gwAgentLabel ?? normalizeAgentId(input.agentId) ?? input.agentId,
705
+ agentRunId: input.agentRunId ?? sessionRunId,
706
+ };
707
+ if (gw) {
708
+ return await gw.contextCheckpoint({ ...normalized, vaultId: resolveVault(input.vaultId) });
709
+ }
710
+ return await contextCheckpoint(ctx, { ...normalized, vaultId: resolveVaultId(ctx, input.vaultId) });
711
+ })));
712
+ // ─── list_active_agents ─────────────────────────────────────────────────
713
+ server.registerTool("list_active_agents", {
714
+ description: "List agents that have recently written memories. Useful for cross-agent coordination and understanding who has been active.",
715
+ inputSchema: {
716
+ sinceDays: s(z.number().int().min(1).max(365).optional().describe("Look back N days (default 30)")),
717
+ limit: s(z.number().int().min(1).max(100).optional().describe("Max agents to return (default 20)")),
718
+ },
719
+ }, auto.wrap(wrapToolHandler(async (args) => {
720
+ const { sinceDays, limit } = args;
721
+ return gw
722
+ ? await gw.listActiveAgents(sinceDays, limit)
723
+ : await listActiveAgents(ctx, { sinceDays, limit });
724
+ })));
725
+ // ─── cleanup_memories ────────────────────────────────────────────────────
726
+ server.registerTool("cleanup_memories", {
727
+ description: "Find and archive stale, low-importance, superseded, or failed-indexing memories. Supports dry-run mode to preview candidates before archiving. Use this periodically to keep memory clean.",
728
+ inputSchema: {
729
+ vaultId: s(z.string().uuid().optional().describe("Vault to clean up. Defaults to defaultVaultId.")),
730
+ maxEpisodicAgeDays: s(z.number().int().min(1).max(365).optional().describe("Archive episodic memories older than N days (default 30)")),
731
+ maxImportance: s(z.number().int().min(1).max(5).optional().describe("Archive memories with importance <= this (default 1)")),
732
+ staleAfterDays: s(z.number().int().min(1).max(365).optional().describe("Archive stale memories not updated in N days (default 14)")),
733
+ dryRun: s(z.boolean().optional().describe("Preview candidates without archiving (default false)")),
734
+ },
735
+ }, auto.wrap(wrapToolHandler(async (args) => {
736
+ const input = args;
737
+ if (gw) {
738
+ return await gw.cleanupMemories({ ...input, vaultId: resolveVault(input.vaultId) });
739
+ }
740
+ return await cleanupMemories(ctx, input);
741
+ })));
742
+ // ─── session_start ──────────────────────────────────────────────────────
743
+ server.registerTool("session_start", {
744
+ description: "Load recent context at the start of a session. Returns recent session summaries (episodic), high-importance facts, active tasks, constraints, available vaults, and recently active agents. Optionally accepts a query for targeted context retrieval. Call this at the beginning of every session to resume where you left off.",
745
+ inputSchema: {
746
+ vaultId: s(z.string().uuid().optional().describe("Scope to a specific vault")),
747
+ query: s(z.string().optional().describe("Optional query to retrieve targeted context via semantic search")),
748
+ mode: s(z.enum(["default", "planning", "incident", "handoff", "deep", "minimal", "none"]).optional().describe("Context profile preset. 'planning'=more facts/tasks, 'incident'=more recent context, 'handoff'=session transfer, 'deep'=maximum context, 'minimal'=lightweight (1 episodic, 2 facts), 'none'=skip all memory queries (vaults+agents only). Explicit max* params override mode.")),
749
+ maxEpisodic: s(z.number().int().min(0).max(10).optional().describe("Max recent session summaries (default depends on mode)")),
750
+ maxFacts: s(z.number().int().min(0).max(20).optional().describe("Max important facts (default depends on mode)")),
751
+ maxTasks: s(z.number().int().min(0).max(10).optional().describe("Max active tasks (default depends on mode)")),
752
+ maxConstraints: s(z.number().int().min(0).max(20).optional().describe("Max constraints (default depends on mode)")),
753
+ summaryOnly: s(z.boolean().optional().describe("Return memory summaries instead of full content (default: true). Set to false to get full content.")),
754
+ includeDocuments: s(z.array(z.enum(["soul", "instructions", "skills", "checks"])).optional().describe("Which documents to include in the response (default: [\"soul\"]). Use read_document to fetch others on demand.")),
755
+ },
756
+ }, wrapToolHandler(async (args) => {
757
+ // Mark injected so auto-session doesn't fire on subsequent tool calls
758
+ auto.markInjected();
759
+ const input = args;
760
+ const agentId = gwAgentLabel ?? gwAgentType ?? "mcp_stdio";
761
+ if (gw) {
762
+ return await gw.sessionStart({ ...input, vaultId: resolveVault(input.vaultId) });
763
+ }
764
+ return await sessionStart(ctx, { ...input, agentId, vaultId: resolveVaultId(ctx, input.vaultId) });
765
+ }));
766
+ // ─── get_links ─────────────────────────────────────────────────────────────
767
+ server.registerTool("get_links", {
768
+ description: "Get knowledge graph links for a note or memory. Returns outgoing, incoming, or both directions. Use this to explore connections between notes and memories.",
769
+ inputSchema: {
770
+ nodeType: s(z.enum(["note", "memory"]).describe("Type of the node to get links for")),
771
+ nodeId: s(z.string().uuid().describe("ID of the note or memory")),
772
+ direction: s(z.enum(["outgoing", "incoming", "both"]).optional().describe("Link direction filter (default: both)")),
773
+ limit: s(z.number().int().min(1).max(200).optional().describe("Max links to return (default 50)")),
774
+ },
775
+ }, auto.wrap(wrapToolHandler(async (args) => {
776
+ const input = args;
777
+ return gw ? await gw.getLinks(input) : await getLinks(ctx, input);
778
+ })));
779
+ // ─── add_link ─────────────────────────────────────────────────────────────
780
+ server.registerTool("add_link", {
781
+ description: "Create a knowledge graph link between two nodes (notes and/or memories). Supports relation types: wiki_link, derived_from, contradicts, refines, part_of, supersedes, source_of, references, manual.",
782
+ inputSchema: {
783
+ sourceType: s(z.enum(["note", "memory"]).describe("Type of the source node")),
784
+ sourceId: s(z.string().uuid().describe("ID of the source node")),
785
+ targetType: s(z.enum(["note", "memory"]).describe("Type of the target node")),
786
+ targetId: s(z.string().uuid().describe("ID of the target node")),
787
+ relationType: s(z.enum([
788
+ "wiki_link", "derived_from", "contradicts", "refines",
789
+ "part_of", "supersedes", "source_of", "references", "manual",
790
+ ]).describe("Type of relationship between the nodes")),
791
+ label: s(z.string().max(500).optional().describe("Optional display label for the link (will be encrypted)")),
792
+ },
793
+ }, auto.wrap(wrapToolHandler(async (args) => {
794
+ const input = args;
795
+ return gw ? await gw.addLink(input) : await addLink(ctx, input);
796
+ })));
797
+ // ─── remove_link ──────────────────────────────────────────────────────────
798
+ server.registerTool("remove_link", {
799
+ description: "Remove a knowledge graph link by its ID.",
800
+ inputSchema: {
801
+ linkId: s(z.string().uuid().describe("The link ID to remove")),
802
+ },
803
+ }, auto.wrap(wrapToolHandler(async (args) => {
804
+ const { linkId } = args;
805
+ return gw ? await gw.removeLink(linkId) : await removeLink(ctx, linkId);
806
+ })));
807
+ // ─── explore_graph ──────────────────────────────────────────────────────
808
+ server.registerTool("explore_graph", {
809
+ description: "Navigate the knowledge graph WITHOUT burning LLM tokens. " +
810
+ "Provide a `query` (semantic search for entry points) and/or `nodeId`+`nodeType` (start from a specific node). " +
811
+ "Returns a map of nodes (memories + notes with summaries) and edges (relationships). " +
812
+ "Use this to discover connections, then call `read_memories` or `read_note` to dive deeper into specific nodes. " +
813
+ "Replaces rlm_query — zero LLM calls, pure DB traversal.",
814
+ inputSchema: {
815
+ query: s(z.string().max(2000).optional().describe("Semantic search query to find entry point nodes")),
816
+ nodeId: s(z.string().uuid().optional().describe("Start traversal from this specific node")),
817
+ nodeType: s(z.enum(["note", "memory"]).optional().describe("Type of nodeId (required when nodeId is provided)")),
818
+ maxHops: s(z.number().int().min(1).max(5).optional().describe("Max traversal depth (default 2, max 5)")),
819
+ maxNodes: s(z.number().int().min(1).max(200).optional().describe("Max nodes to return (default 50, max 200)")),
820
+ vaultId: s(z.string().uuid().optional().describe("Vault to scope search to")),
821
+ },
822
+ }, auto.wrap(wrapToolHandler(async (args) => {
823
+ const input = args;
824
+ return gw
825
+ ? await gw.exploreGraph(input)
826
+ : await exploreGraph(ctx, input);
827
+ })));
828
+ // ─── create_task ─────────────────────────────────────────────────────────
829
+ // Thin wrapper around writeMemory — tasks are stored as memories with memoryType='task'
830
+ const taskStatusEnum = z.enum(["backlog", "todo", "in_progress", "done", "blocked"]);
831
+ server.registerTool("create_task", {
832
+ description: "Create a new task on the kanban board. Tasks are stored as memories with memoryType='task'. Returns the created task/memory ID.",
833
+ inputSchema: {
834
+ title: s(z.string().min(1).max(500).describe("Task title (plaintext — encrypted before storage)")),
835
+ description: s(z.string().max(5000).optional().describe("Optional task description (plaintext — encrypted before storage)")),
836
+ vaultId: s(z.string().uuid().optional().describe("Vault to scope the task to. Defaults to defaultVaultId.")),
837
+ status: s(taskStatusEnum.optional().describe("Initial status. Defaults to 'todo'.")),
838
+ priority: s(z.number().int().min(1).max(5).optional().describe("Priority 1-5 (5=critical). Defaults to 3.")),
839
+ agentId: s(z.string().max(200).optional().describe("Agent that created this task.")),
840
+ agentRunId: s(z.string().max(200).optional().describe("Agent run/session ID.")),
841
+ assignedAgentId: s(z.string().max(200).optional().describe("Agent assigned to work on this task. Use 'any' to let any agent pick it up.")),
842
+ doneWhen: s(z.string().max(1000).optional().describe("Natural language completion criteria. When set, ExoVault can auto-detect task completion.")),
843
+ },
844
+ }, auto.wrap(wrapToolHandler(async (args) => {
845
+ const { title, description, status, priority, assignedAgentId, doneWhen, ...rest } = args;
846
+ // Infer status from title/description if caller left it as default "todo"
847
+ const effectiveStatus = inferTaskStatus(title, description, status) ?? status ?? "todo";
848
+ const metadata = { taskStatus: effectiveStatus };
849
+ if (assignedAgentId)
850
+ metadata.assignedAgentId = assignedAgentId;
851
+ if (doneWhen)
852
+ metadata.doneWhen = doneWhen;
853
+ const memoryInput = {
854
+ content: title,
855
+ summary: description,
856
+ memoryType: "task",
857
+ importance: priority ?? 3,
858
+ metadata,
859
+ agentId: rest.agentId,
860
+ agentRunId: rest.agentRunId ?? sessionRunId,
861
+ vaultId: rest.vaultId,
862
+ };
863
+ if (gw) {
864
+ return await gw.createTask({
865
+ title,
866
+ description,
867
+ status: effectiveStatus,
868
+ priority: priority ?? 3,
869
+ agentId: rest.agentId,
870
+ agentRunId: rest.agentRunId ?? sessionRunId,
871
+ assignedAgentId,
872
+ doneWhen,
873
+ vaultId: resolveVault(rest.vaultId),
874
+ });
875
+ }
876
+ return await writeMemory(ctx, memoryInput);
877
+ })));
878
+ // ─── update_task ────────────────────────────────────────────────────────
879
+ // Thin wrapper around updateMemoryTool — updates metadata JSONB for task fields
880
+ server.registerTool("update_task", {
881
+ description: "Update an existing task's status, priority, or assignment on the kanban board. Tasks are memories — this updates the memory's metadata and importance.",
882
+ inputSchema: {
883
+ taskId: s(z.string().uuid().describe("ID of the task (memory) to update.")),
884
+ status: s(taskStatusEnum.optional().describe("New status for the task.")),
885
+ priority: s(z.number().int().min(1).max(5).optional().describe("New priority (1-5).")),
886
+ assignedAgentId: s(z.string().max(200).nullable().optional().describe("Assign to a specific agent, 'any' for any agent, or null to unassign.")),
887
+ doneWhen: s(z.string().max(1000).nullable().optional().describe("Natural language completion criteria. Set null to clear.")),
888
+ },
889
+ }, auto.wrap(wrapToolHandler(async (args) => {
890
+ const { taskId, status, priority, assignedAgentId, doneWhen } = args;
891
+ const metaUpdate = {};
892
+ if (status !== undefined)
893
+ metaUpdate.taskStatus = status;
894
+ if (assignedAgentId !== undefined)
895
+ metaUpdate.assignedAgentId = assignedAgentId;
896
+ if (doneWhen !== undefined)
897
+ metaUpdate.doneWhen = doneWhen;
898
+ if (gw) {
899
+ return await gw.updateTask({ taskId, status, priority, assignedAgentId, doneWhen });
900
+ }
901
+ const memoryInput = { memoryId: taskId };
902
+ if (priority !== undefined)
903
+ memoryInput.importance = priority;
904
+ if (Object.keys(metaUpdate).length > 0)
905
+ memoryInput.metadata = metaUpdate;
906
+ return await updateMemoryTool(ctx, memoryInput);
907
+ })));
908
+ // ─── list_tasks ─────────────────────────────────────────────────────────
909
+ server.registerTool("list_tasks", {
910
+ description: "List tasks from the kanban board. Returns decrypted titles and descriptions. Filter by vault, status, assigned agent, or limit.",
911
+ inputSchema: {
912
+ vaultId: s(z.string().uuid().optional().describe("Filter tasks by vault. Defaults to defaultVaultId.")),
913
+ status: s(taskStatusEnum.optional().describe("Filter tasks by status.")),
914
+ assignedAgentId: s(z.string().max(200).optional().describe("Filter tasks assigned to a specific agent. Use your agent type to find tasks assigned to you.")),
915
+ limit: s(z.number().int().min(1).max(200).optional().describe("Max tasks to return (default 100).")),
916
+ },
917
+ }, auto.wrap(wrapToolHandler(async (args) => {
918
+ const input = args;
919
+ if (gw) {
920
+ return await gw.listTasks({
921
+ ...input,
922
+ vaultId: resolveVault(input.vaultId),
923
+ });
924
+ }
925
+ // Direct mode: list_tasks still needs gateway or a direct query
926
+ // For now, use searchMemories with memoryType='task' as a fallback
927
+ return await searchMemories(ctx, {
928
+ query: input.status ?? "task",
929
+ memoryType: "task",
930
+ vaultId: input.vaultId,
931
+ topK: input.limit ?? 100,
932
+ includeArchived: false,
933
+ });
934
+ })));
935
+ // ─── create_plan_tasks ────────────────────────────────────────────────────
936
+ // Decomposes a natural language plan into tracked tasks using LLM
937
+ server.registerTool("create_plan_tasks", {
938
+ description: "Break a plan into tracked tasks on the kanban board. Uses LLM to decompose the plan into actionable tasks with completion criteria (doneWhen). The first task is set to 'in_progress', rest to 'todo'. All share a planGroupId for grouping.",
939
+ inputSchema: {
940
+ plan: s(z.string().min(1).max(50_000).describe("Natural language plan description to decompose into tasks")),
941
+ vaultId: s(z.string().uuid().optional().describe("Vault to create tasks in. Defaults to defaultVaultId.")),
942
+ assignedAgentId: s(z.string().max(200).optional().describe("Agent to assign all plan tasks to.")),
943
+ agentId: s(z.string().max(200).optional().describe("Agent creating the plan.")),
944
+ agentRunId: s(z.string().max(200).optional().describe("Agent run/session ID.")),
945
+ },
946
+ }, auto.wrap(wrapToolHandler(async (args) => {
947
+ const { plan, assignedAgentId, ...rest } = args;
948
+ if (!extractionClient) {
949
+ throw new Error("create_plan_tasks requires LLM configuration (llmApiKey, llmBaseUrl, llmModelId in config). " +
950
+ "Create tasks manually with create_task instead.");
951
+ }
952
+ // Fetch existing tasks for dedup context
953
+ let existingTasks = [];
954
+ try {
955
+ if (gw) {
956
+ const raw = await gw.listTasks({ vaultId: resolveVault(rest.vaultId) });
957
+ const parsed = JSON.parse(raw);
958
+ existingTasks = (parsed.tasks ?? []).map((t) => ({ title: t.title, status: t.status }));
959
+ }
960
+ }
961
+ catch { /* ignore — dedup is optional */ }
962
+ // Build prompt and call LLM
963
+ const { prompt, systemPrompt } = buildPlanTasksPrompt(plan, existingTasks);
964
+ const llmResult = await extractionClient.extract(`${systemPrompt}\n\n${prompt}`, 500);
965
+ const planned = parsePlanTasksResult(llmResult.text);
966
+ if (!planned || planned.length === 0) {
967
+ return JSON.stringify({ planGroupId: null, tasks: [], message: "LLM returned no tasks. Create them manually." });
968
+ }
969
+ // Generate planGroupId and create tasks
970
+ const planGroupId = randomUUID();
971
+ const created = [];
972
+ for (let i = 0; i < planned.length; i++) {
973
+ const task = planned[i];
974
+ const status = i === 0 ? "in_progress" : "todo";
975
+ const metadata = {
976
+ taskStatus: status,
977
+ planGroupId,
978
+ doneWhen: task.doneWhen,
979
+ };
980
+ if (assignedAgentId)
981
+ metadata.assignedAgentId = assignedAgentId;
982
+ try {
983
+ if (gw) {
984
+ const result = await gw.createTask({
985
+ title: task.title,
986
+ description: task.description,
987
+ status,
988
+ priority: task.priority,
989
+ agentId: rest.agentId,
990
+ agentRunId: rest.agentRunId ?? sessionRunId,
991
+ assignedAgentId,
992
+ doneWhen: task.doneWhen,
993
+ vaultId: resolveVault(rest.vaultId),
994
+ dedup: true,
995
+ });
996
+ const parsed = JSON.parse(result);
997
+ created.push({ taskId: parsed.taskId, title: task.title, status });
998
+ }
999
+ else {
1000
+ const result = await writeMemory(ctx, {
1001
+ content: task.description
1002
+ ? `${task.title}\n\n${task.description}`
1003
+ : task.title,
1004
+ summary: task.title,
1005
+ memoryType: "task",
1006
+ importance: task.priority,
1007
+ metadata,
1008
+ agentId: rest.agentId,
1009
+ agentRunId: rest.agentRunId ?? sessionRunId,
1010
+ vaultId: rest.vaultId,
1011
+ });
1012
+ const parsed = JSON.parse(result);
1013
+ created.push({ taskId: parsed.memoryId, title: task.title, status });
1014
+ }
1015
+ }
1016
+ catch (e) {
1017
+ process.stderr.write(`[exovault-mcp] create_plan_tasks: failed to create "${task.title}": ${e.message}\n`);
1018
+ }
1019
+ }
1020
+ return JSON.stringify({ planGroupId, tasks: created }, null, 2);
1021
+ })));
1022
+ // ─── ingest_turn ─────────────────────────────────────────────────────────
1023
+ server.registerTool("ingest_turn", {
1024
+ description: "Push a conversation turn for automatic fact extraction. ExoVault will run signal detection and, if the turn contains extractable knowledge, queue it for background LLM extraction. Use this to capture important conversation exchanges that should become durable memories. Only available in gateway mode.",
1025
+ inputSchema: {
1026
+ content: s(z.string().min(1).max(100_000).describe("The conversation turn content (plaintext)")),
1027
+ role: s(z.enum(["user", "assistant"]).describe("Who said this — 'user' or 'assistant'")),
1028
+ vaultId: s(z.string().uuid().optional().describe("Vault to scope the turn to. Defaults to defaultVaultId.")),
1029
+ agentId: s(z.string().optional().describe("Agent identifier")),
1030
+ agentRunId: s(z.string().optional().describe("Agent run/session ID")),
1031
+ },
1032
+ }, auto.wrap(wrapToolHandler(async (args) => {
1033
+ const { content, role, vaultId, agentId, agentRunId } = args;
1034
+ if (gw) {
1035
+ return await gw.ingestTurn({
1036
+ content,
1037
+ role,
1038
+ vaultId: resolveVault(vaultId),
1039
+ agentId: gwAgentLabel ?? normalizeAgentId(agentId) ?? agentId,
1040
+ agentRunId: agentRunId ?? sessionRunId,
1041
+ });
1042
+ }
1043
+ // Direct mode: no extraction pipeline available without gateway
1044
+ throw new Error("ingest_turn is only available in gateway mode. " +
1045
+ "Configure EXOVAULT_AGENT_KEY to use automatic fact extraction.");
1046
+ })));
1047
+ // ─── update_document ────────────────────────────────────────────────────
1048
+ server.registerTool("update_document", {
1049
+ description: "Append content to an agent-editable vault document (instructions, skills, or checks). Documents are append-only — agents cannot replace existing content. Soul document is read-only. Use read_document to view current content before appending.",
1050
+ inputSchema: {
1051
+ vaultId: s(z.string().uuid().optional().describe("Vault ID. Defaults to defaultVaultId.")),
1052
+ documentType: s(z.enum(["instructions", "skills", "checks"]).describe("Document to append to. 'instructions' = operational guidance, 'skills' = learned procedures, 'checks' = validation/QA checks.")),
1053
+ appendContent: s(z.string().min(1).max(100_000).describe("Markdown content to append to the document.")),
1054
+ },
1055
+ }, auto.wrap(wrapToolHandler(async (args) => {
1056
+ const input = args;
1057
+ if (gw) {
1058
+ return await gw.updateDocument({
1059
+ vaultId: resolveVault(input.vaultId),
1060
+ documentType: input.documentType,
1061
+ appendContent: input.appendContent,
1062
+ });
1063
+ }
1064
+ throw new Error("update_document is only available in gateway mode. Configure EXOVAULT_AGENT_KEY to use this feature.");
1065
+ })));
1066
+ // ─── read_document ─────────────────────────────────────────────────────
1067
+ server.registerTool("read_document", {
1068
+ description: "Read a vault document on demand. Documents contain operational guidance that supplements the server instructions. Types: 'soul' (agent identity, read-only), 'instructions' (operational guide — memory protocols, search triggers, task lifecycle, entity conventions), 'skills' (learned procedures), 'checks' (quality gates). soul.md is included in session_start by default; use this tool to fetch the others when needed.",
1069
+ inputSchema: {
1070
+ vaultId: s(z.string().uuid().optional().describe("Vault ID. Defaults to defaultVaultId.")),
1071
+ documentType: s(z.enum(["soul", "instructions", "skills", "checks"]).describe("Document to read.")),
1072
+ },
1073
+ }, auto.wrap(wrapToolHandler(async (args) => {
1074
+ const input = args;
1075
+ if (gw) {
1076
+ return await gw.readDocument({
1077
+ vaultId: resolveVault(input.vaultId),
1078
+ documentType: input.documentType,
1079
+ });
1080
+ }
1081
+ throw new Error("read_document is only available in gateway mode. Configure EXOVAULT_AGENT_KEY to use this feature.");
1082
+ })));
1083
+ // ─── read_docs (public documentation) ─────────────────────────────────────
1084
+ server.registerTool("read_docs", {
1085
+ description: "Read ExoVault product documentation. Use { list: true } to get all available doc slugs. " +
1086
+ "Use { slug: 'getting-started/quickstart' } to read a specific page as raw markdown. " +
1087
+ "Docs include agent-reference comment blocks with compact structured data for efficient consumption.",
1088
+ inputSchema: {
1089
+ slug: s(z.string().optional().describe("Doc page slug, e.g. 'getting-started/quickstart' or 'mcp-tools/write-memory'")),
1090
+ list: s(z.boolean().optional().describe("Set true to list all available doc pages")),
1091
+ },
1092
+ }, auto.wrap(wrapToolHandler(async (args) => {
1093
+ const input = args;
1094
+ if (gw) {
1095
+ return await gw.readDocs(input);
1096
+ }
1097
+ // Direct mode: use hardcoded production URL (public endpoint)
1098
+ const apiUrl = "https://exovault.co";
1099
+ const res = await fetch(`${apiUrl}/api/agent/read-docs`, {
1100
+ method: "POST",
1101
+ headers: { "Content-Type": "application/json" },
1102
+ body: JSON.stringify(input),
1103
+ });
1104
+ if (!res.ok) {
1105
+ const err = await res.json().catch(() => ({ error: res.statusText }));
1106
+ throw new Error(`read_docs failed: ${err.error}`);
1107
+ }
1108
+ return JSON.stringify(await res.json());
1109
+ })));
1110
+ // ─── send_message ─────────────────────────────────────────────────────────
1111
+ server.registerTool("send_message", {
1112
+ description: "Send a directed message to another agent, the user, or broadcast to all agents. " +
1113
+ "Messages are transient coordination artifacts (not durable memories). " +
1114
+ "Categories: directive (instruction), question (needs answer), info (FYI), task (work request), alert (urgent). " +
1115
+ "Priority 1-5 (5=critical). Messages auto-expire after 30 days by default.",
1116
+ inputSchema: {
1117
+ targetId: s(z.string().min(1).max(200).describe("Recipient: specific agentId, 'user', or '*' for broadcast")),
1118
+ category: s(z.enum(["directive", "question", "info", "task", "alert"]).optional().describe("Message category (default: info)")),
1119
+ priority: s(z.number().int().min(1).max(5).optional().describe("Priority 1-5, 5=critical (default: 3)")),
1120
+ subject: s(z.string().max(200).optional().describe("Short subject line (max 200 chars, not encrypted)")),
1121
+ content: s(z.string().min(1).max(10_240).describe("Message body (max 10KB, encrypted at rest)")),
1122
+ vaultId: s(z.string().uuid().optional().describe("Vault scope")),
1123
+ expiresInDays: s(z.number().int().min(1).max(365).optional().describe("Days until expiration (default: 30)")),
1124
+ metadata: s(z.record(z.string(), z.unknown()).optional().describe("Arbitrary metadata")),
1125
+ template: s(z.enum(["task_assignment", "status_update", "alert", "question"]).optional().describe("Message template type for structured rendering")),
1126
+ templateData: s(z.record(z.string(), z.unknown()).optional().describe("Template-specific data (task, deadline, severity, etc.)")),
1127
+ parentMessageId: s(z.string().uuid().optional().describe("Reply to a specific message (creates/joins a thread)")),
1128
+ },
1129
+ }, auto.wrap(wrapToolHandler(async (args) => {
1130
+ const input = args;
1131
+ const senderId = gwAgentLabel ?? gwAgentType ?? "mcp_stdio";
1132
+ if (gw) {
1133
+ return await gw.sendMessage({ ...input, vaultId: resolveVault(input.vaultId) });
1134
+ }
1135
+ return await sendMessage(ctx, { ...input, senderId, vaultId: resolveVaultId(ctx, input.vaultId) });
1136
+ })));
1137
+ // ─── ack_message ──────────────────────────────────────────────────────────
1138
+ server.registerTool("ack_message", {
1139
+ description: "Acknowledge a received message, marking it as processed. " +
1140
+ "Call this after acting on a directive, answering a question, or completing a task request.",
1141
+ inputSchema: {
1142
+ messageId: s(z.string().uuid().describe("The message ID to acknowledge")),
1143
+ },
1144
+ }, auto.wrap(wrapToolHandler(async (args) => {
1145
+ const { messageId } = args;
1146
+ if (gw) {
1147
+ return await gw.ackMessage(messageId);
1148
+ }
1149
+ return await ackMessage(ctx, { messageId });
1150
+ })));
1151
+ // ─── read_messages ────────────────────────────────────────────────────────
1152
+ server.registerTool("read_messages", {
1153
+ description: "Read messages in your inbox. Returns decrypted messages sorted by priority (highest first). " +
1154
+ "Pending messages are automatically marked as delivered. " +
1155
+ "Use status filter to see previously delivered or acknowledged messages.",
1156
+ inputSchema: {
1157
+ status: s(z.enum(["pending", "delivered", "acknowledged"]).optional().describe("Filter by status (default: pending)")),
1158
+ category: s(z.enum(["directive", "question", "info", "task", "alert"]).optional().describe("Filter by category")),
1159
+ limit: s(z.number().int().min(1).max(50).optional().describe("Max messages to return (default: 20)")),
1160
+ includeBroadcast: s(z.boolean().optional().describe("Include broadcast messages (default: true)")),
1161
+ vaultId: s(z.string().uuid().optional().describe("Vault scope")),
1162
+ },
1163
+ }, auto.wrap(wrapToolHandler(async (args) => {
1164
+ const input = args;
1165
+ const agentId = gwAgentLabel ?? gwAgentType ?? "mcp_stdio";
1166
+ if (gw) {
1167
+ return await gw.readMessages({ ...input, vaultId: resolveVault(input.vaultId) });
1168
+ }
1169
+ return await readMessages(ctx, { ...input, agentId, vaultId: resolveVaultId(ctx, input.vaultId) });
1170
+ })));
1171
+ // ─── Orphan recovery — flush crashed sessions from previous runs ─────────
1172
+ try {
1173
+ const orphans = await scanOrphanedBuffers(10);
1174
+ for (const orphan of orphans) {
1175
+ process.stderr.write(`[exovault-mcp] Recovering orphaned session: ${orphan.agentRunId}\n`);
1176
+ try {
1177
+ const checkpointFn = async (params) => {
1178
+ if (gw) {
1179
+ return await gw.contextCheckpoint({
1180
+ ...params,
1181
+ vaultId: resolveVault(params.vaultId),
1182
+ });
1183
+ }
1184
+ return await contextCheckpoint(ctx, {
1185
+ ...params,
1186
+ vaultId: resolveVaultId(ctx, params.vaultId),
1187
+ });
1188
+ };
1189
+ await flushSession(orphan, {
1190
+ checkpointed: false,
1191
+ minToolCalls: 5,
1192
+ skipIfMemoriesWritten: 3,
1193
+ checkpointFn,
1194
+ agentRunId: orphan.agentRunId,
1195
+ agentId: orphan.agentId,
1196
+ vaultId: orphan.vaultId,
1197
+ extractionClient,
1198
+ readBudget,
1199
+ writeBudget,
1200
+ });
1201
+ await deleteBufferFile(orphan.agentRunId);
1202
+ process.stderr.write(`[exovault-mcp] Orphan recovery complete: ${orphan.agentRunId}\n`);
1203
+ }
1204
+ catch (e) {
1205
+ process.stderr.write(`[exovault-mcp] Orphan recovery failed for ${orphan.agentRunId}: ${e.message}\n`);
1206
+ // Delete the buffer anyway to avoid infinite retry loops
1207
+ await deleteBufferFile(orphan.agentRunId).catch(() => { });
1208
+ }
1209
+ }
1210
+ }
1211
+ catch (e) {
1212
+ process.stderr.write(`[exovault-mcp] Orphan scan failed: ${e.message}\n`);
1213
+ }
1214
+ // ─── Start server ─────────────────────────────────────────────────────────
1215
+ const transport = new StdioServerTransport();
1216
+ await server.connect(transport);
1217
+ process.stderr.write("ExoVault MCP server ready\n");
1218
+ // ─── Session lifecycle: signal handlers + stdin EOF ─────────────────────
1219
+ // Layer 1: stdin EOF — client disconnected (IDE closed, Ctrl+C on client)
1220
+ // The MCP SDK does NOT listen for stdin 'end', so we add it ourselves.
1221
+ process.stdin.on("end", () => {
1222
+ process.stderr.write("[exovault-mcp] stdin EOF detected — client disconnected\n");
1223
+ lifecycle.flush("stdin_eof").finally(() => {
1224
+ lifecycle.dispose();
1225
+ process.exit(0);
1226
+ });
1227
+ });
1228
+ // Layer 3: Signal handlers — OS-level kill signals
1229
+ const handleSignal = (signal) => {
1230
+ process.stderr.write(`[exovault-mcp] ${signal} received — flushing session\n`);
1231
+ lifecycle.flush(signal.toLowerCase()).finally(() => {
1232
+ lifecycle.dispose();
1233
+ process.exit(0);
1234
+ });
1235
+ };
1236
+ process.on("SIGTERM", () => handleSignal("SIGTERM"));
1237
+ process.on("SIGINT", () => handleSignal("SIGINT"));
1238
+ }
1239
+ main().catch((err) => {
1240
+ process.stderr.write(`Fatal: ${err.message}\n`);
1241
+ process.exit(1);
1242
+ });