@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 +6 -3
- package/assets/dashboard.html +454 -0
- package/dist/cli.js +12 -1
- package/dist/config-read.d.ts +20 -0
- package/dist/config-read.js +84 -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 +26 -4
- package/dist/init.js +254 -55
- package/dist/mcp-server.js +21 -0
- package/dist/nightly.d.ts +8 -0
- package/dist/nightly.js +126 -12
- package/dist/telemetry.d.ts +11 -0
- package/dist/types.d.ts +29 -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
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
|
|
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
|
-
| `
|
|
219
|
-
| `
|
|
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
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
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
|
});
|
package/dist/config-read.d.ts
CHANGED
|
@@ -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;
|
package/dist/config-read.js
CHANGED
|
@@ -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
|
+
}
|