@gamaze/hicortex 0.16.9 → 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);
@@ -219,6 +221,8 @@ async function runNightly(options = {}) {
219
221
  (0, state_js_1.migrateLegacyState)(stateDir);
220
222
  // Check mode: client or server
221
223
  const savedConfig = readNightlyConfig(stateDir);
224
+ // 0.16.8 upgrade guard: warn if ignored per-stage keys are still present.
225
+ (0, config_read_js_1.warnIgnoredConfigKeys)(savedConfig);
222
226
  // 0.16.2 activation gap: pre-0.16.2 installs never re-run init, so their
223
227
  // config has no agentId → capture sent source_agent_id: null forever (the
224
228
  // provenance feature was inert for the whole existing fleet). Self-heal on
@@ -230,14 +234,59 @@ async function runNightly(options = {}) {
230
234
  const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
231
235
  savedConfig.agentId = agentId;
232
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
+ }
233
282
  if (savedConfig?.mode === "client") {
234
283
  // --capture-only is accepted in client mode but irrelevant: client nightly
235
- // is already capture-only (no consolidation step).
236
- 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);
237
287
  return;
238
288
  }
239
289
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
240
- const port = savedConfig?.port ?? 8787;
241
290
  // #192: consolidation's decay/prune stage must score with the same clock as
242
291
  // the server's retrieval path (config decayHalfLifeDays, default 365).
243
292
  (0, retrieval_js_1.configureDecay)({ halfLifeDays: savedConfig?.decayHalfLifeDays });
@@ -367,9 +416,14 @@ async function runNightly(options = {}) {
367
416
  // of the HTTP capture path). Full nightly only — capture-only runs are
368
417
  // intended to run more frequently than once daily.
369
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;
370
423
  if (!dryRun && !captureOnly) {
371
424
  if (!llm || !llmConfig) {
372
425
  console.error("[hicortex] consolidation skipped: no LLM configured — run npx @gamaze/hicortex init");
426
+ consolidationStatus = "no_llm";
373
427
  }
374
428
  else {
375
429
  // One model serves all phases (#231) — there is no separate endpoint to
@@ -388,9 +442,12 @@ async function runNightly(options = {}) {
388
442
  }, {
389
443
  minSimilarity: savedConfig?.supersessionMinSimilarity,
390
444
  maxCalls: savedConfig?.supersessionMaxCalls,
391
- });
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));
392
448
  console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
393
449
  (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
450
+ consolidationStatus = report.status;
394
451
  // Only set when reflection actually RAN (not skipped). A skipped stage
395
452
  // (e.g. endpoint offline, #232 fail-soft) must NOT collapse to 0 — that
396
453
  // would make "endpoint down" indistinguishable from "prompt too tight"
@@ -415,6 +472,53 @@ async function runNightly(options = {}) {
415
472
  }
416
473
  }
417
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
+ }
418
522
  // Anonymous telemetry (fire-and-forget, full nightly only).
419
523
  // Capture-only runs are excluded to avoid inflating install pings.
420
524
  if (!dryRun && !captureOnly && (0, telemetry_js_1.isTelemetryEnabled)(savedConfig)) {
@@ -444,6 +548,7 @@ async function runNightly(options = {}) {
444
548
  mem: storage.countMemories(db),
445
549
  lessons: storage.getLessons(db, 365).length,
446
550
  lessonsGenerated,
551
+ consolidation: consolidationStatus,
447
552
  sessions: batches.length,
448
553
  ok: !hadTransientFailure,
449
554
  shown: adoption.shown,
@@ -459,7 +564,7 @@ async function runNightly(options = {}) {
459
564
  // ---------------------------------------------------------------------------
460
565
  // Client Mode Nightly — denoise locally, POST to remote server's /distill
461
566
  // ---------------------------------------------------------------------------
462
- async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recaptureWindowDays) {
567
+ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recaptureWindowDays, watchdog = false) {
463
568
  const serverUrl = config.serverUrl.replace(/\/+$/, "");
464
569
  const authToken = config.authToken;
465
570
  console.log(`[hicortex] Client nightly starting${dryRun ? " (dry run)" : ""}`);
@@ -469,16 +574,20 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
469
574
  // the whole run — the pre-flight only needs the link back, which can take
470
575
  // ~1 min after wake.
471
576
  //
472
- // Config-overridable (#163): a wired Pi vs a sleeping laptop want different
473
- // 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).
474
583
  //
475
584
  // WALL-CLOCK NOTE: setTimeout and AbortSignal.timeout do NOT advance while
476
- // 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-
477
586
  // optimistic — a sleeping laptop can straddle sleep cycles and the real
478
587
  // elapsed time can exceed it. Not a defect: the capture lock isn't held
479
588
  // during the retry and the cursor design is dup-over-loss, so a late success
480
- // is harmless. Just don't treat 2m45s as a hard wall-clock bound.
481
- 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);
482
591
  const PREFLIGHT_ATTEMPTS = Math.max(1, Math.floor((0, config_read_js_1.readPositiveConfig)(config, "preflightAttempts", 3)));
483
592
  const PREFLIGHT_RETRY_GAP_MS = (0, config_read_js_1.readPositiveConfig)(config, "preflightRetryGapMs", 60_000);
484
593
  let reachable = false;
@@ -511,7 +620,12 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
511
620
  // no loop), and fire the telemetry ping (ok=false) so the abort is
512
621
  // distinguishable from "powered off / uninstalled" in the activity aggregate.
513
622
  process.exitCode = 1;
514
- 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)) {
515
629
  await (0, telemetry_js_1.sendTelemetry)({
516
630
  id: (0, telemetry_js_1.getTelemetryId)(stateDir),
517
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.9",
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": {