@gamaze/hicortex 0.18.3 → 0.19.0

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/README.md CHANGED
@@ -319,6 +319,66 @@ If no LLM is configured, the server starts in **recall-only mode**: search, less
319
319
 
320
320
  Canonical location: `~/.hicortex/hicortex.db`. The OC plugin no longer owns its own database — it is a thin client to the server. Previously, OC installations at `~/.openclaw/data/hicortex.db` were migrated automatically on upgrade; this migration path remains in the server's `resolveDbPath` for any pre-0.10.0 installations.
321
321
 
322
+ ## Backups
323
+
324
+ The live DB runs with WAL on, so a plain `cp`/`tar` of `hicortex.db` is **torn** (the `-wal` file holds uncommitted pages → a copy that looks fine until you restore it). `hicortex backup` uses SQLite's online-backup API (`db.backup()`) to fold the WAL into a single consistent snapshot, then packages it with the hand-edited identity layer and capture state into one `tar.gz`.
325
+
326
+ **Run a backup:**
327
+
328
+ ```bash
329
+ npx @gamaze/hicortex backup
330
+ # → writes ~/.hicortex/backups/hicortex-<ISO>.tar.gz
331
+ ```
332
+
333
+ The artifact contains the irreplaceable data only: `hicortex.db`, the whole `identity/` tree (global + per-agent `agents/<id>/`), `state.json`, and `capture-cursors.json`. It deliberately **excludes** `config.json` (secrets: `authToken`/`llmApiKey`), `models/`, logs, and the `backups/` dir itself.
334
+
335
+ **Offsite copy via a hook.** Set `backupCommand` in `~/.hicortex/config.json` and it runs after every backup with the artifact path appended as the last arg (cloud creds + alerting stay in your wrapper, out of the product):
336
+
337
+ ```jsonc
338
+ {
339
+ "backupDir": "/mnt/backups/hicortex", // optional; default <home>/backups
340
+ "backupCommand": "rclone copyto" // invoked: rclone copyto <path> remote:hicortex/
341
+ }
342
+ ```
343
+
344
+ A failing, missing, or timed-out hook (5 min) reports failure and **never throws** — the artifact is already on disk; only the offsite copy didn't land. Both `hicortex backup` (non-zero exit) and the nightly stage surface the failure.
345
+
346
+ **Stream to stdout** (pipe to any offsite transport, no on-disk artifact):
347
+
348
+ ```bash
349
+ npx @gamaze/hicortex backup --stdout | rclone rcat remote:hicortex/$(date -I).tar.gz
350
+ ```
351
+
352
+ `--stdout` is mutually exclusive with `--out`/`backupDir`.
353
+
354
+ **Nightly stage.** Every full nightly run (not `--capture-only`) takes a backup automatically after consolidation and runs the hook if configured. Backup failure does NOT fail the nightly — capture + consolidation have already succeeded; the failure surfaces as `backupOk:false` in the dashboard snapshot and telemetry for alerting.
355
+
356
+ **Restore (manual):**
357
+
358
+ ```bash
359
+ # 0. Stop the server (so no writer is touching the DB).
360
+ # 1. Save a current artifact (if the server is still up):
361
+ npx @gamaze/hicortex backup --stdout > save.tar.gz
362
+ # 2. Extract it:
363
+ mkdir /tmp/restore && tar xzf save.tar.gz -C /tmp/restore
364
+ # 3. Drop the snapshot into place + restore the identity/state files.
365
+ # IMPORTANT: restore the DB to the path the server ACTUALLY uses — check
366
+ # `hicortex status` for the resolved DB path. It is ~/.hicortex/hicortex.db
367
+ # by default, BUT if you set HICORTEX_DB_PATH or run a hosted tenant (where
368
+ # the DB lives at /data/hicortex.db), restore there instead:
369
+ DB=~/.hicortex/hicortex.db # ← verify with `hicortex status`
370
+ cp /tmp/restore/hicortex.db "$DB"
371
+ cp -r /tmp/restore/identity/* "$(dirname "$DB")/identity/" 2>/dev/null || \
372
+ cp -r /tmp/restore/identity/* ~/.hicortex/identity/
373
+ cp /tmp/restore/state.json "$(dirname "$DB")/state.json" 2>/dev/null || \
374
+ cp /tmp/restore/state.json ~/.hicortex/state.json
375
+ cp /tmp/restore/capture-cursors.json "$(dirname "$DB")/capture-cursors.json" 2>/dev/null || \
376
+ cp /tmp/restore/capture-cursors.json ~/.hicortex/capture-cursors.json
377
+ # 4. Restart the server and verify (hicortex status).
378
+ ```
379
+
380
+ **Restore drill — required before relying on this.** A backup you haven't restored is unverified. Before the first paying customer (and periodically after), run the full restore procedure above into a throwaway `HICORTEX_HOME` and confirm the server opens the DB, recall returns seeded memories, and the identity layer renders. A future `hicortex restore` command is planned; the manual drill is the bar for now.
381
+
322
382
  ## Development
323
383
 
324
384
  ```bash
@@ -105,7 +105,7 @@
105
105
  color: var(--text); font-weight: 600; text-decoration: none;
106
106
  letter-spacing: 0.02em;
107
107
  }
108
- .hc-nav-links { margin-left: auto; display: flex; align-items: center; gap: 16px; }
108
+ .hc-nav-links { display: flex; align-items: center; gap: 16px; }
109
109
  .hc-nav-links a {
110
110
  color: var(--text-dim); text-decoration: none; transition: color 0.12s;
111
111
  }
@@ -114,6 +114,13 @@
114
114
  .hc-nav-disabled {
115
115
  color: var(--text-dim); opacity: 0.4; cursor: not-allowed; user-select: none;
116
116
  }
117
+ /* Account identity (hosted): "Name · Org" + a plan pill, far right (the
118
+ links sit LEFT next to the logo; this element owns the right side). */
119
+ .hc-nav-account { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; color: var(--text); }
120
+ .hc-nav-account .plan {
121
+ display: inline-block; padding: 1px 8px; border-radius: 10px;
122
+ background: rgba(128,128,128,0.18); font-size: 11px; color: var(--text-dim);
123
+ }
117
124
  </style>
118
125
  </head>
119
126
  <body>
@@ -125,6 +132,7 @@
125
132
  <a href="/identity/ui">Identity</a>
126
133
  <span class="hc-nav-disabled" title="Coming soon — #250">Self-improvement</span>
127
134
  </div>
135
+ <span id="nav-account" class="hc-nav-account"></span>
128
136
  </nav>
129
137
  <h1>Hicortex — memory dashboard</h1>
130
138
  <div class="sub">View-only analytics. All metrics derived from the live corpus + nightly snapshots.</div>
@@ -219,6 +227,16 @@ async function fetchData(path) {
219
227
  if (token) headers["Authorization"] = "Bearer " + token;
220
228
  const resp = await fetch(path, { headers });
221
229
  if (resp.status === 401) {
230
+ // Offer the hosted Google login when the router has auth configured
231
+ // (bearer-only servers answer 503 on /auth/*); the raw token prompt
232
+ // stays for power users and self-hosted installs.
233
+ try {
234
+ const probe = await fetch("/auth/google/start", { redirect: "manual" });
235
+ if (probe.status !== 503 && confirm("Sign in with Google? (Cancel to enter a token instead)")) {
236
+ location.href = "/auth/google/start";
237
+ throw new Error("redirecting to login");
238
+ }
239
+ } catch (e) { if (String(e.message).includes("redirecting")) throw e; }
222
240
  token = prompt("Hicortex auth token:") || "";
223
241
  if (token) {
224
242
  localStorage.setItem(TOKEN_KEY, token);
@@ -230,6 +248,32 @@ async function fetchData(path) {
230
248
  return resp.json();
231
249
  }
232
250
 
251
+
252
+ // Upgrade → Stripe Checkout (#298). Annual is the default plan (pricing
253
+ // decision: annual pre-selected, "pay for 9, get 12"). Failures alert generic.
254
+ function wireUpgrade() {
255
+ const btn = $("nav-upgrade");
256
+ if (!btn) return;
257
+ btn.addEventListener("click", async () => {
258
+ btn.disabled = true;
259
+ try {
260
+ const resp = await fetch("/stripe/checkout", {
261
+ method: "POST",
262
+ headers: { "content-type": "application/json" },
263
+ body: JSON.stringify({ plan: "annual" }),
264
+ });
265
+ if (resp.status === 401) { location.href = "/auth/google/start"; return; }
266
+ const body = await resp.json().catch(() => ({}));
267
+ if (resp.ok && body.url) { location.href = body.url; return; }
268
+ alert("Checkout unavailable (" + resp.status + ") — try again later.");
269
+ btn.disabled = false;
270
+ } catch {
271
+ alert("Checkout unreachable — try again later.");
272
+ btn.disabled = false;
273
+ }
274
+ });
275
+ }
276
+
233
277
  // ---------------------------------------------------------------------------
234
278
  // State
235
279
  // ---------------------------------------------------------------------------
@@ -282,9 +326,10 @@ function renderHeadline(h) {
282
326
  }
283
327
  // LLM tokens this period (#246): hidden when `used` is 0 (no metered run yet
284
328
  // — nothing to show). When capped (cap > 0), render a usage bar with the
285
- // percentage; amber at ≥85%, red at ≥100%. When uncapped (self-hosted
286
- // default), show just the count. Both shapes include "this month" so the
287
- // billing-period framing is uniform.
329
+ // percentage; amber at ≥85%, red at ≥100% the same gauge pattern as the
330
+ // soft-cap capacity stat. When uncapped (self-hosted default), show just the
331
+ // count (no denominator → no bar, matching the digest section's convention).
332
+ // The total includes distill + consolidation since #287 (always-recorded).
288
333
  let tokenStat = "";
289
334
  const tok = h.tokens;
290
335
  if (tok && tok.used > 0) {
@@ -295,10 +340,10 @@ function renderHeadline(h) {
295
340
  const near = pct >= 85 && !over;
296
341
  const barColor = over ? "var(--bad, #e05555)" : near ? "var(--warn, #e0b055)" : "var(--ok, #61c98f)";
297
342
  tokenStat = `
298
- <div class="stat" style="flex-basis:240px;">
343
+ <div class="stat">
299
344
  <div class="v" style="font-size:18px;">${escapeHtml(usedStr)} <span class="muted" style="font-size:13px;">/ ${escapeHtml(formatTokens(tok.cap))}</span></div>
300
345
  <div class="l">LLM tokens this month ${over ? "(over cap)" : ""}</div>
301
- <div style="margin-top:6px;height:6px;background:rgba(128,128,128,0.2);border-radius:3px;overflow:hidden;">
346
+ <div style="margin-top:6px;max-width:360px;height:6px;background:rgba(128,128,128,0.2);border-radius:3px;overflow:hidden;">
302
347
  <div style="width:${pct.toFixed(1)}%;height:100%;background:${barColor};"></div>
303
348
  </div>
304
349
  </div>`;
@@ -313,7 +358,7 @@ function renderHeadline(h) {
313
358
  $("headline").innerHTML = `
314
359
  <div class="stat"><div class="v">${h.total_memories}</div><div class="l">Total memories</div></div>
315
360
  <div class="stat"><div class="v">${ups}</div><div class="l">Uses per showing (headline)</div></div>
316
- <div class="stat"><div class="v warn">${h.cold_count}</div><div class="l">Cold long-tail (never shown/used)</div></div>
361
+ <div class="stat"><div class="v warn">${h.cold_count}</div><div class="l">Cold long-tail (never recalled)</div></div>
317
362
  ${capStat}
318
363
  ${tokenStat}
319
364
  `;
@@ -621,6 +666,54 @@ function escapeHtml(s) {
621
666
  })[c]);
622
667
  }
623
668
 
669
+ // ---------------------------------------------------------------------------
670
+ // Render: account identity (hosted) — "Name · Org" + plan pill in the nav.
671
+ // Rendered ONLY when at least one field is non-null (all-null = self-hosted
672
+ // default → no element, no empty pill). Values are operator-set config, but
673
+ // escapeHtml anyway (same posture as every other innerHTML sink here).
674
+ // ---------------------------------------------------------------------------
675
+ function renderAccount(a) {
676
+ const host = $("nav-account");
677
+ // Trial users (no plan) get the Upgrade pill — the checkout entry (#298 drill,
678
+ // #299's first brick). Session-authenticated: the browser's hicortex-session
679
+ // cookie rides this same-origin fetch; the router maps it to the tenant.
680
+ const upgrade =
681
+ a && a.plan === null
682
+ ? ' <button id="nav-upgrade" type="button" style="background:var(--accent);color:#fff;border:none;border-radius:4px;padding:4px 12px;font:inherit;font-size:13px;cursor:pointer;">Upgrade</button>'
683
+ : "";
684
+ if (!a || (a.name === null && a.org === null && a.plan === null)) {
685
+ host.innerHTML = upgrade || "";
686
+ if (upgrade) wireUpgrade();
687
+ return;
688
+ }
689
+ const who = [a.name, a.org]
690
+ .filter((s) => typeof s === "string" && s !== "")
691
+ .map(escapeHtml)
692
+ .join(' <span class="muted">·</span> ');
693
+ const plan = typeof a.plan === "string" && a.plan !== ""
694
+ ? `<span class="plan">${escapeHtml(a.plan)}</span>`
695
+ : "";
696
+ host.innerHTML = (who ? who + " " : "") + plan + upgrade;
697
+ if (upgrade) wireUpgrade();
698
+ }
699
+
700
+ // Account data source: the dedicated lightweight GET /account (same shape as
701
+ // the account block /dashboard/data still carries — kept there for the stable
702
+ // payload contract). Fetched independently so the nav renders fail-soft and
703
+ // the code path matches /viz + /identity/ui: 401 or fetch error leaves the
704
+ // span empty, never blocks the page. Runs AFTER the main fetch so a 401
705
+ // token prompt issued by fetchData() has already updated `token`.
706
+ async function loadAccount() {
707
+ const headers = {};
708
+ if (token) headers["Authorization"] = "Bearer " + token;
709
+ try {
710
+ const resp = await fetch("/account", { headers });
711
+ if (!resp.ok) return; // fail-soft — the nav pill is never worth an error
712
+ const data = await resp.json();
713
+ renderAccount(data.account);
714
+ } catch (e) { /* network error → leave the span empty */ }
715
+ }
716
+
624
717
  // ---------------------------------------------------------------------------
625
718
  // Load + wire controls
626
719
  // ---------------------------------------------------------------------------
@@ -636,6 +729,9 @@ async function load() {
636
729
  return;
637
730
  }
638
731
  renderHeadline(state.headline);
732
+ loadAccount();
733
+ // Same range-filtered series as growth/composition — the chart re-renders on
734
+ // every range switch because the range select triggers this full reload.
639
735
  renderGrowth(state.series, $("split").value, $("filter").value);
640
736
  renderComposition(state.composition, compKey);
641
737
  renderDigest(state.digest, state.headline.tokens);
@@ -179,11 +179,18 @@
179
179
  font-size: 14px;
180
180
  }
181
181
  .hc-nav-logo { color: var(--text); font-weight: 600; text-decoration: none; letter-spacing: 0.02em; }
182
- .hc-nav-links { margin-left: auto; display: flex; align-items: center; gap: 16px; }
182
+ .hc-nav-links { display: flex; align-items: center; gap: 16px; }
183
183
  .hc-nav-links a { color: var(--text-dim); text-decoration: none; transition: color 0.12s; }
184
184
  .hc-nav-links a:hover { color: var(--accent); }
185
185
  .hc-nav-links a[data-nav-active] { color: var(--text); }
186
186
  .hc-nav-disabled { color: var(--text-dim); opacity: 0.4; cursor: not-allowed; user-select: none; }
187
+ /* Account identity (hosted): name + plan pill, far right of the nav (the
188
+ links sit LEFT next to the logo; this element owns the right side). */
189
+ .hc-nav-account { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; color: var(--text); }
190
+ .hc-nav-account .plan {
191
+ display: inline-block; padding: 1px 8px; border-radius: 10px;
192
+ background: rgba(128,128,128,0.18); font-size: 11px; color: var(--text-dim);
193
+ }
187
194
  </style>
188
195
  </head>
189
196
  <body>
@@ -195,6 +202,7 @@
195
202
  <a href="/identity/ui" data-nav-active>Identity</a>
196
203
  <span class="hc-nav-disabled" title="Coming soon — #250">Self-improvement</span>
197
204
  </div>
205
+ <span id="nav-account" class="hc-nav-account"></span>
198
206
  </nav>
199
207
  <header>
200
208
  <h1>Hicortex — identity</h1>
@@ -243,6 +251,7 @@
243
251
  Run <code>hicortex status</code> on the server to get it.</p>
244
252
  <input type="password" id="token-input" placeholder="hctx-…" autocomplete="off">
245
253
  <button class="btn" id="token-submit">Connect</button>
254
+ <a id="google-login" href="/auth/google/start" style="display:none;margin-top:10px;">Sign in with Google</a>
246
255
  <div id="token-err">Still unauthorized — check the token.</div>
247
256
  </div>
248
257
  </div>
@@ -304,6 +313,37 @@
304
313
  return h;
305
314
  }
306
315
 
316
+ // =========================================================================
317
+ // Account identity in the nav — GET /account (the lightweight endpoint the
318
+ // dashboard payload also carries). Fail-soft: 401/fetch error leaves the
319
+ // span empty (the main load() owns the 401 token prompt); an all-null
320
+ // account (self-hosted default) renders nothing. Built with textContent
321
+ // only — this page never uses innerHTML for server-derived values.
322
+ // =========================================================================
323
+ function renderAccount(a) {
324
+ var host = document.getElementById("nav-account");
325
+ if (!host) return;
326
+ host.textContent = "";
327
+ if (!a || (a.name === null && a.org === null && a.plan === null)) return;
328
+ var who = [a.name, a.org].filter(function (s) { return typeof s === "string" && s !== ""; });
329
+ who.forEach(function (s, i) {
330
+ if (i > 0) host.appendChild(document.createTextNode(" · "));
331
+ host.appendChild(document.createTextNode(s));
332
+ });
333
+ if (typeof a.plan === "string" && a.plan !== "") {
334
+ var pill = document.createElement("span");
335
+ pill.className = "plan";
336
+ pill.textContent = a.plan;
337
+ host.appendChild(pill);
338
+ }
339
+ }
340
+ function loadAccount() {
341
+ fetch("/account", { headers: authHeaders() })
342
+ .then(function (resp) { return resp.ok ? resp.json() : null; })
343
+ .then(function (data) { if (data) renderAccount(data.account); })
344
+ .catch(function () { /* leave the span empty — nav is never worth an error */ });
345
+ }
346
+
307
347
  // =========================================================================
308
348
  // DOM refs
309
349
  // =========================================================================
@@ -615,6 +655,11 @@
615
655
  setMsg("", "dim");
616
656
  overlay.classList.add("open");
617
657
  tokenInput.focus();
658
+ // Offer the hosted Google login only when the fronting router has auth
659
+ // configured (bearer-only servers answer 503 on /auth/*).
660
+ fetch("/auth/google/start", { redirect: "manual" })
661
+ .then(function (r) { if (r.status !== 503) document.getElementById("google-login").style.display = "block"; })
662
+ .catch(function () {});
618
663
  }
619
664
  function submitToken() {
620
665
  var v = tokenInput.value.trim();
@@ -629,6 +674,7 @@
629
674
  var next = afterAuth || load;
630
675
  afterAuth = null;
631
676
  next();
677
+ loadAccount(); // the initial fail-soft attempt left the nav pill empty on 401
632
678
  }).catch(function () { tokenErr.style.display = "block"; });
633
679
  }
634
680
 
@@ -656,6 +702,7 @@
656
702
  tokenInput.addEventListener("keydown", function (e) { if (e.key === "Enter") submitToken(); });
657
703
 
658
704
  load();
705
+ loadAccount();
659
706
  })();
660
707
  </script>
661
708
  </body>
package/assets/viz.html CHANGED
@@ -71,6 +71,14 @@
71
71
  .hc-nav-links a:hover { color: var(--accent); }
72
72
  .hc-nav-links a[data-nav-active] { color: var(--text); }
73
73
  .hc-nav-disabled { color: var(--text-dim); opacity: 0.4; cursor: not-allowed; user-select: none; }
74
+ /* Account identity (hosted): name + plan pill at the far right. Here the
75
+ topbar's #meta keeps margin-left:auto (it pushes the whole right group —
76
+ meta + account); the account span is simply the last item. */
77
+ .hc-nav-account { display: inline-flex; align-items: center; gap: 8px; color: var(--text); }
78
+ .hc-nav-account .plan {
79
+ display: inline-block; padding: 1px 8px; border-radius: 10px;
80
+ background: rgba(128,128,128,0.18); font-size: 11px; color: var(--text-dim);
81
+ }
74
82
 
75
83
  /* ---- left control panel ---- */
76
84
  #panel {
@@ -245,13 +253,14 @@
245
253
 
246
254
  <div id="topbar">
247
255
  <h1 class="hc-nav-logo"><a href="/dashboard" style="color:inherit;text-decoration:none">Hicortex</a></h1>
248
- <span id="meta"></span>
249
256
  <nav class="hc-nav-links">
250
257
  <a href="/dashboard">Dashboard</a>
251
258
  <a href="/viz" data-nav-active>Graph</a>
252
259
  <a href="/identity/ui">Identity</a>
253
260
  <span class="hc-nav-disabled" title="Coming soon — #250">Self-improvement</span>
254
261
  </nav>
262
+ <span id="meta"></span>
263
+ <span id="nav-account" class="hc-nav-account"></span>
255
264
  </div>
256
265
 
257
266
  <div id="panel">
@@ -328,6 +337,7 @@
328
337
  Run <code>hicortex status</code> on the server to get the token.</p>
329
338
  <input type="password" id="token-input" placeholder="hctx-…" autocomplete="off">
330
339
  <button id="token-submit">Connect</button>
340
+ <a id="google-login" href="/auth/google/start" style="display:none;margin-top:10px;">Sign in with Google</a>
331
341
  <div class="err" id="token-err">Still unauthorized — check the token.</div>
332
342
  </div>
333
343
  </div>
@@ -374,6 +384,39 @@
374
384
  });
375
385
  });
376
386
 
387
+ // =========================================================================
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.
393
+ // =========================================================================
394
+ function renderAccount(a) {
395
+ var host = document.getElementById("nav-account");
396
+ if (!host) return;
397
+ host.textContent = "";
398
+ if (!a || (a.name === null && a.org === null && a.plan === null)) return;
399
+ var who = [a.name, a.org].filter(function (s) { return typeof s === "string" && s !== ""; });
400
+ who.forEach(function (s, i) {
401
+ if (i > 0) host.appendChild(document.createTextNode(" · "));
402
+ host.appendChild(document.createTextNode(s));
403
+ });
404
+ if (typeof a.plan === "string" && a.plan !== "") {
405
+ var pill = document.createElement("span");
406
+ pill.className = "plan";
407
+ pill.textContent = a.plan;
408
+ host.appendChild(pill);
409
+ }
410
+ }
411
+ function loadAccount() {
412
+ var headers = {};
413
+ if (token) headers["Authorization"] = "Bearer " + token;
414
+ fetch("/account", { headers: headers })
415
+ .then(function (resp) { return resp.ok ? resp.json() : null; })
416
+ .then(function (data) { if (data) renderAccount(data.account); })
417
+ .catch(function () { /* leave the span empty — nav is never worth an error */ });
418
+ }
419
+
377
420
  // =========================================================================
378
421
  // DOM references
379
422
  // =========================================================================
@@ -590,6 +633,12 @@
590
633
  hideStatus();
591
634
  overlay.classList.add("open");
592
635
  document.getElementById("token-input").focus();
636
+ // Offer the hosted Google login only when the fronting router has auth
637
+ // configured — a bearer-only/self-hosted server answers 503 on /auth/*.
638
+ // (redirect:"manual" makes the 302 an opaque redirect with status 0.)
639
+ fetch("/auth/google/start", { redirect: "manual" })
640
+ .then((r) => { if (r.status !== 503) document.getElementById("google-login").style.display = "block"; })
641
+ .catch(() => {});
593
642
  }
594
643
  document.getElementById("token-submit").addEventListener("click", submitToken);
595
644
  document.getElementById("token-input").addEventListener("keydown", function (e) {
@@ -609,6 +658,7 @@
609
658
  }
610
659
  overlay.classList.remove("open");
611
660
  fetchGraph();
661
+ loadAccount(); // the initial fail-soft attempt left the nav pill empty on 401
612
662
  }).catch(function () {
613
663
  document.getElementById("token-err").style.display = "block";
614
664
  });
@@ -1162,6 +1212,7 @@
1162
1212
  document.body.classList.toggle("mode-2d", mode === "2d");
1163
1213
  syncModeControl();
1164
1214
  fetchGraph();
1215
+ loadAccount();
1165
1216
  }
1166
1217
  if (document.readyState === "complete" || document.readyState === "interactive") {
1167
1218
  // DOM already parsed — but deferred scripts may still be pending; queue
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Backup mechanism — a transactionally-consistent snapshot of the irreplaceable
3
+ * data (#6, Phase 0B).
4
+ *
5
+ * For a product whose value is accumulated memory, silent backup loss is
6
+ * existential (spec §8). The live DB runs with WAL on, so a plain `cp`/`tar` of
7
+ * `hicortex.db` is torn (the `-wal` file holds uncommitted pages → a copy that
8
+ * looks fine until you restore it). This module uses better-sqlite3's native
9
+ * `db.backup(path)` — the SQLite online-backup API, already proven in `dedup.ts`
10
+ * — which folds the WAL into a single self-contained, consistent snapshot, then
11
+ * packages it with the hand-edited identity layer + capture state into one
12
+ * `tar.gz` artifact the operator ships offsite.
13
+ *
14
+ * Backup set (the irreplaceable data under HICORTEX_HOME):
15
+ * - `hicortex.db` — via db.backup() to a temp snapshot (WAL-safe)
16
+ * - `identity/` (whole tree) — global `*.md` AND per-agent `agents/<id>/*.md`
17
+ * - `context/` (legacy) — additive fallback (identity-store.ts migrate)
18
+ * - `state.json` — cursors/tier/timestamps (state.ts)
19
+ * - `capture-cursors.json` — per-session capture positions (capture-cursors.ts)
20
+ *
21
+ * Excluded by design:
22
+ * - `config.json` — secrets (authToken/llmApiKey); re-creatable via init
23
+ * - `backups/` — output dir (never back up the backups)
24
+ * - logs (`nightly.log`, `server*.log`) — re-creatable, noisy, not data
25
+ * - `models/` — embedder cache (re-downloadable)
26
+ * - `capture.lock` — a transient single-flight lock, not state
27
+ * - `.allow-localhost-bypass` — a hosted fail-closed marker, not data
28
+ *
29
+ * Vectors: `db.backup()` copies the vec0 shadow tables (they're real tables).
30
+ * If a restore ever finds them missing, vectors regenerate from
31
+ * `memories.content` via the embedder — non-destructive either way.
32
+ */
33
+ import type Database from "better-sqlite3";
34
+ export interface CreateBackupOptions {
35
+ /** Open live DB handle (caller manages lifetime). Backed up via db.backup(). */
36
+ db: Database.Database;
37
+ /** HICORTEX_HOME — the dir whose irreplaceable files are packaged. */
38
+ home: string;
39
+ /**
40
+ * Explicit output file path (a tar.gz). Overrides `outDir`/default. Mutually
41
+ * exclusive with `stdout`.
42
+ */
43
+ outFile?: string;
44
+ /**
45
+ * Output directory. Defaults to `<home>/backups`. Ignored when `outFile` is
46
+ * set. Configurable via `config.backupDir`.
47
+ */
48
+ outDir?: string;
49
+ /**
50
+ * Stream the tar.gz to `process.stdout` instead of writing a file (the
51
+ * offsite pattern: `hicortex backup --stdout | rclone rcat ...`). Mutually
52
+ * exclusive with `outFile`/`outDir`.
53
+ */
54
+ stdout?: boolean;
55
+ }
56
+ export interface CreateBackupResult {
57
+ /** Absolute path of the written artifact; undefined when `stdout:true`. */
58
+ path?: string;
59
+ /** Compressed artifact size in bytes (gzip output). */
60
+ bytes: number;
61
+ /** Number of files packaged (DB snapshot + identity tree + state files). */
62
+ files: number;
63
+ }
64
+ export interface BackupHookResult {
65
+ ok: boolean;
66
+ /** Process exit code when the hook ran and exited (0 on success); undefined
67
+ * when the command was missing, couldn't spawn (ENOENT), or timed out. */
68
+ exitCode?: number;
69
+ }
70
+ /**
71
+ * Create a backup artifact. Snapshots the live DB via the online-backup API,
72
+ * packages it with the identity tree + state into a single `tar.gz`, and writes
73
+ * it to `outFile` (default `<home>/backups/hicortex-<ISO>.tar.gz`) or streams
74
+ * to stdout. Returns `{ path?, bytes, files }`.
75
+ *
76
+ * Never partially writes a file artifact on failure: if the tar pipeline errors
77
+ * mid-stream the (likely-truncated) file is removed before the error propagates,
78
+ * so a stale half-backup is never left on disk to masquerade as a good one.
79
+ */
80
+ export declare function createBackup(opts: CreateBackupOptions): Promise<CreateBackupResult>;
81
+ /**
82
+ * Run the operator's post-backup offsite hook. The configured `command` is split
83
+ * on whitespace and the artifact path appended as the LAST arg (e.g.
84
+ * `rclone copyto <path> remote:hicortex/` → `["rclone","copyto",path,...]`).
85
+ * Cloud creds + active alerting stay in the operator's wrapper, out of the
86
+ * product. NEVER throws — a hook failure is reported as `{ ok:false }` so the
87
+ * nightly continues (capture/consolidation already succeeded; the backup itself
88
+ * is on disk). 5 min timeout so a hung upload can't hang the nightly.
89
+ *
90
+ * Whitespace-split caveat: commands with quoted args containing spaces should
91
+ * be a wrapper script (`/etc/hicortex/offsite.sh`), not a one-liner — the split
92
+ * here is deliberately dumb to avoid re-implementing a shell parser.
93
+ */
94
+ export declare function runBackupHook(artifactPath: string, command: string | undefined): Promise<BackupHookResult>;
95
+ export interface BackupCliOptions {
96
+ /** `--out <dir>` — output directory (the artifact is auto-named). Takes precedence over `config.backupDir`. Mutually exclusive with stdout. */
97
+ outDir?: string;
98
+ /** `--stdout` — stream the tar.gz to process.stdout (offsite pipe pattern). */
99
+ stdout?: boolean;
100
+ }
101
+ /**
102
+ * Run the `hicortex backup` CLI command. Loads config (for `backupDir` /
103
+ * `backupCommand`), opens the DB, writes (or streams) the artifact, runs the
104
+ * offsite hook when configured, prints the artifact path, and exits non-zero on
105
+ * any failure (a failed backup must be visible — `cron`/launchd surfaces it).
106
+ */
107
+ export declare function runBackupCli(opts: BackupCliOptions): Promise<void>;