@echomem/mcp 1.4.14 → 1.4.16

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/dist/index.js CHANGED
@@ -13,8 +13,10 @@ import { createHash, randomUUID } from "node:crypto";
13
13
  import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
14
14
  import { runCli } from "./setup.js";
15
15
  import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS } from "./package-metadata.js";
16
+ import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
16
17
  import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
17
18
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
19
+ const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing";
18
20
  /** Thrown when no API token is present yet — the model gets a "run login" nudge, not a hard error. */
19
21
  class NoTokenError extends Error {
20
22
  }
@@ -80,6 +82,56 @@ function describeError(error) {
80
82
  const fallback = stringifyErrorValue(error);
81
83
  return fallback || "Unknown error";
82
84
  }
85
+ function appendPricingSource(url, source) {
86
+ try {
87
+ const parsed = new URL(url);
88
+ if (!parsed.searchParams.has("source")) {
89
+ parsed.searchParams.set("source", source);
90
+ }
91
+ return parsed.toString();
92
+ }
93
+ catch {
94
+ return url;
95
+ }
96
+ }
97
+ function formatUpgradeRequiredResult(error) {
98
+ if (!axios.isAxiosError(error))
99
+ return null;
100
+ const status = error.response?.status;
101
+ if (status !== 402 && status !== 403 && status !== 429)
102
+ return null;
103
+ const data = error.response?.data;
104
+ if (!isRecord(data))
105
+ return null;
106
+ const upgradeUrl = readString(data, "upgradeUrl")
107
+ ?? readString(data, "pricingUrl")
108
+ ?? readString(data, "url")
109
+ ?? ECHO_PRICING_URL;
110
+ const message = readString(data, "message")
111
+ ?? "Memory search and recall require an Echo Pro or Power plan.";
112
+ const code = readString(data, "error") ?? readString(data, "code");
113
+ const plan = readString(data, "plan");
114
+ const pricingUrl = appendPricingSource(upgradeUrl, "mcp_search");
115
+ const normalizedCode = String(code || "").toUpperCase();
116
+ const kind = status === 429 || normalizedCode.includes("QUOTA") || normalizedCode.includes("LIMIT")
117
+ ? "quota_exceeded"
118
+ : status === 402 || normalizedCode.includes("REQUIRES_PLAN")
119
+ ? "plan_required"
120
+ : "billing_attention";
121
+ writeBillingAlert({
122
+ kind,
123
+ code,
124
+ message,
125
+ plan,
126
+ pricingUrl,
127
+ });
128
+ return [
129
+ message,
130
+ "",
131
+ code ? `Reason: ${code}.` : "",
132
+ `Open pricing: ${pricingUrl}`,
133
+ ].filter(Boolean).join("\n");
134
+ }
83
135
  /** Map a thrown error to the telemetry error_kind taxonomy. */
84
136
  function classifyError(error) {
85
137
  if (error instanceof NoTokenError)
@@ -105,6 +157,11 @@ function readNumber(record, key) {
105
157
  const value = record[key];
106
158
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
107
159
  }
160
+ function errorCodeFrom(value) {
161
+ if (!isRecord(value))
162
+ return undefined;
163
+ return readString(value, "error") || readString(value, "code") || readString(value, "errorCode");
164
+ }
108
165
  function compactOneLine(value, maxLength) {
109
166
  if (!value)
110
167
  return undefined;
@@ -569,30 +626,59 @@ class EchoMemApiClient {
569
626
  throw error;
570
627
  }
571
628
  }
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
- });
629
+ async searchMemoriesRaw(query, opts, enc, retrievalOnly = false, trace) {
630
+ const endpoint = "/api/extension/memories/search";
631
+ let response;
632
+ try {
633
+ response = await this.axios.post(endpoint, {
634
+ query,
635
+ k: opts.limit,
636
+ similarityThreshold: opts.threshold,
637
+ timeFrameDays: opts.timeFrameDays,
638
+ requestId: opts.requestId,
639
+ sessionId: this.sessionId,
640
+ });
641
+ trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
642
+ }
643
+ catch (error) {
644
+ trace?.({
645
+ requestId: opts.requestId,
646
+ endpoint,
647
+ httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
648
+ errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
649
+ });
650
+ throw error;
651
+ }
581
652
  const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
582
653
  return retrievalOnly && isRecord(data)
583
654
  ? { ...data, retrievalOnly: true, tuned: false }
584
655
  : data;
585
656
  }
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
- });
657
+ async searchMemoriesRetrieved(query, opts, enc, trace) {
658
+ const endpoint = "/api/extension/memories/deep-search/candidates";
659
+ let response;
660
+ try {
661
+ response = await this.axios.post(endpoint, {
662
+ query,
663
+ requestId: opts.requestId,
664
+ logicalRequestId: opts.requestId,
665
+ sessionId: this.sessionId,
666
+ userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
667
+ });
668
+ trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
669
+ }
670
+ catch (error) {
671
+ trace?.({
672
+ requestId: opts.requestId,
673
+ endpoint,
674
+ httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
675
+ errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
676
+ });
677
+ throw error;
678
+ }
594
679
  const data = response.data ?? {};
595
680
  if (data.success === false) {
681
+ trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
596
682
  throw new Error(`deep-search candidates proxy error: ${data.error || "unknown"}`);
597
683
  }
598
684
  const rawCandidates = Array.isArray(data.personalCandidates) ? data.personalCandidates : [];
@@ -648,16 +734,31 @@ class EchoMemApiClient {
648
734
  // never hits dg-web directly, so no new auth is needed and no other caller is
649
735
  // disrupted. NOTE: encrypted users still get server-side plaintext only; the
650
736
  // 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
- });
737
+ async searchMemoriesTuned(query, requestId, trace) {
738
+ const endpoint = "/api/extension/memories/deep-search";
739
+ let response;
740
+ try {
741
+ response = await this.axios.post(endpoint, {
742
+ query,
743
+ requestId,
744
+ logicalRequestId: requestId,
745
+ sessionId: this.sessionId,
746
+ userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
747
+ });
748
+ trace?.({ requestId, endpoint, httpStatus: response.status });
749
+ }
750
+ catch (error) {
751
+ trace?.({
752
+ requestId,
753
+ endpoint,
754
+ httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
755
+ errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
756
+ });
757
+ throw error;
758
+ }
659
759
  const data = response.data ?? {};
660
760
  if (data.success === false) {
761
+ trace?.({ requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
661
762
  throw new Error(`deep-search proxy error: ${data.error || "unknown"}`);
662
763
  }
663
764
  const primary = (data.retrievedMemorySnapshot?.primary ?? []);
@@ -669,7 +770,7 @@ class EchoMemApiClient {
669
770
  }));
670
771
  return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
671
772
  }
672
- async searchMemories(args) {
773
+ async searchMemories(args, trace) {
673
774
  const parsed = searchMemoriesSchema.parse(args ?? {});
674
775
  const query = parsed.query?.trim();
675
776
  const limit = parsed.limit ?? parsed.k ?? 10;
@@ -695,17 +796,17 @@ class EchoMemApiClient {
695
796
  requestId: randomUUID(),
696
797
  };
697
798
  if (!parsed.includeAnswer) {
698
- return this.searchMemoriesRetrieved(query, searchOpts, enc);
799
+ return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
699
800
  }
700
801
  // Encrypted account: the server can't synthesize over plaintext it doesn't hold, so use the raw
701
802
  // retriever (returns ciphertext) and decrypt locally — zero-knowledge preserved end-to-end.
702
803
  if (enc.enabled) {
703
- return await this.searchMemoriesRaw(query, searchOpts, enc);
804
+ return await this.searchMemoriesRaw(query, searchOpts, enc, false, trace);
704
805
  }
705
806
  // A synthesized answer is metered through the legacy route. Do not fall
706
807
  // back to raw retrieval after a ledger outage: that would return a result
707
808
  // without a durable usage fact.
708
- return this.searchMemoriesTuned(query, searchOpts.requestId);
809
+ return this.searchMemoriesTuned(query, searchOpts.requestId, trace);
709
810
  }
710
811
  /** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
711
812
  async decryptResult(data, key) {
@@ -1101,6 +1202,12 @@ class EchoMemMCPServer {
1101
1202
  if (error instanceof McpError) {
1102
1203
  throw error;
1103
1204
  }
1205
+ const upgradeRequired = formatUpgradeRequiredResult(error);
1206
+ if (upgradeRequired) {
1207
+ return {
1208
+ content: [{ type: "text", text: upgradeRequired }],
1209
+ };
1210
+ }
1104
1211
  return {
1105
1212
  content: [{ type: "text", text: `Error: ${describeError(error)}` }],
1106
1213
  isError: true,
@@ -1129,6 +1236,13 @@ class EchoMemMCPServer {
1129
1236
  map_injected: rec.map_injected,
1130
1237
  encrypted_user: rec.encrypted,
1131
1238
  extracted_memory_count: rec.memories_extracted,
1239
+ // Preserve only the safe correlation fields needed to join a
1240
+ // bridge-local failure to the central operational trace. Query
1241
+ // text and upstream bodies never leave the local event stream.
1242
+ request_id: rec.request_id,
1243
+ endpoint: rec.endpoint,
1244
+ http_status: rec.http_status,
1245
+ error_code: rec.error_code ?? (rec.error_kind === "none" ? undefined : rec.error_kind),
1132
1246
  };
1133
1247
  void this.client.trackMcpAnalyticsEvent(rec.ok ? "[MCP] EchoMem Tool Succeeded" : "[MCP] EchoMem Tool Failed", finalAnalytics, `${analyticsCallId}:generic-completed`);
1134
1248
  void this.client.trackMcpAnalyticsEvent(toolEventName(canonicalName, rec.ok ? "Succeeded" : "Failed"), finalAnalytics, `${analyticsCallId}:completed`);
@@ -1142,7 +1256,15 @@ class EchoMemMCPServer {
1142
1256
  rec.query_len = query.length;
1143
1257
  rec.query_hash = hashText(query);
1144
1258
  }
1145
- const result = await this.client.searchMemories(args);
1259
+ const result = await this.client.searchMemories(args, (trace) => {
1260
+ if (!rec)
1261
+ return;
1262
+ rec.request_id = trace.requestId;
1263
+ rec.endpoint = trace.endpoint;
1264
+ rec.http_status = trace.httpStatus;
1265
+ rec.error_code = trace.errorCode;
1266
+ });
1267
+ clearBillingAlert();
1146
1268
  if (rec) {
1147
1269
  // "Which memories were used": topic keys + delivered context size, for the comparison dataset.
1148
1270
  const mems = Array.isArray(result?.memories) ? result.memories : [];
@@ -1249,7 +1371,7 @@ Details: ${m.details || "N/A"}`)
1249
1371
  typeof capsuleId === "string" && capsuleId ? `Capsule ID: ${capsuleId}` : "",
1250
1372
  typeof contextId === "string" && contextId ? `Context: ${contextId}` : "",
1251
1373
  "",
1252
- `To reload this capsule in a fresh session: get_memories_by_context({ contextId: "${contextId}" })`,
1374
+ `To reload this capsule in a fresh session with Pro/Power recall: get_memories_by_context({ contextId: "${contextId}" })`,
1253
1375
  ].filter(Boolean).join("\n");
1254
1376
  return { content: [{ type: "text", text }] };
1255
1377
  }
@@ -1273,7 +1395,7 @@ Details: ${m.details || "N/A"}`)
1273
1395
  `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.`,
1274
1396
  list,
1275
1397
  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})` : ""}.`
1398
+ ? `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
1399
  : "",
1278
1400
  ].filter(Boolean).join("\n\n");
1279
1401
  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,103 @@ 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 recallTrialAvailable() {
461
+ return !billingStatus || billingStatus.trialAvailable !== false;
462
+ }
463
+ function recallGateBlock() {
464
+ var unlocked = recallGateUnlocked();
465
+ var trialAvailable = recallTrialAvailable();
466
+ return '<div class="recallGateBlock">' +
467
+ '<section class="recallGate' + (unlocked ? " is-hidden" : "") + '" id="recallGate">' +
468
+ '<p class="recallEyebrow"><i></i>Premium recall</p>' +
469
+ '<h3 id="recallGateTitle">' + (trialAvailable ? "Unlock memory search for your agents." : "Ready to turn recall back on?") + '</h3>' +
470
+ '<p id="recallGateBody">' + (trialAvailable ? "Start a 14-day trial and let Codex, Claude, and MCP clients search what Echo saved. Saving conversations keeps working either way." : "Choose Pro or Power to let Codex, Claude, and MCP clients search what Echo saved. Saving conversations keeps working either way.") + '</p>' +
471
+ '<div class="recallTrial' + (trialAvailable ? "" : " is-hidden") + '" id="recallTrialBadge" aria-label="14 days free"><strong>14</strong><span>days free<br>then choose what fits</span></div>' +
472
+ '<p class="recallUsedNote' + (trialAvailable ? " is-hidden" : "") + '" id="recallUsedNote">Your trial was already used. You can still unlock recall by choosing Pro or Power.</p>' +
473
+ '<div class="recallGateActions">' +
474
+ '<button type="button" class="primary" id="recallStartTrial">' + (trialAvailable ? "Compare Pro and Power" : "See pricing") + '</button>' +
475
+ '<button type="button" class="textButton" id="recallSkip">Skip recall for now</button>' +
476
+ '</div>' +
477
+ '<p class="recallFine" id="recallFine">' + (trialAvailable ? "Card required for trial. Cancel anytime." : "Cancel anytime.") + '</p>' +
478
+ '<p class="launchStatus" id="recallGateStatus"></p>' +
479
+ '</section>' +
480
+ '<div id="recallTryWrap" class="recallTryWrap' + (unlocked ? "" : " is-hidden") + '">' +
481
+ '<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>' +
482
+ tryItCard() +
483
+ '</div>' +
484
+ '</div>';
485
+ }
486
+ function setRecallGateStatus(message) {
487
+ var node = document.getElementById("recallGateStatus");
488
+ if (node) node.textContent = message || "";
489
+ }
490
+ function applyRecallGate() {
491
+ var gate = document.getElementById("recallGate");
492
+ var wrap = document.getElementById("recallTryWrap");
493
+ var skipNote = document.getElementById("recallSkipNote");
494
+ var unlocked = recallGateUnlocked();
495
+ if (gate) gate.classList.toggle("is-hidden", unlocked);
496
+ if (wrap) wrap.classList.toggle("is-hidden", !unlocked);
497
+ if (skipNote) skipNote.classList.toggle("is-hidden", !recallGateSkipped);
498
+ var trialAvailable = recallTrialAvailable();
499
+ var badge = document.getElementById("recallTrialBadge");
500
+ if (badge) badge.classList.toggle("is-hidden", !trialAvailable);
501
+ var usedNote = document.getElementById("recallUsedNote");
502
+ if (usedNote) usedNote.classList.toggle("is-hidden", trialAvailable);
503
+ var title = document.getElementById("recallGateTitle");
504
+ if (title) title.textContent = trialAvailable ? "Unlock memory search for your agents." : "Ready to turn recall back on?";
505
+ var body = document.getElementById("recallGateBody");
506
+ if (body) body.textContent = trialAvailable
507
+ ? "Start a 14-day trial and let Codex, Claude, and MCP clients search what Echo saved. Saving conversations keeps working either way."
508
+ : "Choose Pro or Power to let Codex, Claude, and MCP clients search what Echo saved. Saving conversations keeps working either way.";
509
+ var cta = document.getElementById("recallStartTrial");
510
+ if (cta) cta.textContent = trialAvailable ? "Compare Pro and Power" : "See pricing";
511
+ var fine = document.getElementById("recallFine");
512
+ if (fine) fine.textContent = trialAvailable ? "Card required for trial. Cancel anytime." : "Cancel anytime.";
513
+ }
514
+ async function refreshBillingStatus() {
515
+ try {
516
+ billingStatus = await getJson("/billing-status");
517
+ applyRecallGate();
518
+ if (!recallGateUnlocked() && billingStatus && billingStatus.plan === "unknown") {
519
+ setRecallGateStatus("Could not check your plan. You can start the trial or skip and keep setup going.");
520
+ }
521
+ } catch (_) {
522
+ setRecallGateStatus("Could not check your plan. You can start the trial or skip and keep setup going.");
523
+ }
524
+ }
525
+ function openRecallPricing() {
526
+ var url = recallPricingUrl();
527
+ setRecallGateStatus("Opening pricing in a new tab...");
528
+ try {
529
+ var opened = window.open(url, "_blank", "noopener,noreferrer");
530
+ if (!opened) setRecallGateStatus("Your browser blocked the pricing tab. Allow pop-ups for this page, then click Compare plans again.");
531
+ } catch (_) {
532
+ setRecallGateStatus("Could not open a new tab. Allow pop-ups for this page, then click Compare plans again.");
533
+ }
534
+ }
535
+ function skipRecallGate() {
536
+ recallGateSkipped = true;
537
+ applyRecallGate();
538
+ }
539
+ function wireRecallGate() {
540
+ var trialBtn = document.getElementById("recallStartTrial");
541
+ if (trialBtn) trialBtn.addEventListener("click", openRecallPricing);
542
+ var skipBtn = document.getElementById("recallSkip");
543
+ if (skipBtn) skipBtn.addEventListener("click", skipRecallGate);
544
+ wireTryButtons();
545
+ void refreshBillingStatus();
546
+ }
450
547
  function tryItCard() {
451
548
  var prompt = recallPrompt();
452
549
  return '<div class="tryCard">' +
@@ -500,10 +597,10 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
500
597
  '<span class="seal">Skipped for now</span>' +
501
598
  '<h2>You can come back anytime.</h2>' +
502
599
  '<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() +
600
+ recallGateBlock() +
504
601
  hudTip() +
505
602
  '</div>';
506
- wireTryButtons();
603
+ wireRecallGate();
507
604
  }
508
605
  async function endExtraction() {
509
606
  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>