@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 CHANGED
@@ -6,11 +6,12 @@ This package implements a stateless, cloud-first [Model Context Protocol (MCP)](
6
6
 
7
7
  This MCP Server bridges local tools and your EchoMem Cloud API entirely via authenticated REST `fetch()` calls.
8
8
 
9
- - `search_memories`: Connects to `POST /api/extension/memories/search`
9
+ - `search_memories`: Calls the deep-search candidate phase, resolves the retrieved memory rows, and returns those memories only by default
10
10
  - `save_conversation`: Connects to `POST /api/extension/memories/ingest`
11
11
  - `get_memories_by_time_range`: Connects to `POST /api/extension/memories/time-range`
12
12
  - `search_memories_by_keywords`: Connects to `POST /api/extension/memories/keywords`
13
- - `search_others_memories`: Connects to MemoryFeed search after resolving the authenticated Echo user id
13
+ - `search_others_memories`: Connects to MemoryFeed public search without sending the authenticated Echo user id
14
+ - `delete_memory`: Previews one personal memory and returns a confirmation token; only deletes after a second confirmed call
14
15
 
15
16
  No direct access to the `IndexedDB` or local files is required.
16
17
 
@@ -27,7 +28,9 @@ For **zero-knowledge accounts**, the key never leaves your machine. The bridge h
27
28
  - **locked state:** if the key is absent or past its TTL (7 days, matching the extension), tools
28
29
  return a `🔒 locked` nudge to run `echomem-mcp unlock` — **ciphertext is never handed to the model**.
29
30
 
30
- Unencrypted accounts are unaffected and use the tuned two-phase retriever as before.
31
+ Unencrypted accounts are unaffected. `search_memories` returns retrieved memories by default so
32
+ your MCP client does the final answer generation; pass `includeAnswer: true` only if you need the
33
+ legacy EchoMem-synthesized recall answer.
31
34
 
32
35
  ## Quick start (recommended)
33
36
 
@@ -142,11 +145,12 @@ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run st
142
145
 
143
146
  ## Available Tools
144
147
 
145
- * **`search_memories`**: Perform semantic searches against your personal memory vault using cloud embeddings.
148
+ * **`search_memories`**: Retrieve ranked personal memories for a query. Defaults to memory-only output and skips EchoMem answer generation; set `includeAnswer: true` for the legacy synthesized recall.
146
149
  * **`save_conversation`**: Ingest and structure a conversation directly into your EchoMem timeline.
147
150
  * **`get_memories_by_time_range`**: Retrieve memories between explicit start/end timestamps.
148
151
  * **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
149
- * **`search_others_memories`**: Search other users' public memories through MemoryFeed.
152
+ * **`search_others_memories`**: Search other users' public memories through MemoryFeed public search.
153
+ * **`delete_memory`**: Delete a single personal memory through a two-step confirmation flow. First call with `memoryId` only to preview the target and receive `confirmationToken`; after the user explicitly confirms, call again with `confirmed: true` and that exact token. This deletes the memory row only and preserves raw `source_of_truth` conversation records.
150
154
 
151
155
  Legacy aliases are preserved for compatibility:
152
156
 
@@ -0,0 +1,469 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ import { createHash } from "node:crypto";
6
+ import axios from "axios";
7
+ import { KeyStore } from "./keystore.js";
8
+ const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
9
+ const DEFAULT_DAYS = 7;
10
+ const DEFAULT_LIMIT = 50;
11
+ const URL_RE = /\bhttps?:\/\/[^\s<>"')\]]+/i;
12
+ 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;
13
+ const CODE_LIKE_RE = /(```|^\s*(import|export|const|let|var|function|class|interface|type)\s|\b(def|async|await)\s+\w+)/m;
14
+ function hashText(text) {
15
+ return `sha256:${createHash("sha256").update(text).digest("hex")}`;
16
+ }
17
+ function shortHash(text) {
18
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
19
+ }
20
+ function redactText(text) {
21
+ return text
22
+ .replace(URL_RE, "[url]")
23
+ .replace(SECRET_LIKE_RE, "[secret-like-text]")
24
+ .replace(/\s+/g, " ")
25
+ .trim();
26
+ }
27
+ function safeText(prefix, text, previewChars = 300) {
28
+ if (typeof text !== "string" || text.length === 0) {
29
+ return {
30
+ [`${prefix}_available`]: false,
31
+ [`${prefix}_length`]: 0,
32
+ };
33
+ }
34
+ return {
35
+ [`${prefix}_available`]: true,
36
+ [`${prefix}_preview`]: redactText(text).slice(0, previewChars),
37
+ [`${prefix}_length`]: text.length,
38
+ [`${prefix}_hash`]: hashText(text),
39
+ [`${prefix}_contains_url`]: URL_RE.test(text),
40
+ [`${prefix}_contains_code`]: CODE_LIKE_RE.test(text),
41
+ [`${prefix}_contains_secret_like_text`]: SECRET_LIKE_RE.test(text),
42
+ };
43
+ }
44
+ function asRecord(value) {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
46
+ }
47
+ function asString(value) {
48
+ return typeof value === "string" ? value : undefined;
49
+ }
50
+ function asNumber(value) {
51
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
52
+ }
53
+ function parseArgs(value) {
54
+ if (typeof value === "string") {
55
+ try {
56
+ return asRecord(JSON.parse(value));
57
+ }
58
+ catch {
59
+ return {};
60
+ }
61
+ }
62
+ return asRecord(value);
63
+ }
64
+ function contentToText(value) {
65
+ if (typeof value === "string")
66
+ return value;
67
+ if (!Array.isArray(value))
68
+ return "";
69
+ return value
70
+ .map((part) => {
71
+ const rec = asRecord(part);
72
+ return asString(rec.text) || asString(rec.content) || "";
73
+ })
74
+ .filter(Boolean)
75
+ .join("\n");
76
+ }
77
+ function conversationAnalyticsFromMessages(value) {
78
+ if (!Array.isArray(value))
79
+ return {};
80
+ const records = value.map(asRecord).filter((record) => Object.keys(record).length > 0);
81
+ const userMessages = records
82
+ .filter((record) => ["user", "human"].includes(String(record.role ?? "").toLowerCase()) && asString(record.content))
83
+ .map((record) => asString(record.content) ?? "");
84
+ const assistantMessages = records
85
+ .filter((record) => ["assistant", "ai"].includes(String(record.role ?? "").toLowerCase()) && asString(record.content))
86
+ .map((record) => asString(record.content) ?? "");
87
+ const conversationText = records
88
+ .map((record) => `${asString(record.role) ?? "unknown"}: ${asString(record.content) ?? ""}`)
89
+ .join("\n\n");
90
+ return {
91
+ conversation_length: conversationText.length,
92
+ conversation_hash: conversationText ? hashText(conversationText) : undefined,
93
+ conversation_message_count: value.length,
94
+ user_message_count: userMessages.length,
95
+ assistant_message_count: assistantMessages.length,
96
+ ...safeText("first_user_message", userMessages[0]),
97
+ ...safeText("last_user_message", userMessages.at(-1)),
98
+ };
99
+ }
100
+ function summarizeToolArgs(toolName, args) {
101
+ if (toolName === "search_memories" || toolName === "search_memories_by_description_semantic") {
102
+ const query = asString(args.query);
103
+ return {
104
+ ...safeText("query", query, 160),
105
+ query_preview_safe: query ? redactText(query).slice(0, 160) : undefined,
106
+ query_length: query?.length ?? 0,
107
+ limit: asNumber(args.limit) ?? asNumber(args.k),
108
+ threshold: asNumber(args.threshold),
109
+ time_frame_days: asNumber(args.timeFrameDays),
110
+ include_answer: typeof args.includeAnswer === "boolean" ? args.includeAnswer : undefined,
111
+ };
112
+ }
113
+ if (toolName === "save_conversation") {
114
+ const messages = Array.isArray(args.messages) ? args.messages : undefined;
115
+ const conversation = asString(args.conversation);
116
+ return {
117
+ input_mode: messages?.length ? "messages" : "conversation",
118
+ message_count: messages?.length,
119
+ content_length: messages?.reduce((sum, item) => sum + String(asRecord(item).content ?? "").length, 0) ??
120
+ conversation?.length ??
121
+ 0,
122
+ ...(messages?.length
123
+ ? conversationAnalyticsFromMessages(messages)
124
+ : {
125
+ conversation_length: conversation?.length ?? 0,
126
+ conversation_hash: conversation ? hashText(conversation) : undefined,
127
+ }),
128
+ has_title: !!asString(args.title),
129
+ title_length: asString(args.title)?.length,
130
+ has_url: !!asString(args.url),
131
+ has_source: !!asString(args.source),
132
+ tag_count: Array.isArray(args.tags) ? args.tags.length : undefined,
133
+ };
134
+ }
135
+ if (toolName === "get_memories_by_time_range" || toolName === "search_memories_by_time_range") {
136
+ return {
137
+ has_start_date: !!asString(args.startDate),
138
+ has_end_date: !!asString(args.endDate),
139
+ limit: asNumber(args.limit),
140
+ };
141
+ }
142
+ if (toolName === "search_memories_by_keywords") {
143
+ return {
144
+ keyword_count: Array.isArray(args.keywords) ? args.keywords.length : 0,
145
+ limit: asNumber(args.limit),
146
+ };
147
+ }
148
+ if (toolName === "search_others_memories") {
149
+ const query = asString(args.query);
150
+ return {
151
+ ...safeText("query", query, 160),
152
+ query_preview_safe: query ? redactText(query).slice(0, 160) : undefined,
153
+ query_length: query?.length ?? 0,
154
+ };
155
+ }
156
+ return {};
157
+ }
158
+ function summarizeResult(value) {
159
+ const text = typeof value === "string" ? value : JSON.stringify(value ?? "");
160
+ const found = text.match(/Found\s+(\d+)\s+(?:relevant\s+)?memories/i) || text.match(/Retrieved\s+(\d+)\s+memories/i);
161
+ const extracted = text.match(/Extracted\s+(\d+)\s+memory distinct events/i);
162
+ return {
163
+ result_count: found ? Number(found[1]) : undefined,
164
+ extracted_memory_count: extracted ? Number(extracted[1]) : undefined,
165
+ returned_text_length: text.length,
166
+ };
167
+ }
168
+ function eachJsonLine(file, fn) {
169
+ let fd;
170
+ try {
171
+ fd = fs.openSync(file, "r");
172
+ }
173
+ catch {
174
+ return;
175
+ }
176
+ try {
177
+ const decoder = new StringDecoder("utf8");
178
+ const buf = Buffer.allocUnsafe(1 << 20);
179
+ let leftover = "";
180
+ let lineNo = 0;
181
+ const flush = (chunk) => {
182
+ const lines = (leftover + chunk).split("\n");
183
+ leftover = lines.pop() ?? "";
184
+ for (const line of lines) {
185
+ lineNo++;
186
+ const trimmed = line.trim();
187
+ if (!trimmed)
188
+ continue;
189
+ try {
190
+ fn(asRecord(JSON.parse(trimmed)), lineNo);
191
+ }
192
+ catch {
193
+ /* skip malformed lines */
194
+ }
195
+ }
196
+ };
197
+ let bytes = 0;
198
+ while ((bytes = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
199
+ flush(decoder.write(buf.subarray(0, bytes)));
200
+ }
201
+ const last = (leftover + decoder.end()).trim();
202
+ if (last) {
203
+ lineNo++;
204
+ try {
205
+ fn(asRecord(JSON.parse(last)), lineNo);
206
+ }
207
+ catch {
208
+ /* skip malformed final line */
209
+ }
210
+ }
211
+ }
212
+ finally {
213
+ try {
214
+ fs.closeSync(fd);
215
+ }
216
+ catch {
217
+ /* best effort */
218
+ }
219
+ }
220
+ }
221
+ function walk(dir, out = []) {
222
+ let entries;
223
+ try {
224
+ entries = fs.readdirSync(dir, { withFileTypes: true });
225
+ }
226
+ catch {
227
+ return out;
228
+ }
229
+ for (const entry of entries) {
230
+ const full = path.join(dir, entry.name);
231
+ if (entry.isDirectory())
232
+ walk(full, out);
233
+ else if (/rollout-.*\.jsonl$/.test(entry.name))
234
+ out.push(full);
235
+ }
236
+ return out;
237
+ }
238
+ function sessionIdFromPath(file) {
239
+ const match = path.basename(file).match(/(019[a-f0-9-]+)/i);
240
+ return match?.[1] ?? shortHash(file);
241
+ }
242
+ function baseSessionProperties(meta, file) {
243
+ const cwd = meta.cwd;
244
+ const startedAt = meta.timestamp;
245
+ return {
246
+ surface: "codex",
247
+ event_family: "codex_session",
248
+ telemetry_source: "codex_jsonl_sync",
249
+ codex_session_id: meta.id ?? sessionIdFromPath(file),
250
+ codex_session_file_date: startedAt?.slice(0, 10),
251
+ session_started_at: startedAt,
252
+ project_name: cwd ? path.basename(cwd) : undefined,
253
+ cwd_hash: cwd ? hashText(cwd) : undefined,
254
+ originator: meta.originator,
255
+ codex_source: meta.source,
256
+ cli_version: meta.cliVersion,
257
+ model_provider: meta.modelProvider,
258
+ model: meta.model,
259
+ };
260
+ }
261
+ function parseCodexSession(file) {
262
+ const events = [];
263
+ const meta = { id: sessionIdFromPath(file) };
264
+ let lastUser = null;
265
+ let userTurnIndex = 0;
266
+ let userMessageCount = 0;
267
+ let assistantMessageCount = 0;
268
+ let functionCallCount = 0;
269
+ let mcpToolCallCount = 0;
270
+ let echomemToolCallCount = 0;
271
+ let startedAt;
272
+ let endedAt;
273
+ const echomemTriggerTurns = new Set();
274
+ let lastTokenUsage = {};
275
+ const functionCallsById = new Map();
276
+ eachJsonLine(file, (obj, lineNo) => {
277
+ const timestamp = asString(obj.timestamp);
278
+ if (!startedAt && timestamp)
279
+ startedAt = timestamp;
280
+ if (timestamp)
281
+ endedAt = timestamp;
282
+ const payload = asRecord(obj.payload);
283
+ const payloadType = asString(payload.type);
284
+ if (obj.type === "session_meta") {
285
+ const p = payload;
286
+ meta.id = asString(p.id) ?? meta.id;
287
+ meta.timestamp = asString(p.timestamp) ?? timestamp;
288
+ meta.cwd = asString(p.cwd);
289
+ meta.originator = asString(p.originator);
290
+ meta.cliVersion = asString(p.cli_version);
291
+ meta.source = asString(p.source);
292
+ meta.modelProvider = asString(p.model_provider);
293
+ meta.model = asString(p.model);
294
+ return;
295
+ }
296
+ if (payloadType === "user_message") {
297
+ const text = asString(payload.message) ?? contentToText(payload.content);
298
+ lastUser = {
299
+ index: ++userTurnIndex,
300
+ line: lineNo,
301
+ timestamp,
302
+ text,
303
+ };
304
+ userMessageCount++;
305
+ return;
306
+ }
307
+ if (payloadType === "agent_message" || (payloadType === "message" && payload.role === "assistant")) {
308
+ assistantMessageCount++;
309
+ return;
310
+ }
311
+ if (payloadType === "token_count") {
312
+ lastTokenUsage = asRecord(asRecord(payload.info).total_token_usage);
313
+ return;
314
+ }
315
+ if (payloadType === "function_call") {
316
+ functionCallCount++;
317
+ const callId = asString(payload.call_id);
318
+ const toolName = asString(payload.name) ?? "unknown";
319
+ if (callId) {
320
+ functionCallsById.set(callId, { args: parseArgs(payload.arguments), toolName, user: lastUser });
321
+ }
322
+ return;
323
+ }
324
+ if (payloadType !== "mcp_tool_call_end")
325
+ return;
326
+ mcpToolCallCount++;
327
+ const invocation = asRecord(payload.invocation);
328
+ const server = asString(invocation.server) ?? "unknown";
329
+ const toolName = asString(invocation.tool) ?? "unknown";
330
+ const callId = asString(invocation.call_id) ?? asString(invocation.id);
331
+ const matchedCall = callId ? functionCallsById.get(callId) : undefined;
332
+ const args = parseArgs(invocation.arguments ?? matchedCall?.args);
333
+ const triggerUser = matchedCall?.user ?? lastUser;
334
+ const durationMs = asNumber(payload.duration_ms);
335
+ const result = payload.result;
336
+ const base = baseSessionProperties({ ...meta, timestamp: meta.timestamp ?? startedAt }, file);
337
+ const eventBase = {
338
+ ...base,
339
+ timestamp,
340
+ event_line: lineNo,
341
+ turn_index: triggerUser?.index,
342
+ trigger_user_line: triggerUser?.line,
343
+ trigger_user_timestamp: triggerUser?.timestamp,
344
+ mcp_server: server,
345
+ tool_name: toolName,
346
+ canonical_tool_name: toolName,
347
+ duration_ms: durationMs,
348
+ success: true,
349
+ ...safeText("trigger_message", triggerUser?.text),
350
+ ...summarizeToolArgs(toolName, args),
351
+ ...summarizeResult(result),
352
+ };
353
+ const stableId = `${meta.id}:${lineNo}:${server}:${toolName}:${timestamp ?? ""}`;
354
+ events.push({
355
+ eventType: "[Codex] MCP Tool Used",
356
+ insertId: shortHash(`codex-mcp:${stableId}`),
357
+ eventProperties: eventBase,
358
+ });
359
+ if (server === "echomem") {
360
+ echomemToolCallCount++;
361
+ if (triggerUser)
362
+ echomemTriggerTurns.add(triggerUser.index);
363
+ events.push({
364
+ eventType: "[Codex] EchoMem Triggered",
365
+ insertId: shortHash(`codex-echomem:${stableId}`),
366
+ eventProperties: {
367
+ ...eventBase,
368
+ event_family: "codex_echomem",
369
+ integration: "echomem_mcp",
370
+ },
371
+ });
372
+ }
373
+ });
374
+ if (startedAt) {
375
+ const summaryBase = baseSessionProperties({ ...meta, timestamp: meta.timestamp ?? startedAt }, file);
376
+ events.push({
377
+ eventType: "[Codex] Session Summary",
378
+ insertId: shortHash(`codex-summary:${meta.id ?? sessionIdFromPath(file)}`),
379
+ eventProperties: {
380
+ ...summaryBase,
381
+ session_ended_at: endedAt,
382
+ user_message_count: userMessageCount,
383
+ assistant_message_count: assistantMessageCount,
384
+ function_call_count: functionCallCount,
385
+ mcp_tool_call_count: mcpToolCallCount,
386
+ echomem_tool_call_count: echomemToolCallCount,
387
+ echomem_triggering_user_turn_count: echomemTriggerTurns.size,
388
+ token_input: asNumber(lastTokenUsage.input_tokens),
389
+ token_output: asNumber(lastTokenUsage.output_tokens),
390
+ token_cached: asNumber(lastTokenUsage.cached_input_tokens),
391
+ total_tokens: asNumber(lastTokenUsage.total_tokens),
392
+ },
393
+ });
394
+ }
395
+ return events;
396
+ }
397
+ function parseFlags(argv) {
398
+ const flags = {};
399
+ for (let i = 0; i < argv.length; i++) {
400
+ const arg = argv[i];
401
+ if (!arg.startsWith("--"))
402
+ continue;
403
+ const key = arg.slice(2);
404
+ const next = argv[i + 1];
405
+ if (next && !next.startsWith("--")) {
406
+ flags[key] = next;
407
+ i++;
408
+ }
409
+ else {
410
+ flags[key] = true;
411
+ }
412
+ }
413
+ return flags;
414
+ }
415
+ async function uploadEvent(token, event) {
416
+ await axios.post(`${API_BASE_URL}/api/extension/mcp/events`, {
417
+ eventType: event.eventType,
418
+ insertId: event.insertId,
419
+ eventProperties: event.eventProperties,
420
+ }, {
421
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
422
+ timeout: 5000,
423
+ });
424
+ }
425
+ export async function syncCodexUsage(argv) {
426
+ const flags = parseFlags(argv);
427
+ const days = flags.all ? Number.POSITIVE_INFINITY : Number(flags.days ?? DEFAULT_DAYS);
428
+ const limit = Number(flags.limit ?? DEFAULT_LIMIT);
429
+ const dryRun = flags["dry-run"] === true;
430
+ const root = typeof flags.root === "string" ? flags.root : path.join(os.homedir(), ".codex", "sessions");
431
+ const token = new KeyStore().getToken();
432
+ if (!dryRun && !token) {
433
+ console.error("Not logged in. Run `echomem-mcp login` first, or re-run with --dry-run.");
434
+ process.exitCode = 1;
435
+ return;
436
+ }
437
+ const cutoff = Number.isFinite(days) ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
438
+ const files = walk(root)
439
+ .map((file) => ({ file, stat: fs.statSync(file) }))
440
+ .filter(({ stat }) => stat.mtimeMs >= cutoff)
441
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
442
+ .slice(0, Number.isFinite(limit) ? limit : DEFAULT_LIMIT)
443
+ .map(({ file }) => file);
444
+ const events = files.flatMap(parseCodexSession);
445
+ if (dryRun) {
446
+ console.log(JSON.stringify({
447
+ dryRun: true,
448
+ files: files.length,
449
+ events: events.length,
450
+ eventTypes: events.reduce((acc, event) => {
451
+ acc[event.eventType] = (acc[event.eventType] ?? 0) + 1;
452
+ return acc;
453
+ }, {}),
454
+ sample: events.slice(0, 5),
455
+ }, null, 2));
456
+ return;
457
+ }
458
+ let uploaded = 0;
459
+ for (const event of events) {
460
+ try {
461
+ await uploadEvent(token, event);
462
+ uploaded++;
463
+ }
464
+ catch (error) {
465
+ console.error(`Failed to upload ${event.eventType}: ${error instanceof Error ? error.message : String(error)}`);
466
+ }
467
+ }
468
+ console.log(`Synced ${uploaded}/${events.length} Codex usage events from ${files.length} session files.`);
469
+ }
@@ -1,7 +1,9 @@
1
1
  import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey, saltFromBase64 } from "./crypto.js";
2
2
  /** GET the account's encryption status from the EchoMem backend using the bridge's authed client. */
3
3
  export async function fetchEncryptionConfig(axios) {
4
- const res = await axios.get("/api/extension/account/encryption");
4
+ // Bounded: this is on the startup/tool-listing path; an unbounded call on a flaky network would hang
5
+ // the MCP handshake and freeze the whole agent (the "connection timed out after 30000ms" symptom).
6
+ const res = await axios.get("/api/extension/account/encryption", { timeout: 6000 });
5
7
  const data = res.data ?? {};
6
8
  return {
7
9
  enabled: !!data.enabled,