@gamaze/hicortex 0.16.10 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/assets/dashboard.html +454 -0
- package/dist/cli.js +12 -1
- package/dist/config-read.d.ts +13 -0
- package/dist/config-read.js +37 -0
- package/dist/consolidate.d.ts +17 -1
- package/dist/consolidate.js +22 -4
- package/dist/dashboard.d.ts +162 -0
- package/dist/dashboard.js +410 -0
- package/dist/db.js +24 -0
- package/dist/hermes-transcript-reader.d.ts +15 -6
- package/dist/hermes-transcript-reader.js +32 -7
- package/dist/init.d.ts +43 -4
- package/dist/init.js +302 -71
- package/dist/mcp-server.js +17 -0
- package/dist/nightly.d.ts +8 -0
- package/dist/nightly.js +124 -12
- package/dist/telemetry.d.ts +11 -0
- package/dist/types.d.ts +38 -0
- package/dist/uninstall.js +12 -5
- package/dist/viz.d.ts +16 -0
- package/dist/viz.js +49 -0
- package/package.json +1 -1
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
|
-
| `
|
|
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
|
+
| `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
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
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,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
|
package/dist/config-read.js
CHANGED
|
@@ -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
|
package/dist/consolidate.d.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/dist/consolidate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|