@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.
- package/README.md +6 -2
- package/assets/dashboard.html +454 -0
- package/dist/cli.js +12 -1
- package/dist/config-read.d.ts +13 -0
- package/dist/config-read.js +37 -0
- package/dist/consolidate.d.ts +17 -1
- package/dist/consolidate.js +22 -4
- package/dist/dashboard.d.ts +162 -0
- package/dist/dashboard.js +410 -0
- package/dist/db.js +24 -0
- package/dist/hermes-transcript-reader.d.ts +15 -6
- package/dist/hermes-transcript-reader.js +32 -7
- package/dist/init.d.ts +43 -4
- package/dist/init.js +302 -71
- package/dist/mcp-server.js +17 -0
- package/dist/nightly.d.ts +8 -0
- package/dist/nightly.js +124 -12
- package/dist/telemetry.d.ts +11 -0
- package/dist/types.d.ts +38 -0
- package/dist/uninstall.js +12 -5
- package/dist/viz.d.ts +16 -0
- package/dist/viz.js +49 -0
- package/package.json +1 -1
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* We process ENDED sessions (ended_at set) that ended since the last run PLUS
|
|
19
|
+
* OPEN sessions (ended_at NULL) that have messages — a 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
|
|
49
|
-
*
|
|
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(
|
|
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
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
1068
|
-
*
|
|
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
|
-
*
|
|
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
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
|
|
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
|
|
1661
|
-
|
|
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
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
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
|
|
1698
|
-
if (
|
|
1699
|
-
return
|
|
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
|
-
|
|
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,
|
|
1716
|
-
|
|
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
|
|
1884
|
+
<string>${opts.plistLabel}</string>
|
|
1731
1885
|
<key>ProgramArguments</key>
|
|
1732
1886
|
<array>
|
|
1733
1887
|
${programArgs}
|
|
1734
1888
|
</array>
|
|
1735
|
-
|
|
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
|
|
1909
|
+
console.log(` ✓ Installed ${opts.timerDesc} (${slotLabel})`);
|
|
1762
1910
|
}
|
|
1763
1911
|
catch {
|
|
1764
|
-
console.log(` ⚠ Could not load
|
|
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,
|
|
1770
|
-
const timerPath = (0, node_path_1.join)(configDir,
|
|
1771
|
-
const execStart = [...binaryArgs,
|
|
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
|
|
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
|
|
1940
|
+
Description=${opts.timerDesc}
|
|
1788
1941
|
|
|
1789
1942
|
[Timer]
|
|
1790
|
-
|
|
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
|
-
//
|
|
1797
|
-
//
|
|
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)(
|
|
1812
|
-
console.log(` ✓ Installed
|
|
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
|
|
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
|
}
|