@gamaze/hicortex 0.16.9 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -203,7 +203,7 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
203
203
  | `numCtx` | Context window for ollama (default 8192, one value for all phases). Scoring uses ~850 tokens, so 2048 is ample; distill/reflect/classify need more for `detectChunkSize`'s chunk sizing. |
204
204
  | `enableThinking` | Toggle the model's internal reasoning ("thinking") stream for OpenAI-compatible endpoints (default false). Only meaningful for local chat-template-aware servers (ollama, mlx-lm); leave unset for cloud OpenAI/OpenRouter/Groq endpoints (they 400 on the unknown `chat_template_kwargs` field). |
205
205
  | `maxTokens` | Max output tokens for all phases (default 8192). A ceiling, not a target — the model stops early when done. |
206
- | `ollamaFlushEvery` | Flush ollama's accumulated memory every N scoring calls (default 0 = off). Workaround for ollama's memory-growth bug (~171 MB/call). N=15 caps a cycle at ~2.5 GB. Scoped to the fast tier (scoring) only. |
206
+ | `ollamaFlushEvery` | Flush ollama's accumulated memory every N scoring calls. **Off by default (0)** opt-in only for an **ollama** install whose runner RSS growth (~171 MB/call) swap-thrashes long consolidations on a RAM-constrained box; N=15 caps a cycle at ~2.5 GB. Gated on the provider being ollama (local **or** remote) — no effect for non-ollama providers. Only you can judge whether your ollama endpoint actually suffers the growth (a managed/cloud ollama host may not), so it stays off until you set it. |
207
207
  | `ollamaFlushWaitMs` | Milliseconds to wait after an ollama flush for the runner to exit + release memory (default 180000 = 3 min). |
208
208
  | `authToken` | Bearer token for endpoint auth. Generated on first `init` in server mode. Find the active token with `hicortex status` or in `~/.hicortex/config.json`. |
209
209
  | `corsAllowedOrigins` | Browser origins allowed to read cross-origin responses, e.g. `["https://ui.example.com"]`. **Empty by default** — the server sends no `Access-Control-Allow-Origin` and never `Allow-Credentials`, so no external web page can read its data. The bundled `/viz` and `/context/ui` pages are same-origin and need no entry. |
@@ -215,8 +215,11 @@ 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
+ | `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` |
222
+ | `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
223
  | `preflightAttempts` | **Client mode only.** Reachability-check retries before the nightly aborts (default: 3; floored at 1). `1` = single try, no retry |
221
224
  | `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
225
  | `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,3 +29,23 @@ 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;
45
+ /**
46
+ * Warn if the saved config carries keys that 0.16.8+ ignores. Call at every
47
+ * config read (daemon boot + nightly). The warning clears once the keys are
48
+ * removed and (for the model keys) the model is consolidated into
49
+ * llmModel/llmBaseUrl/llmProvider.
50
+ */
51
+ export declare function warnIgnoredConfigKeys(savedConfig: Record<string, unknown> | null | undefined): void;
@@ -16,6 +16,8 @@ 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;
20
+ exports.warnIgnoredConfigKeys = warnIgnoredConfigKeys;
19
21
  /**
20
22
  * Read a positive finite number from a config object. Returns `def` when the
21
23
  * key is absent OR present-but-invalid (with a warn in the latter case).
@@ -58,3 +60,85 @@ function readNonNegativeConfig(config, key, def) {
58
60
  console.warn(`[hicortex] config "${key}" = ${String(v)} is not a non-negative finite number — using default ${def}.`);
59
61
  return def;
60
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
+ }
99
+ /**
100
+ * Config keys that ≤0.16.7 accepted and 0.16.8+ IGNORES. Two groups:
101
+ * - per-stage model keys + the nested `models` block (#231): one model now
102
+ * serves all phases, so distillation/reflection/classification silently run
103
+ * on `llmModel` — a quality downgrade if a larger model was on a distill/
104
+ * reflect tier.
105
+ * - `distillFallback` (#232): removed; strict mode is default. A `"local"`
106
+ * value no longer falls back — failures retry next run.
107
+ * Both were public (documented in the README config table). Warn loudly at the
108
+ * config boundary so neither change is silent.
109
+ *
110
+ * NOTE: this list is deliberately WIDER than the `HicortexConfig` type. In
111
+ * ≤0.16.7 most of these keys (the flat `distill*`/`reflect*` set and
112
+ * `distillFallback`) were NEVER declared in the interface — they were
113
+ * accepted-but-untyped, read loosely off the config object, with the README
114
+ * config table as their only public contract. So deriving this list from the
115
+ * type would miss them. The list is the README's documented keys; don't trim it
116
+ * to match the interface (a future audit that did so would silently re-introduce
117
+ * the ignored-key gap this warning exists to close).
118
+ */
119
+ const IGNORED_CONFIG_KEYS = [
120
+ "distillModel", "distillBaseUrl", "distillApiKey", "distillProvider",
121
+ "reflectModel", "reflectBaseUrl", "reflectApiKey", "reflectProvider",
122
+ "classifyModel", "classifyBaseUrl", "classifyApiKey", "classifyProvider",
123
+ "distillFallback",
124
+ ];
125
+ /**
126
+ * Warn if the saved config carries keys that 0.16.8+ ignores. Call at every
127
+ * config read (daemon boot + nightly). The warning clears once the keys are
128
+ * removed and (for the model keys) the model is consolidated into
129
+ * llmModel/llmBaseUrl/llmProvider.
130
+ */
131
+ function warnIgnoredConfigKeys(savedConfig) {
132
+ if (!savedConfig)
133
+ return;
134
+ const present = IGNORED_CONFIG_KEYS.filter((k) => savedConfig[k] !== undefined);
135
+ const hasModelsBlock = savedConfig.models !== undefined;
136
+ if (present.length === 0 && !hasModelsBlock)
137
+ return;
138
+ const detail = [...present, ...(hasModelsBlock ? ["models"] : [])].join(", ");
139
+ console.warn(`[hicortex] config has keys IGNORED since 0.16.8 (${detail}). They have no effect now. ` +
140
+ `Per-stage model keys / the \`models\` block: one model serves all phases — set ` +
141
+ `llmModel/llmBaseUrl/llmProvider (+ llmApiKey) to your intended model. ` +
142
+ `distillFallback: removed (strict mode is default — a failed distill retries next run). ` +
143
+ `Remove these keys to clear this warning. See the 0.16.8 changelog.`);
144
+ }