total-recall-brain 3.23.2 → 3.23.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.23.2",
3
+ "version": "3.23.3",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Keeps /health cheap without making it dishonest.
3
+ *
4
+ * Embedding coverage is the expensive half of the health check: for every
5
+ * active brain it parses every node in that brain's vault and loads that
6
+ * brain's embeddings index — 12MB on one machine here. Computed inside the
7
+ * request it made /health take 25-57s on a loaded laptop while the dashboard
8
+ * answered in 116ms, so the server read as dead to everything that probes it.
9
+ * That is the worst endpoint to make slow: /health is what the watchdog polls,
10
+ * and a liveness probe that times out is indistinguishable from a server that
11
+ * is down — which is exactly how it was misread.
12
+ *
13
+ * A cache alone would trade one wrong answer for another, because the reading
14
+ * it holds is a criticality signal: a brain with nodes and no vectors serves
15
+ * keyword results that look like real ones. So the snapshot always carries the
16
+ * time it was taken, and reports "no reading yet" as null rather than as clean.
17
+ */
18
+
19
+ const DEFAULT_TTL_MS = 30_000;
20
+
21
+ /**
22
+ * A single-flight, time-bounded cache over an expensive async computation.
23
+ *
24
+ * Never blocks a caller: a stale value is served while a refresh runs behind
25
+ * it, and before the first value lands callers get null. `compute` errors are
26
+ * swallowed rather than left to reject an unawaited promise — a failed refresh
27
+ * keeps the previous reading and its original timestamp, so failure looks like
28
+ * staleness rather than success.
29
+ *
30
+ * @param {object} opts
31
+ * @param {() => Promise<any>} opts.compute the expensive work
32
+ * @param {number} [opts.ttlMs] how long a reading stays fresh
33
+ * @param {() => number} [opts.now] clock seam, for tests
34
+ */
35
+ export function createCoverageCache({ compute, ttlMs = DEFAULT_TTL_MS, now = () => Date.now() }) {
36
+ let value = null;
37
+ let takenAt = 0;
38
+ let inFlight = null;
39
+
40
+ function refresh() {
41
+ // Single-flight: a second caller during a refresh joins it rather than
42
+ // starting a parallel full-vault parse.
43
+ if (inFlight) return inFlight;
44
+ inFlight = Promise.resolve()
45
+ .then(() => compute())
46
+ .then((next) => {
47
+ value = next;
48
+ takenAt = now();
49
+ })
50
+ .catch(() => {
51
+ // Keep the prior reading and its timestamp; a failed refresh must not
52
+ // be able to masquerade as a fresh clean result.
53
+ })
54
+ .finally(() => {
55
+ inFlight = null;
56
+ });
57
+ return inFlight;
58
+ }
59
+
60
+ return {
61
+ /** Last known reading, kicking off a background refresh when stale. */
62
+ snapshot() {
63
+ if (!value || now() - takenAt > ttlMs) refresh();
64
+ return value
65
+ ? { coverage: value, as_of: new Date(takenAt).toISOString() }
66
+ : { coverage: null, as_of: null };
67
+ },
68
+
69
+ /** Resolves once any in-progress refresh settles. Used to warm at boot. */
70
+ async settled() {
71
+ while (inFlight) await inFlight;
72
+ },
73
+ };
74
+ }
@@ -29,6 +29,7 @@ import {
29
29
  } from './auth.mjs';
30
30
  import { logger, logEvents } from "../core/logger.mjs";
31
31
  import { readProcessCommand, entryPathHint, shouldHonorPidLock } from '../core/pid-lock.mjs';
32
+ import { createCoverageCache } from './health-coverage.mjs';
32
33
  import { drainActiveEmbeddings } from './routes/sessions.mjs';
33
34
  import { agentDir as configAgentDir, brainDir as configBrainDir, port as configPort, host as configHost, nodeEnv } from '../core/config.mjs';
34
35
  import { getDaemonStatus, ensureDaemonRunning } from '../core/daemon-control.mjs';
@@ -172,6 +173,56 @@ app.use(cookieParser());
172
173
 
173
174
  // ─── Health Check ───────────────────────────────────────────────────────────────
174
175
 
176
+ /**
177
+ * Embedding coverage is the expensive half of this endpoint — see
178
+ * health-coverage.mjs for why it is computed off the request path. The check
179
+ * itself is unchanged: a brain with nodes and no vectors still reports at
180
+ * critical severity, because recall silently answering from keyword matching
181
+ * is the failure this was written to catch.
182
+ */
183
+ async function computeEmbeddingCoverage() {
184
+ const coverage = [];
185
+ try {
186
+ const { loadEmbeddingsIndex } = await import('../core/embeddings.mjs');
187
+ const { getActiveBrains } = await import('../core/config.mjs');
188
+ const brains = getActiveBrains();
189
+ for (const b of Object.values(brains)) {
190
+ if (!b?.brainDir) continue;
191
+ const vaultPath = path.join(b.brainDir, 'memory-vault');
192
+ if (!fs.existsSync(vaultPath)) continue;
193
+ // Count only what the embedder actually indexes: type:memory nodes.
194
+ // Counting every .md under the vault compares against a denominator that
195
+ // includes proposals/ (optimizer tickets, never embedded by design), so a
196
+ // perfectly healthy brain reported "847/16701 — 5% coverage" and tripped
197
+ // a false degraded alarm. Ask the loader, don't count files.
198
+ let nodeCount = 0;
199
+ try {
200
+ const { loadNodes } = await import('../core/vault.mjs');
201
+ nodeCount = loadNodes(vaultPath).length;
202
+ } catch { /* unreadable vault */ }
203
+ let embedded = 0;
204
+ try {
205
+ embedded = Object.keys(loadEmbeddingsIndex(path.join(b.brainDir, 'memory-derived'))).length;
206
+ } catch { /* index unreadable — reported as 0, which is the honest value */ }
207
+ coverage.push({
208
+ layer: b.layer || 'unknown',
209
+ nodes: nodeCount,
210
+ embedded,
211
+ vector_search: embedded > 0 ? 'on' : 'OFF — keyword-only',
212
+ });
213
+ }
214
+ } catch (err) {
215
+ coverage.push({ error: String(err?.message || err) });
216
+ }
217
+ return coverage;
218
+ }
219
+
220
+ const coverageCache = createCoverageCache({ compute: computeEmbeddingCoverage });
221
+ const embeddingCoverageSnapshot = () => coverageCache.snapshot();
222
+
223
+ // Warm it at boot so the first probe already has a real answer.
224
+ embeddingCoverageSnapshot();
225
+
175
226
  app.get('/health', requireAuthOrLocal, async (req, res) => {
176
227
  let disk = { free: 0, total: 0 };
177
228
  try {
@@ -206,7 +257,7 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
206
257
  // Check daemon status
207
258
  const daemonStatus = getDaemonStatus();
208
259
 
209
- // Embedding coverage per brain.
260
+ // Embedding coverage per brain, served from the background cache above.
210
261
  //
211
262
  // This existed nowhere before, and its absence is exactly why a brain with
212
263
  // 2602 vault nodes and 0 embeddings looked healthy for weeks: compile
@@ -214,43 +265,13 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
214
265
  // real results, and nothing anywhere printed "N of M nodes embedded".
215
266
  // Vector search silently off is a CRITICAL failure, not a cosmetic one —
216
267
  // recall answers from the wrong brain and the caller cannot tell.
217
- const embeddingCoverage = [];
218
- try {
219
- const { loadEmbeddingsIndex } = await import('../core/embeddings.mjs');
220
- const { getActiveBrains } = await import('../core/config.mjs');
221
- const brains = getActiveBrains();
222
- for (const b of Object.values(brains)) {
223
- if (!b?.brainDir) continue;
224
- const vaultPath = path.join(b.brainDir, 'memory-vault');
225
- if (!fs.existsSync(vaultPath)) continue;
226
- // Count only what the embedder actually indexes: type:memory nodes.
227
- // Counting every .md under the vault compares against a denominator that
228
- // includes proposals/ (optimizer tickets, never embedded by design), so a
229
- // perfectly healthy brain reported "847/16701 — 5% coverage" and tripped
230
- // a false degraded alarm. Ask the loader, don't count files.
231
- let nodeCount = 0;
232
- try {
233
- const { loadNodes } = await import('../core/vault.mjs');
234
- nodeCount = loadNodes(vaultPath).length;
235
- } catch { /* unreadable vault */ }
236
- let embedded = 0;
237
- try {
238
- embedded = Object.keys(loadEmbeddingsIndex(path.join(b.brainDir, 'memory-derived'))).length;
239
- } catch { /* index unreadable — reported as 0, which is the honest value */ }
240
- embeddingCoverage.push({
241
- layer: b.layer || 'unknown',
242
- nodes: nodeCount,
243
- embedded,
244
- vector_search: embedded > 0 ? 'on' : 'OFF — keyword-only',
245
- });
246
- }
247
- } catch (err) {
248
- embeddingCoverage.push({ error: String(err?.message || err) });
249
- }
268
+ const { coverage: embeddingCoverage, as_of: coverageAsOf } = embeddingCoverageSnapshot();
250
269
 
251
270
  // A brain with nodes but no vectors answers every query from keyword
252
- // matching. Surface it at the same severity as a dead daemon.
253
- const unembeddedBrains = embeddingCoverage.filter(c => c.nodes > 0 && c.embedded === 0);
271
+ // matching. Surface it at the same severity as a dead daemon. Before the
272
+ // first reading lands there is nothing to judge, and an unknown is reported
273
+ // as unknown rather than quietly counted as healthy.
274
+ const unembeddedBrains = (embeddingCoverage || []).filter(c => c.nodes > 0 && c.embedded === 0);
254
275
 
255
276
  // Determine overall status
256
277
  const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead' || cliAgents.length === 0
@@ -290,6 +311,9 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
290
311
  cli_agents: cliAgents,
291
312
  daemon: daemonStatus,
292
313
  embedding_coverage: embeddingCoverage,
314
+ // When this reading was taken. Null means the first computation has not
315
+ // landed yet, so the coverage field is unknown rather than clean.
316
+ embedding_coverage_as_of: coverageAsOf,
293
317
  // Explicit, greppable signal. A brain with nodes and no vectors serves
294
318
  // keyword-only results that look exactly like real ones.
295
319
  vector_search_degraded: unembeddedBrains.length > 0