@gamaze/hicortex 0.16.10 → 0.17.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/dist/nightly.js CHANGED
@@ -71,6 +71,7 @@ const retrieval_js_1 = require("./retrieval.js");
71
71
  const state_js_1 = require("./state.js");
72
72
  const capture_cursors_js_1 = require("./capture-cursors.js");
73
73
  const capture_js_1 = require("./capture.js");
74
+ const dashboard_js_1 = require("./dashboard.js");
74
75
  const telemetry_js_1 = require("./telemetry.js");
75
76
  const init_js_1 = require("./init.js");
76
77
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
@@ -211,7 +212,8 @@ function rotateNightlyLog(stateDir = HICORTEX_HOME) {
211
212
  }
212
213
  async function runNightly(options = {}) {
213
214
  const dryRun = options.dryRun ?? false;
214
- const captureOnly = options.captureOnly ?? false;
215
+ let captureOnly = options.captureOnly ?? false;
216
+ const watchdog = options.watchdog ?? false;
215
217
  const stateDir = options.stateDir ?? HICORTEX_HOME;
216
218
  const recaptureWindowDays = options.recaptureWindowDays;
217
219
  rotateNightlyLog(stateDir);
@@ -232,14 +234,59 @@ async function runNightly(options = {}) {
232
234
  const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
233
235
  savedConfig.agentId = agentId;
234
236
  }
237
+ const port = savedConfig?.port ?? 8787;
238
+ // Watchdog gate (before the client/server branch so it is uniform). See the
239
+ // option doc above. On skip it returns early; on proceed it forces
240
+ // capture-only and falls through to the normal capture path (the capture
241
+ // lock with waitMs=0 provides single-flight; writeLastRun advances the
242
+ // cooldown marker on success).
243
+ if (watchdog && !dryRun) {
244
+ captureOnly = true; // the watchdog captures only — never consolidates.
245
+ // Success-cooldown: lastNightly is advanced ONLY on a clean capture
246
+ // (writeLastRun is success-gated), so reusing it gives SUCCESS-based
247
+ // cooldown — a FAILED preflight/capture retries on the next tick (minutes),
248
+ // a SUCCESS waits the cooldown. This is the better semantics the custom
249
+ // server watchdog (trigger-based) got wrong.
250
+ // readNonNegativeConfig (not readPositiveConfig) so 0 is honoured: 0 = no
251
+ // cooldown = capture every poll (a valid opt-in for a wired/high-frequency
252
+ // source), not a silent fallback to the default.
253
+ const cooldownH = (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "captureCooldownHours", 6);
254
+ const last = (0, state_js_1.loadState)(stateDir).lastNightly;
255
+ if (last) {
256
+ const ageH = (Date.now() - new Date(last).getTime()) / 3_600_000;
257
+ if (ageH < cooldownH) {
258
+ console.log(`[hicortex] watchdog: last capture ${ageH.toFixed(1)}h ago (< ${cooldownH}h cooldown) — skipping`);
259
+ return;
260
+ }
261
+ }
262
+ // Quick reachability preflight (5s) — a cheap gate so a 20-min poll against
263
+ // a down link costs one short fetch, not the full in-run preflight. Client
264
+ // → remote server; server/co-located → localhost daemon.
265
+ const target = (savedConfig?.mode === "client"
266
+ ? savedConfig.serverUrl
267
+ : `http://127.0.0.1:${port}`).replace(/\/+$/, "");
268
+ try {
269
+ const resp = await fetch(`${target}/health`, { signal: AbortSignal.timeout(5_000) });
270
+ if (!resp.ok) {
271
+ console.log(`[hicortex] watchdog: ${target}/health not ok (${resp.status}) — skipping (retry next tick)`);
272
+ return;
273
+ }
274
+ }
275
+ catch (e) {
276
+ console.log(`[hicortex] watchdog: ${target} unreachable (` +
277
+ `${e instanceof Error ? e.message : String(e)}) — skipping (retry next tick)`);
278
+ return;
279
+ }
280
+ console.log(`[hicortex] watchdog: cooldown elapsed + ${target} reachable — capturing`);
281
+ }
235
282
  if (savedConfig?.mode === "client") {
236
283
  // --capture-only is accepted in client mode but irrelevant: client nightly
237
- // is already capture-only (no consolidation step).
238
- await runClientNightly(savedConfig, dryRun, stateDir, recaptureWindowDays);
284
+ // is already capture-only (no consolidation step). (If watchdog-gated, the
285
+ // gate above already ran.)
286
+ await runClientNightly(savedConfig, dryRun, stateDir, recaptureWindowDays, watchdog);
239
287
  return;
240
288
  }
241
289
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
242
- const port = savedConfig?.port ?? 8787;
243
290
  // #192: consolidation's decay/prune stage must score with the same clock as
244
291
  // the server's retrieval path (config decayHalfLifeDays, default 365).
245
292
  (0, retrieval_js_1.configureDecay)({ halfLifeDays: savedConfig?.decayHalfLifeDays });
@@ -369,9 +416,14 @@ async function runNightly(options = {}) {
369
416
  // of the HTTP capture path). Full nightly only — capture-only runs are
370
417
  // intended to run more frequently than once daily.
371
418
  let lessonsGenerated; // hoisted for telemetry; undefined when reflection didn't run (skipped) — bucketed apart from a real 0
419
+ // Consolidation outcome for telemetry (0.17). undefined on capture-only runs
420
+ // (which send no nightly ping). "skipped" = runConsolidation's built-in
421
+ // nothing-to-do short-circuit (zero LLM calls), NOT a failure.
422
+ let consolidationStatus;
372
423
  if (!dryRun && !captureOnly) {
373
424
  if (!llm || !llmConfig) {
374
425
  console.error("[hicortex] consolidation skipped: no LLM configured — run npx @gamaze/hicortex init");
426
+ consolidationStatus = "no_llm";
375
427
  }
376
428
  else {
377
429
  // One model serves all phases (#231) — there is no separate endpoint to
@@ -390,9 +442,12 @@ async function runNightly(options = {}) {
390
442
  }, {
391
443
  minSimilarity: savedConfig?.supersessionMinSimilarity,
392
444
  maxCalls: savedConfig?.supersessionMaxCalls,
393
- });
445
+ },
446
+ // #241: config-driven total LLM-call ceiling (default 5000, was 200).
447
+ (0, config_read_js_1.readPositiveConfig)(savedConfig ?? {}, "consolidateMaxLlmCalls", consolidate_js_1.CONSOLIDATE_MAX_LLM_CALLS));
394
448
  console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
395
449
  (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
450
+ consolidationStatus = report.status;
396
451
  // Only set when reflection actually RAN (not skipped). A skipped stage
397
452
  // (e.g. endpoint offline, #232 fail-soft) must NOT collapse to 0 — that
398
453
  // would make "endpoint down" indistinguishable from "prompt too tight"
@@ -417,6 +472,53 @@ async function runNightly(options = {}) {
417
472
  }
418
473
  }
419
474
  console.log(`[hicortex] Nightly pipeline complete.`);
475
+ // Dashboard snapshot (#224) — full nightly only. The snapshot reflects
476
+ // corpus state regardless of whether consolidation/LLM ran, so it is
477
+ // ALWAYS written here (the use case is history; an LLM-less install still
478
+ // accrues memories). Capture-only runs SKIP it (above, the block guards
479
+ // on !captureOnly). On the first run after deploy the table is empty →
480
+ // backfill synthesizes one row per day from created_at so the growth/
481
+ // composition charts have real history on day one.
482
+ if (!dryRun && !captureOnly) {
483
+ try {
484
+ const backfilled = (0, dashboard_js_1.backfillSnapshots)(db);
485
+ if (backfilled > 0) {
486
+ console.log(`[hicortex] Dashboard backfill: ${backfilled} day(s) synthesized from created_at`);
487
+ }
488
+ // Per-run deltas. `added` = this run's captured memory count (only
489
+ // available on the server path; client mode never reaches here — it
490
+ // POSTs to a remote /distill). dedup/supersession are derived from
491
+ // the dedup_log / superseded_by link tables: count rows created since
492
+ // the last snapshot (real OR backfilled — both carry valid ISO run_at,
493
+ // so a plain ORDER BY run_at DESC LIMIT 1 is the correct floor) so a
494
+ // manual `hicortex dedup` run between nightlies is still reflected,
495
+ // and a first-write after backfill counts only what landed AFTER the
496
+ // last backfilled day.
497
+ const lastSnap = db
498
+ .prepare("SELECT run_at FROM dashboard_snapshots ORDER BY run_at DESC LIMIT 1")
499
+ .get();
500
+ const sinceTs = lastSnap?.run_at ?? "1970-01-01T00:00:00.000Z";
501
+ const dedup = db
502
+ .prepare("SELECT COUNT(*) AS c FROM dedup_log WHERE merged_at > ?")
503
+ .get(sinceTs).c;
504
+ const supersession = db
505
+ .prepare(`SELECT COUNT(*) AS c FROM memory_links
506
+ WHERE relationship = 'superseded_by' AND created_at > ?`)
507
+ .get(sinceTs).c;
508
+ (0, dashboard_js_1.writeSnapshot)(db, new Date().toISOString(), {
509
+ added: memoriesIngested,
510
+ lessonsGenerated,
511
+ dedup,
512
+ supersession,
513
+ });
514
+ }
515
+ catch (snapErr) {
516
+ // The snapshot is a monitoring side-effect — a failure here must NOT
517
+ // advance to a telemetry gap or move the watermark. Surface + continue.
518
+ console.warn(`[hicortex] Dashboard snapshot write failed: ` +
519
+ `${snapErr instanceof Error ? snapErr.message : String(snapErr)}`);
520
+ }
521
+ }
420
522
  // Anonymous telemetry (fire-and-forget, full nightly only).
421
523
  // Capture-only runs are excluded to avoid inflating install pings.
422
524
  if (!dryRun && !captureOnly && (0, telemetry_js_1.isTelemetryEnabled)(savedConfig)) {
@@ -446,6 +548,7 @@ async function runNightly(options = {}) {
446
548
  mem: storage.countMemories(db),
447
549
  lessons: storage.getLessons(db, 365).length,
448
550
  lessonsGenerated,
551
+ consolidation: consolidationStatus,
449
552
  sessions: batches.length,
450
553
  ok: !hadTransientFailure,
451
554
  shown: adoption.shown,
@@ -461,7 +564,7 @@ async function runNightly(options = {}) {
461
564
  // ---------------------------------------------------------------------------
462
565
  // Client Mode Nightly — denoise locally, POST to remote server's /distill
463
566
  // ---------------------------------------------------------------------------
464
- async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recaptureWindowDays) {
567
+ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recaptureWindowDays, watchdog = false) {
465
568
  const serverUrl = config.serverUrl.replace(/\/+$/, "");
466
569
  const authToken = config.authToken;
467
570
  console.log(`[hicortex] Client nightly starting${dryRun ? " (dry run)" : ""}`);
@@ -471,16 +574,20 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
471
574
  // the whole run — the pre-flight only needs the link back, which can take
472
575
  // ~1 min after wake.
473
576
  //
474
- // Config-overridable (#163): a wired Pi vs a sleeping laptop want different
475
- // values. Defaults: 15s per-attempt timeout, 3 attempts, 60s gap.
577
+ // Config-overridable (#163): a wired Pi vs a sleeping/roaming laptop want
578
+ // different values. Defaults: 20s per-attempt timeout, 3 attempts, 60s gap.
579
+ // The 20s per-attempt (bumped from 15s in 0.17) absorbs a cold Tailscale
580
+ // handshake to a home server (DERP relay + NAT traversal can take 5–30s on a
581
+ // roaming laptop). For a genuinely DOWN link (`fetch failed`) no timeout
582
+ // length helps — the capture watchdog's frequent retry handles that (#239).
476
583
  //
477
584
  // WALL-CLOCK NOTE: setTimeout and AbortSignal.timeout do NOT advance while
478
- // macOS is asleep, so the ~2m45s worst case (3×15s + 2×60s) is wall-clock-
585
+ // macOS is asleep, so the ~3m worst case (3×20s + 2×60s) is wall-clock-
479
586
  // optimistic — a sleeping laptop can straddle sleep cycles and the real
480
587
  // elapsed time can exceed it. Not a defect: the capture lock isn't held
481
588
  // during the retry and the cursor design is dup-over-loss, so a late success
482
- // is harmless. Just don't treat 2m45s as a hard wall-clock bound.
483
- const PREFLIGHT_TIMEOUT_MS = (0, config_read_js_1.readPositiveConfig)(config, "preflightTimeoutMs", 15_000);
589
+ // is harmless. Just don't treat 3m as a hard wall-clock bound.
590
+ const PREFLIGHT_TIMEOUT_MS = (0, config_read_js_1.readPositiveConfig)(config, "preflightTimeoutMs", 20_000);
484
591
  const PREFLIGHT_ATTEMPTS = Math.max(1, Math.floor((0, config_read_js_1.readPositiveConfig)(config, "preflightAttempts", 3)));
485
592
  const PREFLIGHT_RETRY_GAP_MS = (0, config_read_js_1.readPositiveConfig)(config, "preflightRetryGapMs", 60_000);
486
593
  let reachable = false;
@@ -513,7 +620,12 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
513
620
  // no loop), and fire the telemetry ping (ok=false) so the abort is
514
621
  // distinguishable from "powered off / uninstalled" in the activity aggregate.
515
622
  process.exitCode = 1;
516
- if (!dryRun && (0, telemetry_js_1.isTelemetryEnabled)(config)) {
623
+ // In watchdog mode, suppress this failed-preflight ping: the watchdog
624
+ // retries every ~20 min, so a flaky link would otherwise emit up to ~72
625
+ // ok:false pings/day and distort the fleet health ratio (#239 CR). A
626
+ // sustained outage is still visible — as the ABSENCE of success pings, and
627
+ // a non-watchdog (manual) run still emits ok:false.
628
+ if (!dryRun && !watchdog && (0, telemetry_js_1.isTelemetryEnabled)(config)) {
517
629
  await (0, telemetry_js_1.sendTelemetry)({
518
630
  id: (0, telemetry_js_1.getTelemetryId)(stateDir),
519
631
  v: VERSION,
@@ -85,6 +85,17 @@ export interface TelemetryPayload {
85
85
  * and can't reveal a sudden drop in reflection output. 0.16.9+.
86
86
  */
87
87
  lessonsGenerated?: number;
88
+ /**
89
+ * Consolidation outcome for THIS full nightly (server mode only —
90
+ * capture-only runs send no nightly ping, so the field is absent there).
91
+ * `runConsolidation`'s status: "completed" | "skipped" | "failed", plus
92
+ * "no_llm" when consolidation was skipped because no LLM was configured.
93
+ * "skipped" = the built-in nothing-to-do short-circuit (no new + no unscored
94
+ * memories → zero LLM calls), NOT a failure. Lets the fleet aggregate tell a
95
+ * real consolidation run from a no-op without repurposing `ok` (which is the
96
+ * capture-health signal). 0.17+.
97
+ */
98
+ consolidation?: "completed" | "skipped" | "failed" | "no_llm";
88
99
  }
89
100
  /**
90
101
  * Check if telemetry is enabled. Disabled by:
package/dist/types.d.ts CHANGED
@@ -215,6 +215,35 @@ export interface HicortexConfig {
215
215
  * "Unsorted" — if configured — is just a normal domain.
216
216
  */
217
217
  domains?: DomainDef[];
218
+ /**
219
+ * Success-cooldown (hours) for the CAPTURE watchdog (0.17). The capture
220
+ * timer fires `nightly --watchdog` on a short interval (~20 min); the
221
+ * watchdog captures only if MORE than this many hours have passed since the
222
+ * last SUCCESSFUL capture (state `lastNightly`). A failed preflight retries
223
+ * on the next tick (~20 min) — so a transient fire-instant network miss
224
+ * costs minutes, not a day (#239). Default 6 (≈4 captures/day). Read at
225
+ * runtime by the watchdog, not by `init`.
226
+ */
227
+ captureCooldownHours?: number;
228
+ /**
229
+ * Hours (0–23, local time) for the CONSOLIDATION timer — the full nightly
230
+ * (capture + distill + score + reflect + link), installed by `init` for
231
+ * server/co-located mode ONLY (0.17). Default [10, 22]: the 22:00 evening
232
+ * slot runs after the day's capture waves (same-day results); the 10:00
233
+ * morning slot runs AFTER the morning's wake-up capture so it catches those
234
+ * pushes. Omitted on client installs (no local DB → no timer). Validated by
235
+ * parseHours.
236
+ */
237
+ consolidationHours?: number[];
238
+ /**
239
+ * Ceiling on total LLM calls across all classify-tier consolidation stages
240
+ * (content-domain, link discovery, supersession) per nightly run (0.17, #241).
241
+ * A runaway backstop, not a throughput throttle — on a free local model the
242
+ * binding constraint is the nightly unit's wall-clock timeout, not call count.
243
+ * Default `5000` (was a hard-coded 200 that starved link/supersession during a
244
+ * classification backlog and drained large backlogs at ~cap/night).
245
+ */
246
+ consolidateMaxLlmCalls?: number;
218
247
  /**
219
248
  * Minimum cosine(memory embedding, best domain prototype) for a no-fit
220
249
  * memory to earn a WEAK primary instead of decaying (see nofit.ts).
package/dist/uninstall.js CHANGED
@@ -43,11 +43,14 @@ async function runUninstall() {
43
43
  return;
44
44
  }
45
45
  console.log();
46
- // 1. Stop and remove daemon + nightly timer (both units, or the timer
47
- // keeps firing against a half-removed install)
46
+ // 1. Stop and remove daemon + capture/nightly timers (all units, or a timer
47
+ // keeps firing against a half-removed install). Since 0.17 there are two
48
+ // scheduled jobs: the capture timer (hicortex-capture) and the
49
+ // consolidation timer (hicortex-nightly); remove both either way (a client
50
+ // install has no consolidation timer, a server install has both).
48
51
  const os = (0, node_os_1.platform)();
49
52
  if (os === "darwin") {
50
- for (const name of ["com.gamaze.hicortex.plist", "com.gamaze.hicortex-nightly.plist"]) {
53
+ for (const name of ["com.gamaze.hicortex.plist", "com.gamaze.hicortex-nightly.plist", "com.gamaze.hicortex-capture.plist"]) {
51
54
  const plistPath = (0, node_path_1.join)((0, node_os_1.homedir)(), "Library", "LaunchAgents", name);
52
55
  if ((0, node_fs_1.existsSync)(plistPath)) {
53
56
  try {
@@ -68,8 +71,12 @@ async function runUninstall() {
68
71
  (0, node_child_process_1.execSync)("systemctl --user disable --now hicortex-nightly.timer 2>/dev/null");
69
72
  }
70
73
  catch { /* not installed */ }
74
+ try {
75
+ (0, node_child_process_1.execSync)("systemctl --user disable --now hicortex-capture.timer 2>/dev/null");
76
+ }
77
+ catch { /* not installed */ }
71
78
  const unitDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "systemd", "user");
72
- for (const name of ["hicortex.service", "hicortex-nightly.timer", "hicortex-nightly.service"]) {
79
+ for (const name of ["hicortex.service", "hicortex-nightly.timer", "hicortex-nightly.service", "hicortex-capture.timer", "hicortex-capture.service"]) {
73
80
  const unitPath = (0, node_path_1.join)(unitDir, name);
74
81
  try {
75
82
  if ((0, node_fs_1.existsSync)(unitPath))
@@ -81,7 +88,7 @@ async function runUninstall() {
81
88
  (0, node_child_process_1.execSync)("systemctl --user daemon-reload 2>/dev/null");
82
89
  }
83
90
  catch { /* fine */ }
84
- console.log(" ✓ Removed systemd service + nightly timer");
91
+ console.log(" ✓ Removed systemd service + capture/nightly timers");
85
92
  }
86
93
  // 2. Remove MCP from CC
87
94
  try {
package/dist/viz.d.ts CHANGED
@@ -66,6 +66,22 @@ export declare function readContextHtml(): string;
66
66
  * cannot be read, exactly like vizHandler.
67
67
  */
68
68
  export declare function contextUiHandler(): express.RequestHandler;
69
+ /**
70
+ * Resolve the on-disk path of the dashboard page. Throws (fail explicitly)
71
+ * when the asset is missing — same contract as resolveVizHtmlPath and
72
+ * resolveContextHtmlPath. assets/ sits next to both dist/ and src/ (the
73
+ * sibling layout the other resolvers rely on).
74
+ */
75
+ export declare function resolveDashboardHtmlPath(): string;
76
+ /** Read the dashboard page. Read at request time so a reinstall is picked up live. */
77
+ export declare function readDashboardHtml(): string;
78
+ /**
79
+ * Express handler for GET /dashboard — the view-only analytics page (#224).
80
+ * 503 with the usual {error} shape when the asset cannot be read, exactly like
81
+ * vizHandler and contextUiHandler. The page SHELL is public (exempted in
82
+ * createAuthMiddleware); all data comes from GET /dashboard/data (bearer-only).
83
+ */
84
+ export declare function dashboardHandler(): express.RequestHandler;
69
85
  /**
70
86
  * Resolve the on-disk path of an allowlisted vendor bundle, or null when the
71
87
  * requested name is not on the allowlist. The filesystem path is built ONLY
package/dist/viz.js CHANGED
@@ -34,6 +34,9 @@ exports.vizHandler = vizHandler;
34
34
  exports.resolveContextHtmlPath = resolveContextHtmlPath;
35
35
  exports.readContextHtml = readContextHtml;
36
36
  exports.contextUiHandler = contextUiHandler;
37
+ exports.resolveDashboardHtmlPath = resolveDashboardHtmlPath;
38
+ exports.readDashboardHtml = readDashboardHtml;
39
+ exports.dashboardHandler = dashboardHandler;
37
40
  exports.resolveVizVendorPath = resolveVizVendorPath;
38
41
  exports.vizVendorHandler = vizVendorHandler;
39
42
  const node_fs_1 = require("node:fs");
@@ -81,6 +84,14 @@ function createAuthMiddleware(authToken) {
81
84
  // sends it as a normal Authorization header on its /context fetches.
82
85
  if (req.method === "GET" && req.path === "/context/ui")
83
86
  return next();
87
+ // The /dashboard page SHELL is public for the same reason as /viz and
88
+ // /context/ui: a self-contained view-only analytics page, no data and no
89
+ // secrets (it ships verbatim in the npm tarball). All metric data comes
90
+ // from GET /dashboard/data, which stays bearer-only (localhost bypass) like
91
+ // every other data route; the page collects the token client-side and
92
+ // sends it as a normal Authorization header on its /dashboard/data fetch.
93
+ if (req.method === "GET" && req.path === "/dashboard")
94
+ return next();
84
95
  // The pinned renderer bundles the /viz page loads (#139) are public for
85
96
  // the same reason as the shell: static third-party code shipped verbatim
86
97
  // in the npm tarball, zero data. Kept tight: GET only, and ONLY names on
@@ -184,6 +195,44 @@ function contextUiHandler() {
184
195
  }
185
196
  };
186
197
  }
198
+ // ---------------------------------------------------------------------------
199
+ // Dashboard page (/dashboard, #224 — view-only memory analytics)
200
+ // ---------------------------------------------------------------------------
201
+ /**
202
+ * Resolve the on-disk path of the dashboard page. Throws (fail explicitly)
203
+ * when the asset is missing — same contract as resolveVizHtmlPath and
204
+ * resolveContextHtmlPath. assets/ sits next to both dist/ and src/ (the
205
+ * sibling layout the other resolvers rely on).
206
+ */
207
+ function resolveDashboardHtmlPath() {
208
+ const candidates = [(0, node_path_1.join)(__dirname, "..", "assets", "dashboard.html")];
209
+ for (const candidate of candidates) {
210
+ if ((0, node_fs_1.existsSync)(candidate))
211
+ return candidate;
212
+ }
213
+ throw new Error(`dashboard.html asset not found — looked in: ${candidates.join(", ")}. ` +
214
+ `The package install is incomplete (assets/ missing).`);
215
+ }
216
+ /** Read the dashboard page. Read at request time so a reinstall is picked up live. */
217
+ function readDashboardHtml() {
218
+ return (0, node_fs_1.readFileSync)(resolveDashboardHtmlPath(), "utf-8");
219
+ }
220
+ /**
221
+ * Express handler for GET /dashboard — the view-only analytics page (#224).
222
+ * 503 with the usual {error} shape when the asset cannot be read, exactly like
223
+ * vizHandler and contextUiHandler. The page SHELL is public (exempted in
224
+ * createAuthMiddleware); all data comes from GET /dashboard/data (bearer-only).
225
+ */
226
+ function dashboardHandler() {
227
+ return (_req, res) => {
228
+ try {
229
+ res.type("html").send(readDashboardHtml());
230
+ }
231
+ catch (err) {
232
+ res.status(503).json({ error: err instanceof Error ? err.message : String(err) });
233
+ }
234
+ };
235
+ }
187
236
  /**
188
237
  * Resolve the on-disk path of an allowlisted vendor bundle, or null when the
189
238
  * requested name is not on the allowlist. The filesystem path is built ONLY
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.16.10",
3
+ "version": "0.17.0",
4
4
  "description": "Self-learning memory for AI agents — experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {