@echomem/mcp 1.4.18 โ†’ 1.4.20

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
@@ -25,8 +25,11 @@ For **zero-knowledge accounts**, the key never leaves your machine. The bridge h
25
25
  - **writes:** the key is handed to your own backend transiently in the `X-Encryption-Key` header so
26
26
  memories are encrypted at rest (the server processes plaintext for the request only โ€” never stores
27
27
  the key).
28
- - **locked state:** if the key is absent or past its TTL (7 days, matching the extension), tools
29
- return a `๐Ÿ”’ locked` nudge to run `echomem-mcp unlock` โ€” **ciphertext is never handed to the model**.
28
+ - **trusted-device lifecycle:** after a verified login/unlock, the MCP key remains available until
29
+ the user runs `echomem-mcp lock`, logs out, or removes the local credentials. If the key is absent,
30
+ tools tell the user to run `echomem-mcp unlock` in their own terminal. The passphrase prompt is
31
+ visible while typed characters stay hidden, and the current agent session can retry immediately
32
+ after unlock โ€” **ciphertext is never handed to the model**.
30
33
 
31
34
  Unencrypted accounts are unaffected. `search_memories` returns retrieved memories by default so
32
35
  your MCP client does the final answer generation; pass `includeAnswer: true` only if you need the
@@ -47,8 +50,11 @@ to the global install, but installing globally avoids the issue entirely). The c
47
50
  launch-at-login also runs from the installed path. `init` is the flagship one-liner โ€” it wraps `setup --all --with-hud`: it writes each
48
51
  installed agent's MCP config (with **no secret** in it โ€” credentials live in
49
52
  `~/.echomem/credentials.json`, mode 0600), adds the EchoMem memory guidance to their global
50
- `AGENTS.md` / `CLAUDE.md`, opens the browser to approve the device (and unlock the vault for
51
- encrypted accounts), then launches the context HUD. Reload your editors and you're done.
53
+ `AGENTS.md` / `CLAUDE.md`, installs first-party `echomem-search`, `echomem-save`,
54
+ `echomem-forget`, and `echomem-login` skills for Codex, opens the browser to approve the device
55
+ (and unlock the vault for encrypted accounts), then launches the context HUD. EchoMem updates only
56
+ its own skill folders; other memory-provider skills are detected and reported but never modified.
57
+ Reload your editors and you're done.
52
58
 
53
59
  Prefer to keep it minimal? `echomem-mcp setup` configures only the auto-detected editor and skips the
54
60
  HUD; the granular commands below still work.
@@ -64,7 +70,8 @@ HUD; the granular commands below still work.
64
70
  | `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
65
71
  | `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
66
72
  | `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
67
- | `echomem-mcp unlock` | Re-derive the encryption key after its TTL (or `--passphrase`) |
73
+ | `echomem-mcp unlock` | Privately unlock the vault on this trusted device |
74
+ | `echomem-mcp lock` | Remove the local vault key while keeping the device login |
68
75
  | `echomem-mcp status` | Show token / key / detected clients, configured bridge versions, and update guidance |
69
76
  | `echomem-mcp doctor [--no-network]` | Diagnose configured client bridge versions |
70
77
  | `echomem-mcp logout` | Remove stored credentials |
@@ -35,6 +35,12 @@ function normalizedSessionId(value) {
35
35
  const trimmed = value.trim();
36
36
  return new RegExp(`^${UUID_RE.source}$`, "i").test(trimmed) ? trimmed.toLowerCase() : trimmed;
37
37
  }
38
+ function isAutomaticCodexSource(source) {
39
+ if (typeof source !== "string")
40
+ return false;
41
+ const normalized = source.trim().toLowerCase().replace(/-/g, "_");
42
+ return normalized === "auto_review" || normalized === "guardian";
43
+ }
38
44
  function metadataFromFile(file) {
39
45
  try {
40
46
  for (const line of initialJsonLines(file)) {
@@ -63,7 +69,7 @@ function metadataFromFile(file) {
63
69
  return {
64
70
  id: rawId || null,
65
71
  startedAtMs: Number.isFinite(timestamp) ? timestamp : null,
66
- userInitiated: !isSubagent && !hasParent,
72
+ userInitiated: !isSubagent && !hasParent && !isAutomaticCodexSource(source),
67
73
  };
68
74
  }
69
75
  return { id: null, startedAtMs: null, userInitiated: null };
@@ -319,6 +319,9 @@ async function handleBillingStatus(res) {
319
319
  }
320
320
  let plan = "unknown";
321
321
  let paid = false;
322
+ let historicalConversationQuota = null;
323
+ let memoryProcessingQuota = null;
324
+ let memorySearchQuota = null;
322
325
  try {
323
326
  const response = await fetch(`${API_BASE}/api/extension/account/bootstrap`, {
324
327
  headers: { Authorization: `Bearer ${token}` },
@@ -327,34 +330,35 @@ async function handleBillingStatus(res) {
327
330
  if (response.ok) {
328
331
  plan = typeof data.plan === "string" ? data.plan.toLowerCase() : "free";
329
332
  paid = ["pro", "power", "team", "enterprise"].includes(plan);
333
+ historicalConversationQuota = data.historicalConversationQuota ?? null;
334
+ memoryProcessingQuota = data.memoryProcessingQuota ?? null;
335
+ memorySearchQuota = data.memorySearchQuota ?? null;
330
336
  }
331
337
  }
332
338
  catch {
333
339
  /* Local alert still gives the HUD something useful. */
334
340
  }
335
- if (alert && (alert.kind === "quota_exceeded" || !paid)) {
341
+ const quotaPayload = {
342
+ historicalConversationQuota,
343
+ memoryProcessingQuota,
344
+ memorySearchQuota,
345
+ };
346
+ // Free now includes recall. Ignore old plan_required alerts that may still
347
+ // be present on disk from a previous bridge version.
348
+ if (alert && (alert.kind === "quota_exceeded" || alert.kind === "billing_attention")) {
336
349
  return respond({
337
350
  ok: true,
338
351
  state: alert.kind,
339
352
  plan: alert.plan || plan,
340
- message: alert.kind === "quota_exceeded" ? "Recall limit reached." : "Recall needs a plan.",
353
+ message: alert.kind === "quota_exceeded" ? "Plan limit reached." : "Billing needs attention.",
341
354
  detail: alert.message,
342
355
  code: alert.code,
343
356
  pricingUrl: appendSource(alert.pricingUrl || pricingUrl, "hud"),
344
357
  updatedAt: alert.updatedAt,
358
+ ...quotaPayload,
345
359
  });
346
360
  }
347
- if (!paid && plan === "free") {
348
- return respond({
349
- ok: true,
350
- state: "plan_required",
351
- plan,
352
- message: "Recall needs a plan.",
353
- detail: "Search and recall require Echo Pro or Power. Saving conversations keeps working.",
354
- pricingUrl,
355
- });
356
- }
357
- respond({ ok: true, state: "ok", plan, paid, pricingUrl });
361
+ respond({ ok: true, state: "ok", plan, paid, pricingUrl, ...quotaPayload });
358
362
  }
359
363
  function appendSource(url, source) {
360
364
  try {
package/dist/hud/web.js CHANGED
@@ -678,6 +678,85 @@ export const HUD_HTML = String.raw `<!doctype html>
678
678
  box-shadow: 0 12px 20px -16px rgba(122, 36, 30, 0.95);
679
679
  }
680
680
  .billing-link:hover { filter: brightness(0.96); }
681
+ .quota-card {
682
+ display: none;
683
+ margin: 0 0 10px;
684
+ padding: 12px 13px 13px;
685
+ border: 1px solid rgba(26, 58, 143, 0.18);
686
+ border-radius: 12px;
687
+ background: rgba(255, 255, 255, 0.72);
688
+ box-shadow: 0 1px 5px rgba(26, 58, 143, 0.05);
689
+ }
690
+ .quota-head {
691
+ display: flex;
692
+ align-items: center;
693
+ gap: 7px;
694
+ margin-bottom: 9px;
695
+ }
696
+ .quota-title {
697
+ color: var(--ink);
698
+ font-size: 10.5px;
699
+ font-weight: 850;
700
+ letter-spacing: 0.015em;
701
+ }
702
+ .quota-plan {
703
+ border-radius: 999px;
704
+ background: rgba(26, 58, 143, 0.07);
705
+ padding: 2px 7px;
706
+ color: var(--blue);
707
+ font-size: 9px;
708
+ font-weight: 850;
709
+ text-transform: uppercase;
710
+ }
711
+ .quota-manage {
712
+ margin-left: auto;
713
+ border: 0;
714
+ padding: 0;
715
+ background: transparent;
716
+ color: var(--faint);
717
+ font-family: inherit;
718
+ font-size: 9.5px;
719
+ font-weight: 750;
720
+ cursor: pointer;
721
+ }
722
+ .quota-manage:hover { color: var(--blue); }
723
+ .quota-list { display: grid; gap: 9px; }
724
+ .quota-row { --quota-color: var(--blue); }
725
+ .quota-row.is-warn { --quota-color: #c98712; }
726
+ .quota-row.is-limit { --quota-color: #c7372f; }
727
+ .quota-line, .quota-subline {
728
+ display: flex;
729
+ align-items: baseline;
730
+ justify-content: space-between;
731
+ gap: 10px;
732
+ }
733
+ .quota-name { color: var(--muted); font-size: 10px; font-weight: 720; }
734
+ .quota-value {
735
+ color: var(--ink);
736
+ font-family: "JetBrains Mono", ui-monospace, monospace;
737
+ font-size: 9.5px;
738
+ font-weight: 760;
739
+ white-space: nowrap;
740
+ }
741
+ .quota-track {
742
+ height: 4px;
743
+ margin-top: 4px;
744
+ overflow: hidden;
745
+ border-radius: 999px;
746
+ background: rgba(26, 58, 143, 0.11);
747
+ }
748
+ .quota-fill {
749
+ width: 0;
750
+ height: 100%;
751
+ border-radius: inherit;
752
+ background: var(--quota-color);
753
+ transition: width .28s ease, background .2s ease;
754
+ }
755
+ .quota-subline { margin-top: 3px; }
756
+ .quota-reset, .quota-percent { color: var(--faint); font-size: 8.5px; font-weight: 650; }
757
+ .quota-row.is-limit .quota-value,
758
+ .quota-row.is-limit .quota-percent { color: #9d2b25; font-weight: 850; }
759
+ .quota-row.is-unavailable .quota-fill { background: rgba(26, 58, 143, 0.14); }
681
760
  button.renew-btn {
682
761
  display: none;
683
762
  width: 100%;
@@ -838,6 +917,30 @@ export const HUD_HTML = String.raw `<!doctype html>
838
917
  </div>
839
918
  <div class="details">
840
919
  <div class="timeline-scrollbar hidden" id="timelineScrollbar" aria-hidden="true"><span class="timeline-thumb" id="timelineThumb"></span></div>
920
+ <section class="quota-card" id="quotacard" aria-label="Plan usage">
921
+ <div class="quota-head">
922
+ <span class="quota-title">Plan usage</span>
923
+ <span class="quota-plan" id="quotaplan">Free</span>
924
+ <button class="quota-manage" id="quotamanage" type="button">Manage</button>
925
+ </div>
926
+ <div class="quota-list">
927
+ <div class="quota-row" data-quota="processing">
928
+ <div class="quota-line"><span class="quota-name">Processing this week</span><span class="quota-value"></span></div>
929
+ <div class="quota-track"><div class="quota-fill"></div></div>
930
+ <div class="quota-subline"><span class="quota-reset"></span><span class="quota-percent"></span></div>
931
+ </div>
932
+ <div class="quota-row" data-quota="searches">
933
+ <div class="quota-line"><span class="quota-name">Memory searches</span><span class="quota-value"></span></div>
934
+ <div class="quota-track"><div class="quota-fill"></div></div>
935
+ <div class="quota-subline"><span class="quota-reset"></span><span class="quota-percent"></span></div>
936
+ </div>
937
+ <div class="quota-row" data-quota="historical">
938
+ <div class="quota-line"><span class="quota-name">Bulk history imports</span><span class="quota-value"></span></div>
939
+ <div class="quota-track"><div class="quota-fill"></div></div>
940
+ <div class="quota-subline"><span class="quota-reset">Lifetime limit</span><span class="quota-percent"></span></div>
941
+ </div>
942
+ </div>
943
+ </section>
841
944
  <div class="now-card" id="nowcard">
842
945
  <div class="now-sess">
843
946
  <img class="now-icon" id="nowicon" src="/assets/hud/codex.svg" alt="" />
@@ -903,6 +1006,9 @@ export const HUD_HTML = String.raw `<!doctype html>
903
1006
  const billingTitle = document.getElementById("billingtitle");
904
1007
  const billingCopy = document.getElementById("billingcopy");
905
1008
  const billingLink = document.getElementById("billinglink");
1009
+ const quotaCard = document.getElementById("quotacard");
1010
+ const quotaPlan = document.getElementById("quotaplan");
1011
+ const quotaManage = document.getElementById("quotamanage");
906
1012
  const renewBtn = document.getElementById("renewbtn");
907
1013
  const renewResult = document.getElementById("renewresult");
908
1014
  const renewMsg = document.getElementById("renewmsg");
@@ -1134,7 +1240,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1134
1240
  if (!id) return;
1135
1241
  fetch("/jump?id=" + encodeURIComponent(id)).catch(() => {});
1136
1242
  }
1137
- if (billingLink) billingLink.addEventListener("click", (e) => {
1243
+ function openPricing(e) {
1138
1244
  e.stopPropagation();
1139
1245
  const url = billingStatus && billingStatus.pricingUrl;
1140
1246
  if (!url) return;
@@ -1146,7 +1252,9 @@ export const HUD_HTML = String.raw `<!doctype html>
1146
1252
  .catch(() => {
1147
1253
  try { window.open(url, "_blank", "noopener"); } catch {}
1148
1254
  });
1149
- });
1255
+ }
1256
+ if (billingLink) billingLink.addEventListener("click", openPricing);
1257
+ if (quotaManage) quotaManage.addEventListener("click", openPricing);
1150
1258
  function toggleSessionDetails(id) {
1151
1259
  if (!id) return;
1152
1260
  const nextId = lastState && lastState.expandedId === id ? "auto" : id;
@@ -1614,6 +1722,18 @@ export const HUD_HTML = String.raw `<!doctype html>
1614
1722
  const previewExpanded = previewParams.get("expanded") === "1" || previewParams.get("pinned") === "1";
1615
1723
  if (previewParams.get("capture") === "1") hud.classList.add("capture-preview");
1616
1724
  if (previewColor === "green" || previewColor === "amber" || previewColor === "red") {
1725
+ const previewProcessingUsed = previewColor === "red" ? 200000 : previewColor === "amber" ? 163000 : 84000;
1726
+ const previewSearchUsed = previewColor === "red" ? 100 : previewColor === "amber" ? 82 : 32;
1727
+ billingStatus = {
1728
+ ok: true,
1729
+ state: "ok",
1730
+ plan: "pro",
1731
+ paid: true,
1732
+ pricingUrl: "https://yeahecho.com/pricing?source=hud_preview",
1733
+ memoryProcessingQuota: { used: previewProcessingUsed, limit: 200000, remaining: 200000 - previewProcessingUsed, resetAt: new Date(Date.now() + 31 * 60 * 60 * 1000).toISOString() },
1734
+ memorySearchQuota: { used: previewSearchUsed, limit: 100, remaining: 100 - previewSearchUsed, resetAt: new Date(Date.now() + 31 * 60 * 60 * 1000).toISOString() },
1735
+ historicalConversationQuota: { used: 191, limit: 250, remaining: 59 },
1736
+ };
1617
1737
  const sample = {
1618
1738
  client: "codex",
1619
1739
  label: "EchoMem-Chrome",
@@ -1824,25 +1944,96 @@ export const HUD_HTML = String.raw `<!doctype html>
1824
1944
 
1825
1945
  function renderBillingStatus() {
1826
1946
  if (!billingBanner) return;
1947
+ renderQuotaUsage();
1827
1948
  const state = billingStatus && billingStatus.state;
1828
- const blocked = state === "plan_required" || state === "quota_exceeded" || state === "billing_attention";
1949
+ const blocked = state === "quota_exceeded" || state === "billing_attention";
1829
1950
  billingBanner.style.display = blocked ? "block" : "none";
1830
1951
  billingBanner.classList.toggle("limit", state === "quota_exceeded");
1831
1952
  if (!blocked) return;
1832
- const title = state === "quota_exceeded"
1833
- ? "Recall limit reached"
1834
- : state === "plan_required"
1835
- ? "Recall paused ยท Free plan"
1836
- : billingStatus.message || "Billing needs attention";
1837
- const detail = state === "quota_exceeded"
1838
- ? "You used all Pro searches this month. Upgrade or wait for reset."
1839
- : state === "plan_required"
1840
- ? "Search memory requires Pro or Power. Saving still works."
1841
- : billingStatus.detail || "Open pricing to restore memory search.";
1953
+ const title = billingStatus.message || (state === "quota_exceeded" ? "Plan limit reached" : "Billing needs attention");
1954
+ const detail = billingStatus.detail || (state === "quota_exceeded"
1955
+ ? "A weekly limit has been reached. Wait for reset or change your plan."
1956
+ : "Open pricing to review your plan.");
1842
1957
  if (billingTitle) billingTitle.textContent = title;
1843
1958
  if (billingCopy) billingCopy.textContent = detail;
1844
1959
  }
1845
1960
 
1961
+ function renderQuotaUsage() {
1962
+ if (!quotaCard) return;
1963
+ const status = billingStatus || {};
1964
+ const quotaRows = [
1965
+ ["processing", status.memoryProcessingQuota, false],
1966
+ ["searches", status.memorySearchQuota, false],
1967
+ ["historical", status.historicalConversationQuota, true],
1968
+ ];
1969
+ const hasQuota = quotaRows.some((entry) => entry[1] && typeof entry[1] === "object");
1970
+ const visible = hasQuota && status.state !== "not_connected" && status.plan !== "unknown";
1971
+ quotaCard.style.display = visible ? "block" : "none";
1972
+ if (!visible) return;
1973
+ if (quotaPlan) quotaPlan.textContent = planLabel(status.plan || "free");
1974
+ for (const entry of quotaRows) renderQuotaRow(entry[0], entry[1], entry[2]);
1975
+ }
1976
+
1977
+ function renderQuotaRow(key, snapshot, lifetime) {
1978
+ if (!quotaCard) return;
1979
+ const row = quotaCard.querySelector('[data-quota="' + key + '"]');
1980
+ if (!row) return;
1981
+ const valueEl = row.querySelector(".quota-value");
1982
+ const fillEl = row.querySelector(".quota-fill");
1983
+ const resetEl = row.querySelector(".quota-reset");
1984
+ const percentEl = row.querySelector(".quota-percent");
1985
+ const used = quotaNumber(snapshot && snapshot.used);
1986
+ const limit = quotaNumber(snapshot && snapshot.limit);
1987
+ const available = used !== null && limit !== null;
1988
+ const unlimited = available && limit < 0;
1989
+ const percent = available && limit > 0 ? clamp(Math.round(used / limit * 100), 0, 100) : 0;
1990
+
1991
+ row.classList.toggle("is-unavailable", !available);
1992
+ row.classList.toggle("is-warn", available && !unlimited && percent >= 80 && percent < 100);
1993
+ row.classList.toggle("is-limit", available && !unlimited && percent >= 100);
1994
+ if (valueEl) {
1995
+ valueEl.textContent = unlimited
1996
+ ? formatQuotaNumber(used) + " / Unlimited"
1997
+ : (used === null ? "โ€”" : formatQuotaNumber(used)) + " / " + (limit === null ? "โ€”" : formatQuotaNumber(limit));
1998
+ }
1999
+ if (fillEl) fillEl.style.width = unlimited ? "0%" : percent + "%";
2000
+ if (percentEl) percentEl.textContent = !available
2001
+ ? "Usage unavailable"
2002
+ : unlimited
2003
+ ? "Unlimited"
2004
+ : percent >= 100
2005
+ ? "Limit reached"
2006
+ : percent + "%";
2007
+ if (resetEl) resetEl.textContent = lifetime ? "Lifetime limit" : quotaResetLabel(snapshot && snapshot.resetAt);
2008
+ row.setAttribute("aria-label", key + " usage " + (percentEl ? percentEl.textContent : ""));
2009
+ }
2010
+
2011
+ function quotaNumber(value) {
2012
+ return typeof value === "number" && isFinite(value) ? value : null;
2013
+ }
2014
+
2015
+ function formatQuotaNumber(value) {
2016
+ const n = Math.max(0, Number(value) || 0);
2017
+ if (n >= 1000000) return (n / 1000000).toFixed(n < 10000000 ? 1 : 0).replace(/\.0$/, "") + "M";
2018
+ if (n >= 1000) return (n / 1000).toFixed(n < 100000 ? 1 : 0).replace(/\.0$/, "") + "K";
2019
+ return Math.round(n).toLocaleString("en-US");
2020
+ }
2021
+
2022
+ function quotaResetLabel(value) {
2023
+ if (!value) return "Weekly reset";
2024
+ const resetAt = new Date(value);
2025
+ if (!isFinite(resetAt.getTime())) return "Weekly reset";
2026
+ if (resetAt.getTime() <= Date.now()) return "Resetting now";
2027
+ return "Resets " + resetAt.toLocaleString(undefined, {
2028
+ weekday: "short",
2029
+ month: "short",
2030
+ day: "numeric",
2031
+ hour: "numeric",
2032
+ minute: "2-digit",
2033
+ timeZoneName: "short",
2034
+ });
2035
+ }
2036
+
1846
2037
  function renderWhy(score, oldPct, repeatCount, sat, signal, color) {
1847
2038
  const rows = [];
1848
2039
  for (const row of junkBreakdown(score)) rows.push(row);
package/dist/index.js CHANGED
@@ -22,11 +22,6 @@ class NoTokenError extends Error {
22
22
  }
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
- expired;
26
- constructor(expired) {
27
- super("EchoMem vault locked");
28
- this.expired = expired;
29
- }
30
25
  }
31
26
  function stringifyErrorValue(value) {
32
27
  if (value == null) {
@@ -545,10 +540,16 @@ class EchoMemApiClient {
545
540
  async fetchMemoryMap() {
546
541
  try {
547
542
  const enc = await this.encState(); // unencrypted โ†’ {enabled:false}; encrypted+locked โ†’ throws
548
- const today = new Date().toISOString().slice(0, 10);
549
- const response = await this.axios.post("/api/extension/memories/time-range", { startDate: "2000-01-01", endDate: today, limit: 200 }, { timeout: 6000 });
543
+ // Tool-list decoration is not a user-initiated search and must not spend
544
+ // one of the account's weekly searches. Build the best-effort map from
545
+ // the ordinary paginated memory listing instead.
546
+ const response = await this.axios.get("/api/extension/memories?limit=200", { timeout: 6000 });
550
547
  const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
551
- const memories = Array.isArray(data?.memories) ? data.memories : [];
548
+ const memories = Array.isArray(data?.data)
549
+ ? data.data
550
+ : Array.isArray(data?.memories)
551
+ ? data.memories
552
+ : [];
552
553
  const keys = [...new Set(memories.map((m) => String(m?.keys || "").trim()).filter(Boolean))];
553
554
  if (!keys.length)
554
555
  return undefined;
@@ -585,7 +586,7 @@ class EchoMemApiClient {
585
586
  // No usable key: consult the server to tell "unencrypted" apart from "encrypted but locked/expired".
586
587
  const cfg = await this.getEncryptionConfig();
587
588
  if (cfg.enabled)
588
- throw new LockedError(this.store.isKeyExpired());
589
+ throw new LockedError("EchoMem vault locked");
589
590
  return { enabled: false };
590
591
  }
591
592
  pruneDeleteConfirmations(now = Date.now()) {
@@ -789,6 +790,7 @@ class EchoMemApiClient {
789
790
  startDate,
790
791
  endDate,
791
792
  limit,
793
+ requestId: randomUUID(),
792
794
  });
793
795
  return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
794
796
  }
@@ -833,21 +835,41 @@ class EchoMemApiClient {
833
835
  // For an encrypted account, hand the server the key transiently in the X-Encryption-Key header
834
836
  // so it encrypts at rest (mirrors the extension's write path, spec ยง3.1a). Locked โ†’ LockedError.
835
837
  const enc = await this.encState();
836
- const config = enc.enabled && enc.key ? { headers: { "X-Encryption-Key": enc.key } } : undefined;
837
- const response = await this.axios.post("/api/extension/memories/ingest", {
838
- rawData,
839
- sourceUrl: parsed.url,
840
- source: parsed.source || "mcp_server",
841
- title: parsed.title,
842
- // Stable per-session id so multiple saves in this coding session group under one context.
843
- conversationKey: this.sessionId,
844
- passthrough: parsed.passthrough || false,
845
- triggerMessage: parsed.triggerMessage ||
846
- lastUserMessageFromMessages(parsed.messages) ||
847
- lastUserMessageFromConversationText(parsed.conversation),
848
- triggerMessageRole: parsed.triggerMessageRole || "user",
849
- }, config);
850
- return response.data;
838
+ const config = {
839
+ headers: {
840
+ "X-EchoMem-Request-Id": randomUUID(),
841
+ ...(enc.enabled && enc.key ? { "X-Encryption-Key": enc.key } : {}),
842
+ },
843
+ };
844
+ try {
845
+ const response = await this.axios.post("/api/extension/memories/ingest", {
846
+ rawData,
847
+ sourceUrl: parsed.url,
848
+ source: parsed.source || "mcp_server",
849
+ title: parsed.title,
850
+ // Stable per-session id so multiple saves in this coding session group under one context.
851
+ conversationKey: this.sessionId,
852
+ passthrough: parsed.passthrough || false,
853
+ triggerMessage: parsed.triggerMessage ||
854
+ lastUserMessageFromMessages(parsed.messages) ||
855
+ lastUserMessageFromConversationText(parsed.conversation),
856
+ triggerMessageRole: parsed.triggerMessageRole || "user",
857
+ }, config);
858
+ return response.data;
859
+ }
860
+ catch (error) {
861
+ if (axios.isAxiosError(error) && error.response?.status === 429) {
862
+ const data = error.response.data;
863
+ if (data?.error === "MEMORY_PROCESSING_QUOTA_EXCEEDED") {
864
+ const message = typeof data.message === "string"
865
+ ? data.message
866
+ : "Weekly memory-processing limit reached. This conversation was not saved.";
867
+ const upgrade = typeof data.upgradeUrl === "string" ? ` Upgrade: ${data.upgradeUrl}` : "";
868
+ throw new McpError(ErrorCode.InvalidRequest, `${message}${upgrade}`);
869
+ }
870
+ }
871
+ throw error;
872
+ }
851
873
  }
852
874
  async deleteMemory(args) {
853
875
  const parsed = deleteMemorySchema.parse(args ?? {});
@@ -900,6 +922,7 @@ class EchoMemApiClient {
900
922
  startDate: parsed.startDate,
901
923
  endDate: parsed.endDate,
902
924
  limit: parsed.limit,
925
+ requestId: randomUUID(),
903
926
  });
904
927
  return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
905
928
  }
@@ -918,6 +941,7 @@ class EchoMemApiClient {
918
941
  const response = await this.axios.post("/api/extension/memories/keywords", {
919
942
  keywords: parsed.keywords,
920
943
  limit: parsed.limit,
944
+ requestId: randomUUID(),
921
945
  });
922
946
  return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
923
947
  }
@@ -1192,9 +1216,13 @@ class EchoMemMCPServer {
1192
1216
  content: [
1193
1217
  {
1194
1218
  type: "text",
1195
- text: error.expired
1196
- ? "๐Ÿ”’ EchoMem vault is locked โ€” your encryption key expired. Run `echomem-mcp unlock` in a terminal, then retry."
1197
- : "๐Ÿ”’ EchoMem vault is locked. Run `echomem-mcp unlock` (or `echomem-mcp login`) to provide your encryption key, then retry.",
1219
+ text: [
1220
+ "๐Ÿ”’ EchoMem vault is locked.",
1221
+ "This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
1222
+ "Action required from the user: open Terminal and run `echomem-mcp unlock` yourself. Do not have the agent run this interactive command and do not send your passphrase in chat.",
1223
+ "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1224
+ "After the success message, retry this EchoMem action in the current session โ€” no editor restart is needed.",
1225
+ ].join("\n"),
1198
1226
  },
1199
1227
  ],
1200
1228
  };
@@ -1362,7 +1390,7 @@ Details: ${m.details || "N/A"}`)
1362
1390
  rec.conversation_chars = text.length;
1363
1391
  rec.save_source = typeof a?.source === "string" ? a.source : sourceFallback;
1364
1392
  }
1365
- const { success, memoriesExtracted, extractedMemories, contextId, capsuleId, passthrough: isPassthrough, error } = await this.client.saveConversation(enrichedArgs);
1393
+ const { success, memoriesExtracted, memoriesDiscarded, extractedMemories, contextId, capsuleId, passthrough: isPassthrough, error } = await this.client.saveConversation(enrichedArgs);
1366
1394
  if (!success)
1367
1395
  throw new Error(`EchoMem API Error: ${error}`);
1368
1396
  if (rec)
@@ -1374,7 +1402,7 @@ Details: ${m.details || "N/A"}`)
1374
1402
  typeof capsuleId === "string" && capsuleId ? `Capsule ID: ${capsuleId}` : "",
1375
1403
  typeof contextId === "string" && contextId ? `Context: ${contextId}` : "",
1376
1404
  "",
1377
- `To reload this capsule in a fresh session with Pro/Power recall: get_memories_by_context({ contextId: "${contextId}" })`,
1405
+ `To reload this capsule in a fresh session: get_memories_by_context({ contextId: "${contextId}" })`,
1378
1406
  ].filter(Boolean).join("\n");
1379
1407
  return { content: [{ type: "text", text }] };
1380
1408
  }
@@ -1396,9 +1424,12 @@ Details: ${m.details || "N/A"}`)
1396
1424
  .join("\n\n");
1397
1425
  const text = [
1398
1426
  `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.`,
1427
+ typeof memoriesDiscarded === "number" && memoriesDiscarded > 0
1428
+ ? `${memoriesDiscarded} additional memories were not stored because your active-memory limit was reached.`
1429
+ : "",
1399
1430
  list,
1400
1431
  saved.length
1401
- ? `Verify these captured the key facts. With Pro/Power recall, re-fetch this exact batch later by searching the ids above${typeof contextId === "string" && contextId ? ` (context ${contextId})` : ""}.`
1432
+ ? `Verify these captured the key facts. Re-fetch this exact batch later by searching the ids above${typeof contextId === "string" && contextId ? ` (context ${contextId})` : ""}.`
1402
1433
  : "",
1403
1434
  ].filter(Boolean).join("\n\n");
1404
1435
  return { content: [{ type: "text", text }] };
package/dist/keystore.js CHANGED
@@ -10,13 +10,13 @@
10
10
  * Storage posture: a 0600 file in the user's home โ€” the same on-disk trust level as the extension's
11
11
  * `chrome.storage.local`. The OS keychain (spec ยง8's ideal) is a drop-in for `read`/`write` later;
12
12
  * it is deliberately deferred because `keytar` is a native build that complicates `npx` distribution.
13
- * The encryption key carries a TTL (mirrors the extension's 7-day expiry); on expiry the bridge
14
- * returns LOCKED and the user re-runs `unlock`.
13
+ * The MCP bridge uses an explicit trusted-device lifecycle: a verified encryption key remains
14
+ * available until the user runs `lock`/`logout` or removes the local credentials. Interactive app
15
+ * session TTLs are intentionally separate from this device-bound developer workflow.
15
16
  */
16
17
  import fs from "node:fs";
17
18
  import os from "node:os";
18
19
  import path from "node:path";
19
- export const DEFAULT_KEY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
20
  /** The bridge's local config dir (`~/.echomem` or `$ECHO_CONFIG_DIR`) โ€” home for credentials + telemetry. */
21
21
  export function echoConfigDir() {
22
22
  return process.env.ECHO_CONFIG_DIR || path.join(os.homedir(), ".echomem");
@@ -55,29 +55,19 @@ export class KeyStore {
55
55
  getToken() {
56
56
  return process.env.ECHO_API_TOKEN || readFileCreds().token;
57
57
  }
58
- /** Encryption key (base64), or undefined if absent/expired. Env wins (and never expires). */
58
+ /** Encryption key (base64), or undefined if absent. Env wins. */
59
59
  getKey() {
60
60
  if (process.env.ECHO_ENCRYPTION_KEY)
61
61
  return process.env.ECHO_ENCRYPTION_KEY;
62
- const creds = readFileCreds();
63
- if (!creds.key)
64
- return undefined;
65
- if (creds.keyExpiresAt && Date.now() > creds.keyExpiresAt)
66
- return undefined;
67
- return creds.key;
68
- }
69
- /** True when a key is present but past its TTL โ€” used to tell "locked (expired)" from "never set". */
70
- isKeyExpired() {
71
- if (process.env.ECHO_ENCRYPTION_KEY)
72
- return false;
73
- const creds = readFileCreds();
74
- return !!(creds.key && creds.keyExpiresAt && Date.now() > creds.keyExpiresAt);
62
+ return readFileCreds().key;
75
63
  }
76
64
  saveToken(token) {
77
65
  writeFileCreds({ ...readFileCreds(), token });
78
66
  }
79
- saveKey(keyBase64, ttlMs = DEFAULT_KEY_TTL_MS) {
80
- writeFileCreds({ ...readFileCreds(), key: keyBase64, keyExpiresAt: Date.now() + ttlMs });
67
+ saveKey(keyBase64) {
68
+ const creds = readFileCreds();
69
+ delete creds.keyExpiresAt;
70
+ writeFileCreds({ ...creds, key: keyBase64 });
81
71
  }
82
72
  clearKey() {
83
73
  const creds = readFileCreds();