@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/db.js CHANGED
@@ -464,6 +464,30 @@ const MIGRATIONS = [
464
464
  }
465
465
  },
466
466
  },
467
+ {
468
+ version: 12,
469
+ name: "add_dashboard_snapshots",
470
+ up: (db) => {
471
+ // #224 memory analytics dashboard. One row per FULL nightly run (never
472
+ // capture-only — the snapshot reflects corpus state, so it is written
473
+ // from the same !dryRun && !captureOnly block that runs consolidation).
474
+ // `run_at` is the ISO timestamp of the run (PRIMARY KEY so a re-run for
475
+ // the same instant is idempotent — the nightly never retries within one
476
+ // process, and a manual re-run uses a fresh `now`). `metrics` is a JSON
477
+ // blob (not typed columns): the metric set is meant to evolve without a
478
+ // migration (add a key, ship), so the schema stays one column. The
479
+ // backfill (dashboard.ts:backfillSnapshots) also writes here, one row
480
+ // per derived day. Adoption fields are point-in-time and can't be
481
+ // reconstructed from created_at — backfilled rows leave them null.
482
+ // IF NOT EXISTS keeps it idempotent across partial migrations.
483
+ db.exec(`
484
+ CREATE TABLE IF NOT EXISTS dashboard_snapshots (
485
+ run_at TEXT PRIMARY KEY,
486
+ metrics TEXT NOT NULL
487
+ )
488
+ `);
489
+ },
490
+ },
467
491
  ];
468
492
  /**
469
493
  * Run all pending migrations against the database.
@@ -14,15 +14,24 @@
14
14
  * readable here nightly. No runtime plugin capture is needed; the Hermes plugin
15
15
  * is recall-only.
16
16
  *
17
- * We process only ENDED sessions (ended_at set) that ended since the last run.
18
- * A live session is distilled after it ends this avoids partial-session
19
- * distillation and keeps per-session dedup clean (chunks are stored as
20
- * `<sessionId>#<chunkIndex>`; see nightly.ts).
17
+ * We process ENDED sessions (ended_at set) that ended since the last run PLUS
18
+ * OPEN sessions (ended_at NULL) that have messagesa Discord conversation is
19
+ * one long-lived thread that stays open and accrues content, so gating only on
20
+ * ended_at would drop the whole interactive corpus (#240). Open sessions are
21
+ * delta-sliced by the per-session cursor; per-session dedup stays clean
22
+ * (chunks are stored as `<sessionId>#<chunkIndex>`; see nightly.ts).
21
23
  */
22
24
  import type { TranscriptBatch, CursorMap } from "./transcript-reader.js";
23
25
  /**
24
- * Read Hermes sessions that ended since `since`, across all profiles.
25
- * Returns one batch per session, parallel to readCcTranscripts().
26
+ * Read Hermes sessions across all profiles: ended sessions newer than `since`
27
+ * (the bulk watermark) PLUS open sessions (`ended_at IS NULL`) that have any
28
+ * message. Returns one batch per session, parallel to readCcTranscripts().
29
+ *
30
+ * Why open sessions are included (#240): in Hermes' Discord model a
31
+ * conversation is one long-lived thread = one session that stays open for
32
+ * days/weeks and accumulates the interactive content. The per-session cursor
33
+ * slices each such thread to its unseen delta across runs (same discover-
34
+ * broadly / delta-narrowly model as CC readers).
26
35
  *
27
36
  * @param cursors Per-session capture cursors (#189), keyed `hermes:<profile>:<sid>`.
28
37
  * The cursor value is the max `messages.id` already captured; a resumed +
@@ -15,10 +15,12 @@
15
15
  * readable here nightly. No runtime plugin capture is needed; the Hermes plugin
16
16
  * is recall-only.
17
17
  *
18
- * We process only ENDED sessions (ended_at set) that ended since the last run.
19
- * A live session is distilled after it ends this avoids partial-session
20
- * distillation and keeps per-session dedup clean (chunks are stored as
21
- * `<sessionId>#<chunkIndex>`; see nightly.ts).
18
+ * We process ENDED sessions (ended_at set) that ended since the last run PLUS
19
+ * OPEN sessions (ended_at NULL) that have messagesa Discord conversation is
20
+ * one long-lived thread that stays open and accrues content, so gating only on
21
+ * ended_at would drop the whole interactive corpus (#240). Open sessions are
22
+ * delta-sliced by the per-session cursor; per-session dedup stays clean
23
+ * (chunks are stored as `<sessionId>#<chunkIndex>`; see nightly.ts).
22
24
  */
23
25
  var __importDefault = (this && this.__importDefault) || function (mod) {
24
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -45,8 +47,15 @@ const NON_PRIMARY_SOURCES = new Set(["cron"]);
45
47
  */
46
48
  const NOISE_ROLES = new Set(["tool", "session_meta"]);
47
49
  /**
48
- * Read Hermes sessions that ended since `since`, across all profiles.
49
- * Returns one batch per session, parallel to readCcTranscripts().
50
+ * Read Hermes sessions across all profiles: ended sessions newer than `since`
51
+ * (the bulk watermark) PLUS open sessions (`ended_at IS NULL`) that have any
52
+ * message. Returns one batch per session, parallel to readCcTranscripts().
53
+ *
54
+ * Why open sessions are included (#240): in Hermes' Discord model a
55
+ * conversation is one long-lived thread = one session that stays open for
56
+ * days/weeks and accumulates the interactive content. The per-session cursor
57
+ * slices each such thread to its unseen delta across runs (same discover-
58
+ * broadly / delta-narrowly model as CC readers).
50
59
  *
51
60
  * @param cursors Per-session capture cursors (#189), keyed `hermes:<profile>:<sid>`.
52
61
  * The cursor value is the max `messages.id` already captured; a resumed +
@@ -64,8 +73,24 @@ function readHermesSessions(since, hermesHome = HERMES_HOME, cursors = {}) {
64
73
  continue; // locked / unreadable / wrong owner — skip, retry next run
65
74
  }
66
75
  try {
76
+ // Discovery (#240): ended sessions past the watermark (bulk) PLUS open
77
+ // sessions (ended_at IS NULL) that have any message. In Hermes' Discord
78
+ // model a conversation is ONE long-lived thread = one session that stays
79
+ // open for days/weeks and accumulates essentially all the interactive
80
+ // content. Gating discovery only on `ended_at IS NOT NULL` meant those
81
+ // open threads were NEVER discovered → distilled — the entire interactive
82
+ // corpus was silently dropped. The per-session cursor below slices each
83
+ // open session to its unseen delta across runs (same model as CC: discover
84
+ // broadly, delta narrowly), and `rows.length === 0` skips an open session
85
+ // that is already current. The EXISTS probe is one cheap check per open
86
+ // session (typically one open thread per agent).
67
87
  const sessions = db
68
- .prepare("SELECT id, ended_at, source FROM sessions WHERE ended_at IS NOT NULL AND ended_at > ? ORDER BY ended_at")
88
+ .prepare(`SELECT id, ended_at, source FROM sessions
89
+ WHERE (ended_at IS NOT NULL AND ended_at > ?)
90
+ OR (ended_at IS NULL AND EXISTS (
91
+ SELECT 1 FROM messages WHERE messages.session_id = sessions.id
92
+ ))
93
+ ORDER BY (ended_at IS NULL), ended_at`)
69
94
  .all(sinceEpoch);
70
95
  // Cursor is a message id (INTEGER PRIMARY KEY AUTOINCREMENT — strictly
71
96
  // increasing, never reused), so `id > ?` returns exactly the rows added
package/dist/init.d.ts CHANGED
@@ -295,9 +295,31 @@ export declare function runInit(options?: {
295
295
  repairConfig?: boolean;
296
296
  }): Promise<void>;
297
297
  /**
298
- * Resolve the nightly hour (0–23, local time) for the generated schedule.
299
- * Priority: `nightlyHour` in ~/.hicortex/config.json mode default.
300
- * Defaults: client 02:00, server 03:00 staggered so that in mixed fleets
301
- * clients push their sessions before the server's capture + consolidation run.
298
+ * Resolve the CONSOLIDATION hours (the only slot-based timer in 0.17). The
299
+ * CAPTURE mechanism is the watchdog (an interval timer, not slots) — uniform
300
+ * across client + server/co-located — so this function no longer returns a
301
+ * capture schedule. Returns null in client mode (no local DB → no
302
+ * consolidation timer).
303
+ *
304
+ * Priority: `consolidationHours` array → legacy `nightlyHour` (single int, only
305
+ * when the array key is absent → one consolidation slot at H, preserving the
306
+ * pre-0.17 "one daily job" intent) → role default ([10, 22] server / null client).
307
+ */
308
+ export declare function resolveConsolidationHours(mode: "server" | "client", configDir?: string): number[] | null;
309
+ /**
310
+ * Legacy single-hour resolver (pre-0.17). Kept for backward compat + the
311
+ * existing tests; new scheduling goes through `resolveConsolidationHours`.
302
312
  */
303
313
  export declare function resolveNightlyHour(mode: "server" | "client", configDir?: string): number;
314
+ /**
315
+ * One `OnCalendar=*-*-* HH:00:00` line per hour, newline-joined — systemd fires
316
+ * a timer at EACH OnCalendar entry (multi-slot in a single timer). Hours are
317
+ * sorted so the generated file is stable/diffable. Exported for testing.
318
+ */
319
+ export declare function formatOnCalendarLines(hours: number[]): string;
320
+ /**
321
+ * The launchd `StartCalendarInterval` ARRAY body — one `<dict>` per hour.
322
+ * launchd fires the job at each dict; a single dict is the 1-slot special case
323
+ * but the array form is uniform across 1..N. Exported for testing.
324
+ */
325
+ export declare function formatLaunchdIntervals(hours: number[]): string;
package/dist/init.js CHANGED
@@ -36,7 +36,10 @@ exports.isEphemeralNpxPath = isEphemeralNpxPath;
36
36
  exports.installSessionStartHook = installSessionStartHook;
37
37
  exports.installRecallHooks = installRecallHooks;
38
38
  exports.runInit = runInit;
39
+ exports.resolveConsolidationHours = resolveConsolidationHours;
39
40
  exports.resolveNightlyHour = resolveNightlyHour;
41
+ exports.formatOnCalendarLines = formatOnCalendarLines;
42
+ exports.formatLaunchdIntervals = formatLaunchdIntervals;
40
43
  const paths_js_1 = require("./paths.js");
41
44
  const telemetry_js_1 = require("./telemetry.js");
42
45
  const node_fs_1 = require("node:fs");
@@ -46,6 +49,7 @@ const node_child_process_1 = require("node:child_process");
46
49
  const node_readline_1 = require("node:readline");
47
50
  const node_crypto_1 = require("node:crypto");
48
51
  const claude_md_js_1 = require("./claude-md.js");
52
+ const config_read_js_1 = require("./config-read.js");
49
53
  const context_store_js_1 = require("./context-store.js");
50
54
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
51
55
  /** This package's version, for the install lifecycle ping (0.15.2). */
@@ -1452,10 +1456,13 @@ async function runInit(options = {}) {
1452
1456
  console.log(` ✓ Agent name set to '${decision.value}'`);
1453
1457
  }
1454
1458
  }
1455
- // Install the nightly job (capture via localhost /distill + consolidation).
1456
- // Without it a server-mode install never captures or consolidates the
1457
- // daemon only serves recall + /distill. Skips if a schedule already exists.
1458
- installNightlyCron(resolveNightlyHour("server"));
1459
+ // Install the scheduling timers (0.17): a CAPTURE WATCHDOG (short-interval
1460
+ // poll, success-cooldown-throttled uniform with clients) + a CONSOLIDATION
1461
+ // timer (the full nightly, fixed slots). Re-init rewrites both; customize the
1462
+ // consolidation slots via consolidationHours in config.json (capture cadence
1463
+ // via captureCooldownHours).
1464
+ installCaptureWatchdogTimer();
1465
+ installConsolidationTimer(resolveConsolidationHours("server") ?? DEFAULT_CONSOLIDATION_HOURS);
1459
1466
  // Install daemon if needed
1460
1467
  if (!d.localServer && !d.remoteServer) {
1461
1468
  installDaemon();
@@ -1657,8 +1664,12 @@ async function runClientInit(serverUrl, agentName) {
1657
1664
  if ((0, claude_md_js_1.removeLessonsBlock)(claudeMdPath)) {
1658
1665
  console.log(` ✓ Removed old static lessons block from CLAUDE.md — lessons now injected at session start`);
1659
1666
  }
1660
- // Step 7: Install nightly cron (denoise locally, POST to server /distill)
1661
- installNightlyCron(resolveNightlyHour("client"));
1667
+ // Step 7: Install the CAPTURE WATCHDOG (denoise locally, POST to server
1668
+ // /distill, throttled + preflight-gated) — the same capture mechanism every
1669
+ // install gets. Clients have no local DB → no consolidation timer; also
1670
+ // remove any legacy full-nightly timer a pre-0.17 install left behind.
1671
+ installCaptureWatchdogTimer();
1672
+ removeConsolidationTimer();
1662
1673
  // Step 8: Setup Hermes if detected
1663
1674
  if ((0, node_fs_1.existsSync)(HERMES_HOME)) {
1664
1675
  console.log("\nHermes detected — installing plugin...");
@@ -1686,59 +1697,164 @@ async function runClientInit(serverUrl, agentName) {
1686
1697
  console.log("Restart your agents to activate.");
1687
1698
  }
1688
1699
  /**
1689
- * Resolve the nightly hour (0–23, local time) for the generated schedule.
1690
- * Priority: `nightlyHour` in ~/.hicortex/config.json mode default.
1691
- * Defaults: client 02:00, server 03:00 — staggered so that in mixed fleets
1692
- * clients push their sessions before the server's capture + consolidation run.
1700
+ * Scheduling (0.17): one UNIFORM capture mechanism + one role-specific
1701
+ * consolidation timer, replacing the old single daily full-nightly.
1702
+ *
1703
+ * - CAPTURE WATCHDOG (`hicortex-capture`): a short-interval timer fires
1704
+ * `nightly --capture-only --watchdog`. The watchdog throttles by a
1705
+ * success-cooldown (`captureCooldownHours`, default 6 ≈ 4 captures/day) and
1706
+ * preflights before capturing, so a transient fire-instant network miss
1707
+ * retries in minutes, not at the next daily slot (#239: a once-daily client
1708
+ * fire that caught a slow/flaky link lost ~24h of capture). Installed
1709
+ * UNIFORMLY for client AND server/co-located — one capture code path, no
1710
+ * per-topology branch. A failed preflight retries on the next tick; a
1711
+ * successful capture waits the cooldown (success-based cooldown — the
1712
+ * better semantics; the custom server watchdog used trigger-based).
1713
+ * - CONSOLIDATION timer (`hicortex-nightly`): full `nightly` (capture +
1714
+ * distill + score + reflect + link). Server/co-located ONLY — clients have
1715
+ * no local DB, so no consolidation timer is installed (and a legacy one
1716
+ * left by a pre-0.17 install is removed). Fixed slots (default [10, 22]).
1717
+ *
1718
+ * Customize: `consolidationHours` (the consolidation slots) and
1719
+ * `captureCooldownHours` (the watchdog throttle) in config.json. Re-init
1720
+ * rewrites the timers to the resolved standard (the pre-0.17 "never overwrite"
1721
+ * contract is intentionally relaxed so the fleet adopts the standard). The
1722
+ * legacy single `nightlyHour` is honoured as a one-slot consolidation fallback
1723
+ * only when `consolidationHours` is absent (preserves "one daily job at H").
1724
+ */
1725
+ const DEFAULT_CONSOLIDATION_HOURS = [10, 22];
1726
+ /**
1727
+ * Capture-watchdog poll interval (minutes). The capture timer fires
1728
+ * `nightly --capture-only --watchdog` this often; the watchdog itself throttles
1729
+ * by the success-cooldown (`captureCooldownHours`, read at runtime). Short so a
1730
+ * transient fire-instant network miss retries in minutes, not at the next daily
1731
+ * slot (#239). Cheap: a tick against a down server is one 5s preflight.
1732
+ */
1733
+ const CAPTURE_WATCHDOG_INTERVAL_MIN = 20;
1734
+ /**
1735
+ * Resolve the CONSOLIDATION hours (the only slot-based timer in 0.17). The
1736
+ * CAPTURE mechanism is the watchdog (an interval timer, not slots) — uniform
1737
+ * across client + server/co-located — so this function no longer returns a
1738
+ * capture schedule. Returns null in client mode (no local DB → no
1739
+ * consolidation timer).
1740
+ *
1741
+ * Priority: `consolidationHours` array → legacy `nightlyHour` (single int, only
1742
+ * when the array key is absent → one consolidation slot at H, preserving the
1743
+ * pre-0.17 "one daily job" intent) → role default ([10, 22] server / null client).
1744
+ */
1745
+ function resolveConsolidationHours(mode, configDir = HICORTEX_HOME) {
1746
+ let config = {};
1747
+ try {
1748
+ config = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(configDir, "config.json"), "utf-8"));
1749
+ }
1750
+ catch { /* no config yet — use the standard default */ }
1751
+ if (mode === "client")
1752
+ return null; // clients have no local DB → no consolidation timer
1753
+ const arr = (0, config_read_js_1.parseHours)(config, "consolidationHours");
1754
+ if (arr)
1755
+ return arr;
1756
+ const legacy = readLegacyNightlyHour(config);
1757
+ if (legacy !== null)
1758
+ return [legacy];
1759
+ return DEFAULT_CONSOLIDATION_HOURS;
1760
+ }
1761
+ /** Read the legacy `nightlyHour` (single int 0–23) if validly set, else null. */
1762
+ function readLegacyNightlyHour(config) {
1763
+ const h = config.nightlyHour;
1764
+ if (typeof h === "number" && Number.isInteger(h) && h >= 0 && h <= 23)
1765
+ return h;
1766
+ return null;
1767
+ }
1768
+ /**
1769
+ * Legacy single-hour resolver (pre-0.17). Kept for backward compat + the
1770
+ * existing tests; new scheduling goes through `resolveConsolidationHours`.
1693
1771
  */
1694
1772
  function resolveNightlyHour(mode, configDir = HICORTEX_HOME) {
1695
1773
  try {
1696
1774
  const config = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(configDir, "config.json"), "utf-8"));
1697
- const h = config.nightlyHour;
1698
- if (typeof h === "number" && Number.isInteger(h) && h >= 0 && h <= 23)
1699
- return h;
1775
+ const legacy = readLegacyNightlyHour(config);
1776
+ if (legacy !== null)
1777
+ return legacy;
1700
1778
  }
1701
1779
  catch { /* no config yet — use the default */ }
1702
1780
  return mode === "server" ? 3 : 2;
1703
1781
  }
1704
- function installNightlyCron(hour) {
1782
+ /**
1783
+ * One `OnCalendar=*-*-* HH:00:00` line per hour, newline-joined — systemd fires
1784
+ * a timer at EACH OnCalendar entry (multi-slot in a single timer). Hours are
1785
+ * sorted so the generated file is stable/diffable. Exported for testing.
1786
+ */
1787
+ function formatOnCalendarLines(hours) {
1788
+ return [...hours]
1789
+ .sort((a, b) => a - b)
1790
+ .map((h) => `OnCalendar=*-*-* ${String(h).padStart(2, "0")}:00:00`)
1791
+ .join("\n");
1792
+ }
1793
+ /**
1794
+ * The launchd `StartCalendarInterval` ARRAY body — one `<dict>` per hour.
1795
+ * launchd fires the job at each dict; a single dict is the 1-slot special case
1796
+ * but the array form is uniform across 1..N. Exported for testing.
1797
+ */
1798
+ function formatLaunchdIntervals(hours) {
1799
+ return [...hours]
1800
+ .sort((a, b) => a - b)
1801
+ .map((h) => ` <dict>
1802
+ <key>Hour</key>
1803
+ <integer>${h}</integer>
1804
+ <key>Minute</key>
1805
+ <integer>0</integer>
1806
+ </dict>`)
1807
+ .join("\n");
1808
+ }
1809
+ /**
1810
+ * Write + enable one schedule unit (timer + service on Linux, plist on macOS),
1811
+ * multi-slot. Shared by the capture and consolidation installers. Always
1812
+ * rewrites both files (the 0.17 migration decision: re-init brings an install
1813
+ * up to the resolved standard; customization is via config keys, not hand-
1814
+ * edited unit files). Logs the resolved slots.
1815
+ */
1816
+ function writeScheduleUnit(opts) {
1705
1817
  const binaryArgs = resolveBinaryArgs();
1706
1818
  const os = (0, node_os_1.platform)();
1707
- const hh = String(hour).padStart(2, "0");
1708
1819
  // PATH must start with the binary's own directory (see installLaunchd for rationale).
1709
1820
  const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
1710
1821
  // One canonical nightly log path across platforms — status output, docs,
1711
1822
  // and support instructions all reference this single location.
1712
1823
  const logPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly.log");
1824
+ const isInterval = typeof opts.intervalSec === "number";
1825
+ if (!isInterval && (!opts.hours || opts.hours.length === 0)) {
1826
+ throw new Error("writeScheduleUnit: provide either hours or intervalSec");
1827
+ }
1828
+ // Human-readable schedule label for the log line.
1829
+ const slotLabel = isInterval
1830
+ ? `every ${Math.round(opts.intervalSec / 60)} min`
1831
+ : [...opts.hours].sort((a, b) => a - b).map((h) => `${String(h).padStart(2, "0")}:00`).join(", ");
1713
1832
  if (os === "darwin") {
1714
1833
  const plistDir = (0, node_path_1.join)((0, node_os_1.homedir)(), "Library", "LaunchAgents");
1715
- const plistPath = (0, node_path_1.join)(plistDir, "com.gamaze.hicortex-nightly.plist");
1716
- // Never overwrite an existing schedule — users tune these (multi-slot
1717
- // capture windows, quiet hours). Fresh installs only.
1718
- if ((0, node_fs_1.existsSync)(plistPath)) {
1719
- console.log(` ✓ Nightly cron already installed — leaving existing schedule as-is`);
1720
- return;
1721
- }
1722
- const programArgs = [...binaryArgs, "nightly"]
1834
+ const plistPath = (0, node_path_1.join)(plistDir, `${opts.plistLabel}.plist`);
1835
+ const programArgs = [...binaryArgs, ...opts.nightlyArgs]
1723
1836
  .map((a) => ` <string>${a}</string>`)
1724
1837
  .join("\n");
1838
+ // Schedule block: StartInterval (seconds) for the watchdog poll, or
1839
+ // StartCalendarInterval as an ARRAY of dicts (one per hour) for slots.
1840
+ // RunAtLoad ONLY on the interval (watchdog) plist — so a Mac that reboots
1841
+ // gets a first capture tick on load (~parity with systemd's OnBootSec=2min),
1842
+ // not 20 min later. The cooldown gate makes a load-time fire a cheap no-op
1843
+ // if a capture ran recently.
1844
+ const scheduleBlock = isInterval
1845
+ ? ` <key>StartInterval</key>\n <integer>${opts.intervalSec}</integer>\n <key>RunAtLoad</key>\n <true/>`
1846
+ : ` <key>StartCalendarInterval</key>\n <array>\n${formatLaunchdIntervals(opts.hours)}\n </array>`;
1725
1847
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
1726
1848
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1727
1849
  <plist version="1.0">
1728
1850
  <dict>
1729
1851
  <key>Label</key>
1730
- <string>com.gamaze.hicortex-nightly</string>
1852
+ <string>${opts.plistLabel}</string>
1731
1853
  <key>ProgramArguments</key>
1732
1854
  <array>
1733
1855
  ${programArgs}
1734
1856
  </array>
1735
- <key>StartCalendarInterval</key>
1736
- <dict>
1737
- <key>Hour</key>
1738
- <integer>${hour}</integer>
1739
- <key>Minute</key>
1740
- <integer>0</integer>
1741
- </dict>
1857
+ ${scheduleBlock}
1742
1858
  <key>StandardOutPath</key>
1743
1859
  <string>${logPath}</string>
1744
1860
  <key>StandardErrorPath</key>
@@ -1756,63 +1872,146 @@ ${programArgs}
1756
1872
  try {
1757
1873
  (0, node_child_process_1.execSync)(`launchctl unload ${plistPath} 2>/dev/null`, { stdio: "pipe" });
1758
1874
  }
1759
- catch { }
1875
+ catch { /* not loaded */ }
1760
1876
  (0, node_child_process_1.execSync)(`launchctl load ${plistPath}`, { stdio: "pipe" });
1761
- console.log(` ✓ Installed nightly cron (runs daily at ${hh}:00)`);
1877
+ console.log(` ✓ Installed ${opts.timerDesc} (${slotLabel})`);
1762
1878
  }
1763
1879
  catch {
1764
- console.log(` ⚠ Could not load nightly plist. Load manually: launchctl load ${plistPath}`);
1880
+ console.log(` ⚠ Could not load plist. Load manually: launchctl load ${plistPath}`);
1765
1881
  }
1766
1882
  }
1767
1883
  else if (os === "linux") {
1768
1884
  const configDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "systemd", "user");
1769
- const servicePath = (0, node_path_1.join)(configDir, "hicortex-nightly.service");
1770
- const timerPath = (0, node_path_1.join)(configDir, "hicortex-nightly.timer");
1771
- const execStart = [...binaryArgs, "nightly"].join(" ");
1885
+ const servicePath = (0, node_path_1.join)(configDir, `${opts.unitBase}.service`);
1886
+ const timerPath = (0, node_path_1.join)(configDir, `${opts.unitBase}.timer`);
1887
+ const execStart = [...binaryArgs, ...opts.nightlyArgs].join(" ");
1772
1888
  // File logging, not journal: oneshot runs on machines with a volatile
1773
1889
  // journal (e.g. Raspberry Pi defaults) otherwise fail without a trace.
1774
1890
  // Same log path as the macOS plist. append: needs systemd ≥ 240 (2018).
1775
1891
  const service = `[Unit]
1776
- Description=Hicortex Nightly (distill + POST)
1892
+ Description=${opts.serviceDesc}
1777
1893
 
1778
1894
  [Service]
1779
1895
  Type=oneshot
1780
1896
  ExecStart=${execStart}
1781
- StandardOutput=append:${logPath}
1897
+ ${opts.timeoutMin ? `TimeoutStartSec=${opts.timeoutMin}min\n` : ""}StandardOutput=append:${logPath}
1782
1898
  StandardError=append:${logPath}
1783
1899
  Environment=PATH=${binDir}:/usr/local/bin:/usr/bin:/bin
1784
1900
  Environment=HOME=${(0, node_os_1.homedir)()}
1785
1901
  WorkingDirectory=${(0, node_os_1.homedir)()}`;
1902
+ // Timer body: OnUnitActiveSec (interval, watchdog) or one OnCalendar line
1903
+ // per hour (multi-slot). systemd ORs multiple OnCalendar entries.
1904
+ const timerBody = isInterval
1905
+ ? `OnBootSec=2min\nOnUnitActiveSec=${Math.round(opts.intervalSec / 60)}min`
1906
+ : formatOnCalendarLines(opts.hours);
1786
1907
  const timer = `[Unit]
1787
- Description=Hicortex Nightly Timer
1908
+ Description=${opts.timerDesc}
1788
1909
 
1789
1910
  [Timer]
1790
- OnCalendar=*-*-* ${hh}:00:00
1911
+ ${timerBody}
1791
1912
  Persistent=true
1792
1913
 
1793
1914
  [Install]
1794
1915
  WantedBy=timers.target`;
1795
1916
  (0, node_fs_1.mkdirSync)(configDir, { recursive: true });
1796
- // The .service file is ours always refresh it so fixes (like file
1797
- // logging) reach existing installs. The .timer holds the user-tuned
1798
- // schedule and is never overwritten.
1917
+ // Always rewrite both .service and .timer (0.17 migration: re-init adopts
1918
+ // the resolved standard schedule; the config keys are the tuning surface).
1799
1919
  (0, node_fs_1.writeFileSync)(servicePath, service);
1800
- if ((0, node_fs_1.existsSync)(timerPath)) {
1801
- try {
1802
- (0, node_child_process_1.execSync)("systemctl --user daemon-reload", { stdio: "pipe" });
1803
- }
1804
- catch { /* fine */ }
1805
- console.log(` ✓ Nightly service refreshed — existing timer schedule kept as-is`);
1806
- return;
1807
- }
1808
1920
  (0, node_fs_1.writeFileSync)(timerPath, timer);
1809
1921
  try {
1810
1922
  (0, node_child_process_1.execSync)("systemctl --user daemon-reload", { stdio: "pipe" });
1811
- (0, node_child_process_1.execSync)("systemctl --user enable --now hicortex-nightly.timer", { stdio: "pipe" });
1812
- console.log(` ✓ Installed nightly timer (runs daily at ${hh}:00)`);
1923
+ (0, node_child_process_1.execSync)(`systemctl --user enable --now ${opts.unitBase}.timer`, { stdio: "pipe" });
1924
+ console.log(` ✓ Installed ${opts.unitBase}.timer (${slotLabel})`);
1813
1925
  }
1814
1926
  catch {
1815
- console.log(` ⚠ Could not enable nightly timer. Enable manually: systemctl --user enable --now hicortex-nightly.timer`);
1927
+ console.log(` ⚠ Could not enable ${opts.unitBase}.timer. Enable manually: ` +
1928
+ `systemctl --user enable --now ${opts.unitBase}.timer`);
1929
+ }
1930
+ }
1931
+ }
1932
+ /**
1933
+ * Install the CAPTURE WATCHDOG timer — a short-interval poll that fires
1934
+ * `nightly --capture-only --watchdog`. The watchdog itself throttles by the
1935
+ * success-cooldown (`captureCooldownHours`) and preflights, so a transient
1936
+ * fire-instant network miss retries in minutes, not at the next daily slot
1937
+ * (#239). Installed UNIFORMLY for client + server/co-located (one capture
1938
+ * mechanism everywhere — no per-topology branch).
1939
+ */
1940
+ function installCaptureWatchdogTimer() {
1941
+ writeScheduleUnit({
1942
+ unitBase: "hicortex-capture",
1943
+ plistLabel: "com.gamaze.hicortex-capture",
1944
+ serviceDesc: "Hicortex Capture Watchdog (denoise + POST /distill, throttled)",
1945
+ timerDesc: "Hicortex Capture Watchdog",
1946
+ nightlyArgs: ["nightly", "--capture-only", "--watchdog"],
1947
+ intervalSec: CAPTURE_WATCHDOG_INTERVAL_MIN * 60,
1948
+ timeoutMin: 30, // backstop only — capture is no-LLM, bounded; 30min catches a stuck POST
1949
+ });
1950
+ }
1951
+ /**
1952
+ * Install the CONSOLIDATION timer (full `nightly`). Reuses the existing
1953
+ * `hicortex-nightly` unit name (repurposed from the pre-0.17 single full-nightly).
1954
+ */
1955
+ function installConsolidationTimer(hours) {
1956
+ writeScheduleUnit({
1957
+ unitBase: "hicortex-nightly",
1958
+ plistLabel: "com.gamaze.hicortex-nightly",
1959
+ serviceDesc: "Hicortex Nightly (capture + consolidate)",
1960
+ timerDesc: "Hicortex Consolidation Timer",
1961
+ nightlyArgs: ["nightly"],
1962
+ hours,
1963
+ // Backstop only (NOT an operating limit) — set well above the longest
1964
+ // legitimate run so it catches a true hang, never a slow-but-progressing
1965
+ // one. ~5000 LLM calls × ~1–3s/call ≈ 1.4–4.2h → 6h clears it with margin.
1966
+ // Coupled to the consolidateMaxLlmCalls budget (#241): raise together.
1967
+ timeoutMin: 360,
1968
+ });
1969
+ }
1970
+ /**
1971
+ * Remove the consolidation timer + service (and the macOS plist). Used on
1972
+ * CLIENT installs: clients have no local DB, so a pre-0.17 `hicortex-nightly`
1973
+ * timer (which auto-capture-onlys on clients) is redundant with the new capture
1974
+ * timer — remove it so it doesn't double-fire.
1975
+ */
1976
+ function removeConsolidationTimer() {
1977
+ const os = (0, node_os_1.platform)();
1978
+ if (os === "darwin") {
1979
+ const plistPath = (0, node_path_1.join)((0, node_os_1.homedir)(), "Library", "LaunchAgents", "com.gamaze.hicortex-nightly.plist");
1980
+ if ((0, node_fs_1.existsSync)(plistPath)) {
1981
+ try {
1982
+ (0, node_child_process_1.execSync)(`launchctl unload ${plistPath} 2>/dev/null`, { stdio: "pipe" });
1983
+ }
1984
+ catch { /* not loaded */ }
1985
+ try {
1986
+ (0, node_fs_1.rmSync)(plistPath);
1987
+ console.log(" ✓ Removed legacy nightly timer (client mode — capture-only)");
1988
+ }
1989
+ catch { /* leave it */ }
1990
+ }
1991
+ }
1992
+ else if (os === "linux") {
1993
+ try {
1994
+ (0, node_child_process_1.execSync)("systemctl --user disable --now hicortex-nightly.timer 2>/dev/null", { stdio: "pipe" });
1995
+ }
1996
+ catch { /* not installed */ }
1997
+ const unitDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "systemd", "user");
1998
+ let removed = false;
1999
+ for (const name of ["hicortex-nightly.timer", "hicortex-nightly.service"]) {
2000
+ const p = (0, node_path_1.join)(unitDir, name);
2001
+ if ((0, node_fs_1.existsSync)(p)) {
2002
+ try {
2003
+ (0, node_fs_1.rmSync)(p);
2004
+ removed = true;
2005
+ }
2006
+ catch { /* leave it */ }
2007
+ }
2008
+ }
2009
+ if (removed) {
2010
+ try {
2011
+ (0, node_child_process_1.execSync)("systemctl --user daemon-reload 2>/dev/null", { stdio: "pipe" });
2012
+ }
2013
+ catch { /* fine */ }
2014
+ console.log(" ✓ Removed legacy nightly timer (client mode — capture-only)");
1816
2015
  }
1817
2016
  }
1818
2017
  }
@@ -57,11 +57,13 @@ const zod_1 = require("zod");
57
57
  const db_js_1 = require("./db.js");
58
58
  const llm_js_1 = require("./llm.js");
59
59
  const features_js_1 = require("./features.js");
60
+ const config_read_js_1 = require("./config-read.js");
60
61
  const state_js_1 = require("./state.js");
61
62
  const embedder_js_1 = require("./embedder.js");
62
63
  const storage = __importStar(require("./storage.js"));
63
64
  const graph_js_1 = require("./graph.js");
64
65
  const viz_js_1 = require("./viz.js");
66
+ const dashboard_js_1 = require("./dashboard.js");
65
67
  const context_store_js_1 = require("./context-store.js");
66
68
  const retrieval = __importStar(require("./retrieval.js"));
67
69
  const recall_registry_js_1 = require("./recall-registry.js");
@@ -373,6 +375,9 @@ async function startServer(options = {}) {
373
375
  // If nothing is configured: start recall-only with an unmissable warning.
374
376
  // One model serves all phases (#231) — no per-tier overlay here.
375
377
  const savedConfig = readConfigFile(stateDir);
378
+ // 0.16.8 upgrade guard: per-stage keys are silently ignored now. Warn loudly
379
+ // so a carried-over distill/reflect model doesn't silently downgrade quality.
380
+ (0, config_read_js_1.warnIgnoredConfigKeys)(savedConfig);
376
381
  // 0.16.2 activation gap: self-heal the agentId provenance field for
377
382
  // pre-0.16.2 server installs on first boot after upgrade. The server's own
378
383
  // nightly captures its sessions to localhost:8787/distill and needs this id;
@@ -1172,6 +1177,22 @@ async function startServer(options = {}) {
1172
1177
  // bypass). The page collects the token client-side: ?token= URL param
1173
1178
  // (stripped on load) or an in-page prompt on 401, persisted in localStorage.
1174
1179
  app.get("/context/ui", (0, viz_js_1.contextUiHandler)());
1180
+ // GET /dashboard — view-only memory analytics page (#224).
1181
+ //
1182
+ // Self-contained HTML (inline CSS/JS, hand-rolled inline SVG charts, zero
1183
+ // external requests) served from assets/dashboard.html. Fetches
1184
+ // /dashboard/data from its own origin. The page SHELL is public (exempted
1185
+ // in createAuthMiddleware, like /viz and /context/ui — it carries no data);
1186
+ // the /dashboard/data fetch is bearer-only (localhost bypass). The page
1187
+ // collects the token client-side: ?token= URL param (stripped on load) or an
1188
+ // in-page prompt on 401, persisted in localStorage.
1189
+ app.get("/dashboard", (0, viz_js_1.dashboardHandler)());
1190
+ // GET /dashboard/data — the metric payload (series, composition, digest).
1191
+ // Bearer-only (the auth middleware is installed at app boot); no separate
1192
+ // exemption. The handler lives in src/dashboard.ts (pure); this is the thin
1193
+ // express adapter that injects the live db + config. STRICTLY view-only —
1194
+ // no mutation endpoints on the dashboard surface.
1195
+ app.get("/dashboard/data", (0, dashboard_js_1.dashboardDataHandler)(() => db, () => readConfigFile(stateDir)));
1175
1196
  // SSE endpoint — each connection gets its own McpServer + transport
1176
1197
  app.get("/sse", async (req, res) => {
1177
1198
  const transport = new sse_js_1.SSEServerTransport("/messages", res);
package/dist/nightly.d.ts CHANGED
@@ -17,4 +17,12 @@ export declare function runNightly(options?: {
17
17
  stateDir?: string;
18
18
  /** #189 Tier-2 recovery: override discovery to now−N days for one run. */
19
19
  recaptureWindowDays?: number;
20
+ /**
21
+ * Watchdog mode (0.17, #239): the capture timer fires `nightly --watchdog`
22
+ * on a short interval so a transient fire-instant network miss retries in
23
+ * minutes, not at the next daily slot. The gate throttles (success-cooldown)
24
+ * and preflights BEFORE capture; the watchdog never consolidates (it forces
25
+ * capture-only). Uniform across client + server/co-located.
26
+ */
27
+ watchdog?: boolean;
20
28
  }): Promise<void>;