@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 CHANGED
@@ -215,8 +215,12 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
215
215
  | `contextClients` | Which harnesses inject the [context layer](#context-layer) at session start (default `["cc"]`; `"all"` or any subset of `cc`/`hermes`/`oc`) |
216
216
  | `contextAgents` | Per-agent context modes (0.13): `{ "<id>": "override" \| "global" \| "off" }`. Absent + no `agents/<id>/` dir → every agent gets the global set. Boot-time (restart to apply) — see [Per-agent context](#per-agent-context-013) |
217
217
  | `agentName` | This install's per-agent context id sent as `?agent=`. **Unset by default** (CC shares the global context — no `?agent=` sent). Explicit opt-in via `init --agent-name <name>`; `init --agent-name ""` clears it. An empty/whitespace value equals unset |
218
- | `nightlyHour` | Local hour (0–23) for the nightly job installed by `init` (defaults: client 2, server 3). Applied on fresh installs; existing schedules are never overwritten |
219
- | `preflightTimeoutMs` | **Client mode only.** Per-attempt timeout for the nightly's server-reachability check before it starts capturing (default: 15000 ms) |
218
+ | `captureCooldownHours` | Success-cooldown (hours) for the **capture watchdog** (0.17). The capture timer polls every ~20 min; the watchdog captures only if more than this has elapsed since the last *successful* capture (`state.lastNightly`). Default `6` (≈4 captures/day). A failed preflight retries on the next poll (~20 min) so a transient fire-instant network miss costs minutes, not a day (#239) |
219
+ | `consolidationHours` | Hours (0–23, local) for the **consolidation** timer — the full nightly (capture + distill + score + reflect + link). Installed for **server/co-located only** (clients have no local DB). Default `[10, 22]`: the 22:00 evening slot runs after the day's capture waves (same-day results); the 10:00 morning slot runs *after* the morning capture so wake-up pushes are caught. Omitted on clients |
220
+ | `consolidateMaxLlmCalls` | Ceiling on total LLM calls across all classify-tier consolidation stages (content-domain, link discovery, supersession) per run. A runaway **backstop**, not a throughput throttle — on a free local model the binding constraint is the nightly unit's wall-clock timeout, not call count. Default `5000` (was a hard-coded 200 that starved link/supersession during a classification backlog) |
221
+ | `updateChannel` | Release channel pinned into the generated daemon/timer ExecStart for **npx-thin** installs (global-binary installs use the absolute binary and are unaffected). A dist-tag (`"rc"`, `"next"`) or an exact version (`"0.17.1"`). E.g. `"rc"` → the timer runs `npx -y @gamaze/hicortex@rc nightly`, so the host tracks the rc dist-tag (an internal fleet can ride rc through a pre-promotion soak). Validated as `[\w.\-]+` (rejects anything that'd break the unit/plist templates). Absent → auto-detect (bare on `latest`, else `@next`). (0.17.1) |
222
+ | `nightlyHour` | **Deprecated (0.17) single-slot fallback.** Local hour (0–23) honoured only when `consolidationHours` is absent — yields one daily consolidation slot at that hour (preserves the pre-0.17 "one daily job" intent). New installs should use `consolidationHours` |
223
+ | `preflightTimeoutMs` | **Client mode only.** Per-attempt timeout for the nightly's server-reachability check before it starts capturing (default: 20000 ms, bumped from 15000 in 0.17 to absorb a cold Tailscale handshake on a roaming laptop) |
220
224
  | `preflightAttempts` | **Client mode only.** Reachability-check retries before the nightly aborts (default: 3; floored at 1). `1` = single try, no retry |
221
225
  | `preflightRetryGapMs` | **Client mode only.** Delay between reachability retries (default: 60000 ms). Note: timers don't advance while the machine is asleep, so on a sleeping laptop this gap counts awake-time, not wall-clock |
222
226
  | `scoreSimilarityWeight` | Weight of semantic similarity in the ranking score (default: 0.50) |
@@ -0,0 +1,454 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Hicortex — dashboard</title>
7
+ <!--
8
+ Hicortex memory analytics dashboard (#224).
9
+ STRICTLY view-only — no actions, no queue, no mutation endpoints.
10
+ Self-contained: inline CSS/JS, hand-rolled inline SVG charts (owner
11
+ decision — no Chart.js, no CDN, zero external requests). CSP-safe.
12
+
13
+ Data source: GET /dashboard/data on this server (same origin, bearer-only).
14
+ The page SHELL is public (exempted in createAuthMiddleware, like /viz); the
15
+ /dashboard/data call is bearer-only with a localhost bypass, so on the
16
+ server box no token is needed. Remote browsers hand off a token via
17
+ ?token=<t> (stripped from the URL on load), localStorage, or a 401 prompt.
18
+
19
+ Memory entries link to /viz and /memory?id= (view-only drill-down).
20
+ -->
21
+ <style>
22
+ :root {
23
+ --bg: #0f1117;
24
+ --panel: #161a23;
25
+ --panel-border: #262c3a;
26
+ --text: #d5dae4;
27
+ --text-dim: #8b93a5;
28
+ --accent: #5b9dd9;
29
+ --accent-2: #61c98f;
30
+ --warn: #e0b055;
31
+ --danger: #e05555;
32
+ }
33
+ @media (prefers-color-scheme: light) {
34
+ :root {
35
+ --bg: #ffffff;
36
+ --panel: #f6f7f9;
37
+ --panel-border: #d9dce2;
38
+ --text: #1c1f26;
39
+ --text-dim: #656d7a;
40
+ --accent: #2f6fb0;
41
+ --accent-2: #2f8f4e;
42
+ --warn: #b08030;
43
+ --danger: #c0392b;
44
+ }
45
+ }
46
+ * { box-sizing: border-box; margin: 0; padding: 0; }
47
+ body {
48
+ background: var(--bg);
49
+ color: var(--text);
50
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
51
+ padding: 20px;
52
+ }
53
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
54
+ h2 { font-size: 15px; font-weight: 600; color: var(--text-dim); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
55
+ .sub { color: var(--text-dim); margin-bottom: 24px; }
56
+ .controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
57
+ .controls label { color: var(--text-dim); font-size: 13px; }
58
+ select, input[type="date"] {
59
+ background: var(--panel); color: var(--text);
60
+ border: 1px solid var(--panel-border); border-radius: 4px;
61
+ padding: 4px 8px; font: inherit;
62
+ }
63
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
64
+ @media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
65
+ .panel { background: var(--panel); border: 1px solid var(--panel-border); border-radius: 8px; padding: 16px; }
66
+ .headline { display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 24px; }
67
+ .stat { display: flex; flex-direction: column; gap: 4px; }
68
+ .stat .v { font-size: 28px; font-weight: 600; color: var(--accent); }
69
+ .stat .v.warn { color: var(--warn); }
70
+ .stat .l { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
71
+ svg { display: block; width: 100%; height: 200px; overflow: visible; }
72
+ .chart-x { fill: var(--text-dim); font-size: 10px; }
73
+ .legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); margin-top: 8px; }
74
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
75
+ .legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
76
+ .bar-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; font-size: 13px; }
77
+ .bar-row .k { width: 140px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
+ .bar-row .track { flex: 1; height: 14px; background: rgba(128,128,128,0.12); border-radius: 3px; overflow: hidden; }
79
+ .bar-row .fill { height: 100%; background: var(--accent); }
80
+ .bar-row .n { width: 50px; text-align: right; color: var(--text-dim); font-variant-numeric: tabular-nums; }
81
+ .line { font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text); white-space: pre-wrap; word-break: break-word; }
82
+ .line a { color: var(--accent); text-decoration: none; }
83
+ .line a:hover { text-decoration: underline; }
84
+ .lesson { background: var(--bg); border: 1px solid var(--panel-border); border-radius: 4px; padding: 10px; margin: 6px 0; white-space: pre-wrap; word-break: break-word; }
85
+ .muted { color: var(--text-dim); }
86
+ .err { color: var(--danger); }
87
+ table.digest { width: 100%; border-collapse: collapse; font-size: 13px; }
88
+ table.digest td { padding: 4px 8px; border-bottom: 1px solid var(--panel-border); vertical-align: top; }
89
+ table.digest td.k { color: var(--text-dim); width: 180px; }
90
+ .stage-pill { display: inline-block; padding: 1px 6px; border-radius: 10px; background: rgba(128,128,128,0.18); font-size: 11px; color: var(--text-dim); margin-right: 4px; }
91
+ .empty { color: var(--text-dim); font-style: italic; padding: 12px 0; }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <h1>Hicortex — memory dashboard</h1>
96
+ <div class="sub">View-only analytics. All metrics derived from the live corpus + nightly snapshots.</div>
97
+
98
+ <div class="controls">
99
+ <label>Range
100
+ <select id="range">
101
+ <option value="7d">7 days</option>
102
+ <option value="30d" selected>30 days</option>
103
+ <option value="90d">90 days</option>
104
+ <option value="all">All time</option>
105
+ </select>
106
+ </label>
107
+ <label>Split growth by
108
+ <select id="split">
109
+ <option value="total">Total memories (cumulative)</option>
110
+ <option value="captures">Captures / day</option>
111
+ </select>
112
+ </label>
113
+ <label>Filter
114
+ <select id="filter"></select>
115
+ </label>
116
+ <label>Digest date
117
+ <input type="date" id="date" />
118
+ </label>
119
+ <button id="reload" type="button" style="background:var(--panel);color:var(--text);border:1px solid var(--panel-border);border-radius:4px;padding:4px 12px;font:inherit;cursor:pointer;">Refresh</button>
120
+ </div>
121
+
122
+ <div class="headline" id="headline"></div>
123
+
124
+ <div class="grid">
125
+ <div class="panel">
126
+ <h2>Growth trends</h2>
127
+ <svg id="growth" aria-label="Growth chart"></svg>
128
+ <div class="legend" id="growth-legend"></div>
129
+ </div>
130
+ <div class="panel">
131
+ <h2>Composition</h2>
132
+ <div id="composition-tabs" style="margin-bottom:10px;">
133
+ <button class="ctab" data-k="by_type" style="background:var(--accent);color:#fff;border:none;border-radius:4px;padding:3px 10px;cursor:pointer;font:inherit;">Type</button>
134
+ <button class="ctab" data-k="by_domain" style="background:var(--panel);color:var(--text-dim);border:1px solid var(--panel-border);border-radius:4px;padding:3px 10px;cursor:pointer;font:inherit;margin-left:4px;">Domain</button>
135
+ <button class="ctab" data-k="by_source_agent" style="background:var(--panel);color:var(--text-dim);border:1px solid var(--panel-border);border-radius:4px;padding:3px 10px;cursor:pointer;font:inherit;margin-left:4px;">Source</button>
136
+ </div>
137
+ <div id="composition"></div>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="panel" style="margin-bottom:24px;">
142
+ <h2>Nightly activity digest — <span id="digest-date" class="muted"></span></h2>
143
+ <div id="digest-stages" style="margin-bottom:12px;"></div>
144
+ <h2 style="margin-top:16px;">New memories (sample, rendered as in recall)</h2>
145
+ <div id="digest-sample"></div>
146
+ <h2 style="margin-top:16px;">New lessons</h2>
147
+ <div id="digest-lessons"></div>
148
+ <h2 style="margin-top:16px;">Dedup merges</h2>
149
+ <div id="digest-dedup"></div>
150
+ </div>
151
+
152
+ <div class="sub muted" style="margin-top:24px;">
153
+ Entries link to <a href="/viz" style="color:var(--accent);">/viz</a> and
154
+ <code>/memory?id=</code> for view-only drill-down. No data leaves this server
155
+ through this page.
156
+ </div>
157
+
158
+ <script>
159
+ "use strict";
160
+ // ---------------------------------------------------------------------------
161
+ // Token handling — mirror /viz + /context/ui: ?token= handoff (stripped on
162
+ // load), localStorage persistence, 401 prompt. Same-origin fetch only.
163
+ // ---------------------------------------------------------------------------
164
+ const TOKEN_KEY = "hicortex-dashboard-token";
165
+ function getToken() {
166
+ const u = new URL(location.href);
167
+ const tParam = u.searchParams.get("token");
168
+ if (tParam) {
169
+ localStorage.setItem(TOKEN_KEY, tParam);
170
+ u.searchParams.delete("token");
171
+ history.replaceState(null, "", u.toString());
172
+ return tParam;
173
+ }
174
+ return localStorage.getItem(TOKEN_KEY);
175
+ }
176
+ let token = getToken();
177
+ async function fetchData(path) {
178
+ const headers = {};
179
+ if (token) headers["Authorization"] = "Bearer " + token;
180
+ const resp = await fetch(path, { headers });
181
+ if (resp.status === 401) {
182
+ token = prompt("Hicortex auth token:") || "";
183
+ if (token) {
184
+ localStorage.setItem(TOKEN_KEY, token);
185
+ return fetchData(path); // retry once
186
+ }
187
+ throw new Error("Unauthorized");
188
+ }
189
+ if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (await resp.text()));
190
+ return resp.json();
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // State
195
+ // ---------------------------------------------------------------------------
196
+ let state = null;
197
+ let compKey = "by_type";
198
+
199
+ const $ = (id) => document.getElementById(id);
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Render: headline
203
+ // ---------------------------------------------------------------------------
204
+ function renderHeadline(h) {
205
+ const ups = h.uses_per_showing === null || h.uses_per_showing === undefined
206
+ ? "—"
207
+ : (h.uses_per_showing * 100).toFixed(1) + "%";
208
+ $("headline").innerHTML = `
209
+ <div class="stat"><div class="v">${h.total_memories}</div><div class="l">Total memories</div></div>
210
+ <div class="stat"><div class="v">${ups}</div><div class="l">Uses per showing (headline)</div></div>
211
+ <div class="stat"><div class="v warn">${h.cold_count}</div><div class="l">Cold long-tail (never shown/used)</div></div>
212
+ `;
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Render: growth chart (hand-rolled inline SVG line chart)
217
+ //
218
+ // The filter select splits the series by `agent:<name>` or `domain:<name>` —
219
+ // every snapshot carries by_source_agent / by_domain (cumulative in backfill
220
+ // rows, point-in-time in real ones), so plotting that one key across the
221
+ // series yields a per-scope growth curve. "(all)" plots the corpus total.
222
+ // ---------------------------------------------------------------------------
223
+ const SERIES_COLORS = ["#5b9dd9", "#61c98f", "#e0b055", "#b97fe0", "#e05555", "#7fc4d9"];
224
+ function renderGrowth(series, split, filter) {
225
+ const svg = $("growth");
226
+ const W = svg.clientWidth || 600, H = 200, P = 28;
227
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
228
+ svg.innerHTML = "";
229
+ if (series.length === 0) {
230
+ svg.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" class="chart-x">No snapshots in range. Snapshots are written by the full nightly; a fresh install gets a backfill from created_at.</text>`;
231
+ $("growth-legend").innerHTML = "";
232
+ return;
233
+ }
234
+
235
+ // Resolve the filter to a per-snapshot value extractor.
236
+ // filter values: "" (all), "agent:<name>", "domain:<name>".
237
+ let extract;
238
+ let label = split === "captures" ? "Captures / day" : "Total memories (cumulative)";
239
+ if (filter && filter.startsWith("agent:")) {
240
+ const agent = filter.slice("agent:".length);
241
+ // `agent` is the DECODED filter value (HTMLSelectElement.value un-escapes
242
+ // the option attribute), i.e. the raw source_agent — attacker-controlled.
243
+ // Use it RAW as the object key (metrics keys are the real source_agent
244
+ // strings), but escapeHtml it for the legend LABEL, which lands in
245
+ // innerHTML (line ~299). Escaping the <option> alone is not sufficient
246
+ // precisely because the round-trip decodes.
247
+ extract = (m) => (m.by_source_agent || {})[agent] ?? 0;
248
+ label += ` — agent:${escapeHtml(agent)}`;
249
+ } else if (filter && filter.startsWith("domain:")) {
250
+ const dom = filter.slice("domain:".length);
251
+ // domain is LLM-constrained to the config vocabulary, but escape for
252
+ // consistency with the agent branch (same innerHTML sink).
253
+ extract = (m) => (m.by_domain || {})[dom] ?? 0;
254
+ label += ` — domain:${escapeHtml(dom)}`;
255
+ } else {
256
+ extract = (m) => m.totals.mem;
257
+ }
258
+
259
+ // Points: run_at → {date, value (cumulative), added (per-day delta)}.
260
+ // For "captures/day" the filter is informational (per-day deltas aren't
261
+ // keyed by agent/domain in the snapshot), so the split always reads
262
+ // new_this_run.added regardless of filter.
263
+ const pts = series.map((s) => ({
264
+ d: s.run_at,
265
+ value: extract(s.metrics),
266
+ added: s.metrics.new_this_run ? s.metrics.new_this_run.added : 0,
267
+ }));
268
+ const xMin = new Date(pts[0].d).getTime();
269
+ const xMax = new Date(pts[pts.length - 1].d).getTime() || xMin + 1;
270
+ const xs = (t) => P + ((t - xMin) / (xMax - xMin || 1)) * (W - 2 * P);
271
+
272
+ let yMax;
273
+ if (split === "captures") yMax = Math.max(1, ...pts.map((p) => p.added));
274
+ else yMax = Math.max(1, ...pts.map((p) => p.value));
275
+ const ys = (v) => H - P - (v / yMax) * (H - 2 * P);
276
+
277
+ // Axis lines + gridlines.
278
+ const grid = [0, 0.25, 0.5, 0.75, 1].map((f) => {
279
+ const y = P + f * (H - 2 * P);
280
+ return `<line x1="${P}" y1="${y}" x2="${W - P}" y2="${y}" stroke="rgba(128,128,128,0.15)" stroke-width="1"/>`;
281
+ }).join("");
282
+ const ylabels = [0, 0.5, 1].map((f) => {
283
+ const y = P + f * (H - 2 * P);
284
+ const v = Math.round((1 - f) * yMax);
285
+ return `<text x="${P - 6}" y="${y + 3}" text-anchor="end" class="chart-x">${v}</text>`;
286
+ }).join("");
287
+ const fmt = (iso) => iso.slice(5, 10); // MM-DD
288
+ const xlabels = [pts[0].d, pts[Math.floor(pts.length / 2)].d, pts[pts.length - 1].d]
289
+ .map((d, i) => `<text x="${xs(new Date(d).getTime())}" y="${H - P + 14}" text-anchor="${i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}" class="chart-x">${fmt(d)}</text>`)
290
+ .join("");
291
+
292
+ let body = "";
293
+ if (split === "captures") {
294
+ // Per-day delta bars.
295
+ pts.forEach((p) => {
296
+ const x = xs(new Date(p.d).getTime());
297
+ const y0 = ys(0), y1 = ys(p.added);
298
+ if (p.added > 0) body += `<line x1="${x}" y1="${y0}" x2="${x}" y2="${y1}" stroke="${SERIES_COLORS[0]}" stroke-width="2"/>`;
299
+ });
300
+ } else {
301
+ const d = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${xs(new Date(p.d).getTime())} ${ys(p.value)}`).join(" ");
302
+ body = `<path d="${d}" fill="none" stroke="${SERIES_COLORS[0]}" stroke-width="2"/>`;
303
+ const last = pts[pts.length - 1];
304
+ body += `<circle cx="${xs(new Date(last.d).getTime())}" cy="${ys(last.value)}" r="3" fill="${SERIES_COLORS[0]}"/>`;
305
+ }
306
+ svg.innerHTML = grid + ylabels + xlabels + body;
307
+ $("growth-legend").innerHTML = `<span><i style="background:${SERIES_COLORS[0]}"></i>${label}</span>`;
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Render: composition (horizontal bars)
312
+ // ---------------------------------------------------------------------------
313
+ function renderComposition(comp, key) {
314
+ const obj = comp[key] || {};
315
+ const entries = Object.entries(obj).sort((a, b) => b[1] - a[1]);
316
+ const max = Math.max(1, ...entries.map((e) => e[1]));
317
+ const host = $("composition");
318
+ if (entries.length === 0) {
319
+ host.innerHTML = `<div class="empty">No data.</div>`;
320
+ return;
321
+ }
322
+ host.innerHTML = entries.map(([k, v]) => {
323
+ const pct = (v / max) * 100;
324
+ // escapeHtml is mandatory: k is memory_type/domain/source_agent, which is
325
+ // attacker-influenced (any authenticated /distill client sets source_agent
326
+ // verbatim). Injected into both a title="..." attribute and text — close
327
+ // the attribute and add an on* handler otherwise.
328
+ const ek = escapeHtml(k);
329
+ return `<div class="bar-row"><span class="k" title="${ek}">${ek}</span><span class="track"><span class="fill" style="width:${pct}%"></span></span><span class="n">${v}</span></div>`;
330
+ }).join("");
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Render: digest
335
+ // ---------------------------------------------------------------------------
336
+ function linkId(id) {
337
+ // Short id prefix links to /memory?id= for view-only drill-down. NOTE: a
338
+ // plain <a> navigation does not attach the bearer header, so /memory (a JSON
339
+ // API) 401s for remote browsers — drill-down is localhost-only in v1. The id
340
+ // is a server hex token but escape the text anyway (defense-in-depth).
341
+ return `<a href="/memory?id=${encodeURIComponent(id)}" title="Open memory">${escapeHtml(id.slice(0, 8))}</a>`;
342
+ }
343
+ function renderDigest(digest) {
344
+ $("digest-date").textContent = digest.date || "—";
345
+ const s = digest.stages;
346
+ const stages = [
347
+ s.added !== undefined ? `<span class="stage-pill">added: ${s.added}</span>` : "",
348
+ s.lessonsGenerated !== undefined ? `<span class="stage-pill">lessons: ${s.lessonsGenerated}</span>` : "",
349
+ `<span class="stage-pill">dedup: ${s.dedup}</span>`,
350
+ `<span class="stage-pill">supersession: ${s.supersession}</span>`,
351
+ ].join(" ");
352
+ $("digest-stages").innerHTML = stages;
353
+
354
+ if (digest.sample.length === 0) {
355
+ $("digest-sample").innerHTML = `<div class="empty">No new memories that day.</div>`;
356
+ } else {
357
+ $("digest-sample").innerHTML = digest.sample.map((s) =>
358
+ `<div class="line">- [<a href="/memory?id=${encodeURIComponent(s.id)}">${escapeHtml(s.id.slice(0,8))}</a>] ${escapeHtml(s.line.replace(/^-\s*\[[^\]]+\]\s*/, ""))}</div>`
359
+ ).join("");
360
+ }
361
+
362
+ if (digest.lessons.length === 0) {
363
+ $("digest-lessons").innerHTML = `<div class="empty">No new lessons that day.</div>`;
364
+ } else {
365
+ $("digest-lessons").innerHTML = digest.lessons.map((l) =>
366
+ `<div class="lesson">${escapeHtml(l.content)}</div>`
367
+ ).join("");
368
+ }
369
+
370
+ if (digest.dedup_merges.length === 0) {
371
+ $("digest-dedup").innerHTML = `<div class="empty">No dedup merges that day.</div>`;
372
+ } else {
373
+ $("digest-dedup").innerHTML = `<table class="digest">${
374
+ digest.dedup_merges.map((m) =>
375
+ `<tr><td class="k">${m.merged_at.slice(0, 19).replace("T", " ")}</td>` +
376
+ `<td>${linkId(m.loser_id)} → ${linkId(m.canonical_id)}` +
377
+ (m.content_head ? `<div class="muted">${escapeHtml(m.content_head)}</div>` : "") +
378
+ `</td></tr>`
379
+ ).join("")
380
+ }</table>`;
381
+ }
382
+ }
383
+
384
+ function escapeHtml(s) {
385
+ return String(s).replace(/[&<>"']/g, (c) => ({
386
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
387
+ })[c]);
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Load + wire controls
392
+ // ---------------------------------------------------------------------------
393
+ async function load() {
394
+ const range = $("range").value;
395
+ const date = $("date").value || "";
396
+ try {
397
+ state = await fetchData("/dashboard/data?range=" + encodeURIComponent(range) +
398
+ (date ? "&date=" + encodeURIComponent(date) : ""));
399
+ } catch (e) {
400
+ document.body.insertAdjacentHTML("afterbegin",
401
+ `<div class="panel err">Failed to load dashboard data: ${escapeHtml(e.message)}</div>`);
402
+ return;
403
+ }
404
+ renderHeadline(state.headline);
405
+ renderGrowth(state.series, $("split").value, $("filter").value);
406
+ renderComposition(state.composition, compKey);
407
+ renderDigest(state.digest);
408
+
409
+ // Populate the growth-filter from source_agent + domain union (best-effort —
410
+ // a v1 affordance; the series already carries per-day totals, so the filter
411
+ // is informational for now and does not split the line).
412
+ const agents = new Set();
413
+ const domains = new Set();
414
+ state.series.forEach((s) => {
415
+ Object.keys(s.metrics.by_source_agent || {}).forEach((k) => agents.add(k));
416
+ Object.keys(s.metrics.by_domain || {}).forEach((k) => domains.add(k));
417
+ });
418
+ const sel = $("filter");
419
+ const prev = sel.value;
420
+ // escapeHtml on both the value attribute and the text: agents/domains are
421
+ // attacker-influenced column values (source_agent/domain), same risk as the
422
+ // composition renderer.
423
+ sel.innerHTML = '<option value="">(all)</option>' +
424
+ [...agents].map((a) => { const e = escapeHtml(a); return `<option value="agent:${e}">${e}</option>`; }).join("") +
425
+ [...domains].map((d) => { const e = escapeHtml(d); return `<option value="domain:${e}">${e}</option>`; }).join("");
426
+ if (prev) sel.value = prev;
427
+ }
428
+
429
+ document.addEventListener("DOMContentLoaded", () => {
430
+ // Seed the date picker to the digest date from the first load (today).
431
+ $("reload").addEventListener("click", load);
432
+ $("range").addEventListener("change", load);
433
+ $("split").addEventListener("change", () => renderGrowth(state.series, $("split").value, $("filter").value));
434
+ $("filter").addEventListener("change", () => renderGrowth(state.series, $("split").value, $("filter").value));
435
+ $("date").addEventListener("change", load);
436
+ document.querySelectorAll(".ctab").forEach((b) => {
437
+ b.addEventListener("click", () => {
438
+ compKey = b.dataset.k;
439
+ document.querySelectorAll(".ctab").forEach((x) => {
440
+ x.style.background = "var(--panel)";
441
+ x.style.color = "var(--text-dim)";
442
+ x.style.border = "1px solid var(--panel-border)";
443
+ });
444
+ b.style.background = "var(--accent)";
445
+ b.style.color = "#fff";
446
+ b.style.border = "none";
447
+ renderComposition(state.composition, compKey);
448
+ });
449
+ });
450
+ load();
451
+ });
452
+ </script>
453
+ </body>
454
+ </html>
package/dist/cli.js CHANGED
@@ -69,6 +69,17 @@ switch (command) {
69
69
  else {
70
70
  const dryRun = args.includes("--dry-run");
71
71
  const captureOnly = args.includes("--capture-only");
72
+ const watchdog = args.includes("--watchdog");
73
+ // Timestamp every log line. The nightly writes to a file (launchd /
74
+ // systemd StandardOutput append) with NO per-line timestamp, which made
75
+ // diagnosing capture gaps impossible (the #239 investigation couldn't
76
+ // tell when a fire aborted). The daemon is unaffected — it logs to
77
+ // journald, which already timestamps. Scoped to this process only.
78
+ const origLog = console.log, origWarn = console.warn, origErr = console.error;
79
+ const ts = () => `[${new Date().toISOString()}]`;
80
+ console.log = (...a) => origLog(ts(), ...a);
81
+ console.warn = (...a) => origWarn(ts(), ...a);
82
+ console.error = (...a) => origErr(ts(), ...a);
72
83
  // #189 Tier-2 recovery: re-discover sessions that went quiet before the
73
84
  // upgrade by widening the discovery window to now−N days for one run.
74
85
  let recaptureWindowDays;
@@ -81,7 +92,7 @@ switch (command) {
81
92
  }
82
93
  }
83
94
  import("./nightly.js").then(({ runNightly }) => {
84
- runNightly({ dryRun, captureOnly, recaptureWindowDays }).catch((err) => {
95
+ runNightly({ dryRun, captureOnly, watchdog, recaptureWindowDays }).catch((err) => {
85
96
  console.error("[hicortex] Nightly pipeline failed:", err);
86
97
  process.exit(1);
87
98
  });
@@ -29,6 +29,19 @@ export declare function readStrictBoolean(config: Record<string, unknown>, key:
29
29
  * value (e.g. ollamaFlushEvery).
30
30
  */
31
31
  export declare function readNonNegativeConfig(config: Record<string, unknown>, key: string, def: number): number;
32
+ /**
33
+ * Read + validate an array of scheduling hours (integers 0–23, local time) for
34
+ * the capture/consolidation timers (0.17). Returns the validated, de-duped,
35
+ * ASC-sorted array; `null` when the key is absent (caller applies the role
36
+ * default) OR present-but-invalid (with a warn in the latter case).
37
+ *
38
+ * One invalid element rejects the WHOLE list (→ null → default) rather than
39
+ * silently dropping a slot: a `25` is a typo the user should notice, and a
40
+ * partial schedule like [9, 25→drop, 21] = [9, 21] would quietly change
41
+ * cadence. Matches the readPositiveConfig "reject invalid → default" boundary
42
+ * posture, and the parseConfigDomains leniency contract for the absent case.
43
+ */
44
+ export declare function parseHours(config: Record<string, unknown> | null | undefined, key: string): number[] | null;
32
45
  /**
33
46
  * Warn if the saved config carries keys that 0.16.8+ ignores. Call at every
34
47
  * config read (daemon boot + nightly). The warning clears once the keys are
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.readPositiveConfig = readPositiveConfig;
17
17
  exports.readStrictBoolean = readStrictBoolean;
18
18
  exports.readNonNegativeConfig = readNonNegativeConfig;
19
+ exports.parseHours = parseHours;
19
20
  exports.warnIgnoredConfigKeys = warnIgnoredConfigKeys;
20
21
  /**
21
22
  * Read a positive finite number from a config object. Returns `def` when the
@@ -59,6 +60,42 @@ function readNonNegativeConfig(config, key, def) {
59
60
  console.warn(`[hicortex] config "${key}" = ${String(v)} is not a non-negative finite number — using default ${def}.`);
60
61
  return def;
61
62
  }
63
+ /**
64
+ * Read + validate an array of scheduling hours (integers 0–23, local time) for
65
+ * the capture/consolidation timers (0.17). Returns the validated, de-duped,
66
+ * ASC-sorted array; `null` when the key is absent (caller applies the role
67
+ * default) OR present-but-invalid (with a warn in the latter case).
68
+ *
69
+ * One invalid element rejects the WHOLE list (→ null → default) rather than
70
+ * silently dropping a slot: a `25` is a typo the user should notice, and a
71
+ * partial schedule like [9, 25→drop, 21] = [9, 21] would quietly change
72
+ * cadence. Matches the readPositiveConfig "reject invalid → default" boundary
73
+ * posture, and the parseConfigDomains leniency contract for the absent case.
74
+ */
75
+ function parseHours(config, key) {
76
+ if (!config)
77
+ return null;
78
+ const v = config[key];
79
+ if (v === undefined)
80
+ return null;
81
+ if (!Array.isArray(v)) {
82
+ console.warn(`[hicortex] config "${key}" is not an array — ignored.`);
83
+ return null;
84
+ }
85
+ const seen = new Set();
86
+ const out = [];
87
+ for (const el of v) {
88
+ if (typeof el !== "number" || !Number.isInteger(el) || el < 0 || el > 23) {
89
+ console.warn(`[hicortex] config "${key}" has an invalid hour (${String(el)}; must be an integer 0–23) — ignoring the list, using the default.`);
90
+ return null;
91
+ }
92
+ if (!seen.has(el)) {
93
+ seen.add(el);
94
+ out.push(el);
95
+ }
96
+ }
97
+ return out.length > 0 ? out.sort((a, b) => a - b) : null;
98
+ }
62
99
  /**
63
100
  * Config keys that ≤0.16.7 accepted and 0.16.8+ IGNORES. Two groups:
64
101
  * - per-stage model keys + the nested `models` block (#231): one model now
@@ -8,6 +8,18 @@ import type { Memory, ConsolidationReport } from "./types.js";
8
8
  import type { LlmClient } from "./llm.js";
9
9
  import type { EmbedFn } from "./retrieval.js";
10
10
  import { type DomainDef } from "./domain-classify.js";
11
+ /**
12
+ * Default ceiling on total LLM calls across all classify-tier consolidation
13
+ * stages (content-domain, link discovery, supersession) per run. This is a
14
+ * runaway BACKSTOP, not a throughput throttle — on a free local model there is
15
+ * no per-call cost to defend against; the binding constraint is the nightly
16
+ * unit's wall-clock timeout (TimeoutStartSec), not call count. 5000 clears a
17
+ * one-time classification backlog (a ~2000-memory batch drains in ~1-2 runs
18
+ * instead of ~11 nights at the old 200) with margin for link/supersession, and
19
+ * ~5000 calls x ~1-3s/call ≈ 1.4-4.2h fits the 6h consolidation backstop.
20
+ * Config-overridable as `consolidateMaxLlmCalls` (#241).
21
+ */
22
+ export declare const CONSOLIDATE_MAX_LLM_CALLS = 5000;
11
23
  /**
12
24
  * Minimum COSINE similarity for a link candidate.
13
25
  *
@@ -216,7 +228,11 @@ export interface DomainStageOptions {
216
228
  */
217
229
  weakPrimaryFloor?: number;
218
230
  }
219
- export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions, supersessionOptions?: SupersessionOptions): Promise<ConsolidationReport>;
231
+ export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions, supersessionOptions?: SupersessionOptions,
232
+ /** Total LLM-call ceiling across classify-tier stages (#241). The caller
233
+ * reads `consolidateMaxLlmCalls` from config and passes it; unset → the
234
+ * exported `CONSOLIDATE_MAX_LLM_CALLS` default (5000). */
235
+ budgetMaxCalls?: number): Promise<ConsolidationReport>;
220
236
  /**
221
237
  * Calculate milliseconds until the next occurrence of a given hour (local time).
222
238
  */
@@ -38,7 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
41
+ exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = exports.CONSOLIDATE_MAX_LLM_CALLS = void 0;
42
42
  exports.isContradictionCandidate = isContradictionCandidate;
43
43
  exports.parseJsonLenient = parseJsonLenient;
44
44
  exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
@@ -63,7 +63,18 @@ const domain_classify_js_1 = require("./domain-classify.js");
63
63
  const schema_prototypes_js_1 = require("./schema-prototypes.js");
64
64
  const nofit_js_1 = require("./nofit.js");
65
65
  // Default config constants (matching Python config.py)
66
- const CONSOLIDATE_MAX_LLM_CALLS = 200;
66
+ /**
67
+ * Default ceiling on total LLM calls across all classify-tier consolidation
68
+ * stages (content-domain, link discovery, supersession) per run. This is a
69
+ * runaway BACKSTOP, not a throughput throttle — on a free local model there is
70
+ * no per-call cost to defend against; the binding constraint is the nightly
71
+ * unit's wall-clock timeout (TimeoutStartSec), not call count. 5000 clears a
72
+ * one-time classification backlog (a ~2000-memory batch drains in ~1-2 runs
73
+ * instead of ~11 nights at the old 200) with margin for link/supersession, and
74
+ * ~5000 calls x ~1-3s/call ≈ 1.4-4.2h fits the 6h consolidation backstop.
75
+ * Config-overridable as `consolidateMaxLlmCalls` (#241).
76
+ */
77
+ exports.CONSOLIDATE_MAX_LLM_CALLS = 5000;
67
78
  const CONSOLIDATE_PRUNE_MIN_AGE_DAYS = 90;
68
79
  /**
69
80
  * Minimum COSINE similarity for a link candidate.
@@ -1100,7 +1111,11 @@ function stageDecayPrune(db, dryRun) {
1100
1111
  }
1101
1112
  return { candidates, pruned, failed };
1102
1113
  }
1103
- async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions) {
1114
+ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions,
1115
+ /** Total LLM-call ceiling across classify-tier stages (#241). The caller
1116
+ * reads `consolidateMaxLlmCalls` from config and passes it; unset → the
1117
+ * exported `CONSOLIDATE_MAX_LLM_CALLS` default (5000). */
1118
+ budgetMaxCalls) {
1104
1119
  const start = new Date();
1105
1120
  const report = {
1106
1121
  started_at: start.toISOString(),
@@ -1131,7 +1146,10 @@ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection
1131
1146
  report.completed_at = new Date().toISOString();
1132
1147
  return report;
1133
1148
  }
1134
- const budget = new BudgetTracker(CONSOLIDATE_MAX_LLM_CALLS);
1149
+ // Config-overridable total LLM-call ceiling (#241). Default 5000 (was 200) —
1150
+ // see CONSOLIDATE_MAX_LLM_CALLS. The caller reads `consolidateMaxLlmCalls`
1151
+ // from config and passes it here.
1152
+ const budget = new BudgetTracker(budgetMaxCalls ?? exports.CONSOLIDATE_MAX_LLM_CALLS);
1135
1153
  try {
1136
1154
  // Stage 2: Importance Scoring
1137
1155
  report.stages.importance = await stageImportance(db, scoreMemories, llm, budget, dryRun);