@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/README.md +9 -5
- package/dist/codex-sync.js +469 -0
- package/dist/encryption.js +3 -1
- package/dist/index.js +576 -22
- package/dist/migrate.js +945 -0
- package/dist/report.js +155 -9
- package/dist/setup-page.js +574 -0
- package/dist/setup.js +462 -65
- package/dist/v1-contract.js +80 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|