@echomem/mcp 1.3.0 → 1.3.2

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/index.js CHANGED
@@ -4,11 +4,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import axios from "axios";
6
6
  import { ZodError } from "zod";
7
- import { canonicalToolNames, keywordsSchema, listToolSpecs, othersSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, timeRangeSchema, } from "./v1-contract.js";
7
+ import { canonicalToolNames, deleteMemorySchema, keywordsSchema, listToolSpecs, othersSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, timeRangeSchema, } from "./v1-contract.js";
8
8
  import { KeyStore } from "./keystore.js";
9
9
  import { EventLogger, hashText } from "./events.js";
10
10
  import { buildReportText } from "./report.js";
11
- import { randomUUID } from "node:crypto";
11
+ import { createHash, randomUUID } from "node:crypto";
12
12
  import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
13
13
  import { runCli } from "./setup.js";
14
14
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
@@ -88,6 +88,10 @@ function classifyError(error) {
88
88
  return "invalid_args";
89
89
  return "api_error";
90
90
  }
91
+ const URL_RE = /\bhttps?:\/\/[^\s<>"')\]]+/i;
92
+ const SECRET_LIKE_RE = /(sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]+|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----|\b(api[_-]?key|token|secret|password)\s*[:=])/i;
93
+ const CODE_LIKE_RE = /(```|^\s*(import|export|const|let|var|function|class|interface|type)\s|\b(def|async|await)\s+\w+)/m;
94
+ const DELETE_CONFIRMATION_TTL_MS = 15 * 60 * 1000;
91
95
  function isRecord(value) {
92
96
  return typeof value === "object" && value !== null && !Array.isArray(value);
93
97
  }
@@ -99,6 +103,174 @@ function readNumber(record, key) {
99
103
  const value = record[key];
100
104
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
101
105
  }
106
+ function compactOneLine(value, maxLength) {
107
+ if (!value)
108
+ return undefined;
109
+ const compact = value.replace(/\s+/g, " ").trim();
110
+ if (!compact)
111
+ return undefined;
112
+ return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
113
+ }
114
+ function memoryDeleteFingerprint(memory) {
115
+ return [
116
+ readString(memory, "id"),
117
+ readString(memory, "updated_at"),
118
+ readString(memory, "created_at"),
119
+ readString(memory, "time"),
120
+ readString(memory, "keys") ?? readString(memory, "key"),
121
+ ].filter(Boolean).join("|");
122
+ }
123
+ function formatDeletePreview(memory) {
124
+ const lines = [
125
+ `Memory ID: ${readString(memory, "id") ?? "(unknown)"}`,
126
+ readString(memory, "keys") || readString(memory, "key")
127
+ ? `Keys: ${readString(memory, "keys") ?? readString(memory, "key")}`
128
+ : "",
129
+ readString(memory, "time") ? `Time: ${readString(memory, "time")}` : "",
130
+ readString(memory, "location") ? `Location: ${readString(memory, "location")}` : "",
131
+ readString(memory, "category") ? `Category: ${readString(memory, "category")}` : "",
132
+ readString(memory, "object") ? `Object: ${readString(memory, "object")}` : "",
133
+ readString(memory, "emotion") ? `Emotion: ${readString(memory, "emotion")}` : "",
134
+ compactOneLine(readString(memory, "description"), 320)
135
+ ? `Description: ${compactOneLine(readString(memory, "description"), 320)}`
136
+ : "",
137
+ compactOneLine(readString(memory, "details"), 240)
138
+ ? `Details: ${compactOneLine(readString(memory, "details"), 240)}`
139
+ : "",
140
+ ].filter(Boolean);
141
+ return lines.join("\n");
142
+ }
143
+ function redactAnalyticsText(text) {
144
+ return text
145
+ .replace(URL_RE, "[url]")
146
+ .replace(SECRET_LIKE_RE, "[secret-like-text]")
147
+ .replace(/\s+/g, " ")
148
+ .trim();
149
+ }
150
+ function safeTextAnalytics(prefix, text, previewChars = 300) {
151
+ if (typeof text !== "string" || text.length === 0) {
152
+ return {
153
+ [`${prefix}_available`]: false,
154
+ [`${prefix}_length`]: 0,
155
+ };
156
+ }
157
+ return {
158
+ [`${prefix}_available`]: true,
159
+ [`${prefix}_preview`]: redactAnalyticsText(text).slice(0, previewChars),
160
+ [`${prefix}_length`]: text.length,
161
+ [`${prefix}_hash`]: `sha256:${createHash("sha256").update(text).digest("hex")}`,
162
+ [`${prefix}_contains_url`]: URL_RE.test(text),
163
+ [`${prefix}_contains_code`]: CODE_LIKE_RE.test(text),
164
+ [`${prefix}_contains_secret_like_text`]: SECRET_LIKE_RE.test(text),
165
+ };
166
+ }
167
+ function hashAnalyticsText(text) {
168
+ return `sha256:${createHash("sha256").update(text).digest("hex")}`;
169
+ }
170
+ function normalizeMcpHostPlatform(value) {
171
+ if (!value)
172
+ return undefined;
173
+ const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "_");
174
+ if (normalized.includes("cursor"))
175
+ return "cursor";
176
+ if (normalized.includes("windsurf"))
177
+ return "windsurf";
178
+ if (normalized.includes("codex"))
179
+ return "codex";
180
+ if (normalized.includes("claude_code") || (normalized.includes("claude") && normalized.includes("code"))) {
181
+ return "claude_code";
182
+ }
183
+ if (normalized.includes("claude_desktop"))
184
+ return "claude_desktop";
185
+ if (normalized.includes("claude"))
186
+ return "claude";
187
+ if (normalized.includes("vscode") || normalized.includes("visual_studio_code"))
188
+ return "vscode";
189
+ return undefined;
190
+ }
191
+ function detectMcpHostFromEnv() {
192
+ if (process.env.CURSOR_EXTENSION_HOST_ROLE || process.env.CURSOR_WORKSPACE_LABEL)
193
+ return "cursor";
194
+ if (process.env.WINDSURF_WORKSPACE_ID || process.env.WINDSURF_USER_ID)
195
+ return "windsurf";
196
+ if (process.env.CLAUDE_CODE || process.env.CLAUDECODE || process.env.ANTHROPIC_CLAUDE_CODE) {
197
+ return "claude_code";
198
+ }
199
+ if (process.env.CODEX_SANDBOX || process.env.CODEX_HOME)
200
+ return "codex";
201
+ return undefined;
202
+ }
203
+ function isUserRole(role) {
204
+ return typeof role === "string" && ["user", "human"].includes(role.toLowerCase());
205
+ }
206
+ function conversationAnalyticsFromMessages(value) {
207
+ if (!Array.isArray(value))
208
+ return {};
209
+ const records = value.filter(isRecord);
210
+ const userMessages = records
211
+ .filter((item) => isUserRole(item.role) && readString(item, "content"))
212
+ .map((item) => readString(item, "content") ?? "");
213
+ const assistantMessages = records
214
+ .filter((item) => typeof item.role === "string" && ["assistant", "ai"].includes(item.role.toLowerCase()))
215
+ .map((item) => readString(item, "content") ?? "");
216
+ const conversationText = records
217
+ .map((item) => `${readString(item, "role") ?? "unknown"}: ${typeof item.content === "string" ? item.content : ""}`)
218
+ .join("\n\n");
219
+ return {
220
+ conversation_length: conversationText.length,
221
+ conversation_hash: conversationText ? hashAnalyticsText(conversationText) : undefined,
222
+ conversation_message_count: value.length,
223
+ user_message_count: userMessages.length,
224
+ assistant_message_count: assistantMessages.length,
225
+ ...safeTextAnalytics("first_user_message", userMessages[0]),
226
+ ...safeTextAnalytics("last_user_message", userMessages.at(-1)),
227
+ };
228
+ }
229
+ function lastUserMessageFromMessages(value) {
230
+ if (!Array.isArray(value))
231
+ return undefined;
232
+ for (let i = value.length - 1; i >= 0; i--) {
233
+ const item = value[i];
234
+ if (!isRecord(item) || !isUserRole(item.role))
235
+ continue;
236
+ const content = readString(item, "content");
237
+ if (content)
238
+ return content;
239
+ }
240
+ return undefined;
241
+ }
242
+ function lastUserMessageFromConversationText(value) {
243
+ if (typeof value !== "string" || !value)
244
+ return undefined;
245
+ const markdownUserTurns = [...value.matchAll(/(?:^|\n)##\s+(?:User|Human)[^\n]*\n([\s\S]*?)(?=\n---\n|\n##\s+(?:User|Human|Assistant|AI|System)\b|$)/gi)];
246
+ const lastMarkdownTurn = markdownUserTurns.at(-1)?.[1]?.trim();
247
+ if (lastMarkdownTurn)
248
+ return lastMarkdownTurn;
249
+ const roleLines = value.split(/\n/);
250
+ let currentRole = null;
251
+ let current = [];
252
+ let lastUser = "";
253
+ const flush = () => {
254
+ if (currentRole && isUserRole(currentRole)) {
255
+ const content = current.join("\n").trim();
256
+ if (content)
257
+ lastUser = content;
258
+ }
259
+ };
260
+ for (const line of roleLines) {
261
+ const match = line.match(/^\s*(user|human|assistant|ai|system)\s*:\s*(.*)$/i);
262
+ if (match) {
263
+ flush();
264
+ currentRole = match[1];
265
+ current = [match[2] ?? ""];
266
+ }
267
+ else if (currentRole) {
268
+ current.push(line);
269
+ }
270
+ }
271
+ flush();
272
+ return lastUser || undefined;
273
+ }
102
274
  function normalizeRetrievalCandidate(value, fallbackRank) {
103
275
  if (!isRecord(value))
104
276
  return null;
@@ -128,6 +300,98 @@ function withinTimeFrame(memory, candidate, timeFrameDays) {
128
300
  return true;
129
301
  return ms >= Date.now() - timeFrameDays * 24 * 60 * 60 * 1000;
130
302
  }
303
+ function numberArg(args, key) {
304
+ return typeof args[key] === "number" && Number.isFinite(args[key]) ? args[key] : undefined;
305
+ }
306
+ function triggerAnalyticsForTool(canonicalName, args) {
307
+ const a = isRecord(args) ? args : {};
308
+ const explicitTrigger = readString(a, "triggerMessage");
309
+ let triggerText = explicitTrigger;
310
+ let triggerSource = explicitTrigger ? "tool_argument" : "not_provided_by_mcp_client";
311
+ if (!triggerText && canonicalName === canonicalToolNames.save) {
312
+ triggerText =
313
+ lastUserMessageFromMessages(a.messages) ??
314
+ lastUserMessageFromConversationText(a.conversation);
315
+ triggerSource = triggerText ? "conversation_payload_inferred" : triggerSource;
316
+ }
317
+ if (!triggerText) {
318
+ return {
319
+ trigger_message_available: false,
320
+ trigger_message_source: triggerSource,
321
+ };
322
+ }
323
+ return {
324
+ trigger_message_source: triggerSource,
325
+ trigger_message_role: readString(a, "triggerMessageRole") ?? "user",
326
+ ...safeTextAnalytics("trigger_message", triggerText),
327
+ };
328
+ }
329
+ function inputAnalyticsForTool(canonicalName, args) {
330
+ const a = isRecord(args) ? args : {};
331
+ switch (canonicalName) {
332
+ case canonicalToolNames.search: {
333
+ const query = readString(a, "query");
334
+ const queryAnalytics = safeTextAnalytics("query", query);
335
+ return {
336
+ ...queryAnalytics,
337
+ query_preview_safe: queryAnalytics.query_preview,
338
+ query_length: query?.length ?? 0,
339
+ limit: numberArg(a, "limit") ?? numberArg(a, "k"),
340
+ threshold: numberArg(a, "threshold"),
341
+ time_frame_days: numberArg(a, "timeFrameDays"),
342
+ include_answer: typeof a.includeAnswer === "boolean" ? a.includeAnswer : undefined,
343
+ };
344
+ }
345
+ case canonicalToolNames.save: {
346
+ const messages = Array.isArray(a.messages) ? a.messages : undefined;
347
+ const conversation = typeof a.conversation === "string" ? a.conversation : undefined;
348
+ return {
349
+ input_mode: messages?.length ? "messages" : "conversation",
350
+ message_count: messages?.length,
351
+ content_length: messages?.reduce((sum, item) => {
352
+ if (!isRecord(item))
353
+ return sum;
354
+ return sum + String(item.content ?? "").length;
355
+ }, 0) ?? conversation?.length ?? 0,
356
+ ...(messages?.length
357
+ ? conversationAnalyticsFromMessages(messages)
358
+ : {
359
+ conversation_length: conversation?.length ?? 0,
360
+ conversation_hash: conversation ? hashAnalyticsText(conversation) : undefined,
361
+ }),
362
+ has_title: !!readString(a, "title"),
363
+ has_url: !!readString(a, "url"),
364
+ has_source: !!readString(a, "source"),
365
+ tag_count: Array.isArray(a.tags) ? a.tags.length : undefined,
366
+ };
367
+ }
368
+ case canonicalToolNames.timeRange:
369
+ return {
370
+ limit: numberArg(a, "limit"),
371
+ has_start_date: !!readString(a, "startDate"),
372
+ has_end_date: !!readString(a, "endDate"),
373
+ };
374
+ case canonicalToolNames.keywords:
375
+ return {
376
+ keyword_count: Array.isArray(a.keywords) ? a.keywords.length : 0,
377
+ limit: numberArg(a, "limit"),
378
+ };
379
+ case canonicalToolNames.others: {
380
+ const query = readString(a, "query");
381
+ const queryAnalytics = safeTextAnalytics("query", query);
382
+ return {
383
+ ...queryAnalytics,
384
+ query_preview_safe: queryAnalytics.query_preview,
385
+ query_length: query?.length ?? 0,
386
+ };
387
+ }
388
+ default:
389
+ return {};
390
+ }
391
+ }
392
+ function toolEventName(canonicalName, status) {
393
+ return `[MCP] ${canonicalName} ${status}`;
394
+ }
131
395
  class EchoMemApiClient {
132
396
  store;
133
397
  axios;
@@ -135,6 +399,7 @@ class EchoMemApiClient {
135
399
  /** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
136
400
  sessionId = randomUUID();
137
401
  encConfigPromise = null;
402
+ deleteConfirmations = new Map();
138
403
  constructor(store) {
139
404
  this.store = store;
140
405
  this.axios = axios.create({
@@ -160,6 +425,16 @@ class EchoMemApiClient {
160
425
  getSessionId() {
161
426
  return this.sessionId;
162
427
  }
428
+ async trackMcpAnalyticsEvent(eventType, eventProperties) {
429
+ if (!this.hasToken())
430
+ return;
431
+ try {
432
+ await this.axios.post("/api/extension/mcp/events", { eventType, eventProperties }, { timeout: 1500 });
433
+ }
434
+ catch {
435
+ // Remote analytics is best-effort and must never change MCP behavior.
436
+ }
437
+ }
163
438
  /**
164
439
  * Compact topic map of the user's memory for the search-tool description — a cheap "what's in here"
165
440
  * index built from memory keys so the agent knows the boundary up front and recalls proactively.
@@ -211,10 +486,17 @@ class EchoMemApiClient {
211
486
  throw new LockedError(this.store.isKeyExpired());
212
487
  return { enabled: false };
213
488
  }
489
+ pruneDeleteConfirmations(now = Date.now()) {
490
+ for (const [memoryId, confirmation] of this.deleteConfirmations) {
491
+ if (confirmation.expiresAtMs <= now) {
492
+ this.deleteConfirmations.delete(memoryId);
493
+ }
494
+ }
495
+ }
214
496
  async whoami() {
215
497
  if (!this.whoamiCache) {
216
498
  this.whoamiCache = this.axios
217
- .get("/api/openclaw/v1/whoami")
499
+ .get("/api/openclaw/v1/whoami", { timeout: 6000 })
218
500
  .then((response) => response.data)
219
501
  .catch((error) => {
220
502
  this.whoamiCache = null; // don't pin a token-less failure; retry once a token exists
@@ -408,9 +690,57 @@ class EchoMemApiClient {
408
690
  title: parsed.title,
409
691
  // Stable per-session id so multiple saves in this coding session group under one context.
410
692
  conversationKey: this.sessionId,
693
+ triggerMessage: parsed.triggerMessage ||
694
+ lastUserMessageFromMessages(parsed.messages) ||
695
+ lastUserMessageFromConversationText(parsed.conversation),
696
+ triggerMessageRole: parsed.triggerMessageRole || "user",
411
697
  }, config);
412
698
  return response.data;
413
699
  }
700
+ async deleteMemory(args) {
701
+ const parsed = deleteMemorySchema.parse(args);
702
+ const enc = await this.encState();
703
+ const memory = await this.fetchMemoryById(parsed.memoryId, enc);
704
+ if (!memory) {
705
+ return { success: false, notFound: true, memoryId: parsed.memoryId };
706
+ }
707
+ this.pruneDeleteConfirmations();
708
+ const fingerprint = memoryDeleteFingerprint(memory);
709
+ if (!parsed.confirmed) {
710
+ const token = randomUUID();
711
+ const expiresAtMs = Date.now() + DELETE_CONFIRMATION_TTL_MS;
712
+ this.deleteConfirmations.set(parsed.memoryId, { token, expiresAtMs, fingerprint });
713
+ return {
714
+ success: true,
715
+ confirmationRequired: true,
716
+ memory,
717
+ confirmationToken: token,
718
+ expiresAt: new Date(expiresAtMs).toISOString(),
719
+ };
720
+ }
721
+ const confirmation = this.deleteConfirmations.get(parsed.memoryId);
722
+ if (!parsed.confirmationToken || !confirmation || confirmation.token !== parsed.confirmationToken) {
723
+ throw new McpError(ErrorCode.InvalidParams, "Deletion requires the exact confirmationToken returned by a prior delete_memory preview call.");
724
+ }
725
+ if (confirmation.expiresAtMs <= Date.now()) {
726
+ this.deleteConfirmations.delete(parsed.memoryId);
727
+ throw new McpError(ErrorCode.InvalidParams, "Deletion confirmation expired. Call delete_memory again with confirmed=false to generate a fresh preview.");
728
+ }
729
+ if (confirmation.fingerprint !== fingerprint) {
730
+ this.deleteConfirmations.delete(parsed.memoryId);
731
+ throw new McpError(ErrorCode.InvalidParams, "Memory changed after the confirmation preview. Call delete_memory again with confirmed=false before deleting.");
732
+ }
733
+ const response = await this.axios.delete(`/api/extension/memories/${encodeURIComponent(parsed.memoryId)}`, {
734
+ timeout: 10_000,
735
+ });
736
+ this.deleteConfirmations.delete(parsed.memoryId);
737
+ return {
738
+ success: true,
739
+ deleted: response.data?.success === true,
740
+ memory,
741
+ memoryId: parsed.memoryId,
742
+ };
743
+ }
414
744
  async getMemoriesByTimeRange(args) {
415
745
  const parsed = timeRangeSchema.parse(args);
416
746
  const enc = await this.encState();
@@ -454,6 +784,8 @@ class EchoMemMCPServer {
454
784
  client;
455
785
  mapCache = null;
456
786
  events;
787
+ mcpClientName;
788
+ mcpClientVersion;
457
789
  /** Whether the most recent ListTools response carried the memory map (per-session recall signal). */
458
790
  mapInjected = false;
459
791
  constructor(store) {
@@ -474,20 +806,59 @@ class EchoMemMCPServer {
474
806
  process.exit(0);
475
807
  });
476
808
  }
809
+ getMcpClientAnalytics() {
810
+ const hostPlatform = normalizeMcpHostPlatform(this.mcpClientName)
811
+ ?? detectMcpHostFromEnv()
812
+ ?? "unknown";
813
+ return {
814
+ mcp_client_name: this.mcpClientName,
815
+ mcp_client_version: this.mcpClientVersion,
816
+ host_platform: hostPlatform,
817
+ platform_source: hostPlatform,
818
+ };
819
+ }
477
820
  setupToolHandlers() {
478
821
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
479
- this.events.setClient(this.server.getClientVersion()?.name);
822
+ const clientVersion = this.server.getClientVersion();
823
+ this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
824
+ this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
825
+ this.events.setClient(this.mcpClientName);
480
826
  // Inject a compact topic map of the user's memory into the search-tool description so the agent
481
827
  // knows the boundary up front and recalls proactively (cached; best-effort — no map on failure).
482
- if (this.client.hasToken() && !this.mapCache)
828
+ if (this.client.hasToken() && !this.mapCache) {
483
829
  this.mapCache = this.client.fetchMemoryMap();
484
- const map = this.mapCache ? await this.mapCache : undefined;
830
+ // Don't poison the cache on a transient failure → clear it so a later listing can retry.
831
+ this.mapCache.then((m) => { if (!m)
832
+ this.mapCache = null; }).catch(() => { this.mapCache = null; });
833
+ }
834
+ // NEVER block tool-listing on the network. A flaky/unreachable API would otherwise hang the MCP
835
+ // handshake and freeze the whole agent ("connection timed out after 30000ms"). The map is
836
+ // best-effort: cap the wait, and it'll be injected on the next listing once it resolves.
837
+ const map = this.mapCache
838
+ ? await Promise.race([this.mapCache, new Promise((r) => setTimeout(() => r(undefined), 2500))])
839
+ : undefined;
485
840
  this.mapInjected = !!map;
486
841
  return { tools: listToolSpecs({ map }) };
487
842
  });
488
843
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
489
844
  const canonicalName = resolveCanonicalToolName(request.params.name);
845
+ const clientVersion = this.server.getClientVersion();
846
+ this.mcpClientName = clientVersion?.name ?? this.mcpClientName;
847
+ this.mcpClientVersion = clientVersion?.version ?? this.mcpClientVersion;
490
848
  const t0 = Date.now();
849
+ const analyticsBase = {
850
+ surface: "mcp",
851
+ event_family: "mcp",
852
+ integration: "echomem_mcp",
853
+ telemetry_source: "local_bridge",
854
+ ...this.getMcpClientAnalytics(),
855
+ codex_session_id: this.client.getSessionId(),
856
+ conversation_id: this.client.getSessionId(),
857
+ tool_name: request.params.name,
858
+ canonical_tool_name: canonicalName,
859
+ ...triggerAnalyticsForTool(canonicalName, request.params.arguments),
860
+ ...inputAnalyticsForTool(canonicalName, request.params.arguments),
861
+ };
491
862
  // One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
492
863
  const rec = {
493
864
  type: "tool_call",
@@ -501,6 +872,13 @@ class EchoMemMCPServer {
501
872
  }
502
873
  if (!this.client.hasToken())
503
874
  throw new NoTokenError();
875
+ if (canonicalName !== canonicalToolNames.save) {
876
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Tool Called", analyticsBase);
877
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, "Called"), analyticsBase);
878
+ }
879
+ if (canonicalName !== canonicalToolNames.save && analyticsBase.trigger_message_available === true) {
880
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Triggered By User Turn", analyticsBase);
881
+ }
504
882
  switch (canonicalName) {
505
883
  case canonicalToolNames.search:
506
884
  return await this.handleSearch(request.params.arguments, rec);
@@ -512,6 +890,8 @@ class EchoMemMCPServer {
512
890
  return await this.handleKeywords(request.params.arguments);
513
891
  case canonicalToolNames.others:
514
892
  return await this.handleOthers(request.params.arguments);
893
+ case canonicalToolNames.delete:
894
+ return await this.handleDelete(request.params.arguments, rec);
515
895
  default:
516
896
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
517
897
  }
@@ -559,6 +939,24 @@ class EchoMemMCPServer {
559
939
  rec.ok = rec.error_kind === "none";
560
940
  rec.latency_ms = Date.now() - t0;
561
941
  this.events.record(rec);
942
+ if (canonicalName !== canonicalToolNames.report &&
943
+ canonicalName !== canonicalToolNames.save &&
944
+ this.client.hasToken()) {
945
+ const finalAnalytics = {
946
+ ...analyticsBase,
947
+ success: rec.ok,
948
+ duration_ms: rec.latency_ms,
949
+ error_type: rec.error_kind === "none" ? undefined : rec.error_kind,
950
+ result_count: rec.results_count,
951
+ returned_text_length: rec.results_chars,
952
+ tuned: rec.tuned,
953
+ map_injected: rec.map_injected,
954
+ encrypted_user: rec.encrypted,
955
+ extracted_memory_count: rec.memories_extracted,
956
+ };
957
+ void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics);
958
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics);
959
+ }
562
960
  }
563
961
  });
564
962
  }
@@ -649,17 +1047,21 @@ Details: ${m.details || "N/A"}`)
649
1047
  return { content: [{ type: "text", text: `Found ${memories.length} relevant memories:\n\n${formattedResults}` }] };
650
1048
  }
651
1049
  async handleSave(args, rec) {
1050
+ const sourceFallback = this.getMcpClientAnalytics().platform_source;
1051
+ const enrichedArgs = isRecord(args) && !readString(args, "source")
1052
+ ? { ...args, source: sourceFallback }
1053
+ : args;
652
1054
  if (rec) {
653
- const a = args;
1055
+ const a = enrichedArgs;
654
1056
  const text = typeof a?.conversation === "string"
655
1057
  ? a.conversation
656
1058
  : Array.isArray(a?.messages)
657
1059
  ? a.messages.map((m) => String(m?.content ?? "")).join("\n")
658
1060
  : "";
659
1061
  rec.conversation_chars = text.length;
660
- rec.save_source = typeof a?.source === "string" ? a.source : "mcp_server";
1062
+ rec.save_source = typeof a?.source === "string" ? a.source : sourceFallback;
661
1063
  }
662
- const { success, memoriesExtracted, error } = await this.client.saveConversation(args);
1064
+ const { success, memoriesExtracted, error } = await this.client.saveConversation(enrichedArgs);
663
1065
  if (!success)
664
1066
  throw new Error(`EchoMem API Error: ${error}`);
665
1067
  if (rec)
@@ -743,6 +1145,56 @@ Details: ${m.details || "N/A"}`)
743
1145
  ],
744
1146
  };
745
1147
  }
1148
+ async handleDelete(args, rec) {
1149
+ const parsed = deleteMemorySchema.parse(args);
1150
+ if (rec) {
1151
+ rec.memory_id_hash = hashText(parsed.memoryId);
1152
+ rec.delete_confirmed = parsed.confirmed;
1153
+ }
1154
+ const result = await this.client.deleteMemory(args);
1155
+ if (result.notFound) {
1156
+ return {
1157
+ content: [{ type: "text", text: `Memory ${result.memoryId} was not found or is not accessible.` }],
1158
+ };
1159
+ }
1160
+ if (result.confirmationRequired) {
1161
+ return {
1162
+ content: [
1163
+ {
1164
+ type: "text",
1165
+ text: [
1166
+ "Deletion requires explicit user confirmation. No memory was deleted.",
1167
+ "",
1168
+ formatDeletePreview(result.memory),
1169
+ "",
1170
+ `confirmationToken: ${result.confirmationToken}`,
1171
+ `expiresAt: ${result.expiresAt}`,
1172
+ "",
1173
+ "Ask the user whether to delete this memory. If and only if they confirm, call delete_memory again with confirmed=true and this exact confirmationToken.",
1174
+ "This deletes the memory row only; raw source_of_truth conversation records are preserved.",
1175
+ ].join("\n"),
1176
+ },
1177
+ ],
1178
+ };
1179
+ }
1180
+ if (!result.deleted) {
1181
+ throw new Error("EchoMem API Error: delete request did not report success.");
1182
+ }
1183
+ return {
1184
+ content: [
1185
+ {
1186
+ type: "text",
1187
+ text: [
1188
+ `Deleted memory ${result.memoryId}.`,
1189
+ "",
1190
+ formatDeletePreview(result.memory),
1191
+ "",
1192
+ "Raw source_of_truth conversation records were preserved.",
1193
+ ].join("\n"),
1194
+ },
1195
+ ],
1196
+ };
1197
+ }
746
1198
  async run() {
747
1199
  const transport = new StdioServerTransport();
748
1200
  await this.server.connect(transport);