@echomem/mcp 1.4.14 → 1.4.15

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.
@@ -0,0 +1,139 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { echoConfigDir, KeyStore } from "../keystore.js";
5
+ const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
6
+ const EVENTS_TAIL_LINES = 400;
7
+ function record(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value)
9
+ ? value
10
+ : null;
11
+ }
12
+ function number(value) {
13
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
14
+ }
15
+ function text(value) {
16
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
17
+ }
18
+ function briefError(error) {
19
+ const message = error instanceof Error ? error.message : String(error || "Connection failed");
20
+ return message
21
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [redacted]")
22
+ .replace(/ec_[A-Za-z0-9._-]+/g, "ec_[redacted]")
23
+ .replace(/\s+/g, " ")
24
+ .slice(0, 180);
25
+ }
26
+ function normalizePlan(value) {
27
+ const plan = text(value)?.toLowerCase();
28
+ return plan && ["free", "pro", "power", "team", "enterprise"].includes(plan) ? plan : undefined;
29
+ }
30
+ /** Parse the account bootstrap contract without importing app-only code into the npm package. */
31
+ export function accountDiagnosticsFromBootstrap(payload) {
32
+ const root = record(payload) ?? {};
33
+ const entitlements = record(root.entitlements) ?? {};
34
+ const usage = record(root.usage) ?? {};
35
+ const activation = record(root.activation) ?? {};
36
+ const limit = number(entitlements.deepSearchPerMonth);
37
+ const searchesThisMonth = number(activation.memorySearchMonthToDateCount);
38
+ const searchesRemaining = limit === undefined || searchesThisMonth === undefined
39
+ ? undefined
40
+ : limit < 0
41
+ ? null
42
+ : Math.max(0, limit - searchesThisMonth);
43
+ return {
44
+ plan: normalizePlan(root.plan),
45
+ deepSearchPerMonth: limit,
46
+ searchesThisMonth,
47
+ searchesRemaining,
48
+ memoryCount: number(usage.memories),
49
+ sourceCount: number(usage.sources),
50
+ };
51
+ }
52
+ /** Read only the latest memory-search trace; never return a query, token, or memory content. */
53
+ export function latestSearchTraceFromEvents(lines) {
54
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
55
+ try {
56
+ const event = record(JSON.parse(lines[index] || ""));
57
+ if (!event || event.type !== "tool_call" || event.tool !== "search_memories")
58
+ continue;
59
+ const at = text(event.ts);
60
+ if (!at)
61
+ continue;
62
+ return {
63
+ at,
64
+ ok: typeof event.ok === "boolean" ? event.ok : undefined,
65
+ requestId: text(event.request_id),
66
+ endpoint: text(event.endpoint),
67
+ httpStatus: number(event.http_status),
68
+ errorCode: text(event.error_code),
69
+ errorKind: text(event.error_kind),
70
+ latencyMs: number(event.latency_ms),
71
+ };
72
+ }
73
+ catch {
74
+ // A concurrently appended partial line or old malformed record is safely ignored.
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+ function loadLatestSearchTrace() {
80
+ try {
81
+ const file = path.join(echoConfigDir(), "events.jsonl");
82
+ return latestSearchTraceFromEvents(fs.readFileSync(file, "utf8").split("\n").slice(-EVENTS_TAIL_LINES));
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ /**
89
+ * Read-only, local HUD diagnostics. Credentials are used only in the Authorization header to
90
+ * EchoMem's authenticated account endpoint; this never sends transcripts, queries, or memories.
91
+ */
92
+ export async function loadHudDiagnostics() {
93
+ const updatedAt = new Date().toISOString();
94
+ const lastSearch = loadLatestSearchTrace();
95
+ const token = new KeyStore().getToken();
96
+ if (!token) {
97
+ return { updatedAt, account: { state: "not_logged_in" }, accountRequest: {}, lastSearch };
98
+ }
99
+ const requestId = `hud-account-${randomUUID()}`;
100
+ const startedAt = Date.now();
101
+ try {
102
+ const response = await fetch(`${API_BASE}/api/extension/account/bootstrap`, {
103
+ headers: {
104
+ Authorization: `Bearer ${token}`,
105
+ "X-EchoMem-Request-Id": requestId,
106
+ },
107
+ });
108
+ const payload = await response.json().catch(() => ({}));
109
+ const telemetry = record(payload)?.telemetry;
110
+ const echoedRequestId = text(record(telemetry)?.requestId) ?? requestId;
111
+ const accountRequest = {
112
+ requestId: echoedRequestId,
113
+ httpStatus: response.status,
114
+ elapsedMs: Date.now() - startedAt,
115
+ };
116
+ if (!response.ok) {
117
+ return {
118
+ updatedAt,
119
+ account: { state: "error", error: `Account check returned HTTP ${response.status}` },
120
+ accountRequest,
121
+ lastSearch,
122
+ };
123
+ }
124
+ return {
125
+ updatedAt,
126
+ account: { state: "connected", ...accountDiagnosticsFromBootstrap(payload) },
127
+ accountRequest,
128
+ lastSearch,
129
+ };
130
+ }
131
+ catch (error) {
132
+ return {
133
+ updatedAt,
134
+ account: { state: "error", error: briefError(error) },
135
+ accountRequest: { requestId, elapsedMs: Date.now() - startedAt },
136
+ lastSearch,
137
+ };
138
+ }
139
+ }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { HudMonitor } from "./monitor.js";
7
7
  import { HUD_HTML } from "./web.js";
8
8
  import { buildCapsuleText } from "./capsule.js";
9
+ import { loadHudDiagnostics } from "./diagnostics.js";
9
10
  import { homePath, newestFile, walkFiles } from "./fs.js";
10
11
  import { KeyStore } from "../keystore.js";
11
12
  import { assembleCodex, assembleClaude } from "../migrate.js";
@@ -42,6 +43,24 @@ export async function createHudServer(opts = {}) {
42
43
  res.end(JSON.stringify(latest, null, 2));
43
44
  return;
44
45
  }
46
+ if (url.pathname === "/diagnostics") {
47
+ loadHudDiagnostics().then((diagnostics) => {
48
+ if (res.writableEnded)
49
+ return;
50
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
51
+ res.end(JSON.stringify(diagnostics));
52
+ }).catch(() => {
53
+ if (res.writableEnded)
54
+ return;
55
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
56
+ res.end(JSON.stringify({
57
+ updatedAt: new Date().toISOString(),
58
+ account: { state: "error", error: "Diagnostics unavailable" },
59
+ accountRequest: {},
60
+ }));
61
+ });
62
+ return;
63
+ }
45
64
  if (url.pathname === "/select") {
46
65
  const id = url.searchParams.get("id");
47
66
  monitor.pin(id && id !== "auto" ? id : null);
package/dist/hud/web.js CHANGED
@@ -217,6 +217,32 @@ export const HUD_HTML = String.raw `<!doctype html>
217
217
  cursor: grab;
218
218
  }
219
219
  #hud.open .details { display: block; }
220
+ .diagnostics {
221
+ margin: 10px 0 0;
222
+ padding: 10px 11px;
223
+ border: 1px solid rgba(26, 58, 143, 0.18);
224
+ border-radius: 10px;
225
+ background: rgba(255, 255, 255, 0.72);
226
+ }
227
+ .diagnostics-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
228
+ .diagnostics-title { font-size: 10px; font-weight: 820; letter-spacing: .02em; color: var(--blue); }
229
+ .diagnostics-refresh {
230
+ border: 0;
231
+ padding: 0;
232
+ background: transparent;
233
+ color: var(--faint);
234
+ font-family: inherit;
235
+ font-size: 10px;
236
+ font-weight: 730;
237
+ cursor: pointer;
238
+ }
239
+ .diagnostics-refresh:hover { color: var(--blue); }
240
+ .diagnostics-body { display: grid; gap: 5px; margin-top: 7px; }
241
+ .diagnostics-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 10px; line-height: 1.25; }
242
+ .diagnostics-k { flex: 0 0 auto; color: var(--faint); font-weight: 650; }
243
+ .diagnostics-v { min-width: 0; overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 9.5px; font-weight: 650; }
244
+ .diagnostics-v.bad { color: var(--brick); }
245
+ .diagnostics-v.wrap { white-space: normal; overflow-wrap: anywhere; }
220
246
  /* The current session is the one object every number and action belongs to — one card. */
221
247
  .now-card {
222
248
  border: 1px solid rgba(26, 58, 143, 0.24);
@@ -690,6 +716,10 @@ export const HUD_HTML = String.raw `<!doctype html>
690
716
  </div>
691
717
  </div>
692
718
  </div>
719
+ <section class="diagnostics" aria-label="Memory access diagnostics">
720
+ <div class="diagnostics-head"><span class="diagnostics-title">Memory access</span><button class="diagnostics-refresh" id="diagnosticsrefresh" type="button">Refresh</button></div>
721
+ <div class="diagnostics-body" id="diagnosticsbody"><span class="diagnostics-v">Open this panel to check account status.</span></div>
722
+ </section>
693
723
  <div class="tabs" id="tabs"></div>
694
724
  <div class="meta-row"><span class="meta-line" id="metaline"></span><button class="switchbtn agent-switch" id="switchbtn" title="Switch agent" style="display:none" type="button"></button></div>
695
725
  <div class="path" id="path"></div>
@@ -730,6 +760,8 @@ export const HUD_HTML = String.raw `<!doctype html>
730
760
  const switchBtn = document.getElementById("switchbtn");
731
761
  const revealEl = document.getElementById("reveal");
732
762
  const missing = document.getElementById("missing");
763
+ const diagnosticsBody = document.getElementById("diagnosticsbody");
764
+ const diagnosticsRefresh = document.getElementById("diagnosticsrefresh");
733
765
  let prevTurnState = null;
734
766
  let lastCapsule = "";
735
767
  let renewResultSessionKey = "";
@@ -741,6 +773,8 @@ export const HUD_HTML = String.raw `<!doctype html>
741
773
  let renewStartMs = 0;
742
774
  let renewEta = null;
743
775
  let renewEstimateSeq = 0;
776
+ let diagnosticsLoaded = false;
777
+ let diagnosticsInFlight = false;
744
778
  const CHECKPOINT_CACHE_KEY = "echomemHudCheckpointCacheV2";
745
779
  const CHECKPOINT_CACHE_LIMIT = 24;
746
780
  const CHECKPOINT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -753,6 +787,85 @@ export const HUD_HTML = String.raw `<!doctype html>
753
787
  // viewing", so the user can always point the HUD at the right session. Flip false to show the viewer.
754
788
  const DEMO_CLEAN = true;
755
789
 
790
+ function diagnosticsLine(label, value, bad, wrap) {
791
+ return '<span class="diagnostics-line"><span class="diagnostics-k">' + esc(label) + '</span><span class="diagnostics-v' + (bad ? ' bad' : '') + (wrap ? ' wrap' : '') + '">' + esc(value) + '</span></span>';
792
+ }
793
+
794
+ function requestLabel(requestId) {
795
+ return requestId ? requestId : "not available";
796
+ }
797
+
798
+ function planLabel(plan) {
799
+ if (!plan) return "connected";
800
+ return plan.slice(0, 1).toUpperCase() + plan.slice(1);
801
+ }
802
+
803
+ function renderDiagnostics(data) {
804
+ if (!diagnosticsBody) return;
805
+ const account = (data && data.account) || {};
806
+ const accountRequest = (data && data.accountRequest) || {};
807
+ const lines = [];
808
+ if (account.state === "connected") {
809
+ lines.push(diagnosticsLine("account", planLabel(account.plan) + " · authenticated"));
810
+ if (typeof account.searchesThisMonth === "number" && typeof account.deepSearchPerMonth === "number") {
811
+ const limit = account.deepSearchPerMonth < 0 ? "Unlimited" : String(account.deepSearchPerMonth);
812
+ const remaining = account.searchesRemaining === null ? "" : (typeof account.searchesRemaining === "number" ? " · " + account.searchesRemaining + " left" : "");
813
+ lines.push(diagnosticsLine("searches", account.searchesThisMonth + " / " + limit + " UTC month" + remaining));
814
+ } else {
815
+ lines.push(diagnosticsLine("searches", "month-to-date not reported"));
816
+ }
817
+ } else if (account.state === "not_logged_in") {
818
+ lines.push(diagnosticsLine("account", "not logged in", true));
819
+ } else {
820
+ lines.push(diagnosticsLine("account", account.error || "unavailable", true));
821
+ }
822
+ const accountStatus = typeof accountRequest.httpStatus === "number"
823
+ ? "HTTP " + accountRequest.httpStatus + (typeof accountRequest.elapsedMs === "number" ? " · " + accountRequest.elapsedMs + "ms" : "")
824
+ : "not sent";
825
+ lines.push(diagnosticsLine("account check", accountStatus, account.state === "error"));
826
+ if (accountRequest.requestId) lines.push(diagnosticsLine("account request", requestLabel(accountRequest.requestId), false, true));
827
+
828
+ const last = data && data.lastSearch;
829
+ if (!last) {
830
+ lines.push(diagnosticsLine("last search", "no local trace yet"));
831
+ } else {
832
+ const outcome = last.ok === false ? "failed" : last.ok === true ? "succeeded" : "recorded";
833
+ const status = typeof last.httpStatus === "number" ? "HTTP " + last.httpStatus : outcome;
834
+ const errorLabel = last.errorCode || (last.errorKind && last.errorKind !== "none" ? last.errorKind : "");
835
+ const detail = [status, errorLabel].filter(Boolean).join(" · ");
836
+ lines.push(diagnosticsLine("last search", detail || outcome, last.ok === false));
837
+ if (last.requestId) lines.push(diagnosticsLine("search request", requestLabel(last.requestId), last.ok === false, true));
838
+ }
839
+ diagnosticsBody.innerHTML = lines.join("");
840
+ diagnosticsLoaded = true;
841
+ syncHeight();
842
+ }
843
+
844
+ async function refreshDiagnostics() {
845
+ if (!diagnosticsBody || diagnosticsInFlight) return;
846
+ diagnosticsInFlight = true;
847
+ diagnosticsBody.innerHTML = diagnosticsLine("memory access", "checking…");
848
+ if (diagnosticsRefresh) diagnosticsRefresh.disabled = true;
849
+ try {
850
+ const response = await fetch("/diagnostics");
851
+ renderDiagnostics(await response.json());
852
+ } catch (_) {
853
+ renderDiagnostics({ account: { state: "error", error: "local diagnostic fetch failed" }, accountRequest: {} });
854
+ } finally {
855
+ diagnosticsInFlight = false;
856
+ if (diagnosticsRefresh) diagnosticsRefresh.disabled = false;
857
+ }
858
+ }
859
+
860
+ function maybeLoadDiagnostics() {
861
+ if (!diagnosticsLoaded && !diagnosticsInFlight) refreshDiagnostics();
862
+ }
863
+
864
+ if (diagnosticsRefresh) diagnosticsRefresh.addEventListener("click", (event) => {
865
+ event.stopPropagation();
866
+ refreshDiagnostics();
867
+ });
868
+
756
869
  // Mini mode: resting state is a small pill; opening the panel shows the full layout, closing
757
870
  // returns to the pill. Preference persists (localStorage here, window size in main's bounds file).
758
871
  const miniBtn = document.getElementById("minibtn");
@@ -889,6 +1002,7 @@ export const HUD_HTML = String.raw `<!doctype html>
889
1002
  }
890
1003
  if (event.target instanceof HTMLElement && (event.target.closest(".switchbtn") || event.target.closest(".cornerbtn"))) return;
891
1004
  hud.classList.toggle("open");
1005
+ if (hud.classList.contains("open")) maybeLoadDiagnostics();
892
1006
  syncHeight();
893
1007
  });
894
1008
 
package/dist/index.js CHANGED
@@ -105,6 +105,11 @@ function readNumber(record, key) {
105
105
  const value = record[key];
106
106
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
107
107
  }
108
+ function errorCodeFrom(value) {
109
+ if (!isRecord(value))
110
+ return undefined;
111
+ return readString(value, "code") || readString(value, "errorCode");
112
+ }
108
113
  function compactOneLine(value, maxLength) {
109
114
  if (!value)
110
115
  return undefined;
@@ -569,30 +574,59 @@ class EchoMemApiClient {
569
574
  throw error;
570
575
  }
571
576
  }
572
- async searchMemoriesRaw(query, opts, enc, retrievalOnly = false) {
573
- const response = await this.axios.post("/api/extension/memories/search", {
574
- query,
575
- k: opts.limit,
576
- similarityThreshold: opts.threshold,
577
- timeFrameDays: opts.timeFrameDays,
578
- requestId: opts.requestId,
579
- sessionId: this.sessionId,
580
- });
577
+ async searchMemoriesRaw(query, opts, enc, retrievalOnly = false, trace) {
578
+ const endpoint = "/api/extension/memories/search";
579
+ let response;
580
+ try {
581
+ response = await this.axios.post(endpoint, {
582
+ query,
583
+ k: opts.limit,
584
+ similarityThreshold: opts.threshold,
585
+ timeFrameDays: opts.timeFrameDays,
586
+ requestId: opts.requestId,
587
+ sessionId: this.sessionId,
588
+ });
589
+ trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
590
+ }
591
+ catch (error) {
592
+ trace?.({
593
+ requestId: opts.requestId,
594
+ endpoint,
595
+ httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
596
+ errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
597
+ });
598
+ throw error;
599
+ }
581
600
  const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
582
601
  return retrievalOnly && isRecord(data)
583
602
  ? { ...data, retrievalOnly: true, tuned: false }
584
603
  : data;
585
604
  }
586
- async searchMemoriesRetrieved(query, opts, enc) {
587
- const response = await this.axios.post("/api/extension/memories/deep-search/candidates", {
588
- query,
589
- requestId: opts.requestId,
590
- logicalRequestId: opts.requestId,
591
- sessionId: this.sessionId,
592
- userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
593
- });
605
+ async searchMemoriesRetrieved(query, opts, enc, trace) {
606
+ const endpoint = "/api/extension/memories/deep-search/candidates";
607
+ let response;
608
+ try {
609
+ response = await this.axios.post(endpoint, {
610
+ query,
611
+ requestId: opts.requestId,
612
+ logicalRequestId: opts.requestId,
613
+ sessionId: this.sessionId,
614
+ userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
615
+ });
616
+ trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
617
+ }
618
+ catch (error) {
619
+ trace?.({
620
+ requestId: opts.requestId,
621
+ endpoint,
622
+ httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
623
+ errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
624
+ });
625
+ throw error;
626
+ }
594
627
  const data = response.data ?? {};
595
628
  if (data.success === false) {
629
+ trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
596
630
  throw new Error(`deep-search candidates proxy error: ${data.error || "unknown"}`);
597
631
  }
598
632
  const rawCandidates = Array.isArray(data.personalCandidates) ? data.personalCandidates : [];
@@ -648,16 +682,31 @@ class EchoMemApiClient {
648
682
  // never hits dg-web directly, so no new auth is needed and no other caller is
649
683
  // disrupted. NOTE: encrypted users still get server-side plaintext only; the
650
684
  // phase-1 + bridge-local-decrypt + phase-2 flow (spec §11) is the remaining work.
651
- async searchMemoriesTuned(query, requestId) {
652
- const response = await this.axios.post("/api/extension/memories/deep-search", {
653
- query,
654
- requestId,
655
- logicalRequestId: requestId,
656
- sessionId: this.sessionId,
657
- userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
658
- });
685
+ async searchMemoriesTuned(query, requestId, trace) {
686
+ const endpoint = "/api/extension/memories/deep-search";
687
+ let response;
688
+ try {
689
+ response = await this.axios.post(endpoint, {
690
+ query,
691
+ requestId,
692
+ logicalRequestId: requestId,
693
+ sessionId: this.sessionId,
694
+ userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
695
+ });
696
+ trace?.({ requestId, endpoint, httpStatus: response.status });
697
+ }
698
+ catch (error) {
699
+ trace?.({
700
+ requestId,
701
+ endpoint,
702
+ httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
703
+ errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
704
+ });
705
+ throw error;
706
+ }
659
707
  const data = response.data ?? {};
660
708
  if (data.success === false) {
709
+ trace?.({ requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
661
710
  throw new Error(`deep-search proxy error: ${data.error || "unknown"}`);
662
711
  }
663
712
  const primary = (data.retrievedMemorySnapshot?.primary ?? []);
@@ -669,7 +718,7 @@ class EchoMemApiClient {
669
718
  }));
670
719
  return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
671
720
  }
672
- async searchMemories(args) {
721
+ async searchMemories(args, trace) {
673
722
  const parsed = searchMemoriesSchema.parse(args ?? {});
674
723
  const query = parsed.query?.trim();
675
724
  const limit = parsed.limit ?? parsed.k ?? 10;
@@ -695,17 +744,17 @@ class EchoMemApiClient {
695
744
  requestId: randomUUID(),
696
745
  };
697
746
  if (!parsed.includeAnswer) {
698
- return this.searchMemoriesRetrieved(query, searchOpts, enc);
747
+ return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
699
748
  }
700
749
  // Encrypted account: the server can't synthesize over plaintext it doesn't hold, so use the raw
701
750
  // retriever (returns ciphertext) and decrypt locally — zero-knowledge preserved end-to-end.
702
751
  if (enc.enabled) {
703
- return await this.searchMemoriesRaw(query, searchOpts, enc);
752
+ return await this.searchMemoriesRaw(query, searchOpts, enc, false, trace);
704
753
  }
705
754
  // A synthesized answer is metered through the legacy route. Do not fall
706
755
  // back to raw retrieval after a ledger outage: that would return a result
707
756
  // without a durable usage fact.
708
- return this.searchMemoriesTuned(query, searchOpts.requestId);
757
+ return this.searchMemoriesTuned(query, searchOpts.requestId, trace);
709
758
  }
710
759
  /** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
711
760
  async decryptResult(data, key) {
@@ -1129,6 +1178,13 @@ class EchoMemMCPServer {
1129
1178
  map_injected: rec.map_injected,
1130
1179
  encrypted_user: rec.encrypted,
1131
1180
  extracted_memory_count: rec.memories_extracted,
1181
+ // Preserve only the safe correlation fields needed to join a
1182
+ // bridge-local failure to the central operational trace. Query
1183
+ // text and upstream bodies never leave the local event stream.
1184
+ request_id: rec.request_id,
1185
+ endpoint: rec.endpoint,
1186
+ http_status: rec.http_status,
1187
+ error_code: rec.error_code ?? (rec.error_kind === "none" ? undefined : rec.error_kind),
1132
1188
  };
1133
1189
  void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics, `${analyticsCallId}:generic-completed`);
1134
1190
  void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics, `${analyticsCallId}:completed`);
@@ -1142,7 +1198,14 @@ class EchoMemMCPServer {
1142
1198
  rec.query_len = query.length;
1143
1199
  rec.query_hash = hashText(query);
1144
1200
  }
1145
- const result = await this.client.searchMemories(args);
1201
+ const result = await this.client.searchMemories(args, (trace) => {
1202
+ if (!rec)
1203
+ return;
1204
+ rec.request_id = trace.requestId;
1205
+ rec.endpoint = trace.endpoint;
1206
+ rec.http_status = trace.httpStatus;
1207
+ rec.error_code = trace.errorCode;
1208
+ });
1146
1209
  if (rec) {
1147
1210
  // "Which memories were used": topic keys + delivered context size, for the comparison dataset.
1148
1211
  const mems = Array.isArray(result?.memories) ? result.memories : [];
@@ -1249,7 +1312,7 @@ Details: ${m.details || "N/A"}`)
1249
1312
  typeof capsuleId === "string" && capsuleId ? `Capsule ID: ${capsuleId}` : "",
1250
1313
  typeof contextId === "string" && contextId ? `Context: ${contextId}` : "",
1251
1314
  "",
1252
- `To reload this capsule in a fresh session: get_memories_by_context({ contextId: "${contextId}" })`,
1315
+ `To reload this capsule in a fresh session with Pro/Power recall: get_memories_by_context({ contextId: "${contextId}" })`,
1253
1316
  ].filter(Boolean).join("\n");
1254
1317
  return { content: [{ type: "text", text }] };
1255
1318
  }
@@ -1273,7 +1336,7 @@ Details: ${m.details || "N/A"}`)
1273
1336
  `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.`,
1274
1337
  list,
1275
1338
  saved.length
1276
- ? `Verify these captured the key facts. To re-fetch this exact batch later, search by the ids above${typeof contextId === "string" && contextId ? ` (context ${contextId})` : ""}.`
1339
+ ? `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})` : ""}.`
1277
1340
  : "",
1278
1341
  ].filter(Boolean).join("\n\n");
1279
1342
  return { content: [{ type: "text", text }] };
@@ -21,6 +21,8 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
21
21
  var reportMounted = false; // report shell (city iframe) mounted once; loading is an overlay on it, not a separate page
22
22
  var authUrl = "";
23
23
  var workspacePath = "";
24
+ var billingStatus = null;
25
+ var recallGateSkipped = false;
24
26
  var authWindow = null;
25
27
  var connectionPollStarted = false;
26
28
  var statsPollStarted = false;
@@ -342,7 +342,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
342
342
  '<div class="resultNumber"><strong>' + esc(number(extracted)) + '</strong><span>' + esc(memoryNoun) + '</span></div>') +
343
343
  '</div>' +
344
344
  '</div>' +
345
- (done ? tryItCard() : "") +
345
+ (done ? recallGateBlock() : "") +
346
346
  (done ? "" :
347
347
  '<div class="memoryReceipt">' +
348
348
  '<p class="receiptK">What just happened</p>' +
@@ -353,7 +353,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
353
353
  '</div>') +
354
354
  '</div>' +
355
355
  (done ? hudTip() + doneCloseNote() : "");
356
- wireTryButtons();
356
+ wireRecallGate();
357
357
  return;
358
358
  }
359
359
  setHead(preparing ? "Preparing" : "Extracting", preparing ? "Preparing" : "Extracting");
@@ -447,6 +447,82 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
447
447
  setLaunchStatus(msg);
448
448
  }
449
449
  }
450
+ function paidRecallPlan(plan) {
451
+ return ["pro", "power", "team", "enterprise"].indexOf(String(plan || "").toLowerCase()) >= 0;
452
+ }
453
+ function recallPricingUrl() {
454
+ return (billingStatus && billingStatus.pricingUrl)
455
+ || "https://echoknows.com/pricing?source=mcp_onboarding";
456
+ }
457
+ function recallGateUnlocked() {
458
+ return recallGateSkipped || paidRecallPlan(billingStatus && billingStatus.plan);
459
+ }
460
+ function recallGateBlock() {
461
+ var unlocked = recallGateUnlocked();
462
+ return '<div class="recallGateBlock">' +
463
+ '<section class="recallGate' + (unlocked ? " is-hidden" : "") + '" id="recallGate">' +
464
+ '<p class="recallEyebrow">Memory saved. Recall is next.</p>' +
465
+ '<h3>Choose your 14-day trial plan for Codex and Claude recall.</h3>' +
466
+ '<p>Saving conversations and extracting memory stay included. Compare Pro and Power, then choose the plan that should unlock <code>search_memories</code>, project recall, and the prompt below.</p>' +
467
+ '<div class="recallGateActions">' +
468
+ '<button type="button" class="primary" id="recallStartTrial">Compare plans</button>' +
469
+ '<button type="button" class="secondary" id="recallSkip">Skip for now</button>' +
470
+ '</div>' +
471
+ '<p class="recallFine">Card required for trial. Cancel anytime.</p>' +
472
+ '<p class="launchStatus" id="recallGateStatus"></p>' +
473
+ '</section>' +
474
+ '<div id="recallTryWrap" class="recallTryWrap' + (unlocked ? "" : " is-hidden") + '">' +
475
+ '<p id="recallSkipNote" class="recallSkipNote' + (recallGateSkipped ? "" : " is-hidden") + '">Saving will keep working. Search and recall require Pro or Power when this prompt calls <code>search_memories</code>.</p>' +
476
+ tryItCard() +
477
+ '</div>' +
478
+ '</div>';
479
+ }
480
+ function setRecallGateStatus(message) {
481
+ var node = document.getElementById("recallGateStatus");
482
+ if (node) node.textContent = message || "";
483
+ }
484
+ function applyRecallGate() {
485
+ var gate = document.getElementById("recallGate");
486
+ var wrap = document.getElementById("recallTryWrap");
487
+ var skipNote = document.getElementById("recallSkipNote");
488
+ var unlocked = recallGateUnlocked();
489
+ if (gate) gate.classList.toggle("is-hidden", unlocked);
490
+ if (wrap) wrap.classList.toggle("is-hidden", !unlocked);
491
+ if (skipNote) skipNote.classList.toggle("is-hidden", !recallGateSkipped);
492
+ }
493
+ async function refreshBillingStatus() {
494
+ try {
495
+ billingStatus = await getJson("/billing-status");
496
+ applyRecallGate();
497
+ if (!recallGateUnlocked() && billingStatus && billingStatus.plan === "unknown") {
498
+ setRecallGateStatus("Could not check your plan. You can start the trial or skip and keep setup going.");
499
+ }
500
+ } catch (_) {
501
+ setRecallGateStatus("Could not check your plan. You can start the trial or skip and keep setup going.");
502
+ }
503
+ }
504
+ function openRecallPricing() {
505
+ var url = recallPricingUrl();
506
+ setRecallGateStatus("Opening pricing in a new tab...");
507
+ try {
508
+ var opened = window.open(url, "_blank", "noopener,noreferrer");
509
+ if (!opened) setRecallGateStatus("Your browser blocked the pricing tab. Allow pop-ups for this page, then click Compare plans again.");
510
+ } catch (_) {
511
+ setRecallGateStatus("Could not open a new tab. Allow pop-ups for this page, then click Compare plans again.");
512
+ }
513
+ }
514
+ function skipRecallGate() {
515
+ recallGateSkipped = true;
516
+ applyRecallGate();
517
+ }
518
+ function wireRecallGate() {
519
+ var trialBtn = document.getElementById("recallStartTrial");
520
+ if (trialBtn) trialBtn.addEventListener("click", openRecallPricing);
521
+ var skipBtn = document.getElementById("recallSkip");
522
+ if (skipBtn) skipBtn.addEventListener("click", skipRecallGate);
523
+ wireTryButtons();
524
+ void refreshBillingStatus();
525
+ }
450
526
  function tryItCard() {
451
527
  var prompt = recallPrompt();
452
528
  return '<div class="tryCard">' +
@@ -500,10 +576,10 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
500
576
  '<span class="seal">Skipped for now</span>' +
501
577
  '<h2>You can come back anytime.</h2>' +
502
578
  '<p>Echo saved what already finished. When you are ready, run <code>echomem-mcp init</code> again — it picks up right where it left off.</p>' +
503
- tryItCard() +
579
+ recallGateBlock() +
504
580
  hudTip() +
505
581
  '</div>';
506
- wireTryButtons();
582
+ wireRecallGate();
507
583
  }
508
584
  async function endExtraction() {
509
585
  if (extractionEnded) return;
@@ -1,5 +1,71 @@
1
1
  // Generated by splitting the previous inline setup page. Keep this source readable: it is emitted into one browser IIFE.
2
2
  export const SETUP_PAGE_CLIENT_LIFECYCLE = String.raw ` /* ---------- scan-first poll for the forensic report ---------- */
3
+ function renderLocalScanConsent() {
4
+ setScanMode(false);
5
+ setCityMode(false);
6
+ setExtractMode(false);
7
+ setReadyMode(false);
8
+ setHead("Local file access", "Consent");
9
+ app.className = "consentStage";
10
+ app.innerHTML =
11
+ '<section class="localConsent">' +
12
+ '<p class="consentEyebrow">Local visualization</p>' +
13
+ '<h2>Let Echo scan locally to build your Agent Doctor view?</h2>' +
14
+ '<p>Echo reads local Codex and Claude Code history on this machine only, then turns it into a private usage visualization. Nothing from this scan leaves your device.</p>' +
15
+ '<div class="consentActions">' +
16
+ '<button type="button" class="primary" id="allowLocalScan">Continue with local scan</button>' +
17
+ '<button type="button" class="textButton" id="declineLocalScan">Skip the insight for now</button>' +
18
+ '</div>' +
19
+ '<p class="consentFine" id="consentStatus"></p>' +
20
+ '</section>';
21
+ var allow = document.getElementById("allowLocalScan");
22
+ if (allow) allow.onclick = allowLocalScan;
23
+ var decline = document.getElementById("declineLocalScan");
24
+ if (decline) decline.onclick = declineLocalScan;
25
+ }
26
+ function setConsentStatus(message) {
27
+ var node = document.getElementById("consentStatus");
28
+ if (node) node.textContent = message || "";
29
+ }
30
+ async function allowLocalScan() {
31
+ setConsentStatus("Starting local scan...");
32
+ try {
33
+ await postJson("/report-consent", { allowed: true });
34
+ await showReport();
35
+ } catch (error) {
36
+ renderReportIssue("CONSENT_SAVE_FAILED", error && error.message ? error.message : "Could not start the local scan.");
37
+ }
38
+ }
39
+ async function declineLocalScan() {
40
+ setConsentStatus("Skipping Agent Doctor...");
41
+ try {
42
+ await postJson("/report-consent", { allowed: false });
43
+ } catch (_) {}
44
+ renderLocalScanSkipped();
45
+ }
46
+ function renderLocalScanSkipped() {
47
+ setScanMode(false);
48
+ setCityMode(false);
49
+ setExtractMode(false);
50
+ setReadyMode(true);
51
+ setHead("Agent Doctor skipped", connected ? "Signed in" : "Optional");
52
+ app.className = "consentStage";
53
+ app.innerHTML =
54
+ '<section class="localConsent">' +
55
+ '<p class="consentEyebrow">Visualization skipped</p>' +
56
+ '<h2>No problem. Echo can still set up memory.</h2>' +
57
+ '<p>We will skip Agent Doctor and continue with EchoMem setup.</p>' +
58
+ '<div class="consentActions">' +
59
+ '<button type="button" class="primary" data-connect-echo>' + (connected ? "Continue setup" : "Connect EchoMem") + '</button>' +
60
+ '<button type="button" class="textButton" id="changeConsent">Go back</button>' +
61
+ '</div>' +
62
+ '<p class="consentFine" data-signin-help></p>' +
63
+ '<a class="button secondary hidden" data-signin-fallback target="_blank" rel="noopener noreferrer">Open sign-in</a>' +
64
+ '</section>';
65
+ bindConnect();
66
+ var change = document.getElementById("changeConsent");
67
+ if (change) change.onclick = renderLocalScanConsent;
68
+ }
3
69
  async function showReport() {
4
70
  if (reportPollStarted) return;
5
71
  reportPollStarted = true;
@@ -103,14 +169,14 @@ export const SETUP_PAGE_CLIENT_LIFECYCLE = String.raw ` /* ---------- scan-
103
169
  }
104
170
  return;
105
171
  }
106
- // Learn the auth URL + connection state, but don't block the local scan on it.
107
- getJson("/config").then(function (cfg) {
172
+ // Learn the auth URL + connection state before asking for optional local file access.
173
+ try {
174
+ var cfg = await getJson("/config");
108
175
  authUrl = cfg.authUrl || authUrl;
109
176
  connected = !!cfg.connected;
110
177
  workspacePath = cfg.workspacePath || workspacePath;
111
- }).catch(function () {});
112
- // Scan-first: show the local forensic report before asking the user to connect.
113
- await showReport();
178
+ } catch (_) {}
179
+ renderLocalScanConsent();
114
180
  }
115
181
  __ECHO_SETUP_STARTUP__;
116
182
  `;
@@ -11,7 +11,7 @@ const SETUP_PAGE_BODY = String.raw `
11
11
  </div>
12
12
  <div class="pill" id="status">Local</div>
13
13
  </header>
14
- <section id="app" class="state">Starting local scan...</section>
14
+ <section id="app" class="state">Preparing setup...</section>
15
15
  </main>
16
16
  <dialog id="echo-note" class="echo-note-dlg">
17
17
  <button class="nclose" aria-label="Close">&times;</button>
@@ -59,7 +59,73 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
59
59
  }
60
60
  .primary { background: var(--echo-ink-primary); color: #fff; }
61
61
  .secondary { background: #fff; color: var(--echo-ink-primary); }
62
+ .textButton {
63
+ min-height: 36px;
64
+ border-color: transparent;
65
+ background: transparent;
66
+ color: var(--echo-ink-faint);
67
+ padding: 0 8px;
68
+ font-size: 13px;
69
+ font-weight: 700;
70
+ }
71
+ .textButton:hover { color: var(--echo-ink-primary); background: transparent; }
62
72
  button:disabled { cursor: not-allowed; opacity: 0.55; }
73
+ .consentStage {
74
+ display: grid;
75
+ place-items: center;
76
+ min-height: min(620px, calc(100vh - 180px));
77
+ }
78
+ .localConsent {
79
+ width: min(680px, 100%);
80
+ border: 2px solid var(--echo-ink-primary);
81
+ border-radius: 8px;
82
+ background: var(--echo-paper-white);
83
+ padding: clamp(22px, 4vw, 34px);
84
+ box-shadow: 0 18px 38px -28px rgba(26,58,143,0.45);
85
+ }
86
+ .consentEyebrow {
87
+ margin: 0 0 10px;
88
+ font-family: var(--echo-font-mono);
89
+ font-size: 11px;
90
+ font-weight: 800;
91
+ letter-spacing: 0.08em;
92
+ text-transform: uppercase;
93
+ color: var(--echo-ink-primary);
94
+ }
95
+ .localConsent h2 {
96
+ max-width: 620px;
97
+ margin: 0;
98
+ font-size: clamp(30px, 3.4vw, 42px);
99
+ line-height: 1.04;
100
+ color: var(--echo-ink-primary);
101
+ }
102
+ .localConsent > p:not(.consentEyebrow):not(.consentFine) {
103
+ max-width: 590px;
104
+ margin: 14px 0 0;
105
+ font-size: 16px;
106
+ line-height: 1.55;
107
+ color: var(--echo-ink-mute);
108
+ }
109
+ .consentActions {
110
+ display: flex;
111
+ flex-wrap: wrap;
112
+ gap: 10px;
113
+ align-items: center;
114
+ margin-top: 22px;
115
+ }
116
+ .consentActions button {
117
+ min-height: 48px;
118
+ padding: 0 18px;
119
+ }
120
+ .consentFine {
121
+ min-height: 18px;
122
+ margin: 10px 0 0;
123
+ font-family: var(--echo-font-mono);
124
+ font-size: 11px;
125
+ line-height: 1.45;
126
+ color: var(--echo-ink-faint);
127
+ }
128
+ .consentFine:empty { display: none; }
63
129
  .track { height: 12px; overflow: hidden; border-radius: 999px; background: #e8e8e1; margin: 16px 0 10px; }
64
130
  .fill { height: 100%; width: 0%; background: var(--echo-ink-primary); transition: width 180ms ease; }
65
131
  /* indeterminate "we're working on it" bar for the prep phase (before job counts move) */
@@ -93,6 +159,7 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
93
159
  .explainCopy h2 { font-size: 38px; }
94
160
  .explainCta .promiseActions { width: 100%; }
95
161
  .explainCta .primary, .explainCta .secondary { width: 100%; }
162
+ .consentActions .primary { width: 100%; }
96
163
  .cityHero { min-height: auto; }
97
164
  .cityShell {
98
165
  width: 100vw;
@@ -615,6 +682,81 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
615
682
  .exCountdown strong { color: var(--echo-ink-primary); font-weight: 700; }
616
683
  .exOverdue { margin: 0; max-width: 620px; border: 1px solid rgba(196, 148, 42, 0.45); background: rgba(233, 185, 73, 0.12); border-radius: 8px; padding: 12px 14px; font-size: 13px; line-height: 1.5; color: var(--echo-ink-text); }
617
684
  .exOverdue strong { display: block; margin-bottom: 4px; color: var(--echo-ink-primary); }
685
+ .is-hidden { display: none !important; }
686
+ .recallGateBlock { width: min(760px, 100%); margin: 2px auto 0; }
687
+ .recallGate {
688
+ width: 100%;
689
+ border: 2px solid var(--echo-ink-primary);
690
+ border-radius: 8px;
691
+ background: var(--echo-paper-white);
692
+ padding: clamp(18px, 3vw, 26px);
693
+ box-shadow: 0 18px 38px -28px rgba(26,58,143,0.45);
694
+ text-align: left;
695
+ }
696
+ .recallEyebrow {
697
+ margin: 0 0 10px;
698
+ font-family: var(--echo-font-mono);
699
+ font-size: 11px;
700
+ font-weight: 800;
701
+ letter-spacing: 0.08em;
702
+ text-transform: uppercase;
703
+ color: var(--echo-ink-primary);
704
+ }
705
+ .recallGate h3 {
706
+ margin: 0;
707
+ max-width: 680px;
708
+ font-family: var(--echo-font-brand);
709
+ font-size: clamp(30px, 3.8vw, 48px);
710
+ line-height: 0.98;
711
+ font-weight: 900;
712
+ color: var(--echo-ink-primary);
713
+ letter-spacing: 0;
714
+ }
715
+ .recallGate p {
716
+ max-width: 620px;
717
+ margin: 14px 0 0;
718
+ font-size: 15px;
719
+ line-height: 1.55;
720
+ color: var(--echo-ink-mute);
721
+ }
722
+ .recallGate code,
723
+ .recallSkipNote code {
724
+ font-family: var(--echo-font-mono);
725
+ font-size: 0.92em;
726
+ background: var(--echo-paper-soft);
727
+ border: 1px solid var(--echo-line-soft);
728
+ border-radius: 5px;
729
+ padding: 1px 5px;
730
+ color: var(--echo-ink-primary);
731
+ }
732
+ .recallGateActions {
733
+ display: flex;
734
+ flex-wrap: wrap;
735
+ gap: 10px;
736
+ align-items: center;
737
+ margin-top: 22px;
738
+ }
739
+ .recallGateActions button {
740
+ min-height: 48px;
741
+ padding: 0 18px;
742
+ }
743
+ .recallGate .recallFine {
744
+ margin-top: 10px;
745
+ font-family: var(--echo-font-mono);
746
+ font-size: 11px;
747
+ color: var(--echo-ink-faint);
748
+ }
749
+ .recallTryWrap { width: 100%; }
750
+ .recallSkipNote {
751
+ margin: 0 0 12px;
752
+ border: 1px solid var(--echo-line-soft);
753
+ border-radius: 8px;
754
+ background: rgba(246,244,238,0.86);
755
+ padding: 10px 12px;
756
+ font-size: 13px;
757
+ line-height: 1.45;
758
+ color: var(--echo-ink-mute);
759
+ }
618
760
  .tryCard { margin-top: 2px; max-width: 760px; border: 2px solid var(--echo-ink-primary); border-radius: 8px; background: var(--echo-paper-white); padding: clamp(18px, 3vw, 26px); box-shadow: 0 18px 38px -28px rgba(26,58,143,0.45); }
619
761
  .tryCard .tryK { margin: 0 0 10px; font-family: var(--echo-font-mono); font-size: 12px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--echo-ink-primary); }
620
762
  .tryCard .tryTitle {
package/dist/setup.js CHANGED
@@ -38,6 +38,7 @@ import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "
38
38
  // device unless the user explicitly starts migration. Override the hosted auth origin with ECHO_WEB_URL.
39
39
  const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
40
40
  const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
41
+ const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
41
42
  function home(...p) {
42
43
  return path.join(os.homedir(), ...p);
43
44
  }
@@ -1250,6 +1251,29 @@ export function startCallbackServer(opts = {}) {
1250
1251
  json(res, 200, payload);
1251
1252
  return;
1252
1253
  }
1254
+ if (route === "/billing-status" && req.method === "GET") {
1255
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
1256
+ return void text(res, 403, "bad nonce");
1257
+ const token = new KeyStore().getToken();
1258
+ const pricingUrl = `${PRICING_URL}?source=mcp_onboarding`;
1259
+ if (!token) {
1260
+ json(res, 200, { plan: "free", paid: false, pricingUrl });
1261
+ return;
1262
+ }
1263
+ try {
1264
+ const response = await authedAxios(token).get("/api/extension/account/bootstrap", { timeout: 6000 });
1265
+ const plan = (asString(response.data?.plan) || "free").toLowerCase();
1266
+ json(res, 200, {
1267
+ plan,
1268
+ paid: ["pro", "power", "team", "enterprise"].includes(plan),
1269
+ pricingUrl,
1270
+ });
1271
+ }
1272
+ catch {
1273
+ json(res, 200, { plan: "unknown", paid: false, pricingUrl });
1274
+ }
1275
+ return;
1276
+ }
1253
1277
  if (route === "/report" && req.method === "GET") {
1254
1278
  // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
1255
1279
  res.setHeader("Cache-Control", "no-store");
@@ -1353,6 +1377,22 @@ export function startCallbackServer(opts = {}) {
1353
1377
  json(res, 200, progress);
1354
1378
  return;
1355
1379
  }
1380
+ if (route === "/report-consent" && req.method === "POST") {
1381
+ let body;
1382
+ try {
1383
+ body = await readJsonBody(req);
1384
+ }
1385
+ catch {
1386
+ text(res, 400, "bad json");
1387
+ return;
1388
+ }
1389
+ if (!checkNonce(asString(body.nonce)))
1390
+ return void text(res, 403, "bad nonce");
1391
+ const allowed = body.allowed === true;
1392
+ opts.onReportConsent?.(allowed);
1393
+ json(res, 200, { ok: true, allowed });
1394
+ return;
1395
+ }
1356
1396
  if (route === "/logout" && req.method === "POST") {
1357
1397
  let body;
1358
1398
  try {
@@ -1780,6 +1820,8 @@ async function cmdLogin(flags) {
1780
1820
  const nonce = devNonce || randomUUID();
1781
1821
  let stats = null;
1782
1822
  let forensicReport = null;
1823
+ let forensicConsent = "pending";
1824
+ let forensicScanStarted = false;
1783
1825
  const forensicStartedAt = Date.now();
1784
1826
  let forensicStageStartedAt = forensicStartedAt;
1785
1827
  let forensicStage = "starting";
@@ -1799,6 +1841,28 @@ async function cmdLogin(flags) {
1799
1841
  getStats: () => stats,
1800
1842
  getReport: () => forensicReport,
1801
1843
  getReportProgress: () => forensicProgress,
1844
+ onReportConsent: (allowed) => {
1845
+ if (!allowed) {
1846
+ forensicConsent = "declined";
1847
+ forensicProgress = {
1848
+ status: "failed",
1849
+ scanned: 0,
1850
+ total: 0,
1851
+ stage: "failed",
1852
+ label: "Local scan skipped",
1853
+ elapsedMs: Date.now() - forensicStartedAt,
1854
+ stageElapsedMs: Date.now() - forensicStageStartedAt,
1855
+ updatedAt: Date.now(),
1856
+ error: {
1857
+ code: "REPORT_SCAN_DECLINED",
1858
+ message: "Local file analysis was skipped. EchoMem can still connect, save conversations, and import memories.",
1859
+ },
1860
+ };
1861
+ return;
1862
+ }
1863
+ forensicConsent = "allowed";
1864
+ startForensicScan();
1865
+ },
1802
1866
  });
1803
1867
  const callbackUrl = `http://127.0.0.1:${srv.port}/callback`;
1804
1868
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
@@ -1812,46 +1876,50 @@ async function cmdLogin(flags) {
1812
1876
  openBrowser(localSetupUrl);
1813
1877
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
1814
1878
  console.log("Waiting for browser approval for up to 15 minutes…");
1815
- // Scan-first: build the local forensic "Context Doctor" report off-thread so the page shows it
1816
- // BEFORE the user connects an account (the scan is local-only; nothing leaves the machine).
1817
- buildForensicReportOffThread((progress) => {
1818
- const now = Date.now();
1819
- const nextStage = progress.stage || forensicStage;
1820
- if (nextStage !== forensicStage) {
1821
- forensicStage = nextStage;
1822
- forensicStageStartedAt = now;
1823
- console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
1824
- }
1825
- forensicProgress = {
1826
- status: "running",
1827
- scanned: progress.done,
1828
- total: progress.total,
1829
- stage: forensicStage,
1830
- label: forensicStageLabel(forensicStage),
1831
- detail: progress.detail,
1832
- elapsedMs: now - forensicStartedAt,
1833
- stageElapsedMs: now - forensicStageStartedAt,
1834
- updatedAt: now,
1835
- };
1836
- })
1837
- .then((r) => {
1838
- forensicReport = r;
1839
- })
1840
- .catch((e) => {
1841
- const now = Date.now();
1842
- forensicProgress = {
1843
- status: "failed",
1844
- scanned: forensicProgress.scanned,
1845
- total: forensicProgress.total,
1846
- stage: "failed",
1847
- label: "Local scan failed",
1848
- elapsedMs: now - forensicStartedAt,
1849
- stageElapsedMs: now - forensicStageStartedAt,
1850
- updatedAt: now,
1851
- error: safeForensicError(e),
1852
- };
1853
- console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
1854
- });
1879
+ const startForensicScan = () => {
1880
+ if (forensicScanStarted || forensicConsent !== "allowed")
1881
+ return;
1882
+ forensicScanStarted = true;
1883
+ // Build the local forensic "Context Doctor" report off-thread only after explicit consent.
1884
+ buildForensicReportOffThread((progress) => {
1885
+ const now = Date.now();
1886
+ const nextStage = progress.stage || forensicStage;
1887
+ if (nextStage !== forensicStage) {
1888
+ forensicStage = nextStage;
1889
+ forensicStageStartedAt = now;
1890
+ console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
1891
+ }
1892
+ forensicProgress = {
1893
+ status: "running",
1894
+ scanned: progress.done,
1895
+ total: progress.total,
1896
+ stage: forensicStage,
1897
+ label: forensicStageLabel(forensicStage),
1898
+ detail: progress.detail,
1899
+ elapsedMs: now - forensicStartedAt,
1900
+ stageElapsedMs: now - forensicStageStartedAt,
1901
+ updatedAt: now,
1902
+ };
1903
+ })
1904
+ .then((r) => {
1905
+ forensicReport = r;
1906
+ })
1907
+ .catch((e) => {
1908
+ const now = Date.now();
1909
+ forensicProgress = {
1910
+ status: "failed",
1911
+ scanned: forensicProgress.scanned,
1912
+ total: forensicProgress.total,
1913
+ stage: "failed",
1914
+ label: "Local scan failed",
1915
+ elapsedMs: now - forensicStartedAt,
1916
+ stageElapsedMs: now - forensicStageStartedAt,
1917
+ updatedAt: now,
1918
+ error: safeForensicError(e),
1919
+ };
1920
+ console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
1921
+ });
1922
+ };
1855
1923
  let token;
1856
1924
  let key;
1857
1925
  try {
@@ -108,6 +108,7 @@ export function listToolSpecs(opts = {}) {
108
108
  const currentTime = new Date().toISOString();
109
109
  const map = opts.map?.trim();
110
110
  const updateNotice = opts.updateNotice?.trim();
111
+ const paidRecallNote = "Requires Echo Pro or Power; saving conversations and memory extraction remain included.";
111
112
  const updateSection = updateNotice ? `\n\nUPDATE NOTICE: ${updateNotice}` : "";
112
113
  const mapSection = map
113
114
  ? `\n\nThis user's EchoMem currently covers these topics (a relevance guide — recall when the task relates to one of them):\n${map}\n`
@@ -115,7 +116,7 @@ export function listToolSpecs(opts = {}) {
115
116
  return [
116
117
  {
117
118
  name: canonicalToolNames.search,
118
- description: withMcpVersion(`Recall the user's prior decisions, preferences, and project context from EchoMem — their long-term memory across ALL their AI tools, not just this session. Use it instead of re-deriving or re-asking what the user already settled.${mapSection}\nReturns the ranked memories; set includeAnswer=true only if you need the legacy synthesized answer. Current time: ${currentTime}.${updateSection}`),
119
+ description: withMcpVersion(`Recall the user's prior decisions, preferences, and project context from EchoMem — their long-term memory across ALL their AI tools, not just this session. Use it instead of re-deriving or re-asking what the user already settled. ${paidRecallNote}${mapSection}\nReturns the ranked memories; set includeAnswer=true only if you need the legacy synthesized answer. Current time: ${currentTime}.${updateSection}`),
119
120
  inputSchema: {
120
121
  type: "object",
121
122
  properties: {
@@ -138,7 +139,7 @@ export function listToolSpecs(opts = {}) {
138
139
  },
139
140
  {
140
141
  name: "search_memories_by_description_semantic",
141
- description: "Legacy alias for search_memories.",
142
+ description: `Legacy alias for search_memories. ${paidRecallNote}`,
142
143
  inputSchema: {
143
144
  type: "object",
144
145
  properties: {
@@ -161,7 +162,7 @@ export function listToolSpecs(opts = {}) {
161
162
  },
162
163
  {
163
164
  name: canonicalToolNames.save,
164
- description: "Save this conversation into EchoMem as long-term memory (durable memories are extracted automatically). passthrough=true stores the text verbatim as a session capsule instead reload it later via get_memories_by_context.",
165
+ description: "Save this conversation into EchoMem as long-term memory (durable memories are extracted automatically). Saving is included. passthrough=true stores the text verbatim as a session capsule; reloading/searching saved memories requires Echo Pro or Power.",
165
166
  inputSchema: {
166
167
  type: "object",
167
168
  properties: {
@@ -194,7 +195,7 @@ export function listToolSpecs(opts = {}) {
194
195
  },
195
196
  {
196
197
  name: canonicalToolNames.timeRange,
197
- description: `Retrieve memories within a specific date range. Current time: ${currentTime}.`,
198
+ description: `Retrieve memories within a specific date range. ${paidRecallNote} Current time: ${currentTime}.`,
198
199
  inputSchema: {
199
200
  type: "object",
200
201
  properties: {
@@ -212,7 +213,7 @@ export function listToolSpecs(opts = {}) {
212
213
  },
213
214
  {
214
215
  name: canonicalToolNames.keywords,
215
- description: "Search memories based on keywords in keys field.",
216
+ description: `Search memories based on keywords in keys field. ${paidRecallNote}`,
216
217
  inputSchema: {
217
218
  type: "object",
218
219
  properties: {
@@ -369,7 +370,7 @@ export function listToolSpecs(opts = {}) {
369
370
  },
370
371
  {
371
372
  name: canonicalToolNames.getByContext,
372
- description: withMcpVersion(`Deterministically re-fetch the exact batch of memories saved under one contextId — no semantic search, no ranking, just that session's saved capsule. save_conversation returns a contextId; pass it here to pull back precisely those memories, e.g. to warm up a fresh session with what a prior session saved, or to verify the saved facts are still present. Current time: ${currentTime}.`),
373
+ description: withMcpVersion(`Deterministically re-fetch the exact batch of memories saved under one contextId — no semantic search, no ranking, just that session's saved capsule. ${paidRecallNote} save_conversation returns a contextId; pass it here to pull back precisely those memories, e.g. to warm up a fresh session with what a prior session saved, or to verify the saved facts are still present. Current time: ${currentTime}.`),
373
374
  inputSchema: {
374
375
  type: "object",
375
376
  properties: {
@@ -386,7 +387,7 @@ export function listToolSpecs(opts = {}) {
386
387
  },
387
388
  {
388
389
  name: canonicalToolNames.checkpointByContext,
389
- description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. Use this when the user or EchoMem HUD gives you a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. Current time: ${currentTime}.`),
390
+ description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. ${paidRecallNote} Use this when the user or EchoMem HUD gives you a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. Current time: ${currentTime}.`),
390
391
  inputSchema: {
391
392
  type: "object",
392
393
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.14",
3
+ "version": "1.4.15",
4
4
  "description": "EchoMem MCP bridge: cloud-first memory tools, local context HUD, and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",