@echomem/mcp 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,277 @@ 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;
95
+ function isRecord(value) {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ }
98
+ function readString(record, key) {
99
+ const value = record[key];
100
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
101
+ }
102
+ function readNumber(record, key) {
103
+ const value = record[key];
104
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
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 isUserRole(role) {
171
+ return typeof role === "string" && ["user", "human"].includes(role.toLowerCase());
172
+ }
173
+ function conversationAnalyticsFromMessages(value) {
174
+ if (!Array.isArray(value))
175
+ return {};
176
+ const records = value.filter(isRecord);
177
+ const userMessages = records
178
+ .filter((item) => isUserRole(item.role) && readString(item, "content"))
179
+ .map((item) => readString(item, "content") ?? "");
180
+ const assistantMessages = records
181
+ .filter((item) => typeof item.role === "string" && ["assistant", "ai"].includes(item.role.toLowerCase()))
182
+ .map((item) => readString(item, "content") ?? "");
183
+ const conversationText = records
184
+ .map((item) => `${readString(item, "role") ?? "unknown"}: ${typeof item.content === "string" ? item.content : ""}`)
185
+ .join("\n\n");
186
+ return {
187
+ conversation_length: conversationText.length,
188
+ conversation_hash: conversationText ? hashAnalyticsText(conversationText) : undefined,
189
+ conversation_message_count: value.length,
190
+ user_message_count: userMessages.length,
191
+ assistant_message_count: assistantMessages.length,
192
+ ...safeTextAnalytics("first_user_message", userMessages[0]),
193
+ ...safeTextAnalytics("last_user_message", userMessages.at(-1)),
194
+ };
195
+ }
196
+ function lastUserMessageFromMessages(value) {
197
+ if (!Array.isArray(value))
198
+ return undefined;
199
+ for (let i = value.length - 1; i >= 0; i--) {
200
+ const item = value[i];
201
+ if (!isRecord(item) || !isUserRole(item.role))
202
+ continue;
203
+ const content = readString(item, "content");
204
+ if (content)
205
+ return content;
206
+ }
207
+ return undefined;
208
+ }
209
+ function lastUserMessageFromConversationText(value) {
210
+ if (typeof value !== "string" || !value)
211
+ return undefined;
212
+ const markdownUserTurns = [...value.matchAll(/(?:^|\n)##\s+(?:User|Human)[^\n]*\n([\s\S]*?)(?=\n---\n|\n##\s+(?:User|Human|Assistant|AI|System)\b|$)/gi)];
213
+ const lastMarkdownTurn = markdownUserTurns.at(-1)?.[1]?.trim();
214
+ if (lastMarkdownTurn)
215
+ return lastMarkdownTurn;
216
+ const roleLines = value.split(/\n/);
217
+ let currentRole = null;
218
+ let current = [];
219
+ let lastUser = "";
220
+ const flush = () => {
221
+ if (currentRole && isUserRole(currentRole)) {
222
+ const content = current.join("\n").trim();
223
+ if (content)
224
+ lastUser = content;
225
+ }
226
+ };
227
+ for (const line of roleLines) {
228
+ const match = line.match(/^\s*(user|human|assistant|ai|system)\s*:\s*(.*)$/i);
229
+ if (match) {
230
+ flush();
231
+ currentRole = match[1];
232
+ current = [match[2] ?? ""];
233
+ }
234
+ else if (currentRole) {
235
+ current.push(line);
236
+ }
237
+ }
238
+ flush();
239
+ return lastUser || undefined;
240
+ }
241
+ function normalizeRetrievalCandidate(value, fallbackRank) {
242
+ if (!isRecord(value))
243
+ return null;
244
+ const id = readString(value, "memory_id") ?? readString(value, "id");
245
+ if (!id)
246
+ return null;
247
+ return {
248
+ id,
249
+ bucket: readString(value, "bucket") ?? "primary",
250
+ rank: readNumber(value, "rank") ?? fallbackRank,
251
+ score: readNumber(value, "retrieval_similarity_score")
252
+ ?? readNumber(value, "similarity_score")
253
+ ?? readNumber(value, "similarity"),
254
+ key: readString(value, "key") ?? readString(value, "keys"),
255
+ time: readString(value, "time"),
256
+ isPublic: typeof value.is_public === "boolean" ? value.is_public : undefined,
257
+ };
258
+ }
259
+ function withinTimeFrame(memory, candidate, timeFrameDays) {
260
+ if (!timeFrameDays)
261
+ return true;
262
+ const timestamp = readString(memory, "time") ?? readString(memory, "created_at") ?? candidate.time;
263
+ if (!timestamp)
264
+ return true;
265
+ const ms = Date.parse(timestamp);
266
+ if (!Number.isFinite(ms))
267
+ return true;
268
+ return ms >= Date.now() - timeFrameDays * 24 * 60 * 60 * 1000;
269
+ }
270
+ function numberArg(args, key) {
271
+ return typeof args[key] === "number" && Number.isFinite(args[key]) ? args[key] : undefined;
272
+ }
273
+ function triggerAnalyticsForTool(canonicalName, args) {
274
+ const a = isRecord(args) ? args : {};
275
+ const explicitTrigger = readString(a, "triggerMessage");
276
+ let triggerText = explicitTrigger;
277
+ let triggerSource = explicitTrigger ? "tool_argument" : "not_provided_by_mcp_client";
278
+ if (!triggerText && canonicalName === canonicalToolNames.save) {
279
+ triggerText =
280
+ lastUserMessageFromMessages(a.messages) ??
281
+ lastUserMessageFromConversationText(a.conversation);
282
+ triggerSource = triggerText ? "conversation_payload_inferred" : triggerSource;
283
+ }
284
+ if (!triggerText) {
285
+ return {
286
+ trigger_message_available: false,
287
+ trigger_message_source: triggerSource,
288
+ };
289
+ }
290
+ return {
291
+ trigger_message_source: triggerSource,
292
+ trigger_message_role: readString(a, "triggerMessageRole") ?? "user",
293
+ ...safeTextAnalytics("trigger_message", triggerText),
294
+ };
295
+ }
296
+ function inputAnalyticsForTool(canonicalName, args) {
297
+ const a = isRecord(args) ? args : {};
298
+ switch (canonicalName) {
299
+ case canonicalToolNames.search: {
300
+ const query = readString(a, "query");
301
+ const queryAnalytics = safeTextAnalytics("query", query);
302
+ return {
303
+ ...queryAnalytics,
304
+ query_preview_safe: queryAnalytics.query_preview,
305
+ query_length: query?.length ?? 0,
306
+ limit: numberArg(a, "limit") ?? numberArg(a, "k"),
307
+ threshold: numberArg(a, "threshold"),
308
+ time_frame_days: numberArg(a, "timeFrameDays"),
309
+ include_answer: typeof a.includeAnswer === "boolean" ? a.includeAnswer : undefined,
310
+ };
311
+ }
312
+ case canonicalToolNames.save: {
313
+ const messages = Array.isArray(a.messages) ? a.messages : undefined;
314
+ const conversation = typeof a.conversation === "string" ? a.conversation : undefined;
315
+ return {
316
+ input_mode: messages?.length ? "messages" : "conversation",
317
+ message_count: messages?.length,
318
+ content_length: messages?.reduce((sum, item) => {
319
+ if (!isRecord(item))
320
+ return sum;
321
+ return sum + String(item.content ?? "").length;
322
+ }, 0) ?? conversation?.length ?? 0,
323
+ ...(messages?.length
324
+ ? conversationAnalyticsFromMessages(messages)
325
+ : {
326
+ conversation_length: conversation?.length ?? 0,
327
+ conversation_hash: conversation ? hashAnalyticsText(conversation) : undefined,
328
+ }),
329
+ has_title: !!readString(a, "title"),
330
+ has_url: !!readString(a, "url"),
331
+ has_source: !!readString(a, "source"),
332
+ tag_count: Array.isArray(a.tags) ? a.tags.length : undefined,
333
+ };
334
+ }
335
+ case canonicalToolNames.timeRange:
336
+ return {
337
+ limit: numberArg(a, "limit"),
338
+ has_start_date: !!readString(a, "startDate"),
339
+ has_end_date: !!readString(a, "endDate"),
340
+ };
341
+ case canonicalToolNames.keywords:
342
+ return {
343
+ keyword_count: Array.isArray(a.keywords) ? a.keywords.length : 0,
344
+ limit: numberArg(a, "limit"),
345
+ };
346
+ case canonicalToolNames.others: {
347
+ const query = readString(a, "query");
348
+ const queryAnalytics = safeTextAnalytics("query", query);
349
+ return {
350
+ ...queryAnalytics,
351
+ query_preview_safe: queryAnalytics.query_preview,
352
+ query_length: query?.length ?? 0,
353
+ };
354
+ }
355
+ default:
356
+ return {};
357
+ }
358
+ }
359
+ function toolEventName(canonicalName, status) {
360
+ return `[MCP] ${canonicalName} ${status}`;
361
+ }
91
362
  class EchoMemApiClient {
92
363
  store;
93
364
  axios;
@@ -95,6 +366,7 @@ class EchoMemApiClient {
95
366
  /** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
96
367
  sessionId = randomUUID();
97
368
  encConfigPromise = null;
369
+ deleteConfirmations = new Map();
98
370
  constructor(store) {
99
371
  this.store = store;
100
372
  this.axios = axios.create({
@@ -120,6 +392,16 @@ class EchoMemApiClient {
120
392
  getSessionId() {
121
393
  return this.sessionId;
122
394
  }
395
+ async trackMcpAnalyticsEvent(eventType, eventProperties) {
396
+ if (!this.hasToken())
397
+ return;
398
+ try {
399
+ await this.axios.post("/api/extension/mcp/events", { eventType, eventProperties }, { timeout: 1500 });
400
+ }
401
+ catch {
402
+ // Remote analytics is best-effort and must never change MCP behavior.
403
+ }
404
+ }
123
405
  /**
124
406
  * Compact topic map of the user's memory for the search-tool description — a cheap "what's in here"
125
407
  * index built from memory keys so the agent knows the boundary up front and recalls proactively.
@@ -171,10 +453,17 @@ class EchoMemApiClient {
171
453
  throw new LockedError(this.store.isKeyExpired());
172
454
  return { enabled: false };
173
455
  }
456
+ pruneDeleteConfirmations(now = Date.now()) {
457
+ for (const [memoryId, confirmation] of this.deleteConfirmations) {
458
+ if (confirmation.expiresAtMs <= now) {
459
+ this.deleteConfirmations.delete(memoryId);
460
+ }
461
+ }
462
+ }
174
463
  async whoami() {
175
464
  if (!this.whoamiCache) {
176
465
  this.whoamiCache = this.axios
177
- .get("/api/openclaw/v1/whoami")
466
+ .get("/api/openclaw/v1/whoami", { timeout: 6000 })
178
467
  .then((response) => response.data)
179
468
  .catch((error) => {
180
469
  this.whoamiCache = null; // don't pin a token-less failure; retry once a token exists
@@ -190,6 +479,89 @@ class EchoMemApiClient {
190
479
  const idx = desc.indexOf("Content:");
191
480
  return (idx >= 0 ? desc.slice(idx + "Content:".length) : desc).trim();
192
481
  }
482
+ async fetchMemoryById(id, enc) {
483
+ try {
484
+ const response = await this.axios.get(`/api/extension/memories/${encodeURIComponent(id)}`, {
485
+ timeout: 10_000,
486
+ });
487
+ const row = isRecord(response.data) ? response.data : {};
488
+ return enc.enabled ? await decryptMemoryFields(row, enc.key) : row;
489
+ }
490
+ catch (error) {
491
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
492
+ return null;
493
+ }
494
+ throw error;
495
+ }
496
+ }
497
+ async searchMemoriesRaw(query, opts, enc, retrievalOnly = false) {
498
+ const response = await this.axios.post("/api/extension/memories/search", {
499
+ query,
500
+ k: opts.limit,
501
+ similarityThreshold: opts.threshold,
502
+ timeFrameDays: opts.timeFrameDays,
503
+ });
504
+ const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
505
+ return retrievalOnly && isRecord(data)
506
+ ? { ...data, retrievalOnly: true, tuned: false }
507
+ : data;
508
+ }
509
+ async searchMemoriesRetrieved(query, opts, enc) {
510
+ const response = await this.axios.post("/api/extension/memories/deep-search/candidates", {
511
+ query,
512
+ userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
513
+ });
514
+ const data = response.data ?? {};
515
+ if (data.success === false) {
516
+ throw new Error(`deep-search candidates proxy error: ${data.error || "unknown"}`);
517
+ }
518
+ const rawCandidates = Array.isArray(data.personalCandidates) ? data.personalCandidates : [];
519
+ const candidates = rawCandidates
520
+ .map((candidate, index) => normalizeRetrievalCandidate(candidate, index))
521
+ .filter((candidate) => candidate !== null);
522
+ const primaryIds = new Set(candidates.filter((c) => c.bucket === "primary").map((c) => c.id));
523
+ const seen = new Set();
524
+ const dedupedCandidates = candidates.filter((candidate) => {
525
+ if (candidate.bucket === "inference" && primaryIds.has(candidate.id))
526
+ return false;
527
+ if (candidate.score !== undefined && candidate.score < opts.threshold)
528
+ return false;
529
+ if (seen.has(candidate.id))
530
+ return false;
531
+ seen.add(candidate.id);
532
+ return true;
533
+ });
534
+ const memories = [];
535
+ for (const candidate of dedupedCandidates) {
536
+ const row = await this.fetchMemoryById(candidate.id, enc);
537
+ if (!row || !withinTimeFrame(row, candidate, opts.timeFrameDays))
538
+ continue;
539
+ const key = readString(row, "key") ?? readString(row, "keys") ?? candidate.key ?? "";
540
+ const memory = {
541
+ ...row,
542
+ id: readString(row, "id") ?? candidate.id,
543
+ key,
544
+ keys: readString(row, "keys") ?? key,
545
+ retrieval_bucket: candidate.bucket,
546
+ retrieval_rank: candidate.rank,
547
+ is_public: typeof row.is_public === "boolean" ? row.is_public : candidate.isPublic,
548
+ };
549
+ if (candidate.score !== undefined) {
550
+ memory.similarity = candidate.score;
551
+ memory.similarity_score = candidate.score;
552
+ memory.retrieval_similarity_score = candidate.score;
553
+ }
554
+ memories.push(memory);
555
+ if (memories.length >= opts.limit)
556
+ break;
557
+ }
558
+ return {
559
+ success: true,
560
+ tuned: true,
561
+ retrievalOnly: true,
562
+ memories,
563
+ };
564
+ }
193
565
  // Tuned recall. Routes through echo-mem-chrome's AUTHENTICATED deep-search proxy
194
566
  // (the same route the Chrome extension uses): it verifies our ec_ token, derives
195
567
  // user_id server-side, and forwards to dg-web's two-phase retriever. The bridge
@@ -233,29 +605,26 @@ class EchoMemApiClient {
233
605
  });
234
606
  return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
235
607
  }
608
+ const searchOpts = { limit, threshold, timeFrameDays: parsed.timeFrameDays };
609
+ if (!parsed.includeAnswer) {
610
+ try {
611
+ return await this.searchMemoriesRetrieved(query, searchOpts, enc);
612
+ }
613
+ catch {
614
+ return await this.searchMemoriesRaw(query, searchOpts, enc, true);
615
+ }
616
+ }
236
617
  // Encrypted account: the server can't synthesize over plaintext it doesn't hold, so use the raw
237
618
  // retriever (returns ciphertext) and decrypt locally — zero-knowledge preserved end-to-end.
238
619
  if (enc.enabled) {
239
- const response = await this.axios.post("/api/extension/memories/search", {
240
- query,
241
- k: limit,
242
- similarityThreshold: threshold,
243
- timeFrameDays: parsed.timeFrameDays,
244
- });
245
- return await this.decryptResult(response.data, enc.key);
620
+ return await this.searchMemoriesRaw(query, searchOpts, enc);
246
621
  }
247
622
  // Unencrypted: prefer the tuned two-phase retriever; degrade gracefully to the untuned path.
248
623
  try {
249
624
  return await this.searchMemoriesTuned(query);
250
625
  }
251
626
  catch {
252
- const response = await this.axios.post("/api/extension/memories/search", {
253
- query,
254
- k: limit,
255
- similarityThreshold: threshold,
256
- timeFrameDays: parsed.timeFrameDays,
257
- });
258
- return response.data;
627
+ return await this.searchMemoriesRaw(query, searchOpts, enc);
259
628
  }
260
629
  }
261
630
  /** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
@@ -288,9 +657,57 @@ class EchoMemApiClient {
288
657
  title: parsed.title,
289
658
  // Stable per-session id so multiple saves in this coding session group under one context.
290
659
  conversationKey: this.sessionId,
660
+ triggerMessage: parsed.triggerMessage ||
661
+ lastUserMessageFromMessages(parsed.messages) ||
662
+ lastUserMessageFromConversationText(parsed.conversation),
663
+ triggerMessageRole: parsed.triggerMessageRole || "user",
291
664
  }, config);
292
665
  return response.data;
293
666
  }
667
+ async deleteMemory(args) {
668
+ const parsed = deleteMemorySchema.parse(args);
669
+ const enc = await this.encState();
670
+ const memory = await this.fetchMemoryById(parsed.memoryId, enc);
671
+ if (!memory) {
672
+ return { success: false, notFound: true, memoryId: parsed.memoryId };
673
+ }
674
+ this.pruneDeleteConfirmations();
675
+ const fingerprint = memoryDeleteFingerprint(memory);
676
+ if (!parsed.confirmed) {
677
+ const token = randomUUID();
678
+ const expiresAtMs = Date.now() + DELETE_CONFIRMATION_TTL_MS;
679
+ this.deleteConfirmations.set(parsed.memoryId, { token, expiresAtMs, fingerprint });
680
+ return {
681
+ success: true,
682
+ confirmationRequired: true,
683
+ memory,
684
+ confirmationToken: token,
685
+ expiresAt: new Date(expiresAtMs).toISOString(),
686
+ };
687
+ }
688
+ const confirmation = this.deleteConfirmations.get(parsed.memoryId);
689
+ if (!parsed.confirmationToken || !confirmation || confirmation.token !== parsed.confirmationToken) {
690
+ throw new McpError(ErrorCode.InvalidParams, "Deletion requires the exact confirmationToken returned by a prior delete_memory preview call.");
691
+ }
692
+ if (confirmation.expiresAtMs <= Date.now()) {
693
+ this.deleteConfirmations.delete(parsed.memoryId);
694
+ throw new McpError(ErrorCode.InvalidParams, "Deletion confirmation expired. Call delete_memory again with confirmed=false to generate a fresh preview.");
695
+ }
696
+ if (confirmation.fingerprint !== fingerprint) {
697
+ this.deleteConfirmations.delete(parsed.memoryId);
698
+ throw new McpError(ErrorCode.InvalidParams, "Memory changed after the confirmation preview. Call delete_memory again with confirmed=false before deleting.");
699
+ }
700
+ const response = await this.axios.delete(`/api/extension/memories/${encodeURIComponent(parsed.memoryId)}`, {
701
+ timeout: 10_000,
702
+ });
703
+ this.deleteConfirmations.delete(parsed.memoryId);
704
+ return {
705
+ success: true,
706
+ deleted: response.data?.success === true,
707
+ memory,
708
+ memoryId: parsed.memoryId,
709
+ };
710
+ }
294
711
  async getMemoriesByTimeRange(args) {
295
712
  const parsed = timeRangeSchema.parse(args);
296
713
  const enc = await this.encState();
@@ -313,9 +730,7 @@ class EchoMemApiClient {
313
730
  async searchOthersMemories(args) {
314
731
  const parsed = othersSchema.parse(args);
315
732
  try {
316
- const whoami = await this.whoami();
317
- const response = await axios.post(`${MEMORY_FEED_API_URL.replace(/\/$/, "")}/api/search`, {
318
- userId: whoami.user_id,
733
+ const response = await axios.post(`${MEMORY_FEED_API_URL.replace(/\/$/, "")}/api/search/public`, {
319
734
  query: parsed.query,
320
735
  excludedMemoryIds: [],
321
736
  }, {
@@ -361,15 +776,36 @@ class EchoMemMCPServer {
361
776
  this.events.setClient(this.server.getClientVersion()?.name);
362
777
  // Inject a compact topic map of the user's memory into the search-tool description so the agent
363
778
  // knows the boundary up front and recalls proactively (cached; best-effort — no map on failure).
364
- if (this.client.hasToken() && !this.mapCache)
779
+ if (this.client.hasToken() && !this.mapCache) {
365
780
  this.mapCache = this.client.fetchMemoryMap();
366
- const map = this.mapCache ? await this.mapCache : undefined;
781
+ // Don't poison the cache on a transient failure → clear it so a later listing can retry.
782
+ this.mapCache.then((m) => { if (!m)
783
+ this.mapCache = null; }).catch(() => { this.mapCache = null; });
784
+ }
785
+ // NEVER block tool-listing on the network. A flaky/unreachable API would otherwise hang the MCP
786
+ // handshake and freeze the whole agent ("connection timed out after 30000ms"). The map is
787
+ // best-effort: cap the wait, and it'll be injected on the next listing once it resolves.
788
+ const map = this.mapCache
789
+ ? await Promise.race([this.mapCache, new Promise((r) => setTimeout(() => r(undefined), 2500))])
790
+ : undefined;
367
791
  this.mapInjected = !!map;
368
792
  return { tools: listToolSpecs({ map }) };
369
793
  });
370
794
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
371
795
  const canonicalName = resolveCanonicalToolName(request.params.name);
372
796
  const t0 = Date.now();
797
+ const analyticsBase = {
798
+ surface: "mcp",
799
+ event_family: "mcp",
800
+ integration: "echomem_mcp",
801
+ telemetry_source: "local_bridge",
802
+ codex_session_id: this.client.getSessionId(),
803
+ conversation_id: this.client.getSessionId(),
804
+ tool_name: request.params.name,
805
+ canonical_tool_name: canonicalName,
806
+ ...triggerAnalyticsForTool(canonicalName, request.params.arguments),
807
+ ...inputAnalyticsForTool(canonicalName, request.params.arguments),
808
+ };
373
809
  // One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
374
810
  const rec = {
375
811
  type: "tool_call",
@@ -383,6 +819,13 @@ class EchoMemMCPServer {
383
819
  }
384
820
  if (!this.client.hasToken())
385
821
  throw new NoTokenError();
822
+ if (canonicalName !== canonicalToolNames.save) {
823
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Tool Called", analyticsBase);
824
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, "Called"), analyticsBase);
825
+ }
826
+ if (canonicalName !== canonicalToolNames.save && analyticsBase.trigger_message_available === true) {
827
+ void this.client.trackMcpAnalyticsEvent("[MCP] EchoMem Triggered By User Turn", analyticsBase);
828
+ }
386
829
  switch (canonicalName) {
387
830
  case canonicalToolNames.search:
388
831
  return await this.handleSearch(request.params.arguments, rec);
@@ -394,6 +837,8 @@ class EchoMemMCPServer {
394
837
  return await this.handleKeywords(request.params.arguments);
395
838
  case canonicalToolNames.others:
396
839
  return await this.handleOthers(request.params.arguments);
840
+ case canonicalToolNames.delete:
841
+ return await this.handleDelete(request.params.arguments, rec);
397
842
  default:
398
843
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
399
844
  }
@@ -441,6 +886,24 @@ class EchoMemMCPServer {
441
886
  rec.ok = rec.error_kind === "none";
442
887
  rec.latency_ms = Date.now() - t0;
443
888
  this.events.record(rec);
889
+ if (canonicalName !== canonicalToolNames.report &&
890
+ canonicalName !== canonicalToolNames.save &&
891
+ this.client.hasToken()) {
892
+ const finalAnalytics = {
893
+ ...analyticsBase,
894
+ success: rec.ok,
895
+ duration_ms: rec.latency_ms,
896
+ error_type: rec.error_kind === "none" ? undefined : rec.error_kind,
897
+ result_count: rec.results_count,
898
+ returned_text_length: rec.results_chars,
899
+ tuned: rec.tuned,
900
+ map_injected: rec.map_injected,
901
+ encrypted_user: rec.encrypted,
902
+ extracted_memory_count: rec.memories_extracted,
903
+ };
904
+ void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics);
905
+ void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics);
906
+ }
444
907
  }
445
908
  });
446
909
  }
@@ -459,6 +922,47 @@ class EchoMemMCPServer {
459
922
  rec.results_chars = mems.reduce((n, m) => n + String(m?.description ?? "").length, 0);
460
923
  rec.memory_keys = mems.map((m) => String(m?.key ?? m?.keys ?? "").trim()).filter(Boolean).slice(0, 40);
461
924
  }
925
+ // Default MCP recall path: return only retrieved memories. The calling agent does the answer
926
+ // generation, so we avoid spending an extra EchoMem model call and avoid double synthesis.
927
+ if (result?.retrievalOnly) {
928
+ const memories = Array.isArray(result.memories) ? result.memories.filter(isRecord) : [];
929
+ if (!memories.length) {
930
+ return { content: [{ type: "text", text: "No relevant memories found." }] };
931
+ }
932
+ const formattedResults = memories
933
+ .map((m, idx) => {
934
+ const key = readString(m, "key") ?? readString(m, "keys") ?? "Saved memory";
935
+ const score = readNumber(m, "similarity")
936
+ ?? readNumber(m, "similarity_score")
937
+ ?? readNumber(m, "retrieval_similarity_score");
938
+ const meta = [
939
+ readString(m, "time") ? `Time: ${readString(m, "time")}` : "",
940
+ readString(m, "location") ? `Location: ${readString(m, "location")}` : "",
941
+ readString(m, "category") ? `Category: ${readString(m, "category")}` : "",
942
+ readString(m, "object") ? `Object: ${readString(m, "object")}` : "",
943
+ readString(m, "emotion") ? `Emotion: ${readString(m, "emotion")}` : "",
944
+ readString(m, "retrieval_bucket") ? `Bucket: ${readString(m, "retrieval_bucket")}` : "",
945
+ ].filter(Boolean).join(" | ");
946
+ const description = typeof m.description === "string"
947
+ ? m.description
948
+ : m.description == null
949
+ ? ""
950
+ : String(m.description);
951
+ const details = typeof m.details === "string"
952
+ ? m.details.trim()
953
+ : m.details == null
954
+ ? ""
955
+ : String(m.details).trim();
956
+ return [
957
+ `[${idx + 1}] ${key}${typeof score === "number" ? ` (score ${score.toFixed(3)})` : ""}`,
958
+ meta,
959
+ `Description: ${description}`,
960
+ details ? `Details: ${details}` : "",
961
+ ].filter(Boolean).join("\n");
962
+ })
963
+ .join("\n\n");
964
+ return { content: [{ type: "text", text: `Retrieved ${memories.length} memories:\n\n${formattedResults}` }] };
965
+ }
462
966
  // Tuned two-phase path: synthesized brief + ranked source memories.
463
967
  if (result?.tuned) {
464
968
  const { answer, memories } = result;
@@ -584,6 +1088,56 @@ Details: ${m.details || "N/A"}`)
584
1088
  ],
585
1089
  };
586
1090
  }
1091
+ async handleDelete(args, rec) {
1092
+ const parsed = deleteMemorySchema.parse(args);
1093
+ if (rec) {
1094
+ rec.memory_id_hash = hashText(parsed.memoryId);
1095
+ rec.delete_confirmed = parsed.confirmed;
1096
+ }
1097
+ const result = await this.client.deleteMemory(args);
1098
+ if (result.notFound) {
1099
+ return {
1100
+ content: [{ type: "text", text: `Memory ${result.memoryId} was not found or is not accessible.` }],
1101
+ };
1102
+ }
1103
+ if (result.confirmationRequired) {
1104
+ return {
1105
+ content: [
1106
+ {
1107
+ type: "text",
1108
+ text: [
1109
+ "Deletion requires explicit user confirmation. No memory was deleted.",
1110
+ "",
1111
+ formatDeletePreview(result.memory),
1112
+ "",
1113
+ `confirmationToken: ${result.confirmationToken}`,
1114
+ `expiresAt: ${result.expiresAt}`,
1115
+ "",
1116
+ "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.",
1117
+ "This deletes the memory row only; raw source_of_truth conversation records are preserved.",
1118
+ ].join("\n"),
1119
+ },
1120
+ ],
1121
+ };
1122
+ }
1123
+ if (!result.deleted) {
1124
+ throw new Error("EchoMem API Error: delete request did not report success.");
1125
+ }
1126
+ return {
1127
+ content: [
1128
+ {
1129
+ type: "text",
1130
+ text: [
1131
+ `Deleted memory ${result.memoryId}.`,
1132
+ "",
1133
+ formatDeletePreview(result.memory),
1134
+ "",
1135
+ "Raw source_of_truth conversation records were preserved.",
1136
+ ].join("\n"),
1137
+ },
1138
+ ],
1139
+ };
1140
+ }
587
1141
  async run() {
588
1142
  const transport = new StdioServerTransport();
589
1143
  await this.server.connect(transport);