@gamaze/hicortex 0.18.2 → 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 +60 -0
- package/assets/dashboard.html +103 -7
- package/assets/identity.html +48 -1
- package/assets/viz.html +52 -1
- package/dist/backup.d.ts +107 -0
- package/dist/backup.js +343 -0
- package/dist/capture.d.ts +20 -0
- package/dist/capture.js +9 -1
- package/dist/cli.js +30 -0
- package/dist/config-read.d.ts +28 -2
- package/dist/config-read.js +32 -2
- package/dist/consolidate.js +2 -4
- package/dist/dashboard.d.ts +71 -6
- package/dist/dashboard.js +61 -7
- package/dist/distiller.d.ts +4 -2
- package/dist/distiller.js +13 -5
- package/dist/hosted-boot.d.ts +61 -0
- package/dist/hosted-boot.js +72 -0
- package/dist/init.d.ts +15 -0
- package/dist/init.js +99 -8
- package/dist/localhost-bypass.d.ts +27 -0
- package/dist/localhost-bypass.js +71 -0
- package/dist/mcp-server.d.ts +18 -0
- package/dist/mcp-server.js +147 -4
- package/dist/memory-instructions.js +1 -1
- package/dist/nightly.js +104 -2
- package/dist/prompts.js +19 -4
- package/dist/telemetry.d.ts +14 -0
- package/dist/token-budget.d.ts +34 -0
- package/dist/token-budget.js +139 -0
- package/dist/type-classify.js +7 -4
- package/dist/types.d.ts +49 -0
- package/dist/viz.d.ts +9 -1
- package/dist/viz.js +11 -2
- package/package.json +6 -4
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
|
package/assets/dashboard.html
CHANGED
|
@@ -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 {
|
|
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
|
|
286
|
-
//
|
|
287
|
-
//
|
|
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"
|
|
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
|
|
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);
|
package/assets/identity.html
CHANGED
|
@@ -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 {
|
|
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
|
package/dist/backup.d.ts
ADDED
|
@@ -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>;
|