@gamaze/hicortex 0.16.10 → 0.17.1

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.
@@ -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
@@ -262,6 +262,23 @@ export declare const GENERIC_DEFAULT_DOMAINS: DomainDef[];
262
262
  export declare function scaffoldDefaultDomains(configPath: string): {
263
263
  scaffolded: boolean;
264
264
  };
265
+ /**
266
+ * Determine the npm package specifier used in the generated daemon/timer
267
+ * ExecStart (for npx-thin installs — global-binary installs use the absolute
268
+ * binary path and never call this). Tag-based so restarts pick up new versions.
269
+ *
270
+ * Priority:
271
+ * 1. `updateChannel` config key (e.g. "rc") → `@gamaze/hicortex@<channel>`.
272
+ * Lets an install pin a release channel — the internal fleet sets "rc" so
273
+ * its npx-thin hosts track the rc dist-tag through a pre-promotion soak
274
+ * (otherwise the auto-detect below pins @next, which lags rc).
275
+ * 2. Auto-detect: bare `@gamaze/hicortex` if the running version matches the
276
+ * npm `latest` tag, else `@gamaze/hicortex@next` (legacy pre-0.10 cron
277
+ * installs follow @next).
278
+ *
279
+ * Exported + `configDir`-parametrised so the channel override is unit-testable.
280
+ */
281
+ export declare function getPackageSpec(configDir?: string): string;
265
282
  /**
266
283
  * True if a resolved binary path lives in npm's ephemeral npx cache
267
284
  * (`~/.npm/_npx/<hash>/node_modules/.bin/…`). When `hicortex init` is itself
@@ -295,9 +312,31 @@ export declare function runInit(options?: {
295
312
  repairConfig?: boolean;
296
313
  }): Promise<void>;
297
314
  /**
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.
315
+ * Resolve the CONSOLIDATION hours (the only slot-based timer in 0.17). The
316
+ * CAPTURE mechanism is the watchdog (an interval timer, not slots) — uniform
317
+ * across client + server/co-located — so this function no longer returns a
318
+ * capture schedule. Returns null in client mode (no local DB → no
319
+ * consolidation timer).
320
+ *
321
+ * Priority: `consolidationHours` array → legacy `nightlyHour` (single int, only
322
+ * when the array key is absent → one consolidation slot at H, preserving the
323
+ * pre-0.17 "one daily job" intent) → role default ([10, 22] server / null client).
324
+ */
325
+ export declare function resolveConsolidationHours(mode: "server" | "client", configDir?: string): number[] | null;
326
+ /**
327
+ * Legacy single-hour resolver (pre-0.17). Kept for backward compat + the
328
+ * existing tests; new scheduling goes through `resolveConsolidationHours`.
302
329
  */
303
330
  export declare function resolveNightlyHour(mode: "server" | "client", configDir?: string): number;
331
+ /**
332
+ * One `OnCalendar=*-*-* HH:00:00` line per hour, newline-joined — systemd fires
333
+ * a timer at EACH OnCalendar entry (multi-slot in a single timer). Hours are
334
+ * sorted so the generated file is stable/diffable. Exported for testing.
335
+ */
336
+ export declare function formatOnCalendarLines(hours: number[]): string;
337
+ /**
338
+ * The launchd `StartCalendarInterval` ARRAY body — one `<dict>` per hour.
339
+ * launchd fires the job at each dict; a single dict is the 1-slot special case
340
+ * but the array form is uniform across 1..N. Exported for testing.
341
+ */
342
+ export declare function formatLaunchdIntervals(hours: number[]): string;
package/dist/init.js CHANGED
@@ -32,11 +32,15 @@ exports.decideAgentName = decideAgentName;
32
32
  exports.writeAgentNameConfig = writeAgentNameConfig;
33
33
  exports.writeClientConfig = writeClientConfig;
34
34
  exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
35
+ exports.getPackageSpec = getPackageSpec;
35
36
  exports.isEphemeralNpxPath = isEphemeralNpxPath;
36
37
  exports.installSessionStartHook = installSessionStartHook;
37
38
  exports.installRecallHooks = installRecallHooks;
38
39
  exports.runInit = runInit;
40
+ exports.resolveConsolidationHours = resolveConsolidationHours;
39
41
  exports.resolveNightlyHour = resolveNightlyHour;
42
+ exports.formatOnCalendarLines = formatOnCalendarLines;
43
+ exports.formatLaunchdIntervals = formatLaunchdIntervals;
40
44
  const paths_js_1 = require("./paths.js");
41
45
  const telemetry_js_1 = require("./telemetry.js");
42
46
  const node_fs_1 = require("node:fs");
@@ -46,6 +50,7 @@ const node_child_process_1 = require("node:child_process");
46
50
  const node_readline_1 = require("node:readline");
47
51
  const node_crypto_1 = require("node:crypto");
48
52
  const claude_md_js_1 = require("./claude-md.js");
53
+ const config_read_js_1 = require("./config-read.js");
49
54
  const context_store_js_1 = require("./context-store.js");
50
55
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
51
56
  /** This package's version, for the install lifecycle ping (0.15.2). */
@@ -238,15 +243,23 @@ function verifyCcMcp() {
238
243
  }
239
244
  }
240
245
  function allowHicortexTools() {
241
- let settings = {};
242
- if ((0, node_fs_1.existsSync)(CC_SETTINGS)) {
243
- try {
244
- settings = JSON.parse((0, node_fs_1.readFileSync)(CC_SETTINGS, "utf-8"));
245
- }
246
- catch {
247
- console.log(` ${CC_SETTINGS} exists but is not valid JSON — skipping tool permissions. Fix the file, then re-run init or add "mcp__hicortex__*" to permissions.allow manually.`);
248
- return;
249
- }
246
+ // Only EDIT an existing CC settings file — never invent one. On a host with
247
+ // no CC client (a server, or a Hermes-only box) ~/.claude/settings.json is
248
+ // absent; creating a stub there is wrong, and the earlier mkdir+write was the
249
+ // ENOENT throw on bedrock's init. Without this entry CC just PROMPTS before
250
+ // tool use instead of auto-allowing — the MCP still works either way.
251
+ if (!(0, node_fs_1.existsSync)(CC_SETTINGS)) {
252
+ console.log(` ${CC_SETTINGS} not found — skipping CC tool permissions (no CC client here; ` +
253
+ `the MCP works, CC will ask before tool use).`);
254
+ return;
255
+ }
256
+ let settings;
257
+ try {
258
+ settings = JSON.parse((0, node_fs_1.readFileSync)(CC_SETTINGS, "utf-8"));
259
+ }
260
+ catch {
261
+ console.log(` ⚠ ${CC_SETTINGS} exists but is not valid JSON — skipping tool permissions. Fix the file, then re-run init or add "mcp__hicortex__*" to permissions.allow manually.`);
262
+ return;
250
263
  }
251
264
  if (!settings.permissions)
252
265
  settings.permissions = {};
@@ -257,7 +270,6 @@ function allowHicortexTools() {
257
270
  const rule = "mcp__hicortex__*";
258
271
  if (!allow.includes(rule)) {
259
272
  allow.push(rule);
260
- (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(CC_SETTINGS), { recursive: true });
261
273
  (0, node_fs_1.writeFileSync)(CC_SETTINGS, JSON.stringify(settings, null, 2));
262
274
  console.log(` ✓ Added Hicortex tool permissions to ${CC_SETTINGS}`);
263
275
  }
@@ -1064,14 +1076,38 @@ function scaffoldDefaultDomains(configPath) {
1064
1076
  return { scaffolded: true };
1065
1077
  }
1066
1078
  /**
1067
- * Determine the npm package specifier for the daemon.
1068
- * Uses tag-based resolution so restarts pick up new versions automatically.
1079
+ * Determine the npm package specifier used in the generated daemon/timer
1080
+ * ExecStart (for npx-thin installs global-binary installs use the absolute
1081
+ * binary path and never call this). Tag-based so restarts pick up new versions.
1082
+ *
1083
+ * Priority:
1084
+ * 1. `updateChannel` config key (e.g. "rc") → `@gamaze/hicortex@<channel>`.
1085
+ * Lets an install pin a release channel — the internal fleet sets "rc" so
1086
+ * its npx-thin hosts track the rc dist-tag through a pre-promotion soak
1087
+ * (otherwise the auto-detect below pins @next, which lags rc).
1088
+ * 2. Auto-detect: bare `@gamaze/hicortex` if the running version matches the
1089
+ * npm `latest` tag, else `@gamaze/hicortex@next` (legacy pre-0.10 cron
1090
+ * installs follow @next).
1069
1091
  *
1070
- * Checks if the current version matches the npm `latest` tag.
1071
- * If not (e.g. running from @next), uses @gamaze/hicortex@next.
1072
- * If it does match latest, uses bare @gamaze/hicortex.
1092
+ * Exported + `configDir`-parametrised so the channel override is unit-testable.
1073
1093
  */
1074
- function getPackageSpec() {
1094
+ function getPackageSpec(configDir = HICORTEX_HOME) {
1095
+ try {
1096
+ const { config } = loadConfigStrict((0, node_path_1.join)(configDir, "config.json"));
1097
+ const ch = config.updateChannel;
1098
+ if (typeof ch === "string") {
1099
+ const trimmed = ch.trim();
1100
+ // A dist-tag (rc/next/latest) or an exact version — alphanumerics, dot,
1101
+ // dash, underscore only. Reject anything else: a newline would break the
1102
+ // systemd `ExecStart=` one-liner and `<`/`&` would break the launchd plist
1103
+ // XML the timer writes. Fall through to auto-detect + warn.
1104
+ if (/^[\w.\-]+$/.test(trimmed))
1105
+ return `@gamaze/hicortex@${trimmed}`;
1106
+ console.warn(`[hicortex] config "updateChannel" = ${JSON.stringify(ch)} is not a valid dist-tag/version ` +
1107
+ `(use e.g. "rc", "next", or "0.17.1") — ignored.`);
1108
+ }
1109
+ }
1110
+ catch { /* no config yet, or malformed — fall through to auto-detect */ }
1075
1111
  try {
1076
1112
  const currentVersion = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8")).version;
1077
1113
  const latestVersion = (0, node_child_process_1.execSync)("npm view @gamaze/hicortex version 2>/dev/null", {
@@ -1452,10 +1488,13 @@ async function runInit(options = {}) {
1452
1488
  console.log(` ✓ Agent name set to '${decision.value}'`);
1453
1489
  }
1454
1490
  }
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"));
1491
+ // Install the scheduling timers (0.17): a CAPTURE WATCHDOG (short-interval
1492
+ // poll, success-cooldown-throttled uniform with clients) + a CONSOLIDATION
1493
+ // timer (the full nightly, fixed slots). Re-init rewrites both; customize the
1494
+ // consolidation slots via consolidationHours in config.json (capture cadence
1495
+ // via captureCooldownHours).
1496
+ installCaptureWatchdogTimer();
1497
+ installConsolidationTimer(resolveConsolidationHours("server") ?? DEFAULT_CONSOLIDATION_HOURS);
1459
1498
  // Install daemon if needed
1460
1499
  if (!d.localServer && !d.remoteServer) {
1461
1500
  installDaemon();
@@ -1657,8 +1696,12 @@ async function runClientInit(serverUrl, agentName) {
1657
1696
  if ((0, claude_md_js_1.removeLessonsBlock)(claudeMdPath)) {
1658
1697
  console.log(` ✓ Removed old static lessons block from CLAUDE.md — lessons now injected at session start`);
1659
1698
  }
1660
- // Step 7: Install nightly cron (denoise locally, POST to server /distill)
1661
- installNightlyCron(resolveNightlyHour("client"));
1699
+ // Step 7: Install the CAPTURE WATCHDOG (denoise locally, POST to server
1700
+ // /distill, throttled + preflight-gated) — the same capture mechanism every
1701
+ // install gets. Clients have no local DB → no consolidation timer; also
1702
+ // remove any legacy full-nightly timer a pre-0.17 install left behind.
1703
+ installCaptureWatchdogTimer();
1704
+ removeConsolidationTimer();
1662
1705
  // Step 8: Setup Hermes if detected
1663
1706
  if ((0, node_fs_1.existsSync)(HERMES_HOME)) {
1664
1707
  console.log("\nHermes detected — installing plugin...");
@@ -1686,59 +1729,164 @@ async function runClientInit(serverUrl, agentName) {
1686
1729
  console.log("Restart your agents to activate.");
1687
1730
  }
1688
1731
  /**
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.
1732
+ * Scheduling (0.17): one UNIFORM capture mechanism + one role-specific
1733
+ * consolidation timer, replacing the old single daily full-nightly.
1734
+ *
1735
+ * - CAPTURE WATCHDOG (`hicortex-capture`): a short-interval timer fires
1736
+ * `nightly --capture-only --watchdog`. The watchdog throttles by a
1737
+ * success-cooldown (`captureCooldownHours`, default 6 ≈ 4 captures/day) and
1738
+ * preflights before capturing, so a transient fire-instant network miss
1739
+ * retries in minutes, not at the next daily slot (#239: a once-daily client
1740
+ * fire that caught a slow/flaky link lost ~24h of capture). Installed
1741
+ * UNIFORMLY for client AND server/co-located — one capture code path, no
1742
+ * per-topology branch. A failed preflight retries on the next tick; a
1743
+ * successful capture waits the cooldown (success-based cooldown — the
1744
+ * better semantics; the custom server watchdog used trigger-based).
1745
+ * - CONSOLIDATION timer (`hicortex-nightly`): full `nightly` (capture +
1746
+ * distill + score + reflect + link). Server/co-located ONLY — clients have
1747
+ * no local DB, so no consolidation timer is installed (and a legacy one
1748
+ * left by a pre-0.17 install is removed). Fixed slots (default [10, 22]).
1749
+ *
1750
+ * Customize: `consolidationHours` (the consolidation slots) and
1751
+ * `captureCooldownHours` (the watchdog throttle) in config.json. Re-init
1752
+ * rewrites the timers to the resolved standard (the pre-0.17 "never overwrite"
1753
+ * contract is intentionally relaxed so the fleet adopts the standard). The
1754
+ * legacy single `nightlyHour` is honoured as a one-slot consolidation fallback
1755
+ * only when `consolidationHours` is absent (preserves "one daily job at H").
1756
+ */
1757
+ const DEFAULT_CONSOLIDATION_HOURS = [10, 22];
1758
+ /**
1759
+ * Capture-watchdog poll interval (minutes). The capture timer fires
1760
+ * `nightly --capture-only --watchdog` this often; the watchdog itself throttles
1761
+ * by the success-cooldown (`captureCooldownHours`, read at runtime). Short so a
1762
+ * transient fire-instant network miss retries in minutes, not at the next daily
1763
+ * slot (#239). Cheap: a tick against a down server is one 5s preflight.
1764
+ */
1765
+ const CAPTURE_WATCHDOG_INTERVAL_MIN = 20;
1766
+ /**
1767
+ * Resolve the CONSOLIDATION hours (the only slot-based timer in 0.17). The
1768
+ * CAPTURE mechanism is the watchdog (an interval timer, not slots) — uniform
1769
+ * across client + server/co-located — so this function no longer returns a
1770
+ * capture schedule. Returns null in client mode (no local DB → no
1771
+ * consolidation timer).
1772
+ *
1773
+ * Priority: `consolidationHours` array → legacy `nightlyHour` (single int, only
1774
+ * when the array key is absent → one consolidation slot at H, preserving the
1775
+ * pre-0.17 "one daily job" intent) → role default ([10, 22] server / null client).
1776
+ */
1777
+ function resolveConsolidationHours(mode, configDir = HICORTEX_HOME) {
1778
+ let config = {};
1779
+ try {
1780
+ config = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(configDir, "config.json"), "utf-8"));
1781
+ }
1782
+ catch { /* no config yet — use the standard default */ }
1783
+ if (mode === "client")
1784
+ return null; // clients have no local DB → no consolidation timer
1785
+ const arr = (0, config_read_js_1.parseHours)(config, "consolidationHours");
1786
+ if (arr)
1787
+ return arr;
1788
+ const legacy = readLegacyNightlyHour(config);
1789
+ if (legacy !== null)
1790
+ return [legacy];
1791
+ return DEFAULT_CONSOLIDATION_HOURS;
1792
+ }
1793
+ /** Read the legacy `nightlyHour` (single int 0–23) if validly set, else null. */
1794
+ function readLegacyNightlyHour(config) {
1795
+ const h = config.nightlyHour;
1796
+ if (typeof h === "number" && Number.isInteger(h) && h >= 0 && h <= 23)
1797
+ return h;
1798
+ return null;
1799
+ }
1800
+ /**
1801
+ * Legacy single-hour resolver (pre-0.17). Kept for backward compat + the
1802
+ * existing tests; new scheduling goes through `resolveConsolidationHours`.
1693
1803
  */
1694
1804
  function resolveNightlyHour(mode, configDir = HICORTEX_HOME) {
1695
1805
  try {
1696
1806
  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;
1807
+ const legacy = readLegacyNightlyHour(config);
1808
+ if (legacy !== null)
1809
+ return legacy;
1700
1810
  }
1701
1811
  catch { /* no config yet — use the default */ }
1702
1812
  return mode === "server" ? 3 : 2;
1703
1813
  }
1704
- function installNightlyCron(hour) {
1814
+ /**
1815
+ * One `OnCalendar=*-*-* HH:00:00` line per hour, newline-joined — systemd fires
1816
+ * a timer at EACH OnCalendar entry (multi-slot in a single timer). Hours are
1817
+ * sorted so the generated file is stable/diffable. Exported for testing.
1818
+ */
1819
+ function formatOnCalendarLines(hours) {
1820
+ return [...hours]
1821
+ .sort((a, b) => a - b)
1822
+ .map((h) => `OnCalendar=*-*-* ${String(h).padStart(2, "0")}:00:00`)
1823
+ .join("\n");
1824
+ }
1825
+ /**
1826
+ * The launchd `StartCalendarInterval` ARRAY body — one `<dict>` per hour.
1827
+ * launchd fires the job at each dict; a single dict is the 1-slot special case
1828
+ * but the array form is uniform across 1..N. Exported for testing.
1829
+ */
1830
+ function formatLaunchdIntervals(hours) {
1831
+ return [...hours]
1832
+ .sort((a, b) => a - b)
1833
+ .map((h) => ` <dict>
1834
+ <key>Hour</key>
1835
+ <integer>${h}</integer>
1836
+ <key>Minute</key>
1837
+ <integer>0</integer>
1838
+ </dict>`)
1839
+ .join("\n");
1840
+ }
1841
+ /**
1842
+ * Write + enable one schedule unit (timer + service on Linux, plist on macOS),
1843
+ * multi-slot. Shared by the capture and consolidation installers. Always
1844
+ * rewrites both files (the 0.17 migration decision: re-init brings an install
1845
+ * up to the resolved standard; customization is via config keys, not hand-
1846
+ * edited unit files). Logs the resolved slots.
1847
+ */
1848
+ function writeScheduleUnit(opts) {
1705
1849
  const binaryArgs = resolveBinaryArgs();
1706
1850
  const os = (0, node_os_1.platform)();
1707
- const hh = String(hour).padStart(2, "0");
1708
1851
  // PATH must start with the binary's own directory (see installLaunchd for rationale).
1709
1852
  const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
1710
1853
  // One canonical nightly log path across platforms — status output, docs,
1711
1854
  // and support instructions all reference this single location.
1712
1855
  const logPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly.log");
1856
+ const isInterval = typeof opts.intervalSec === "number";
1857
+ if (!isInterval && (!opts.hours || opts.hours.length === 0)) {
1858
+ throw new Error("writeScheduleUnit: provide either hours or intervalSec");
1859
+ }
1860
+ // Human-readable schedule label for the log line.
1861
+ const slotLabel = isInterval
1862
+ ? `every ${Math.round(opts.intervalSec / 60)} min`
1863
+ : [...opts.hours].sort((a, b) => a - b).map((h) => `${String(h).padStart(2, "0")}:00`).join(", ");
1713
1864
  if (os === "darwin") {
1714
1865
  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"]
1866
+ const plistPath = (0, node_path_1.join)(plistDir, `${opts.plistLabel}.plist`);
1867
+ const programArgs = [...binaryArgs, ...opts.nightlyArgs]
1723
1868
  .map((a) => ` <string>${a}</string>`)
1724
1869
  .join("\n");
1870
+ // Schedule block: StartInterval (seconds) for the watchdog poll, or
1871
+ // StartCalendarInterval as an ARRAY of dicts (one per hour) for slots.
1872
+ // RunAtLoad ONLY on the interval (watchdog) plist — so a Mac that reboots
1873
+ // gets a first capture tick on load (~parity with systemd's OnBootSec=2min),
1874
+ // not 20 min later. The cooldown gate makes a load-time fire a cheap no-op
1875
+ // if a capture ran recently.
1876
+ const scheduleBlock = isInterval
1877
+ ? ` <key>StartInterval</key>\n <integer>${opts.intervalSec}</integer>\n <key>RunAtLoad</key>\n <true/>`
1878
+ : ` <key>StartCalendarInterval</key>\n <array>\n${formatLaunchdIntervals(opts.hours)}\n </array>`;
1725
1879
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
1726
1880
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1727
1881
  <plist version="1.0">
1728
1882
  <dict>
1729
1883
  <key>Label</key>
1730
- <string>com.gamaze.hicortex-nightly</string>
1884
+ <string>${opts.plistLabel}</string>
1731
1885
  <key>ProgramArguments</key>
1732
1886
  <array>
1733
1887
  ${programArgs}
1734
1888
  </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>
1889
+ ${scheduleBlock}
1742
1890
  <key>StandardOutPath</key>
1743
1891
  <string>${logPath}</string>
1744
1892
  <key>StandardErrorPath</key>
@@ -1756,63 +1904,146 @@ ${programArgs}
1756
1904
  try {
1757
1905
  (0, node_child_process_1.execSync)(`launchctl unload ${plistPath} 2>/dev/null`, { stdio: "pipe" });
1758
1906
  }
1759
- catch { }
1907
+ catch { /* not loaded */ }
1760
1908
  (0, node_child_process_1.execSync)(`launchctl load ${plistPath}`, { stdio: "pipe" });
1761
- console.log(` ✓ Installed nightly cron (runs daily at ${hh}:00)`);
1909
+ console.log(` ✓ Installed ${opts.timerDesc} (${slotLabel})`);
1762
1910
  }
1763
1911
  catch {
1764
- console.log(` ⚠ Could not load nightly plist. Load manually: launchctl load ${plistPath}`);
1912
+ console.log(` ⚠ Could not load plist. Load manually: launchctl load ${plistPath}`);
1765
1913
  }
1766
1914
  }
1767
1915
  else if (os === "linux") {
1768
1916
  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(" ");
1917
+ const servicePath = (0, node_path_1.join)(configDir, `${opts.unitBase}.service`);
1918
+ const timerPath = (0, node_path_1.join)(configDir, `${opts.unitBase}.timer`);
1919
+ const execStart = [...binaryArgs, ...opts.nightlyArgs].join(" ");
1772
1920
  // File logging, not journal: oneshot runs on machines with a volatile
1773
1921
  // journal (e.g. Raspberry Pi defaults) otherwise fail without a trace.
1774
1922
  // Same log path as the macOS plist. append: needs systemd ≥ 240 (2018).
1775
1923
  const service = `[Unit]
1776
- Description=Hicortex Nightly (distill + POST)
1924
+ Description=${opts.serviceDesc}
1777
1925
 
1778
1926
  [Service]
1779
1927
  Type=oneshot
1780
1928
  ExecStart=${execStart}
1781
- StandardOutput=append:${logPath}
1929
+ ${opts.timeoutMin ? `TimeoutStartSec=${opts.timeoutMin}min\n` : ""}StandardOutput=append:${logPath}
1782
1930
  StandardError=append:${logPath}
1783
1931
  Environment=PATH=${binDir}:/usr/local/bin:/usr/bin:/bin
1784
1932
  Environment=HOME=${(0, node_os_1.homedir)()}
1785
1933
  WorkingDirectory=${(0, node_os_1.homedir)()}`;
1934
+ // Timer body: OnUnitActiveSec (interval, watchdog) or one OnCalendar line
1935
+ // per hour (multi-slot). systemd ORs multiple OnCalendar entries.
1936
+ const timerBody = isInterval
1937
+ ? `OnBootSec=2min\nOnUnitActiveSec=${Math.round(opts.intervalSec / 60)}min`
1938
+ : formatOnCalendarLines(opts.hours);
1786
1939
  const timer = `[Unit]
1787
- Description=Hicortex Nightly Timer
1940
+ Description=${opts.timerDesc}
1788
1941
 
1789
1942
  [Timer]
1790
- OnCalendar=*-*-* ${hh}:00:00
1943
+ ${timerBody}
1791
1944
  Persistent=true
1792
1945
 
1793
1946
  [Install]
1794
1947
  WantedBy=timers.target`;
1795
1948
  (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.
1949
+ // Always rewrite both .service and .timer (0.17 migration: re-init adopts
1950
+ // the resolved standard schedule; the config keys are the tuning surface).
1799
1951
  (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
1952
  (0, node_fs_1.writeFileSync)(timerPath, timer);
1809
1953
  try {
1810
1954
  (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)`);
1955
+ (0, node_child_process_1.execSync)(`systemctl --user enable --now ${opts.unitBase}.timer`, { stdio: "pipe" });
1956
+ console.log(` ✓ Installed ${opts.unitBase}.timer (${slotLabel})`);
1813
1957
  }
1814
1958
  catch {
1815
- console.log(` ⚠ Could not enable nightly timer. Enable manually: systemctl --user enable --now hicortex-nightly.timer`);
1959
+ console.log(` ⚠ Could not enable ${opts.unitBase}.timer. Enable manually: ` +
1960
+ `systemctl --user enable --now ${opts.unitBase}.timer`);
1961
+ }
1962
+ }
1963
+ }
1964
+ /**
1965
+ * Install the CAPTURE WATCHDOG timer — a short-interval poll that fires
1966
+ * `nightly --capture-only --watchdog`. The watchdog itself throttles by the
1967
+ * success-cooldown (`captureCooldownHours`) and preflights, so a transient
1968
+ * fire-instant network miss retries in minutes, not at the next daily slot
1969
+ * (#239). Installed UNIFORMLY for client + server/co-located (one capture
1970
+ * mechanism everywhere — no per-topology branch).
1971
+ */
1972
+ function installCaptureWatchdogTimer() {
1973
+ writeScheduleUnit({
1974
+ unitBase: "hicortex-capture",
1975
+ plistLabel: "com.gamaze.hicortex-capture",
1976
+ serviceDesc: "Hicortex Capture Watchdog (denoise + POST /distill, throttled)",
1977
+ timerDesc: "Hicortex Capture Watchdog",
1978
+ nightlyArgs: ["nightly", "--capture-only", "--watchdog"],
1979
+ intervalSec: CAPTURE_WATCHDOG_INTERVAL_MIN * 60,
1980
+ timeoutMin: 30, // backstop only — capture is no-LLM, bounded; 30min catches a stuck POST
1981
+ });
1982
+ }
1983
+ /**
1984
+ * Install the CONSOLIDATION timer (full `nightly`). Reuses the existing
1985
+ * `hicortex-nightly` unit name (repurposed from the pre-0.17 single full-nightly).
1986
+ */
1987
+ function installConsolidationTimer(hours) {
1988
+ writeScheduleUnit({
1989
+ unitBase: "hicortex-nightly",
1990
+ plistLabel: "com.gamaze.hicortex-nightly",
1991
+ serviceDesc: "Hicortex Nightly (capture + consolidate)",
1992
+ timerDesc: "Hicortex Consolidation Timer",
1993
+ nightlyArgs: ["nightly"],
1994
+ hours,
1995
+ // Backstop only (NOT an operating limit) — set well above the longest
1996
+ // legitimate run so it catches a true hang, never a slow-but-progressing
1997
+ // one. ~5000 LLM calls × ~1–3s/call ≈ 1.4–4.2h → 6h clears it with margin.
1998
+ // Coupled to the consolidateMaxLlmCalls budget (#241): raise together.
1999
+ timeoutMin: 360,
2000
+ });
2001
+ }
2002
+ /**
2003
+ * Remove the consolidation timer + service (and the macOS plist). Used on
2004
+ * CLIENT installs: clients have no local DB, so a pre-0.17 `hicortex-nightly`
2005
+ * timer (which auto-capture-onlys on clients) is redundant with the new capture
2006
+ * timer — remove it so it doesn't double-fire.
2007
+ */
2008
+ function removeConsolidationTimer() {
2009
+ const os = (0, node_os_1.platform)();
2010
+ if (os === "darwin") {
2011
+ const plistPath = (0, node_path_1.join)((0, node_os_1.homedir)(), "Library", "LaunchAgents", "com.gamaze.hicortex-nightly.plist");
2012
+ if ((0, node_fs_1.existsSync)(plistPath)) {
2013
+ try {
2014
+ (0, node_child_process_1.execSync)(`launchctl unload ${plistPath} 2>/dev/null`, { stdio: "pipe" });
2015
+ }
2016
+ catch { /* not loaded */ }
2017
+ try {
2018
+ (0, node_fs_1.rmSync)(plistPath);
2019
+ console.log(" ✓ Removed legacy nightly timer (client mode — capture-only)");
2020
+ }
2021
+ catch { /* leave it */ }
2022
+ }
2023
+ }
2024
+ else if (os === "linux") {
2025
+ try {
2026
+ (0, node_child_process_1.execSync)("systemctl --user disable --now hicortex-nightly.timer 2>/dev/null", { stdio: "pipe" });
2027
+ }
2028
+ catch { /* not installed */ }
2029
+ const unitDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "systemd", "user");
2030
+ let removed = false;
2031
+ for (const name of ["hicortex-nightly.timer", "hicortex-nightly.service"]) {
2032
+ const p = (0, node_path_1.join)(unitDir, name);
2033
+ if ((0, node_fs_1.existsSync)(p)) {
2034
+ try {
2035
+ (0, node_fs_1.rmSync)(p);
2036
+ removed = true;
2037
+ }
2038
+ catch { /* leave it */ }
2039
+ }
2040
+ }
2041
+ if (removed) {
2042
+ try {
2043
+ (0, node_child_process_1.execSync)("systemctl --user daemon-reload 2>/dev/null", { stdio: "pipe" });
2044
+ }
2045
+ catch { /* fine */ }
2046
+ console.log(" ✓ Removed legacy nightly timer (client mode — capture-only)");
1816
2047
  }
1817
2048
  }
1818
2049
  }