@echomem/mcp 1.4.20 → 1.4.22

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
@@ -66,7 +66,7 @@ HUD; the granular commands below still work.
66
66
  | `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
67
67
  | `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
68
68
  | `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config without opening the browser or changing credentials |
69
- | `npx -y @echomem/mcp@latest update --all` | One-shot update: repoint detected client configs to the latest bridge, with no browser login |
69
+ | `npx -y @echomem/mcp@latest update --all` | One-shot update: install the latest bridge durably and repoint detected client configs, with no browser login |
70
70
  | `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
71
71
  | `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
72
72
  | `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
package/dist/crypto.js CHANGED
@@ -95,6 +95,10 @@ export async function decrypt(ciphertext, key) {
95
95
  const plainBuffer = await subtle.decrypt({ name: ALGORITHM, iv }, key, data);
96
96
  return new TextDecoder().decode(plainBuffer);
97
97
  }
98
+ /** Create a server-verifiable token for a newly configured encryption key. */
99
+ export async function createVerification(key) {
100
+ return encrypt(VERIFICATION_PLAINTEXT, key);
101
+ }
98
102
  /** Verify a key against a stored verification token (current + legacy plaintexts). */
99
103
  export async function verifyKey(key, token) {
100
104
  try {
@@ -1,4 +1,5 @@
1
- import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey, saltFromBase64 } from "./crypto.js";
1
+ import { createVerification, deriveKey, exportKeyToBase64, generateSalt, importKeyFromBase64, decrypt, saltFromBase64, saltToBase64, verifyKey, } from "./crypto.js";
2
+ const DEFAULT_KEY_ITERATIONS = 600_000;
2
3
  /** GET the account's encryption status from the EchoMem backend using the bridge's authed client. */
3
4
  export async function fetchEncryptionConfig(axios) {
4
5
  // Bounded: this is on the startup/tool-listing path; an unbounded call on a flaky network would hang
@@ -21,11 +22,22 @@ export async function deriveAndVerifyKey(passphrase, config) {
21
22
  if (!config.enabled || !config.salt || !config.verification)
22
23
  return null;
23
24
  const salt = saltFromBase64(config.salt);
24
- const key = await deriveKey(passphrase, salt, config.iterations ?? 600_000);
25
+ const key = await deriveKey(passphrase, salt, config.iterations ?? DEFAULT_KEY_ITERATIONS);
25
26
  if (!(await verifyKey(key, config.verification)))
26
27
  return null;
27
28
  return exportKeyToBase64(key);
28
29
  }
30
+ /** Create a fresh account encryption config locally. The passphrase/key never leaves the device. */
31
+ export async function setupNewEncryptionKey(passphrase, iterations = DEFAULT_KEY_ITERATIONS) {
32
+ const salt = generateSalt();
33
+ const key = await deriveKey(passphrase, salt, iterations);
34
+ return {
35
+ keyBase64: await exportKeyToBase64(key),
36
+ saltBase64: saltToBase64(salt),
37
+ verification: await createVerification(key),
38
+ iterations,
39
+ };
40
+ }
29
41
  /** Verify an already-exported base64 key against the server's verification token. */
30
42
  export async function verifyKeyB64(keyBase64, config) {
31
43
  if (!config.enabled || !config.verification)
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { EventLogger, hashText } from "./events.js";
10
10
  import { buildReportText } from "./report.js";
11
11
  import { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
- import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
13
+ import { fetchEncryptionConfig, decryptMemoryFields, verifyKeyB64, } from "./encryption.js";
14
14
  import { runCli } from "./setup.js";
15
15
  import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS } from "./package-metadata.js";
16
16
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
@@ -23,6 +23,9 @@ class NoTokenError extends Error {
23
23
  /** Thrown when an encrypted account has no usable key — the model gets an unlock nudge, never ciphertext. */
24
24
  class LockedError extends Error {
25
25
  }
26
+ /** Thrown when the bridge cannot prove whether the account is encrypted. */
27
+ class EncryptionStatusUnavailableError extends Error {
28
+ }
26
29
  function stringifyErrorValue(value) {
27
30
  if (value == null) {
28
31
  return "";
@@ -121,7 +124,7 @@ function formatUpgradeRequiredResult(error) {
121
124
  pricingUrl,
122
125
  });
123
126
  return [
124
- "ACTION REQUIRED: tell the user to start a 7-day Pro trial or choose a subscription at this exact URL:",
127
+ "ACTION REQUIRED: tell the user to start a 14-day Pro trial or choose a subscription at this exact URL:",
125
128
  pricingUrl,
126
129
  "When replying, include the exact URL above. Do not reply only with \"connect\" or \"upgrade\".",
127
130
  "",
@@ -130,6 +133,15 @@ function formatUpgradeRequiredResult(error) {
130
133
  code ? `Reason: ${code}.` : "",
131
134
  ].filter(Boolean).join("\n");
132
135
  }
136
+ function formatReconnectRequiredResult(error) {
137
+ if (!axios.isAxiosError(error) || error.response?.status !== 401)
138
+ return null;
139
+ return [
140
+ "🔌 EchoMem's saved login is no longer accepted (Unauthorized, HTTP 401).",
141
+ "Action required from the user: run `echomem-mcp login` in a terminal to reconnect this device, then retry this action.",
142
+ "No editor restart is needed. Keep all credentials out of chat.",
143
+ ].join("\n");
144
+ }
133
145
  /** Map a thrown error to the telemetry error_kind taxonomy. */
134
146
  function classifyError(error) {
135
147
  if (error instanceof NoTokenError)
@@ -564,8 +576,8 @@ class EchoMemApiClient {
564
576
  if (!this.encConfigPromise) {
565
577
  this.encConfigPromise = fetchEncryptionConfig(this.axios).catch((error) => {
566
578
  this.encConfigPromise = null; // allow retry next call
567
- console.error(`[encryption] config fetch failed: ${describeError(error)} — proceeding as unencrypted`);
568
- return { enabled: false };
579
+ console.error(`[encryption] config fetch failed: ${describeError(error)} — refusing memory access`);
580
+ throw new EncryptionStatusUnavailableError("EchoMem could not verify the account encryption state");
569
581
  });
570
582
  }
571
583
  return this.encConfigPromise;
@@ -576,18 +588,20 @@ class EchoMemApiClient {
576
588
  * handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
577
589
  */
578
590
  async encState() {
579
- // A stored key ONLY exists because the user unlocked an ENCRYPTED vault, so treat it as
580
- // authoritative: a flaky/failed `/account/encryption` fetch (which defaults to enabled:false) must
581
- // NOT downgrade an unlocked encrypted account to plaintext — that causes a 422 on save and leaks
582
- // ciphertext on read. So if we hold a usable key, we're encrypted-and-unlocked, period.
583
- const key = this.store.getKey();
584
- if (key)
585
- return { enabled: true, key };
586
- // No usable key: consult the server to tell "unencrypted" apart from "encrypted but locked/expired".
587
591
  const cfg = await this.getEncryptionConfig();
588
- if (cfg.enabled)
592
+ if (!cfg.enabled)
593
+ return { enabled: false };
594
+ const key = this.store.getKey();
595
+ if (!key)
589
596
  throw new LockedError("EchoMem vault locked");
590
- return { enabled: false };
597
+ if (!(await verifyKeyB64(key, cfg))) {
598
+ // A file-backed stale key can be removed safely; environment-provided
599
+ // keys remain under the caller's control and are ignored until corrected.
600
+ if (!process.env.ECHO_ENCRYPTION_KEY)
601
+ this.store.clearKey();
602
+ throw new LockedError("EchoMem vault key is invalid");
603
+ }
604
+ return { enabled: true, key };
591
605
  }
592
606
  pruneDeleteConfirmations(now = Date.now()) {
593
607
  for (const [memoryId, confirmation] of this.deleteConfirmations) {
@@ -608,13 +622,6 @@ class EchoMemApiClient {
608
622
  }
609
623
  return this.whoamiCache;
610
624
  }
611
- cleanMemoryDescription(desc) {
612
- if (!desc)
613
- return "";
614
- // The tuned snapshot wraps text as "Here is a memory: …Content: <text>".
615
- const idx = desc.indexOf("Content:");
616
- return (idx >= 0 ? desc.slice(idx + "Content:".length) : desc).trim();
617
- }
618
625
  async fetchMemoryById(id, enc) {
619
626
  try {
620
627
  const response = await this.axios.get(`/api/extension/memories/${encodeURIComponent(id)}`, {
@@ -630,34 +637,6 @@ class EchoMemApiClient {
630
637
  throw error;
631
638
  }
632
639
  }
633
- async searchMemoriesRaw(query, opts, enc, retrievalOnly = false, trace) {
634
- const endpoint = "/api/extension/memories/search";
635
- let response;
636
- try {
637
- response = await this.axios.post(endpoint, {
638
- query,
639
- k: opts.limit,
640
- similarityThreshold: opts.threshold,
641
- timeFrameDays: opts.timeFrameDays,
642
- requestId: opts.requestId,
643
- sessionId: this.sessionId,
644
- });
645
- trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
646
- }
647
- catch (error) {
648
- trace?.({
649
- requestId: opts.requestId,
650
- endpoint,
651
- httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
652
- errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
653
- });
654
- throw error;
655
- }
656
- const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
657
- return retrievalOnly && isRecord(data)
658
- ? { ...data, retrievalOnly: true, tuned: false }
659
- : data;
660
- }
661
640
  async searchMemoriesRetrieved(query, opts, enc, trace) {
662
641
  const endpoint = "/api/extension/memories/deep-search/candidates";
663
642
  let response;
@@ -732,48 +711,6 @@ class EchoMemApiClient {
732
711
  memories,
733
712
  };
734
713
  }
735
- // Tuned recall. Routes through echo-mem-chrome's AUTHENTICATED deep-search proxy
736
- // (the same route the Chrome extension uses): it verifies our ec_ token, derives
737
- // user_id server-side, and forwards to dg-web's two-phase retriever. The bridge
738
- // never hits dg-web directly, so no new auth is needed and no other caller is
739
- // disrupted. NOTE: encrypted users still get server-side plaintext only; the
740
- // phase-1 + bridge-local-decrypt + phase-2 flow (spec §11) is the remaining work.
741
- async searchMemoriesTuned(query, requestId, trace) {
742
- const endpoint = "/api/extension/memories/deep-search";
743
- let response;
744
- try {
745
- response = await this.axios.post(endpoint, {
746
- query,
747
- requestId,
748
- logicalRequestId: requestId,
749
- sessionId: this.sessionId,
750
- userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
751
- });
752
- trace?.({ requestId, endpoint, httpStatus: response.status });
753
- }
754
- catch (error) {
755
- trace?.({
756
- requestId,
757
- endpoint,
758
- httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
759
- errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
760
- });
761
- throw error;
762
- }
763
- const data = response.data ?? {};
764
- if (data.success === false) {
765
- trace?.({ requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
766
- throw new Error(`deep-search proxy error: ${data.error || "unknown"}`);
767
- }
768
- const primary = (data.retrievedMemorySnapshot?.primary ?? []);
769
- const memories = primary.map((m) => ({
770
- key: String(m.key ?? ""),
771
- description: this.cleanMemoryDescription(String(m.description ?? "")),
772
- time: m.time,
773
- similarity: typeof m.similarity_score === "number" ? m.similarity_score : undefined,
774
- }));
775
- return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
776
- }
777
714
  async searchMemories(args, trace) {
778
715
  const parsed = searchMemoriesSchema.parse(args ?? {});
779
716
  const query = parsed.query?.trim();
@@ -800,18 +737,11 @@ class EchoMemApiClient {
800
737
  timeFrameDays: parsed.timeFrameDays,
801
738
  requestId: randomUUID(),
802
739
  };
803
- if (!parsed.includeAnswer) {
804
- return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
805
- }
806
- // Encrypted account: the server can't synthesize over plaintext it doesn't hold, so use the raw
807
- // retriever (returns ciphertext) and decrypt locally — zero-knowledge preserved end-to-end.
808
- if (enc.enabled) {
809
- return await this.searchMemoriesRaw(query, searchOpts, enc, false, trace);
810
- }
811
- // A synthesized answer is metered through the legacy route. Do not fall
812
- // back to raw retrieval after a ledger outage: that would return a result
813
- // without a durable usage fact.
814
- return this.searchMemoriesTuned(query, searchOpts.requestId, trace);
740
+ // MCP recall is retrieval-only. `includeAnswer` remains accepted by the
741
+ // parser for older clients, but it must never select an EchoMem/dg-web LLM
742
+ // path. The MCP host model receives the ranked memories and writes the
743
+ // final answer itself.
744
+ return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
815
745
  }
816
746
  /** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
817
747
  async decryptResult(data, key) {
@@ -1227,12 +1157,31 @@ class EchoMemMCPServer {
1227
1157
  ],
1228
1158
  };
1229
1159
  }
1160
+ if (error instanceof EncryptionStatusUnavailableError) {
1161
+ return {
1162
+ content: [{
1163
+ type: "text",
1164
+ text: [
1165
+ "⚠️ EchoMem could not verify whether this account's vault is encrypted.",
1166
+ "No memory data was returned. Retry this action after the service recovers.",
1167
+ ].join("\n"),
1168
+ }],
1169
+ isError: true,
1170
+ };
1171
+ }
1230
1172
  if (error instanceof ZodError) {
1231
1173
  throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.message}`);
1232
1174
  }
1233
1175
  if (error instanceof McpError) {
1234
1176
  throw error;
1235
1177
  }
1178
+ const reconnectRequired = formatReconnectRequiredResult(error);
1179
+ if (reconnectRequired) {
1180
+ return {
1181
+ content: [{ type: "text", text: reconnectRequired }],
1182
+ isError: true,
1183
+ };
1184
+ }
1236
1185
  const upgradeRequired = formatUpgradeRequiredResult(error);
1237
1186
  if (upgradeRequired) {
1238
1187
  return {
@@ -1345,20 +1294,6 @@ class EchoMemMCPServer {
1345
1294
  .join("\n\n");
1346
1295
  return { content: [{ type: "text", text: `Retrieved ${memories.length} memories:\n\n${formattedResults}` }] };
1347
1296
  }
1348
- // Tuned two-phase path: synthesized brief + ranked source memories.
1349
- if (result?.tuned) {
1350
- const { answer, memories } = result;
1351
- if (!answer && !memories?.length) {
1352
- return { content: [{ type: "text", text: "No relevant memories found." }] };
1353
- }
1354
- const sources = (memories || [])
1355
- .map((m, idx) => `[${idx + 1}] ${m.key}${typeof m.similarity === "number" ? ` (sim ${m.similarity.toFixed(3)})` : ""}\n${m.description}`)
1356
- .join("\n\n");
1357
- const text = [answer && `Recall:\n${answer}`, sources && `---\nSource memories:\n\n${sources}`]
1358
- .filter(Boolean)
1359
- .join("\n\n");
1360
- return { content: [{ type: "text", text }] };
1361
- }
1362
1297
  // Fallback: untuned / time-range shape.
1363
1298
  const { success, memories, error } = result;
1364
1299
  if (!success)
package/dist/migrate.js CHANGED
@@ -525,6 +525,37 @@ export async function cancelQueuedImportSession(client, sessionId) {
525
525
  throw new Error("IMPORT_SESSION_ID_REQUIRED");
526
526
  await client.patch(`/api/extension/import-sessions/${encodeURIComponent(sessionId)}`, { action: "cancel" });
527
527
  }
528
+ async function findRecoverableImportSession(client, sessions, bareConversationId, signal) {
529
+ const items = sessions.map((session) => ({
530
+ conversationId: bareConversationId(session),
531
+ platform: session.source,
532
+ sourceDate: session.firstTs,
533
+ }));
534
+ const response = await client.post("/api/extension/import-sessions/status", { items }, signal ? { signal } : undefined);
535
+ const data = response.data && typeof response.data === "object"
536
+ ? response.data
537
+ : {};
538
+ const required = new Set(sessions.map((session) => mapKey(session.source, bareConversationId(session))));
539
+ for (const candidate of data.recoverableSessions ?? []) {
540
+ const id = typeof candidate.id === "string" ? candidate.id : "";
541
+ const jobs = Array.isArray(candidate.jobs)
542
+ ? candidate.jobs
543
+ .filter((job) => typeof job.id === "string"
544
+ && typeof job.platform === "string"
545
+ && typeof job.conversation_id === "string")
546
+ .map((job) => ({
547
+ id: job.id,
548
+ platform: job.platform,
549
+ conversation_id: job.conversation_id,
550
+ }))
551
+ : [];
552
+ const covered = new Set(jobs.map((job) => mapKey(job.platform, job.conversation_id)));
553
+ if (id && required.size === jobs.length && [...required].every((key) => covered.has(key))) {
554
+ return { id, concurrency: MIGRATE_CONCURRENCY, jobs };
555
+ }
556
+ }
557
+ return null;
558
+ }
528
559
  function isRecord(value) {
529
560
  return typeof value === "object" && value !== null && !Array.isArray(value);
530
561
  }
@@ -984,7 +1015,13 @@ export async function startMigration(opts) {
984
1015
  throw codedError("FORBIDDEN_SCOPE");
985
1016
  const data = responseData(e);
986
1017
  const max = Number(data.maxConversations);
987
- if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
1018
+ if (responseStatus(e) === 409 && data.error === "IMPORT_ALREADY_IN_PROGRESS") {
1019
+ const recovered = await findRecoverableImportSession(client, toImport, bareId, opts.signal);
1020
+ if (!recovered)
1021
+ throw e;
1022
+ session = recovered;
1023
+ }
1024
+ else if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
988
1025
  capped = max;
989
1026
  toImport = opts.pending.slice(0, max);
990
1027
  try {