@gamaze/hicortex 0.20.2 → 0.20.4

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/assets/viz.html CHANGED
@@ -80,6 +80,68 @@
80
80
  background: rgba(128,128,128,0.18); font-size: 11px; color: var(--text-dim);
81
81
  }
82
82
 
83
+ /* ---- account menu (#365) ---- */
84
+ /* Dropdown under the nav account element: token reveal/copy, connect
85
+ snippets, plan & billing, sign-out. The wrapper (#nav-account) becomes
86
+ the positioning context; the panel is absolute + right-aligned under it,
87
+ above graph content (the topbar is z-10, the panel gets 60 within it). */
88
+ .hc-nav-account { position: relative; }
89
+ .acct-trigger {
90
+ display: inline-flex; align-items: center; gap: 6px;
91
+ background: transparent; color: var(--text); border: none; border-radius: 6px;
92
+ padding: 4px 8px; font: inherit; cursor: pointer;
93
+ }
94
+ .acct-trigger:hover { background: rgba(128,128,128,0.15); }
95
+ .acct-caret { color: var(--text-dim); font-size: 10px; }
96
+ .acct-menu {
97
+ display: none; position: absolute; top: calc(100% + 8px); right: 0;
98
+ min-width: 320px; max-width: min(420px, calc(100vw - 40px));
99
+ background: var(--panel); border: 1px solid var(--panel-border); border-radius: 8px;
100
+ padding: 12px; z-index: 60;
101
+ box-shadow: 0 8px 24px rgba(0,0,0,0.35);
102
+ color: var(--text); font-size: 13px; text-align: left;
103
+ }
104
+ .acct-menu.open { display: block; }
105
+ .acct-label {
106
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
107
+ color: var(--text-dim); margin: 8px 0 4px;
108
+ }
109
+ .acct-menu .acct-label:first-child { margin-top: 0; }
110
+ .acct-token-row { display: flex; align-items: center; gap: 6px; }
111
+ .acct-token {
112
+ flex: 1; min-width: 0;
113
+ font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
114
+ background: var(--bg); border: 1px solid var(--panel-border); border-radius: 4px;
115
+ padding: 4px 8px; color: var(--text);
116
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
117
+ }
118
+ .acct-btn {
119
+ background: var(--panel); color: var(--text);
120
+ border: 1px solid var(--panel-border); border-radius: 4px;
121
+ padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer;
122
+ }
123
+ .acct-btn:hover { border-color: var(--accent); color: var(--accent); }
124
+ .acct-btn:disabled { opacity: 0.5; cursor: default; }
125
+ .acct-menu details { border-top: 1px solid var(--panel-border); margin-top: 10px; padding-top: 8px; }
126
+ .acct-menu summary { cursor: pointer; color: var(--text-dim); font-size: 13px; }
127
+ .acct-menu summary:hover { color: var(--accent); }
128
+ .acct-snippet { margin-bottom: 8px; }
129
+ .acct-snippet pre {
130
+ background: var(--bg); border: 1px solid var(--panel-border); border-radius: 4px;
131
+ padding: 8px; margin: 4px 0;
132
+ font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
133
+ white-space: pre-wrap; word-break: break-all;
134
+ max-height: 200px; overflow-y: auto;
135
+ }
136
+ .acct-link {
137
+ display: block; width: 100%; text-align: left;
138
+ background: transparent; border: none; border-radius: 4px;
139
+ color: var(--text); font: inherit; font-size: 13px;
140
+ padding: 6px 8px; cursor: pointer;
141
+ }
142
+ .acct-link:hover { background: rgba(128,128,128,0.15); }
143
+ .acct-link.acct-danger:hover { color: var(--danger); }
144
+
83
145
  /* ---- left control panel ---- */
84
146
  #panel {
85
147
  position: fixed; top: 48px; left: 12px; width: 236px;
@@ -385,28 +447,43 @@
385
447
  });
386
448
 
387
449
  // =========================================================================
388
- // Account identity in the nav — GET /account (the lightweight endpoint, the
389
- // same shape the dashboard payload carries). Fail-soft: 401/fetch error
390
- // leaves the span empty (fetchGraph owns the 401 token prompt); an all-null
391
- // account (self-hosted default) renders nothing. textContent only this
392
- // page uses innerHTML exclusively for the escaped 3D-force-graph tooltips.
450
+ // Account identity in the nav + the ACCOUNT MENU (#365) — GET /account (the
451
+ // lightweight endpoint, the same shape the dashboard payload carries).
452
+ // Fail-soft: 401/fetch error leaves the span empty (fetchGraph owns the 401
453
+ // token prompt); an all-null account (self-hosted default) renders nothing —
454
+ // no menu either. createElement/textContent only this page uses innerHTML
455
+ // exclusively for the escaped 3D-force-graph tooltips.
393
456
  // =========================================================================
394
457
  function renderAccount(a) {
395
458
  var host = document.getElementById("nav-account");
396
459
  if (!host) return;
397
460
  host.textContent = "";
398
461
  if (!a || (a.name === null && a.org === null && a.plan === null)) return;
462
+ var trigger = document.createElement("button");
463
+ trigger.type = "button";
464
+ trigger.id = "acct-trigger";
465
+ trigger.className = "acct-trigger";
466
+ trigger.setAttribute("aria-haspopup", "menu");
467
+ trigger.setAttribute("aria-expanded", "false");
399
468
  var who = [a.name, a.org].filter(function (s) { return typeof s === "string" && s !== ""; });
400
469
  who.forEach(function (s, i) {
401
- if (i > 0) host.appendChild(document.createTextNode(" · "));
402
- host.appendChild(document.createTextNode(s));
470
+ if (i > 0) trigger.appendChild(document.createTextNode(" · "));
471
+ trigger.appendChild(document.createTextNode(s));
403
472
  });
404
473
  if (typeof a.plan === "string" && a.plan !== "") {
405
474
  var pill = document.createElement("span");
406
475
  pill.className = "plan";
407
476
  pill.textContent = a.plan;
408
- host.appendChild(pill);
477
+ trigger.appendChild(pill);
409
478
  }
479
+ var caret = document.createElement("span");
480
+ caret.className = "acct-caret";
481
+ caret.setAttribute("aria-hidden", "true");
482
+ caret.textContent = "▾";
483
+ trigger.appendChild(caret);
484
+ host.appendChild(trigger);
485
+ host.appendChild(buildAcctMenu());
486
+ wireAcctMenu();
410
487
  }
411
488
  function loadAccount() {
412
489
  var headers = {};
@@ -417,6 +494,273 @@
417
494
  .catch(function () { /* leave the span empty — nav is never worth an error */ });
418
495
  }
419
496
 
497
+ // =========================================================================
498
+ // Account menu (#365) — dropdown under the nav account element: token
499
+ // reveal/copy, connect snippets, plan & billing, sign-out. The token shown
500
+ // is AUTHORITATIVE server truth: a lazy GET /account/token on the FIRST
501
+ // open (echo-only endpoint — the fetch already proves the caller holds the
502
+ // token). localStorage is NEVER trusted for the display: it can be stale
503
+ // after token rotation and is absent entirely for browser sessions the
504
+ // hosted router authenticates via its session→bearer injection. On fetch
505
+ // failure the row fails explicitly ("token unavailable", actions disabled).
506
+ // Built with createElement/textContent only (page convention).
507
+ // =========================================================================
508
+ var acctToken = null; // token from /account/token (server truth)
509
+ var acctTokenState = "unfetched"; // "unfetched" | "ok" | "unavailable"
510
+ var acctTokenFetched = false; // one lazy fetch attempt per page load
511
+ var acctRevealed = false; // unmasked while the menu is open
512
+
513
+ function maskToken(t) {
514
+ // hctx-••••••23f9 — first 5 chars + 6 bullets + last 4. Real tokens are
515
+ // hctx-<32hex>; the guard keeps a degenerate short token from leaking.
516
+ if (typeof t !== "string" || t.length < 10) return "••••••••••";
517
+ return t.slice(0, 5) + "••••••" + t.slice(-4);
518
+ }
519
+
520
+ function el(tag, id, cls, text) {
521
+ var n = document.createElement(tag);
522
+ if (id) n.id = id;
523
+ if (cls) n.className = cls;
524
+ if (text !== undefined) n.textContent = text;
525
+ return n;
526
+ }
527
+
528
+ // Static menu skeleton — token-dependent values fill in renderAcctToken().
529
+ function buildAcctMenu() {
530
+ var menu = el("div", "acct-menu", "acct-menu");
531
+ menu.appendChild(el("div", null, "acct-label", "Connection token"));
532
+ var row = el("div", null, "acct-token-row");
533
+ row.appendChild(el("code", "acct-token-val", "acct-token", "…"));
534
+ row.appendChild(el("button", "acct-reveal", "acct-btn", "Reveal"));
535
+ row.appendChild(el("button", "acct-copy-token", "acct-btn", "Copy"));
536
+ menu.appendChild(row);
537
+ var details = el("details", "acct-connect");
538
+ details.appendChild(el("summary", null, null, "Connect an agent"));
539
+ details.appendChild(el("div", null, "acct-label", "CLI"));
540
+ var snip1 = el("div", null, "acct-snippet");
541
+ snip1.appendChild(el("pre", "acct-cli"));
542
+ snip1.appendChild(el("button", "acct-copy-cli", "acct-btn", "Copy"));
543
+ details.appendChild(snip1);
544
+ details.appendChild(el("div", null, "acct-label", "MCP — ~/.claude.json"));
545
+ var snip2 = el("div", null, "acct-snippet");
546
+ snip2.appendChild(el("pre", "acct-mcp"));
547
+ snip2.appendChild(el("button", "acct-copy-mcp", "acct-btn", "Copy"));
548
+ details.appendChild(snip2);
549
+ menu.appendChild(details);
550
+ menu.appendChild(el("button", "acct-plan", "acct-link", "Plan & billing"));
551
+ menu.appendChild(el("button", "acct-signout", "acct-link acct-danger", "Sign out"));
552
+ // All menu buttons are type=button (never submit — no form here, but the
553
+ // page's convention is explicit types).
554
+ var btns = menu.querySelectorAll("button");
555
+ for (var i = 0; i < btns.length; i++) btns[i].type = "button";
556
+ return menu;
557
+ }
558
+
559
+ // The connect line: server URL + token on ONE line, space-separated.
560
+ function acctConnectLine() {
561
+ return location.origin + " " + acctToken;
562
+ }
563
+
564
+ // CLI snippet — init has NO --token flag; it prompts interactively. The
565
+ // comment mirrors init.ts's own printed hint ("When prompted for the token,
566
+ // paste: …").
567
+ function acctCliText() {
568
+ return "# When prompted for the token, paste: " + acctToken +
569
+ "\nnpx @gamaze/hicortex init --server " + location.origin;
570
+ }
571
+
572
+ // MCP snippet — mirrors EXACTLY what runClientInit writes into
573
+ // ~/.claude.json (init.ts): SSE transport at <origin>/sse with a bearer
574
+ // Authorization header.
575
+ function acctMcpText() {
576
+ return JSON.stringify({
577
+ mcpServers: {
578
+ hicortex: {
579
+ type: "sse",
580
+ url: location.origin + "/sse",
581
+ headers: { Authorization: "Bearer " + acctToken }
582
+ }
583
+ }
584
+ }, null, 2);
585
+ }
586
+
587
+ function renderAcctToken() {
588
+ var val = document.getElementById("acct-token-val");
589
+ if (!val) return; // menu not rendered (self-hosted all-null account)
590
+ var revealBtn = document.getElementById("acct-reveal");
591
+ var copyToken = document.getElementById("acct-copy-token");
592
+ var copyCli = document.getElementById("acct-copy-cli");
593
+ var copyMcp = document.getElementById("acct-copy-mcp");
594
+ var cli = document.getElementById("acct-cli");
595
+ var mcp = document.getElementById("acct-mcp");
596
+ if (acctTokenState !== "ok") {
597
+ val.textContent = acctTokenState === "unfetched" ? "…" : "token unavailable";
598
+ revealBtn.disabled = true;
599
+ copyToken.disabled = true;
600
+ copyCli.disabled = true;
601
+ copyMcp.disabled = true;
602
+ cli.textContent = acctTokenState === "unfetched" ? "" : "token unavailable";
603
+ mcp.textContent = acctTokenState === "unfetched" ? "" : "token unavailable";
604
+ return;
605
+ }
606
+ revealBtn.disabled = false;
607
+ copyToken.disabled = false;
608
+ copyCli.disabled = false;
609
+ copyMcp.disabled = false;
610
+ val.textContent = acctRevealed ? acctToken : maskToken(acctToken);
611
+ revealBtn.textContent = acctRevealed ? "Hide" : "Reveal";
612
+ cli.textContent = acctCliText();
613
+ mcp.textContent = acctMcpText();
614
+ }
615
+
616
+ function fetchAcctToken() {
617
+ if (acctTokenFetched) return;
618
+ acctTokenFetched = true;
619
+ var headers = {};
620
+ if (token) headers["Authorization"] = "Bearer " + token; // same as loadAccount
621
+ fetch("/account/token", { headers: headers })
622
+ .then(function (resp) {
623
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
624
+ return resp.json();
625
+ })
626
+ .then(function (data) {
627
+ if (typeof data.token !== "string" || !data.token) throw new Error("bad payload");
628
+ acctToken = data.token;
629
+ acctTokenState = "ok";
630
+ renderAcctToken();
631
+ })
632
+ .catch(function () {
633
+ acctTokenState = "unavailable"; // fail explicitly — no localStorage fallback
634
+ renderAcctToken();
635
+ });
636
+ }
637
+
638
+ function closeAcctMenu() {
639
+ var menu = document.getElementById("acct-menu");
640
+ if (!menu) return;
641
+ menu.classList.remove("open");
642
+ var trigger = document.getElementById("acct-trigger");
643
+ if (trigger) trigger.setAttribute("aria-expanded", "false");
644
+ // Masked again + connect section collapsed on EVERY close (locked design).
645
+ acctRevealed = false;
646
+ var details = document.getElementById("acct-connect");
647
+ if (details) details.removeAttribute("open");
648
+ renderAcctToken();
649
+ }
650
+
651
+ function openAcctMenu() {
652
+ var menu = document.getElementById("acct-menu");
653
+ if (!menu) return;
654
+ menu.classList.add("open");
655
+ document.getElementById("acct-trigger").setAttribute("aria-expanded", "true");
656
+ fetchAcctToken(); // lazy — one attempt, on first open only
657
+ }
658
+
659
+ // Clipboard helper: async API when available, temporary <textarea> +
660
+ // execCommand fallback (http remote origins lack the async clipboard API).
661
+ // Brief "Copied" feedback (label swap for ~1.5 s); total failure alerts.
662
+ function copyToClipboard(text, btn) {
663
+ var flash = function () {
664
+ var orig = btn.dataset.origLabel || (btn.dataset.origLabel = btn.textContent);
665
+ btn.textContent = "Copied";
666
+ clearTimeout(btn._copiedTimer);
667
+ btn._copiedTimer = setTimeout(function () { btn.textContent = orig; }, 1500);
668
+ };
669
+ var legacy = function () {
670
+ try {
671
+ var ta = document.createElement("textarea");
672
+ ta.value = text;
673
+ ta.style.position = "fixed";
674
+ ta.style.opacity = "0";
675
+ document.body.appendChild(ta);
676
+ ta.select();
677
+ var ok = document.execCommand("copy");
678
+ document.body.removeChild(ta);
679
+ return ok;
680
+ } catch (e) { return false; }
681
+ };
682
+ var fallback = function () {
683
+ if (legacy()) { flash(); return; }
684
+ alert("Copy failed — select the text and copy it manually.");
685
+ };
686
+ if (navigator.clipboard && navigator.clipboard.writeText) {
687
+ navigator.clipboard.writeText(text).then(flash, fallback);
688
+ } else {
689
+ fallback();
690
+ }
691
+ }
692
+
693
+ // Sign-out is CLIENT-SIDE ONLY (#365): there is no server session to
694
+ // invalidate in the Hicortex server itself — auth is per-request bearer, so
695
+ // dropping the locally stored tokens IS the sign-out. (The hosted router
696
+ // separately keeps a Google session cookie and offers /auth/logout — out of
697
+ // scope here; this only ends the browser's console session.)
698
+ function signOut() {
699
+ try {
700
+ localStorage.removeItem("hicortexToken");
701
+ localStorage.removeItem("hicortex-dashboard-token");
702
+ } catch (e) { /* private mode — reload anyway */ }
703
+ location.reload(); // back to the unauthenticated 401-prompt state
704
+ }
705
+
706
+ function wireAcctMenu() {
707
+ var trigger = document.getElementById("acct-trigger");
708
+ if (!trigger) return;
709
+ trigger.addEventListener("click", function () {
710
+ var menu = document.getElementById("acct-menu");
711
+ if (menu && menu.classList.contains("open")) closeAcctMenu();
712
+ else openAcctMenu();
713
+ });
714
+ document.getElementById("acct-reveal").addEventListener("click", function () {
715
+ acctRevealed = !acctRevealed;
716
+ renderAcctToken();
717
+ });
718
+ var copyToken = document.getElementById("acct-copy-token");
719
+ copyToken.addEventListener("click", function () {
720
+ if (acctTokenState === "ok") copyToClipboard(acctConnectLine(), copyToken);
721
+ });
722
+ var copyCli = document.getElementById("acct-copy-cli");
723
+ copyCli.addEventListener("click", function () {
724
+ if (acctTokenState === "ok") {
725
+ copyToClipboard("npx @gamaze/hicortex init --server " + location.origin, copyCli);
726
+ }
727
+ });
728
+ var copyMcp = document.getElementById("acct-copy-mcp");
729
+ copyMcp.addEventListener("click", function () {
730
+ if (acctTokenState === "ok") copyToClipboard(acctMcpText(), copyMcp);
731
+ });
732
+ // Plan & billing lives on the dashboard page — navigate there (append
733
+ // ?token= when one is known, mirroring the shared-nav link handler) and
734
+ // land on the digest panel's budget/usage anchor (#371 lands there).
735
+ document.getElementById("acct-plan").addEventListener("click", function () {
736
+ var t = null;
737
+ try {
738
+ t = token
739
+ || localStorage.getItem("hicortexToken")
740
+ || localStorage.getItem("hicortex-dashboard-token");
741
+ } catch (e) { /* private mode — navigate without a token */ }
742
+ var u = new URL("/dashboard", location.origin);
743
+ if (t) u.searchParams.set("token", t);
744
+ u.hash = "plan-billing";
745
+ location.href = u.toString();
746
+ });
747
+ document.getElementById("acct-signout").addEventListener("click", signOut);
748
+ renderAcctToken();
749
+ }
750
+
751
+ // Click-outside + Escape close the menu (wired once; the handlers no-op
752
+ // when the menu isn't rendered).
753
+ document.addEventListener("click", function (e) {
754
+ var menu = document.getElementById("acct-menu");
755
+ if (!menu || !menu.classList.contains("open")) return;
756
+ var wrap = document.getElementById("nav-account");
757
+ if (wrap && wrap.contains(e.target)) return;
758
+ closeAcctMenu();
759
+ });
760
+ window.addEventListener("keydown", function (e) {
761
+ if (e.key === "Escape") closeAcctMenu();
762
+ });
763
+
420
764
  // =========================================================================
421
765
  // DOM references
422
766
  // =========================================================================
package/dist/cli.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Commands:
6
6
  * server Start the MCP HTTP/SSE server (persistent daemon)
7
+ * mcp Speak MCP over stdio (bridge to the local daemon or HICORTEX_SERVER_URL)
7
8
  * init Detect existing setup and configure for CC/OC
8
9
  * nightly Run capture + consolidate (manual trigger)
9
10
  * nightly --capture-only Capture only, skip consolidation
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Commands:
7
7
  * server Start the MCP HTTP/SSE server (persistent daemon)
8
+ * mcp Speak MCP over stdio (bridge to the local daemon or HICORTEX_SERVER_URL)
8
9
  * init Detect existing setup and configure for CC/OC
9
10
  * nightly Run capture + consolidate (manual trigger)
10
11
  * nightly --capture-only Capture only, skip consolidation
@@ -34,6 +35,26 @@ switch (command) {
34
35
  });
35
36
  break;
36
37
  }
38
+ case "mcp": {
39
+ // Stdio MCP bridge (#375): speak MCP on stdin/stdout, backed by the
40
+ // daemon's SSE endpoint (or a remote server via HICORTEX_SERVER_URL).
41
+ // Registry clients (Claude Desktop, Cursor, the MCP Registry's install
42
+ // flow) launch stdio commands — this is the command the registry's
43
+ // server.json declares (`npx -y @gamaze/hicortex mcp`). Starts a local
44
+ // daemon when the target is loopback and none is running; never spawns
45
+ // for remote targets. stdout carries ONLY the MCP protocol — all
46
+ // diagnostics go to stderr (the bridge owns that discipline internally).
47
+ import("./mcp-stdio.js").then(({ runMcpStdio }) => {
48
+ runMcpStdio().catch((err) => {
49
+ console.error(err instanceof Error ? err.message : `[hicortex] mcp bridge failed: ${err}`);
50
+ process.exit(1);
51
+ });
52
+ }).catch((err) => {
53
+ console.error("[hicortex] Failed to load the mcp bridge:", err);
54
+ process.exit(1);
55
+ });
56
+ break;
57
+ }
37
58
  case "init": {
38
59
  const serverArg = process.argv.indexOf("--server");
39
60
  const serverUrl = serverArg !== -1 ? process.argv[serverArg + 1] : undefined;
@@ -331,6 +352,7 @@ Usage: hicortex <command> [options]
331
352
 
332
353
  Commands:
333
354
  server Start the MCP HTTP/SSE server (server mode)
355
+ mcp Speak MCP over stdio (bridge to the local daemon or HICORTEX_SERVER_URL)
334
356
  init Set up Hicortex (server mode, local DB + daemon)
335
357
  Scaffolds 5 editable default memory domains (Work, Personal,
336
358
  People, Health, Finance) in ~/.hicortex/config.json
@@ -351,3 +351,20 @@ export declare function dashboardDataHandler(getDb: () => Database.Database, get
351
351
  * 500 with the usual {error} shape (same as dashboardDataHandler).
352
352
  */
353
353
  export declare function accountHandler(getConfig: () => Record<string, unknown> | null | undefined): express.RequestHandler;
354
+ /**
355
+ * Express adapter for GET /account/token — the install's connection token for
356
+ * the console account menu (#365). SECURITY: echo-only — the caller must
357
+ * ALREADY present the token (bearer, or the localhost bypass) to receive it,
358
+ * so this endpoint grants no privilege. It exists so the menu shows the
359
+ * AUTHORITATIVE server-side token instead of trusting localStorage, which can
360
+ * be stale after token rotation and is absent entirely for browser sessions
361
+ * the hosted router authenticates via its session→bearer injection.
362
+ *
363
+ * `getToken` receives the boot-resolved PRIMARY token (config authToken ??
364
+ * HICORTEX_AUTH_TOKEN env) — the value the auth middleware itself accepts as
365
+ * current, so the menu survives rotation and never echoes the rotation-grace
366
+ * token. When no token is configured the handler answers 503 (mirrors how the
367
+ * /auth/* endpoints answer "not configured"); other failures surface as a 500
368
+ * {error} exactly like accountHandler.
369
+ */
370
+ export declare function accountTokenHandler(getToken: () => string | undefined): express.RequestHandler;
package/dist/dashboard.js CHANGED
@@ -26,6 +26,7 @@ exports.backfillSnapshots = backfillSnapshots;
26
26
  exports.handleDashboardData = handleDashboardData;
27
27
  exports.dashboardDataHandler = dashboardDataHandler;
28
28
  exports.accountHandler = accountHandler;
29
+ exports.accountTokenHandler = accountTokenHandler;
29
30
  const recall_index_js_1 = require("./recall-index.js");
30
31
  const config_read_js_1 = require("./config-read.js");
31
32
  const consolidate_js_1 = require("./consolidate.js");
@@ -526,3 +527,34 @@ function accountHandler(getConfig) {
526
527
  }
527
528
  };
528
529
  }
530
+ /**
531
+ * Express adapter for GET /account/token — the install's connection token for
532
+ * the console account menu (#365). SECURITY: echo-only — the caller must
533
+ * ALREADY present the token (bearer, or the localhost bypass) to receive it,
534
+ * so this endpoint grants no privilege. It exists so the menu shows the
535
+ * AUTHORITATIVE server-side token instead of trusting localStorage, which can
536
+ * be stale after token rotation and is absent entirely for browser sessions
537
+ * the hosted router authenticates via its session→bearer injection.
538
+ *
539
+ * `getToken` receives the boot-resolved PRIMARY token (config authToken ??
540
+ * HICORTEX_AUTH_TOKEN env) — the value the auth middleware itself accepts as
541
+ * current, so the menu survives rotation and never echoes the rotation-grace
542
+ * token. When no token is configured the handler answers 503 (mirrors how the
543
+ * /auth/* endpoints answer "not configured"); other failures surface as a 500
544
+ * {error} exactly like accountHandler.
545
+ */
546
+ function accountTokenHandler(getToken) {
547
+ return (_req, res) => {
548
+ try {
549
+ const token = getToken();
550
+ if (!token) {
551
+ res.status(503).json({ error: "no auth token configured on this install" });
552
+ return;
553
+ }
554
+ res.status(200).json({ token });
555
+ }
556
+ catch (err) {
557
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
558
+ }
559
+ };
560
+ }
@@ -1599,6 +1599,16 @@ async function startServer(options = {}) {
1599
1599
  // (standard auth middleware, no shell exemption — it carries data); localhost
1600
1600
  // bypass applies. Handler lives in src/dashboard.ts next to its twin.
1601
1601
  app.get("/account", (0, dashboard_js_1.accountHandler)(() => readConfigFile(stateDir)));
1602
+ // GET /account/token — the install's connection token for the console
1603
+ // account menu (#365). Echo-only (see accountTokenHandler's JSDoc): the
1604
+ // caller must already present the token (bearer or localhost bypass), so
1605
+ // this grants no privilege — it exists so the menu shows authoritative
1606
+ // server-side truth instead of a possibly-stale localStorage copy. Passes
1607
+ // the boot-resolved PRIMARY token (the one the auth middleware itself
1608
+ // accepts as current — survives rotation, never echoes the grace token).
1609
+ // Bearer-only like /account: standard auth middleware, no shell exemption
1610
+ // (it carries data); localhost bypass applies.
1611
+ app.get("/account/token", (0, dashboard_js_1.accountTokenHandler)(() => authToken));
1602
1612
  // SSE endpoint — each connection gets its own McpServer + transport
1603
1613
  app.get("/sse", async (req, res) => {
1604
1614
  const transport = new sse_js_1.SSEServerTransport("/messages", res);
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Hicortex stdio MCP bridge (#375) — the `hicortex mcp` subcommand.
3
+ *
4
+ * Registry/stdio MCP clients (Claude Desktop, Cursor, the MCP Registry's own
5
+ * install flow) launch a stdio command and speak MCP over stdin/stdout. The
6
+ * daemon's MCP surface is HTTP/SSE on :8787, so this module bridges the two:
7
+ * a low-level SDK `Server` over `StdioServerTransport` downstream (the client
8
+ * side) and an SDK `Client` over `SSEClientTransport` upstream (the daemon
9
+ * side), forwarding tools/list + tools/call. The daemon's MCP surface is
10
+ * tools-only (no resources/prompts registered in mcp-server.ts) and ping is
11
+ * auto-answered by the SDK Protocol base, so tools-forwarding loses nothing.
12
+ *
13
+ * WHY proxy instead of in-process stdio with direct DB access (design note,
14
+ * issue #375):
15
+ * 1. db.ts enables WAL but sets no busy_timeout — a second writer process
16
+ * (the common case: the daemon already running on server-mode installs)
17
+ * takes immediate SQLITE_BUSY during nightly consolidation's long
18
+ * transactions → tool-call failures.
19
+ * 2. createMcpServer()'s nine tool handlers close over ~10 module-level
20
+ * vars initialized by the ~250-line boot inside startServer() —
21
+ * in-process would mean refactoring the production boot path or
22
+ * duplicating it (drift).
23
+ * 3. warmEmbedder loads a 150-300 MB ONNX model per process — one per MCP
24
+ * client (Claude Desktop + CC + Cursor = 3x), vs zero for the bridge.
25
+ * 4. mcp-server.ts's own header states the model: "One process, one DB
26
+ * connection, one embedder".
27
+ *
28
+ * Target resolution (precedence): HICORTEX_SERVER_URL env → remote bridge,
29
+ * NEVER spawns anything; else config via the SAME semantics as
30
+ * learnings-identity.resolveConfig() (client-mode serverUrl → remote;
31
+ * server-mode → http://127.0.0.1:<port ?? 8787>); no usable config →
32
+ * http://127.0.0.1:8787. Token: HICORTEX_AUTH_TOKEN env → config.authToken.
33
+ * The token rides SSEClientTransport's requestInit headers (verified in the
34
+ * installed SDK 1.28: merged into BOTH the GET /sse and POST /messages).
35
+ *
36
+ * Local autostart: when the target is loopback and /health is
37
+ * connection-refused, spawn a DETACHED `cli.js server --port <n>` (unref,
38
+ * stdio ignored) and poll /health (~250 ms interval, 30 s cap). Concurrent
39
+ * bridges racing EADDRINUSE self-heal — the loser's child dies, the winner's
40
+ * daemon answers the poll, so the loop keeps polling regardless of child
41
+ * state. /health answering but not ok = a foreign or broken service on the
42
+ * port: explicit error, never a spawn. A remote target that is down is
43
+ * likewise an explicit error — we never spawn for remote URLs.
44
+ *
45
+ * STDIO DISCIPLINE: stdout carries ONLY the MCP protocol. Every diagnostic
46
+ * goes to stderr; fatal errors are a one-liner on stderr + non-zero exit
47
+ * (thrown to cli.ts's catch). Cancellation downstream→upstream rides the
48
+ * SDK-native path: the Protocol base aborts the handler's extra.signal on
49
+ * notifications/cancelled, and passing that signal into client.callTool makes
50
+ * the upstream Client emit its own notifications/cancelled with the CORRECT
51
+ * upstream request id (a verbatim forward would carry the downstream id,
52
+ * which means nothing to the daemon) — and reject the in-flight bridge call.
53
+ */
54
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
55
+ /** Where the bridge target came from — surfaces in the startup diagnostic. */
56
+ export type BridgeTargetSource = "option" | "env" | "config" | "default";
57
+ export interface BridgeTarget {
58
+ /** Base URL, trailing slashes stripped (endpoints append /sse, /health). */
59
+ url: string;
60
+ /** Loopback target → eligible for local autostart. */
61
+ local: boolean;
62
+ /** Port of the resolved URL — what an autospawned daemon must listen on. */
63
+ port: number;
64
+ source: BridgeTargetSource;
65
+ }
66
+ /** Loopback check for URL hostnames (Node's URL keeps the brackets on [::1]). */
67
+ export declare function isLoopbackHost(hostname: string): boolean;
68
+ /**
69
+ * Resolve the daemon/server the bridge should talk to. Precedence: explicit
70
+ * option (the runMcpStdio test seam) → HICORTEX_SERVER_URL env → config file
71
+ * (client-mode serverUrl = remote; server-mode = localhost, the
72
+ * resolveConfig() semantics already shared by both CC hooks) → default local
73
+ * 8787. Blank/whitespace env values are ignored, not mistaken for targets.
74
+ */
75
+ export declare function resolveBridgeTarget(explicitUrl?: string): BridgeTarget;
76
+ /**
77
+ * Resolve the bearer token for the upstream connection: explicit option →
78
+ * HICORTEX_AUTH_TOKEN env → config.authToken. Undefined = no token (a local
79
+ * daemon needs none — loopback bypasses auth).
80
+ */
81
+ export declare function resolveBridgeToken(explicitToken?: string): string | undefined;
82
+ export interface HealthProbe {
83
+ /** Any HTTP response arrived (even a non-200 one). */
84
+ reachable: boolean;
85
+ /** The endpoint answered ok — a healthy Hicortex /health. */
86
+ ok: boolean;
87
+ }
88
+ /**
89
+ * One GET /health probe. Connection refused / timeout / DNS failure →
90
+ * { reachable: false }; a response that is not ok → { reachable: true, ok:
91
+ * false } — the two carry different autostart decisions, so a boolean alone
92
+ * cannot express them.
93
+ */
94
+ export declare function probeHealthOnce(url: string, timeoutMs?: number): Promise<HealthProbe>;
95
+ export type AutostartDecision = {
96
+ action: "bridge";
97
+ } | {
98
+ action: "spawn";
99
+ } | {
100
+ action: "fail";
101
+ reason: string;
102
+ };
103
+ /**
104
+ * Pure decision from one health probe: healthy → bridge; refused + loopback
105
+ * → spawn a local daemon; refused + remote → fail with an actionable message
106
+ * (never spawn for remote URLs); answering-but-not-ok → fail explicitly (a
107
+ * foreign or broken service owns the port — spawning next to it cannot help).
108
+ */
109
+ export declare function decideAutostart(probe: HealthProbe, target: BridgeTarget): AutostartDecision;
110
+ export interface EnsureDaemonOptions {
111
+ /** Local autostart toggle (default true). Remote targets never spawn. */
112
+ autostart?: boolean;
113
+ probeHealth?: (url: string) => Promise<HealthProbe>;
114
+ spawnDaemon?: (port: number) => Promise<void> | void;
115
+ /** Poll pacing overrides — small values keep the timeout test fast. */
116
+ pollIntervalMs?: number;
117
+ pollTotalMs?: number;
118
+ }
119
+ /**
120
+ * Ensure something healthy answers at the target before bridging: probe once,
121
+ * decide, and if spawning — poll until healthy (or the deadline). Throws on
122
+ * every fail-path (explicit error, never silent degradation).
123
+ */
124
+ export declare function ensureDaemonReady(target: BridgeTarget, options?: EnsureDaemonOptions): Promise<void>;
125
+ export interface McpStdioOptions extends EnsureDaemonOptions {
126
+ /** Explicit target URL (test seam; normally resolved from env/config). */
127
+ serverUrl?: string;
128
+ /** Explicit bearer token (test seam; normally env → config). */
129
+ authToken?: string;
130
+ /** Injectable downstream transport (test seam; default: real stdio). */
131
+ downstream?: Transport;
132
+ }
133
+ /**
134
+ * Run the stdio MCP bridge. Resolves only after the downstream transport
135
+ * closes (the lifecycle handlers then exit the process); every setup failure
136
+ * throws for cli.ts to report on stderr and exit 1.
137
+ */
138
+ export declare function runMcpStdio(options?: McpStdioOptions): Promise<void>;