@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.
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { echoConfigDir } from "./keystore.js";
4
+ const ALERT_FILE = "billing-alert.json";
5
+ const DEFAULT_MAX_AGE_MS = 31 * 24 * 60 * 60 * 1000;
6
+ function alertPath() {
7
+ return path.join(echoConfigDir(), ALERT_FILE);
8
+ }
9
+ export function writeBillingAlert(alert) {
10
+ try {
11
+ fs.mkdirSync(echoConfigDir(), { recursive: true, mode: 0o700 });
12
+ fs.writeFileSync(alertPath(), JSON.stringify({ ...alert, updatedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
13
+ }
14
+ catch {
15
+ /* best-effort: billing UI should never break search */
16
+ }
17
+ }
18
+ export function readBillingAlert(maxAgeMs = DEFAULT_MAX_AGE_MS) {
19
+ try {
20
+ const alert = JSON.parse(fs.readFileSync(alertPath(), "utf8"));
21
+ const updatedAt = Date.parse(alert.updatedAt || "");
22
+ if (!updatedAt || Date.now() - updatedAt > maxAgeMs)
23
+ return null;
24
+ if (!alert.message || !alert.pricingUrl)
25
+ return null;
26
+ return alert;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ export function clearBillingAlert() {
33
+ try {
34
+ fs.unlinkSync(alertPath());
35
+ }
36
+ catch {
37
+ /* absent is fine */
38
+ }
39
+ }
@@ -0,0 +1,135 @@
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 usage = record(root.usage) ?? {};
34
+ const quota = record(root.memorySearchQuota) ?? {};
35
+ const weeklySearchLimit = number(quota.limit);
36
+ const searchesThisWeek = number(quota.used);
37
+ const searchesRemaining = quota.remaining === null ? null : number(quota.remaining);
38
+ return {
39
+ plan: normalizePlan(root.plan),
40
+ weeklySearchLimit,
41
+ searchesThisWeek,
42
+ searchesRemaining,
43
+ resetAt: text(quota.resetAt),
44
+ memoryCount: number(usage.memories),
45
+ sourceCount: number(usage.sources),
46
+ };
47
+ }
48
+ /** Read only the latest memory-search trace; never return a query, token, or memory content. */
49
+ export function latestSearchTraceFromEvents(lines) {
50
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
51
+ try {
52
+ const event = record(JSON.parse(lines[index] || ""));
53
+ if (!event || event.type !== "tool_call" || event.tool !== "search_memories")
54
+ continue;
55
+ const at = text(event.ts);
56
+ if (!at)
57
+ continue;
58
+ return {
59
+ at,
60
+ ok: typeof event.ok === "boolean" ? event.ok : undefined,
61
+ requestId: text(event.request_id),
62
+ endpoint: text(event.endpoint),
63
+ httpStatus: number(event.http_status),
64
+ errorCode: text(event.error_code),
65
+ errorKind: text(event.error_kind),
66
+ latencyMs: number(event.latency_ms),
67
+ };
68
+ }
69
+ catch {
70
+ // A concurrently appended partial line or old malformed record is safely ignored.
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+ function loadLatestSearchTrace() {
76
+ try {
77
+ const file = path.join(echoConfigDir(), "events.jsonl");
78
+ return latestSearchTraceFromEvents(fs.readFileSync(file, "utf8").split("\n").slice(-EVENTS_TAIL_LINES));
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ /**
85
+ * Read-only, local HUD diagnostics. Credentials are used only in the Authorization header to
86
+ * EchoMem's authenticated account endpoint; this never sends transcripts, queries, or memories.
87
+ */
88
+ export async function loadHudDiagnostics() {
89
+ const updatedAt = new Date().toISOString();
90
+ const lastSearch = loadLatestSearchTrace();
91
+ const token = new KeyStore().getToken();
92
+ if (!token) {
93
+ return { updatedAt, account: { state: "not_logged_in" }, accountRequest: {}, lastSearch };
94
+ }
95
+ const requestId = `hud-account-${randomUUID()}`;
96
+ const startedAt = Date.now();
97
+ try {
98
+ const response = await fetch(`${API_BASE}/api/extension/account/bootstrap`, {
99
+ headers: {
100
+ Authorization: `Bearer ${token}`,
101
+ "X-EchoMem-Request-Id": requestId,
102
+ },
103
+ });
104
+ const payload = await response.json().catch(() => ({}));
105
+ const telemetry = record(payload)?.telemetry;
106
+ const echoedRequestId = text(record(telemetry)?.requestId) ?? requestId;
107
+ const accountRequest = {
108
+ requestId: echoedRequestId,
109
+ httpStatus: response.status,
110
+ elapsedMs: Date.now() - startedAt,
111
+ };
112
+ if (!response.ok) {
113
+ return {
114
+ updatedAt,
115
+ account: { state: "error", error: `Account check returned HTTP ${response.status}` },
116
+ accountRequest,
117
+ lastSearch,
118
+ };
119
+ }
120
+ return {
121
+ updatedAt,
122
+ account: { state: "connected", ...accountDiagnosticsFromBootstrap(payload) },
123
+ accountRequest,
124
+ lastSearch,
125
+ };
126
+ }
127
+ catch (error) {
128
+ return {
129
+ updatedAt,
130
+ account: { state: "error", error: briefError(error) },
131
+ accountRequest: { requestId, elapsedMs: Date.now() - startedAt },
132
+ lastSearch,
133
+ };
134
+ }
135
+ }
@@ -6,10 +6,13 @@ 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";
13
+ import { readBillingAlert } from "../billing-alert.js";
12
14
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
15
+ const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
13
16
  export async function createHudServer(opts = {}) {
14
17
  const mode = opts.mode || "auto";
15
18
  const port = opts.port ?? 17377;
@@ -42,6 +45,50 @@ export async function createHudServer(opts = {}) {
42
45
  res.end(JSON.stringify(latest, null, 2));
43
46
  return;
44
47
  }
48
+ if (url.pathname === "/billing-status") {
49
+ handleBillingStatus(res).catch((error) => {
50
+ if (res.writableEnded)
51
+ return;
52
+ res.writeHead(200, { "content-type": "application/json" });
53
+ res.end(JSON.stringify({ ok: false, state: "unknown", reason: error instanceof Error ? error.message : "billing status failed" }));
54
+ });
55
+ return;
56
+ }
57
+ if (url.pathname === "/open-external") {
58
+ const target = url.searchParams.get("url") || "";
59
+ let ok = false;
60
+ try {
61
+ const parsed = new URL(target);
62
+ if ((parsed.protocol === "https:" || parsed.protocol === "http:") && typeof opts.onOpenExternal === "function") {
63
+ opts.onOpenExternal(parsed.toString());
64
+ ok = true;
65
+ }
66
+ }
67
+ catch {
68
+ ok = false;
69
+ }
70
+ res.writeHead(200, { "content-type": "application/json" });
71
+ res.end(JSON.stringify({ ok, reason: ok ? undefined : opts.onOpenExternal ? "invalid_url" : "no_desktop" }));
72
+ return;
73
+ }
74
+ if (url.pathname === "/diagnostics") {
75
+ loadHudDiagnostics().then((diagnostics) => {
76
+ if (res.writableEnded)
77
+ return;
78
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
79
+ res.end(JSON.stringify(diagnostics));
80
+ }).catch(() => {
81
+ if (res.writableEnded)
82
+ return;
83
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
84
+ res.end(JSON.stringify({
85
+ updatedAt: new Date().toISOString(),
86
+ account: { state: "error", error: "Diagnostics unavailable" },
87
+ accountRequest: {},
88
+ }));
89
+ });
90
+ return;
91
+ }
45
92
  if (url.pathname === "/select") {
46
93
  const id = url.searchParams.get("id");
47
94
  monitor.pin(id && id !== "auto" ? id : null);
@@ -245,6 +292,69 @@ export async function createHudServer(opts = {}) {
245
292
  }),
246
293
  };
247
294
  }
295
+ async function handleBillingStatus(res) {
296
+ const respond = (obj) => {
297
+ if (res.writableEnded)
298
+ return;
299
+ res.writeHead(200, { "content-type": "application/json" });
300
+ res.end(JSON.stringify(obj));
301
+ };
302
+ const pricingUrl = appendSource(PRICING_URL, "hud");
303
+ const alert = readBillingAlert();
304
+ const token = new KeyStore().getToken();
305
+ if (!token) {
306
+ return respond({ ok: false, state: "not_connected", pricingUrl });
307
+ }
308
+ let plan = "unknown";
309
+ let paid = false;
310
+ try {
311
+ const response = await fetch(`${API_BASE}/api/extension/account/bootstrap`, {
312
+ headers: { Authorization: `Bearer ${token}` },
313
+ });
314
+ const data = (await response.json().catch(() => ({})));
315
+ if (response.ok) {
316
+ plan = typeof data.plan === "string" ? data.plan.toLowerCase() : "free";
317
+ paid = ["pro", "power", "team", "enterprise"].includes(plan);
318
+ }
319
+ }
320
+ catch {
321
+ /* Local alert still gives the HUD something useful. */
322
+ }
323
+ if (alert && (alert.kind === "quota_exceeded" || !paid)) {
324
+ return respond({
325
+ ok: true,
326
+ state: alert.kind,
327
+ plan: alert.plan || plan,
328
+ message: alert.kind === "quota_exceeded" ? "Recall limit reached." : "Recall needs a plan.",
329
+ detail: alert.message,
330
+ code: alert.code,
331
+ pricingUrl: appendSource(alert.pricingUrl || pricingUrl, "hud"),
332
+ updatedAt: alert.updatedAt,
333
+ });
334
+ }
335
+ if (!paid && plan === "free") {
336
+ return respond({
337
+ ok: true,
338
+ state: "plan_required",
339
+ plan,
340
+ message: "Recall needs a plan.",
341
+ detail: "Search and recall require Echo Pro or Power. Saving conversations keeps working.",
342
+ pricingUrl,
343
+ });
344
+ }
345
+ respond({ ok: true, state: "ok", plan, paid, pricingUrl });
346
+ }
347
+ function appendSource(url, source) {
348
+ try {
349
+ const parsed = new URL(url);
350
+ if (!parsed.searchParams.has("source"))
351
+ parsed.searchParams.set("source", source);
352
+ return parsed.toString();
353
+ }
354
+ catch {
355
+ return url;
356
+ }
357
+ }
248
358
  async function handleCheckpointStatus(latest, res) {
249
359
  const respond = (obj) => {
250
360
  if (res.writableEnded)
package/dist/hud/web.js CHANGED
@@ -16,6 +16,10 @@ export const HUD_HTML = String.raw `<!doctype html>
16
16
  --brick: #c7372f;
17
17
  --green: #43d17a;
18
18
  --amber: #e9b949;
19
+ --billing-free-bg: #fff8d9;
20
+ --billing-free-border: rgba(190, 128, 19, 0.36);
21
+ --billing-free: #c08412;
22
+ --billing-free-dark: #87560d;
19
23
  --line: rgba(26, 58, 143, 0.22);
20
24
  --track: rgba(26, 58, 143, 0.1);
21
25
  --shadow: drop-shadow(0 2px 7px rgba(26, 58, 143, 0.12));
@@ -217,6 +221,32 @@ export const HUD_HTML = String.raw `<!doctype html>
217
221
  cursor: grab;
218
222
  }
219
223
  #hud.open .details { display: block; }
224
+ .diagnostics {
225
+ margin: 10px 0 0;
226
+ padding: 10px 11px;
227
+ border: 1px solid rgba(26, 58, 143, 0.18);
228
+ border-radius: 10px;
229
+ background: rgba(255, 255, 255, 0.72);
230
+ }
231
+ .diagnostics-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
232
+ .diagnostics-title { font-size: 10px; font-weight: 820; letter-spacing: .02em; color: var(--blue); }
233
+ .diagnostics-refresh {
234
+ border: 0;
235
+ padding: 0;
236
+ background: transparent;
237
+ color: var(--faint);
238
+ font-family: inherit;
239
+ font-size: 10px;
240
+ font-weight: 730;
241
+ cursor: pointer;
242
+ }
243
+ .diagnostics-refresh:hover { color: var(--blue); }
244
+ .diagnostics-body { display: grid; gap: 5px; margin-top: 7px; }
245
+ .diagnostics-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 10px; line-height: 1.25; }
246
+ .diagnostics-k { flex: 0 0 auto; color: var(--faint); font-weight: 650; }
247
+ .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; }
248
+ .diagnostics-v.bad { color: var(--brick); }
249
+ .diagnostics-v.wrap { white-space: normal; overflow-wrap: anywhere; }
220
250
  /* The current session is the one object every number and action belongs to — one card. */
221
251
  .now-card {
222
252
  border: 1px solid rgba(26, 58, 143, 0.24);
@@ -504,6 +534,75 @@ export const HUD_HTML = String.raw `<!doctype html>
504
534
  background: rgba(199, 55, 47, 0.07);
505
535
  color: #7a241e;
506
536
  }
537
+ .billing-banner {
538
+ display: none;
539
+ margin: 0 0 12px;
540
+ border: 1.4px solid var(--billing-free-border);
541
+ border-radius: 12px;
542
+ background: linear-gradient(180deg, var(--billing-free-bg), #fff);
543
+ padding: 10px;
544
+ color: var(--muted);
545
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
546
+ }
547
+ .billing-banner.limit {
548
+ border-color: rgba(255, 95, 87, 0.45);
549
+ background: linear-gradient(180deg, #fff3f2, #fff);
550
+ }
551
+ .billing-top {
552
+ display: flex;
553
+ align-items: center;
554
+ gap: 8px;
555
+ }
556
+ .billing-dot {
557
+ width: 10px;
558
+ height: 10px;
559
+ flex: 0 0 auto;
560
+ border-radius: 999px;
561
+ background: var(--billing-free);
562
+ box-shadow: 0 0 0 4px rgba(190, 128, 19, 0.12);
563
+ }
564
+ .billing-banner.limit .billing-dot {
565
+ background: #ff5f57;
566
+ box-shadow: 0 0 0 4px rgba(255, 95, 87, 0.12);
567
+ }
568
+ .billing-title {
569
+ min-width: 0;
570
+ flex: 1;
571
+ color: var(--billing-free-dark);
572
+ font-size: 13px;
573
+ font-weight: 900;
574
+ line-height: 1.15;
575
+ }
576
+ .billing-banner.limit .billing-title { color: #7a241e; }
577
+ .billing-copy {
578
+ display: block;
579
+ margin: 8px 0 0 18px;
580
+ font-size: 11.5px;
581
+ line-height: 1.35;
582
+ font-weight: 650;
583
+ }
584
+ .billing-link {
585
+ display: inline-flex;
586
+ align-items: center;
587
+ justify-content: center;
588
+ min-height: 28px;
589
+ border: 1px solid var(--billing-free-dark);
590
+ border-radius: 999px;
591
+ background: var(--billing-free);
592
+ color: #fff;
593
+ padding: 0 13px;
594
+ font-family: inherit;
595
+ font-size: 11px;
596
+ font-weight: 900;
597
+ cursor: pointer;
598
+ box-shadow: 0 12px 20px -16px rgba(135, 86, 13, 0.95);
599
+ }
600
+ .billing-banner.limit .billing-link {
601
+ border-color: #7a241e;
602
+ background: #7a241e;
603
+ box-shadow: 0 12px 20px -16px rgba(122, 36, 30, 0.95);
604
+ }
605
+ .billing-link:hover { filter: brightness(0.96); }
507
606
  button.renew-btn {
508
607
  display: none;
509
608
  width: 100%;
@@ -671,6 +770,14 @@ export const HUD_HTML = String.raw `<!doctype html>
671
770
  <button class="pin-chip" id="pinchip" type="button" title="Return to auto-follow">pinned x</button>
672
771
  <button class="jump-chip" id="jumpchip" type="button" title="Open this conversation">&#8599;</button>
673
772
  </div>
773
+ <div class="billing-banner" id="billingbanner">
774
+ <div class="billing-top">
775
+ <span class="billing-dot"></span>
776
+ <strong class="billing-title" id="billingtitle"></strong>
777
+ <button class="billing-link" id="billinglink" type="button">Open pricing</button>
778
+ </div>
779
+ <span class="billing-copy" id="billingcopy"></span>
780
+ </div>
674
781
  <div class="state-row">
675
782
  <span class="state-dot" id="stateDot"></span>
676
783
  <span class="state-word" id="stateWord">--%</span>
@@ -690,6 +797,10 @@ export const HUD_HTML = String.raw `<!doctype html>
690
797
  </div>
691
798
  </div>
692
799
  </div>
800
+ <section class="diagnostics" aria-label="Memory access diagnostics">
801
+ <div class="diagnostics-head"><span class="diagnostics-title">Memory access</span><button class="diagnostics-refresh" id="diagnosticsrefresh" type="button">Refresh</button></div>
802
+ <div class="diagnostics-body" id="diagnosticsbody"><span class="diagnostics-v">Open this panel to check account status.</span></div>
803
+ </section>
693
804
  <div class="tabs" id="tabs"></div>
694
805
  <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
806
  <div class="path" id="path"></div>
@@ -715,6 +826,10 @@ export const HUD_HTML = String.raw `<!doctype html>
715
826
  const whyBtn = document.getElementById("whybtn");
716
827
  const evidence = document.getElementById("evidence");
717
828
  const nudge = document.getElementById("nudge");
829
+ const billingBanner = document.getElementById("billingbanner");
830
+ const billingTitle = document.getElementById("billingtitle");
831
+ const billingCopy = document.getElementById("billingcopy");
832
+ const billingLink = document.getElementById("billinglink");
718
833
  const renewBtn = document.getElementById("renewbtn");
719
834
  const renewResult = document.getElementById("renewresult");
720
835
  const renewMsg = document.getElementById("renewmsg");
@@ -730,6 +845,8 @@ export const HUD_HTML = String.raw `<!doctype html>
730
845
  const switchBtn = document.getElementById("switchbtn");
731
846
  const revealEl = document.getElementById("reveal");
732
847
  const missing = document.getElementById("missing");
848
+ const diagnosticsBody = document.getElementById("diagnosticsbody");
849
+ const diagnosticsRefresh = document.getElementById("diagnosticsrefresh");
733
850
  let prevTurnState = null;
734
851
  let lastCapsule = "";
735
852
  let renewResultSessionKey = "";
@@ -741,6 +858,9 @@ export const HUD_HTML = String.raw `<!doctype html>
741
858
  let renewStartMs = 0;
742
859
  let renewEta = null;
743
860
  let renewEstimateSeq = 0;
861
+ let billingStatus = null;
862
+ let diagnosticsLoaded = false;
863
+ let diagnosticsInFlight = false;
744
864
  const CHECKPOINT_CACHE_KEY = "echomemHudCheckpointCacheV2";
745
865
  const CHECKPOINT_CACHE_LIMIT = 24;
746
866
  const CHECKPOINT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -753,6 +873,86 @@ export const HUD_HTML = String.raw `<!doctype html>
753
873
  // viewing", so the user can always point the HUD at the right session. Flip false to show the viewer.
754
874
  const DEMO_CLEAN = true;
755
875
 
876
+ function diagnosticsLine(label, value, bad, wrap) {
877
+ return '<span class="diagnostics-line"><span class="diagnostics-k">' + esc(label) + '</span><span class="diagnostics-v' + (bad ? ' bad' : '') + (wrap ? ' wrap' : '') + '">' + esc(value) + '</span></span>';
878
+ }
879
+
880
+ function requestLabel(requestId) {
881
+ return requestId ? requestId : "not available";
882
+ }
883
+
884
+ function planLabel(plan) {
885
+ if (!plan) return "connected";
886
+ return plan.slice(0, 1).toUpperCase() + plan.slice(1);
887
+ }
888
+
889
+ function renderDiagnostics(data) {
890
+ if (!diagnosticsBody) return;
891
+ const account = (data && data.account) || {};
892
+ const accountRequest = (data && data.accountRequest) || {};
893
+ const lines = [];
894
+ if (account.state === "connected") {
895
+ lines.push(diagnosticsLine("account", planLabel(account.plan) + " · authenticated"));
896
+ if (typeof account.searchesThisWeek === "number" && typeof account.weeklySearchLimit === "number") {
897
+ const limit = account.weeklySearchLimit < 0 ? "Unlimited" : String(account.weeklySearchLimit);
898
+ const remaining = account.searchesRemaining === null ? "" : (typeof account.searchesRemaining === "number" ? " · " + account.searchesRemaining + " left" : "");
899
+ lines.push(diagnosticsLine("searches", account.searchesThisWeek + " / " + limit + " UTC week" + remaining));
900
+ if (account.resetAt) lines.push(diagnosticsLine("resets", account.resetAt));
901
+ } else {
902
+ lines.push(diagnosticsLine("searches", "weekly quota not reported"));
903
+ }
904
+ } else if (account.state === "not_logged_in") {
905
+ lines.push(diagnosticsLine("account", "not logged in", true));
906
+ } else {
907
+ lines.push(diagnosticsLine("account", account.error || "unavailable", true));
908
+ }
909
+ const accountStatus = typeof accountRequest.httpStatus === "number"
910
+ ? "HTTP " + accountRequest.httpStatus + (typeof accountRequest.elapsedMs === "number" ? " · " + accountRequest.elapsedMs + "ms" : "")
911
+ : "not sent";
912
+ lines.push(diagnosticsLine("account check", accountStatus, account.state === "error"));
913
+ if (accountRequest.requestId) lines.push(diagnosticsLine("account request", requestLabel(accountRequest.requestId), false, true));
914
+
915
+ const last = data && data.lastSearch;
916
+ if (!last) {
917
+ lines.push(diagnosticsLine("last search", "no local trace yet"));
918
+ } else {
919
+ const outcome = last.ok === false ? "failed" : last.ok === true ? "succeeded" : "recorded";
920
+ const status = typeof last.httpStatus === "number" ? "HTTP " + last.httpStatus : outcome;
921
+ const errorLabel = last.errorCode || (last.errorKind && last.errorKind !== "none" ? last.errorKind : "");
922
+ const detail = [status, errorLabel].filter(Boolean).join(" · ");
923
+ lines.push(diagnosticsLine("last search", detail || outcome, last.ok === false));
924
+ if (last.requestId) lines.push(diagnosticsLine("search request", requestLabel(last.requestId), last.ok === false, true));
925
+ }
926
+ diagnosticsBody.innerHTML = lines.join("");
927
+ diagnosticsLoaded = true;
928
+ syncHeight();
929
+ }
930
+
931
+ async function refreshDiagnostics() {
932
+ if (!diagnosticsBody || diagnosticsInFlight) return;
933
+ diagnosticsInFlight = true;
934
+ diagnosticsBody.innerHTML = diagnosticsLine("memory access", "checking…");
935
+ if (diagnosticsRefresh) diagnosticsRefresh.disabled = true;
936
+ try {
937
+ const response = await fetch("/diagnostics");
938
+ renderDiagnostics(await response.json());
939
+ } catch (_) {
940
+ renderDiagnostics({ account: { state: "error", error: "local diagnostic fetch failed" }, accountRequest: {} });
941
+ } finally {
942
+ diagnosticsInFlight = false;
943
+ if (diagnosticsRefresh) diagnosticsRefresh.disabled = false;
944
+ }
945
+ }
946
+
947
+ function maybeLoadDiagnostics() {
948
+ if (!diagnosticsLoaded && !diagnosticsInFlight) refreshDiagnostics();
949
+ }
950
+
951
+ if (diagnosticsRefresh) diagnosticsRefresh.addEventListener("click", (event) => {
952
+ event.stopPropagation();
953
+ refreshDiagnostics();
954
+ });
955
+
756
956
  // Mini mode: resting state is a small pill; opening the panel shows the full layout, closing
757
957
  // returns to the pill. Preference persists (localStorage here, window size in main's bounds file).
758
958
  const miniBtn = document.getElementById("minibtn");
@@ -871,6 +1071,19 @@ export const HUD_HTML = String.raw `<!doctype html>
871
1071
  e.stopPropagation();
872
1072
  selectSession("auto");
873
1073
  });
1074
+ if (billingLink) billingLink.addEventListener("click", (e) => {
1075
+ e.stopPropagation();
1076
+ const url = billingStatus && billingStatus.pricingUrl;
1077
+ if (!url) return;
1078
+ fetch("/open-external?url=" + encodeURIComponent(url))
1079
+ .then((res) => res.json())
1080
+ .then((data) => {
1081
+ if (!data || !data.ok) window.open(url, "_blank", "noopener");
1082
+ })
1083
+ .catch(() => {
1084
+ try { window.open(url, "_blank", "noopener"); } catch {}
1085
+ });
1086
+ });
874
1087
  function selectSession(id) {
875
1088
  if (!id) return;
876
1089
  fetch("/select?id=" + encodeURIComponent(id))
@@ -889,6 +1102,7 @@ export const HUD_HTML = String.raw `<!doctype html>
889
1102
  }
890
1103
  if (event.target instanceof HTMLElement && (event.target.closest(".switchbtn") || event.target.closest(".cornerbtn"))) return;
891
1104
  hud.classList.toggle("open");
1105
+ if (hud.classList.contains("open")) maybeLoadDiagnostics();
892
1106
  syncHeight();
893
1107
  });
894
1108
 
@@ -1365,6 +1579,19 @@ export const HUD_HTML = String.raw `<!doctype html>
1365
1579
  const events = new EventSource("/events");
1366
1580
  events.onmessage = (event) => render(JSON.parse(event.data));
1367
1581
  fetch("/state").then((r) => r.json()).then(render).catch(() => {});
1582
+ refreshBillingStatus();
1583
+ setInterval(refreshBillingStatus, 30000);
1584
+ }
1585
+
1586
+ async function refreshBillingStatus() {
1587
+ try {
1588
+ const res = await fetch("/billing-status");
1589
+ billingStatus = await res.json();
1590
+ } catch {
1591
+ billingStatus = null;
1592
+ }
1593
+ renderBillingStatus();
1594
+ syncHeight();
1368
1595
  }
1369
1596
 
1370
1597
  function render(state) {
@@ -1422,6 +1649,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1422
1649
  jumpChip.style.display = activeId ? "inline-block" : "none";
1423
1650
  }
1424
1651
  renderWhy(score, oldPct, repeatCount, sat, health, color);
1652
+ renderBillingStatus();
1425
1653
  pathEl.textContent = "";
1426
1654
  currentSrc = score.sourcePath || null;
1427
1655
  if (revealEl) revealEl.style.display = !DEMO_CLEAN && currentSrc ? "inline-block" : "none";
@@ -1482,6 +1710,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1482
1710
  whyOpen = false;
1483
1711
  hud.classList.remove("show-why");
1484
1712
  nudge.style.display = "none";
1713
+ renderBillingStatus();
1485
1714
  if (renewBtn) renewBtn.style.display = "none";
1486
1715
  clearRenewResult();
1487
1716
  pathEl.textContent = "";
@@ -1503,6 +1732,27 @@ export const HUD_HTML = String.raw `<!doctype html>
1503
1732
  if (mrepoLine) mrepoLine.style.display = repo ? "flex" : "none";
1504
1733
  }
1505
1734
 
1735
+ function renderBillingStatus() {
1736
+ if (!billingBanner) return;
1737
+ const state = billingStatus && billingStatus.state;
1738
+ const blocked = state === "plan_required" || state === "quota_exceeded" || state === "billing_attention";
1739
+ billingBanner.style.display = blocked ? "block" : "none";
1740
+ billingBanner.classList.toggle("limit", state === "quota_exceeded");
1741
+ if (!blocked) return;
1742
+ const title = state === "quota_exceeded"
1743
+ ? "Recall limit reached"
1744
+ : state === "plan_required"
1745
+ ? "Recall paused · Free plan"
1746
+ : billingStatus.message || "Billing needs attention";
1747
+ const detail = state === "quota_exceeded"
1748
+ ? "You used all Pro searches this month. Upgrade or wait for reset."
1749
+ : state === "plan_required"
1750
+ ? "Search memory requires Pro or Power. Saving still works."
1751
+ : billingStatus.detail || "Open pricing to restore memory search.";
1752
+ if (billingTitle) billingTitle.textContent = title;
1753
+ if (billingCopy) billingCopy.textContent = detail;
1754
+ }
1755
+
1506
1756
  function renderWhy(score, oldPct, repeatCount, sat, health, color) {
1507
1757
  const rows = [];
1508
1758
  if (sat > 0) rows.push(["context", score.modelContextWindow ? sat + "% full · " + fmt(score.ctTokens) : fmt(score.ctTokens) + " tokens"]);