@echomem/mcp 1.3.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
@@ -11,6 +11,7 @@ This MCP Server bridges local tools and your EchoMem Cloud API entirely via auth
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
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
 
@@ -149,6 +150,7 @@ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run st
149
150
  * **`get_memories_by_time_range`**: Retrieve memories between explicit start/end timestamps.
150
151
  * **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
151
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.
152
154
 
153
155
  Legacy aliases are preserved for compatibility:
154
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,