@gamaze/hicortex 0.20.3 → 0.20.5

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.
Files changed (44) hide show
  1. package/README.md +20 -1
  2. package/assets/dashboard.html +318 -8
  3. package/assets/identity.html +349 -8
  4. package/assets/viz.html +352 -8
  5. package/dist/backup.d.ts +12 -8
  6. package/dist/backup.js +13 -9
  7. package/dist/claude-desktop.d.ts +138 -0
  8. package/dist/claude-desktop.js +251 -0
  9. package/dist/cli.d.ts +7 -2
  10. package/dist/cli.js +84 -3
  11. package/dist/consolidate.d.ts +8 -1
  12. package/dist/consolidate.js +82 -2
  13. package/dist/dashboard.d.ts +17 -0
  14. package/dist/dashboard.js +32 -0
  15. package/dist/db.js +36 -0
  16. package/dist/dedup.d.ts +157 -25
  17. package/dist/dedup.js +376 -83
  18. package/dist/domain-classify.js +4 -2
  19. package/dist/index.js +7 -7
  20. package/dist/init.d.ts +4 -1
  21. package/dist/init.js +103 -1
  22. package/dist/llm.d.ts +19 -13
  23. package/dist/llm.js +25 -14
  24. package/dist/mcp-server.d.ts +6 -0
  25. package/dist/mcp-server.js +94 -13
  26. package/dist/mcp-stdio.d.ts +138 -0
  27. package/dist/mcp-stdio.js +313 -0
  28. package/dist/memory-instructions.d.ts +18 -0
  29. package/dist/memory-instructions.js +39 -2
  30. package/dist/nightly.js +14 -1
  31. package/dist/reconsolidation.d.ts +323 -0
  32. package/dist/reconsolidation.js +1226 -0
  33. package/dist/retrieval.d.ts +14 -0
  34. package/dist/retrieval.js +41 -3
  35. package/dist/state.d.ts +23 -1
  36. package/dist/storage.d.ts +25 -0
  37. package/dist/storage.js +49 -7
  38. package/dist/type-classify.js +4 -2
  39. package/dist/types.d.ts +188 -0
  40. package/hermes-plugin/hicortex/provider.py +29 -17
  41. package/opencode-plugin/hicortex/index.ts +7 -7
  42. package/package.json +4 -2
  43. package/pi-extension/hicortex/index.ts +7 -7
  44. package/server.json +44 -0
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/backup.d.ts CHANGED
@@ -129,19 +129,23 @@ export declare function runBackupHook(artifactPath: string, command: string | un
129
129
  */
130
130
  export declare function newestBackupArtifactMs(dir: string): number | undefined;
131
131
  /**
132
- * Prune a backup dir to the `retention` newest artifacts (#327). Matches ONLY
133
- * files named `hicortex-*.tar.gz` (the nightly/CLI artifact pattern) — anything
134
- * else in the dir (operator copies, notes) is never touched. Keeps the newest
135
- * `retention` by mtime, with the ISO filename (time-ordered by construction)
136
- * as a DESCENDING tie-break so equal mtimes resolve deterministically (the
137
- * just-written artifact is the newest and always survives); deletes the rest,
138
- * oldest first. `retention <= 0` keeps all.
132
+ * Prune a backup dir to the `retention` newest artifacts (#327). By default
133
+ * matches ONLY files named `hicortex-*.tar.gz` (the nightly/CLI artifact
134
+ * pattern); the optional `pattern` override scopes the prune to another
135
+ * product-owned filename family — e.g. the pre-dedup merge backups
136
+ * (`pre-dedup-*.db`, #392) keep their own retention count INDEPENDENT of the
137
+ * full-backup artifacts. Anything else in the dir (operator copies, notes) is
138
+ * never touched. Keeps the newest `retention` by mtime, with the ISO filename
139
+ * (time-ordered by construction) as a DESCENDING tie-break so equal mtimes
140
+ * resolve deterministically (the just-written artifact is the newest and
141
+ * always survives); deletes the rest, oldest first. `retention <= 0` keeps
142
+ * all.
139
143
  *
140
144
  * Best-effort by design: a per-file unlink failure logs and continues (a
141
145
  * stale extra artifact is cheap; failing the nightly AFTER a good backup was
142
146
  * written is not). Returns the number actually removed.
143
147
  */
144
- export declare function pruneBackupArtifacts(dir: string, retention: number): number;
148
+ export declare function pruneBackupArtifacts(dir: string, retention: number, pattern?: RegExp): number;
145
149
  export interface BackupCliOptions {
146
150
  /** `--out <dir>` — output directory (the artifact is auto-named). Takes precedence over `config.backupDir`. Mutually exclusive with stdout. */
147
151
  outDir?: string;
package/dist/backup.js CHANGED
@@ -282,19 +282,23 @@ function newestBackupArtifactMs(dir) {
282
282
  return newest;
283
283
  }
284
284
  /**
285
- * Prune a backup dir to the `retention` newest artifacts (#327). Matches ONLY
286
- * files named `hicortex-*.tar.gz` (the nightly/CLI artifact pattern) — anything
287
- * else in the dir (operator copies, notes) is never touched. Keeps the newest
288
- * `retention` by mtime, with the ISO filename (time-ordered by construction)
289
- * as a DESCENDING tie-break so equal mtimes resolve deterministically (the
290
- * just-written artifact is the newest and always survives); deletes the rest,
291
- * oldest first. `retention <= 0` keeps all.
285
+ * Prune a backup dir to the `retention` newest artifacts (#327). By default
286
+ * matches ONLY files named `hicortex-*.tar.gz` (the nightly/CLI artifact
287
+ * pattern); the optional `pattern` override scopes the prune to another
288
+ * product-owned filename family — e.g. the pre-dedup merge backups
289
+ * (`pre-dedup-*.db`, #392) keep their own retention count INDEPENDENT of the
290
+ * full-backup artifacts. Anything else in the dir (operator copies, notes) is
291
+ * never touched. Keeps the newest `retention` by mtime, with the ISO filename
292
+ * (time-ordered by construction) as a DESCENDING tie-break so equal mtimes
293
+ * resolve deterministically (the just-written artifact is the newest and
294
+ * always survives); deletes the rest, oldest first. `retention <= 0` keeps
295
+ * all.
292
296
  *
293
297
  * Best-effort by design: a per-file unlink failure logs and continues (a
294
298
  * stale extra artifact is cheap; failing the nightly AFTER a good backup was
295
299
  * written is not). Returns the number actually removed.
296
300
  */
297
- function pruneBackupArtifacts(dir, retention) {
301
+ function pruneBackupArtifacts(dir, retention, pattern = /^hicortex-.*\.tar\.gz$/) {
298
302
  if (!Number.isFinite(retention) || retention <= 0)
299
303
  return 0;
300
304
  let names;
@@ -306,7 +310,7 @@ function pruneBackupArtifacts(dir, retention) {
306
310
  return 0;
307
311
  }
308
312
  const artifacts = names
309
- .filter((n) => /^hicortex-.*\.tar\.gz$/.test(n))
313
+ .filter((n) => pattern.test(n))
310
314
  .map((n) => {
311
315
  const abs = (0, node_path_1.join)(dir, n);
312
316
  let mtimeMs = 0;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Claude Desktop auto-configuration (#381) — the init-side writer for the
3
+ * `claude_desktop_config.json` MCP entry.
4
+ *
5
+ * Claude Desktop's ONLY stdio MCP route is this hand-edited file (no CLI, no
6
+ * plugin discovery), which is why Desktop never had an init step while CC
7
+ * (`claude mcp add`), Pi, and opencode (dropped extension files) did. The
8
+ * 0.20.4 `hicortex mcp` stdio bridge makes such an entry meaningful, so init
9
+ * now offers to write it — the same auto-detect pattern as the other
10
+ * harnesses, gated on the Claude Desktop config DIRECTORY existing (the
11
+ * config FILE may not exist yet; creating it fresh is in scope).
12
+ *
13
+ * Two properties dominate the design:
14
+ *
15
+ * 1. IT IS ANOTHER APP'S FILE. `claude_desktop_config.json` is owned by
16
+ * Claude Desktop and carries the user's other servers and app settings.
17
+ * The write is therefore merge-safe (preserve EVERY top-level key and
18
+ * every other server; touch only `mcpServers.hicortex`), takes a
19
+ * timestamped `.bak` copy of the existing file first, lands via
20
+ * tmp-write + JSON-validate + rename in the same directory (atomic),
21
+ * and REFUSES — touching nothing, not even a backup — when the existing
22
+ * file is not valid JSON. Unlike our own config.json there is no repair
23
+ * flow here (quarantening another app's config would break IT); the
24
+ * user fixes the file by hand with the snippet init prints.
25
+ *
26
+ * 2. THE COMMAND MUST BE AN ABSOLUTE NPX PATH. Desktop is a GUI app and
27
+ * does not inherit the shell PATH — a bare "npx" command is the #1
28
+ * Desktop MCP failure mode. Resolution walks PATH plus the common
29
+ * install locations and rejects npm's ephemeral `/_npx/` cache (#176: a
30
+ * path that dies on the next cache GC — the entry would break silently
31
+ * weeks later). Unresolvable → manual instructions, nothing written.
32
+ * Windows .cmd shims cannot be spawned by Electron without a shell, so
33
+ * they are wrapped in `cmd /c`.
34
+ *
35
+ * Every helper is pure/parametrised (platform/env/home/exists injected) so
36
+ * the suite covers darwin/win32/linux without touching a real machine. This
37
+ * module deliberately imports NOTHING from init.ts (init imports this — no
38
+ * cycle); the package spec is passed in from init's getPackageSpec().
39
+ */
40
+ /**
41
+ * The Claude Desktop config directory for this platform, or null where no
42
+ * Desktop build exists (Linux → silent skip). Parametrised so tests cover
43
+ * the platform matrix without a real machine. Detection in init is
44
+ * `desktopConfigDir() !== null && existsSync(dir)` — the config FILE itself
45
+ * may legitimately not exist yet.
46
+ */
47
+ export declare function desktopConfigDir(platform?: NodeJS.Platform, env?: Record<string, string | undefined>, home?: string): string | null;
48
+ export interface DesktopNpxResolveOptions {
49
+ /** The PATH string to search (default: process.env.PATH). */
50
+ pathEnv?: string;
51
+ /** Platform under test (default: the real one). */
52
+ platform?: NodeJS.Platform;
53
+ /** Home dir for the ~-relative common locations (default: homedir()). */
54
+ home?: string;
55
+ /** Env for APPDATA on win32 (default: process.env). */
56
+ env?: Record<string, string | undefined>;
57
+ /** Existence seam (default: existsSync) — unit tests pass fixture sets. */
58
+ exists?: (candidatePath: string) => boolean;
59
+ }
60
+ /**
61
+ * Resolve an ABSOLUTE npx path for the Desktop entry, or null when nothing
62
+ * durable exists (init then prints manual instructions and writes nothing).
63
+ * Rejects npm's ephemeral npx cache in BOTH separator spellings — unix
64
+ * `/_npx/` and Windows `\_npx\` — a bare includes("/_npx/") would let the
65
+ * win32 form through (#176: the path dies on the next cache GC).
66
+ */
67
+ export declare function resolveDesktopNpxPath(options?: DesktopNpxResolveOptions): string | null;
68
+ /** The `mcpServers.hicortex` value — a Claude Desktop stdio server entry. */
69
+ export interface DesktopServerEntry {
70
+ command: string;
71
+ args: string[];
72
+ /** Only in remote mode; absent (not empty) in local/loopback mode. */
73
+ env?: Record<string, string>;
74
+ }
75
+ /**
76
+ * Build the stdio entry for the `hicortex mcp` bridge. NO "type" field —
77
+ * stdio is implied for Desktop entries (the CC `.claude.json` writer needs
78
+ * `"type":"sse"`; this is the other shape). A `.cmd`/`.bat` shim is wrapped
79
+ * in `cmd /c` because Electron spawns without a shell and cannot exec a cmd
80
+ * script directly. An empty/absent env omits the key entirely — a local
81
+ * entry must carry no env (the bridge autostarts the daemon; loopback
82
+ * bypasses auth).
83
+ */
84
+ export declare function buildDesktopServerEntry(npxPath: string, packageSpec: string, env?: Record<string, string>): DesktopServerEntry;
85
+ /**
86
+ * Loopback check for a server URL — decides whether the Desktop entry needs
87
+ * an env block at all (local: none, the bridge resolves + autostarts the
88
+ * daemon; remote: URL + token). Mirrors mcp-stdio's isLoopbackHost (kept
89
+ * local rather than imported to avoid dragging the SDK into init's graph).
90
+ * An unparseable URL is NOT local — fail toward carrying the env, which
91
+ * still works everywhere the URL is real.
92
+ */
93
+ export declare function isLocalServerUrl(url: string): boolean;
94
+ export type DesktopMergeResult = {
95
+ ok: true;
96
+ config: Record<string, unknown>;
97
+ } | {
98
+ ok: false;
99
+ reason: string;
100
+ };
101
+ /**
102
+ * Parse the existing config text and merge our entry in, preserving every
103
+ * top-level key and every other server. `rawText === null` means "no file"
104
+ * (fresh install — a config containing only our entry is created). Malformed
105
+ * JSON, a non-object document, or a non-object `mcpServers` value returns
106
+ * `{ ok: false }` — the caller refuses to write, so the ORIGINAL file and
107
+ * its bytes are what the user keeps.
108
+ */
109
+ export declare function mergeDesktopServerConfig(rawText: string | null, entry: DesktopServerEntry): DesktopMergeResult;
110
+ export type DesktopWriteResult = {
111
+ status: "written";
112
+ backupPath?: string;
113
+ } | {
114
+ status: "refused";
115
+ reason: string;
116
+ } | {
117
+ status: "failed";
118
+ reason: string;
119
+ };
120
+ /**
121
+ * Orchestrate the merge-safe, atomic write of our entry into
122
+ * `claude_desktop_config.json`:
123
+ *
124
+ * 1. Read the existing file (ENOENT → fresh-install path).
125
+ * 2. Merge; a malformed/shape-refused file → `{ status: "refused" }` with
126
+ * NOTHING touched — no backup, no tmp, no bytes changed.
127
+ * 3. Copy the existing file to `<path>.bak-<ISO-colons-stripped>` BEFORE
128
+ * writing (the quarantineMalformedConfig naming convention).
129
+ * 4. Write the serialized payload to a tmp file in the SAME directory
130
+ * (same filesystem → the rename is atomic), JSON-parse the exact bytes
131
+ * that landed on disk, then renameSync over the target.
132
+ *
133
+ * Any unexpected I/O error returns `{ status: "failed" }` after removing the
134
+ * tmp file — a half-written tmp must never masquerade as a config. Never
135
+ * throws; the init wiring turns every non-"written" result into a printed
136
+ * warning and init continues.
137
+ */
138
+ export declare function writeDesktopServerConfig(configPath: string, entry: DesktopServerEntry): DesktopWriteResult;