total-recall-brain 3.23.2 → 3.24.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.
Files changed (45) hide show
  1. package/bin/total-recall.mjs +1 -0
  2. package/frontend/dist/assets/{index-c6GHGS_j.js → index-CtCDaZYZ.js} +1 -1
  3. package/frontend/dist/index.html +1 -1
  4. package/frontend/src/pages/SecretsPage.tsx +111 -52
  5. package/package.json +3 -2
  6. package/scaffold/.agent/skills/total-recall/SKILL.md +199 -7
  7. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +13 -4
  8. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -0
  9. package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
  10. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +1 -2
  11. package/scaffold/.agent/skills/total-recall/research-queue.jsonl +7 -0
  12. package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +2461 -6
  13. package/src/cli/backfill.mjs +176 -0
  14. package/src/cli/init.mjs +1 -1
  15. package/src/cli/proposals.mjs +3 -3
  16. package/src/cli/secret.mjs +84 -0
  17. package/src/core/clarity-rewriter.mjs +25 -39
  18. package/src/core/conclusion-writer.mjs +20 -10
  19. package/src/core/crypto.mjs +86 -16
  20. package/src/core/daemon-loop.mjs +2 -2
  21. package/src/core/dream.mjs +49 -58
  22. package/src/core/env-import.mjs +18 -0
  23. package/src/core/inference-engine.mjs +15 -17
  24. package/src/core/migrate.mjs +5 -1
  25. package/src/core/okf-adapter.mjs +2 -0
  26. package/src/core/post-mortem.mjs +30 -26
  27. package/src/core/proposal-applier.mjs +26 -10
  28. package/src/core/repo-sync.mjs +51 -39
  29. package/src/core/scheduler.mjs +1 -0
  30. package/src/core/schema.mjs +24 -1
  31. package/src/core/search.mjs +1 -1
  32. package/src/core/secrets-env-diff.mjs +132 -0
  33. package/src/core/secrets-rekey.mjs +0 -0
  34. package/src/core/snapshot.mjs +32 -9
  35. package/src/core/ssss-kernel-bridge.mjs +46 -6
  36. package/src/core/task-envelope.mjs +1 -0
  37. package/src/core/validated-write.mjs +68 -5
  38. package/src/core/vault-backfill.mjs +259 -0
  39. package/src/core/vault.mjs +22 -4
  40. package/src/server/auth.mjs +42 -8
  41. package/src/server/health-coverage.mjs +74 -0
  42. package/src/server/index.mjs +60 -36
  43. package/src/server/routes/brains.mjs +1 -1
  44. package/src/server/routes/proposals.mjs +4 -4
  45. package/src/server/routes/secrets.mjs +1 -1
@@ -3,9 +3,27 @@ import path from 'path';
3
3
  import matter from 'gray-matter';
4
4
  import { logger } from './logger.mjs';
5
5
 
6
- // Slug allowlist shared with validated-write.mjs — a slug is a filename;
7
- // anything outside this set risks path traversal on delete.
8
- const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
6
+ // Slug allowlist shared with validated-write.mjs — a slug is a filename, so it
7
+ // must not be able to escape its category directory.
8
+ //
9
+ // Dots are permitted in the interior because repository names legitimately
10
+ // contain them — any domain-style name (`example.com`) produces a slug with a
11
+ // dot on ingest, and the stricter rule rejected thousands of real nodes over an
12
+ // ordinary character. What enables traversal is a path separator or `..`, and
13
+ // neither is reachable here — separators are outside the character class, a
14
+ // leading dot or dash is excluded by the first-character rule, and `..` is
15
+ // rejected outright below.
16
+ const SAFE_NAME = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/;
17
+
18
+ /**
19
+ * True when `name` is safe to use as a path segment.
20
+ */
21
+ export function isSafeVaultName(name) {
22
+ const value = String(name);
23
+ if (!SAFE_NAME.test(value)) return false;
24
+ if (value.includes('..')) return false;
25
+ return true;
26
+ }
9
27
 
10
28
  /**
11
29
  * Atomic file write using write-then-rename to prevent partial corruption.
@@ -158,7 +176,7 @@ export async function writeNode(node, vaultDir, options = {}) {
158
176
  * @returns {boolean}
159
177
  */
160
178
  export function deleteNode(slug, vaultDir) {
161
- if (!SAFE_NAME.test(slug)) return false;
179
+ if (!isSafeVaultName(slug)) return false;
162
180
  if (!fs.existsSync(vaultDir)) return false;
163
181
 
164
182
  // Scan category directories directly instead of loading entire vault
@@ -355,6 +355,42 @@ export function requireSandboxEnabled(req, res, next) {
355
355
  next();
356
356
  }
357
357
 
358
+ /**
359
+ * Cookie options for the dashboard session.
360
+ *
361
+ * `secure` has to describe the connection that is actually in use, not the
362
+ * connection the config wishes for. It was previously derived from
363
+ * `nodeEnv !== 'development' && require_https !== false`, and since nodeEnv
364
+ * defaults to 'production' and the server only ever calls app.listen() — there
365
+ * is no HTTPS listener anywhere in it — every login over the mesh bind issued a
366
+ * `Secure` cookie on a plain-HTTP response. Browsers drop those, so login
367
+ * returned 200, no session was stored, and the next request 401'd straight back
368
+ * to the login page. http://localhost is exempt as a trustworthy origin, which
369
+ * is why this looked fine locally and only bit when reaching the dashboard by
370
+ * its host or mesh IP.
371
+ *
372
+ * Express resolves `req.secure` through `trust proxy`, so a deployment behind
373
+ * an HTTPS-terminating reverse proxy still gets `Secure` set.
374
+ */
375
+ function sessionCookieOptions(req, maxAge) {
376
+ const config = loadSecurityConfig();
377
+ const isSecureConnection = req?.secure === true;
378
+
379
+ if (config.network?.require_https !== false && !isSecureConnection) {
380
+ logger.warn('auth',
381
+ 'require_https is set but this request arrived over plain HTTP; issuing the session '
382
+ + 'cookie without Secure so login works. Terminate TLS in front of the dashboard, or '
383
+ + 'set network.require_https: false to silence this.');
384
+ }
385
+
386
+ return {
387
+ httpOnly: true,
388
+ secure: isSecureConnection,
389
+ maxAge,
390
+ sameSite: 'lax',
391
+ };
392
+ }
393
+
358
394
  export async function loginHandler(req, res) {
359
395
  const ip = req.ip || req.socket?.remoteAddress;
360
396
  if (ip && watchdog.isIpBlocked(ip)) {
@@ -383,13 +419,9 @@ export async function loginHandler(req, res) {
383
419
 
384
420
  const ttlHours = config.dashboard?.session_ttl_hours || 24;
385
421
  const token = jwt.sign({ user: 'admin' }, JWT_SECRET, { expiresIn: `${ttlHours}h` });
386
-
387
- res.cookie('session', token, {
388
- httpOnly: true,
389
- secure: nodeEnv !== 'development' && config.network?.require_https !== false,
390
- maxAge: ttlHours * 60 * 60 * 1000,
391
- sameSite: 'lax'
392
- });
422
+
423
+ res.cookie('session', token, sessionCookieOptions(req, ttlHours * 60 * 60 * 1000));
424
+
393
425
 
394
426
  const requiresReset = !!config.dashboard?.force_password_reset;
395
427
  res.json({ success: true, requiresPasswordReset: requiresReset });
@@ -475,6 +507,8 @@ export async function verifyDashboardPassword(password) {
475
507
  }
476
508
 
477
509
  export function logoutHandler(req, res) {
478
- res.clearCookie('session');
510
+ // Attributes must match those the cookie was set with or the browser keeps it.
511
+ const { maxAge, ...clearOptions } = sessionCookieOptions(req, 0);
512
+ res.clearCookie('session', clearOptions);
479
513
  res.json({ success: true });
480
514
  }
@@ -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
@@ -218,7 +218,7 @@ router.get('/api/brains/:id/nodes', requireAuth, requireScope('ssss:read', 'memo
218
218
  router.post('/api/brains/sync', requireAuth, requireScope('memory:write'), async (req, res) => {
219
219
  try {
220
220
  const { syncAllRepos } = await import('../../core/repo-sync.mjs');
221
- const result = syncAllRepos();
221
+ const result = await syncAllRepos();
222
222
  res.json({
223
223
  ok: true,
224
224
  totalIngested: result.totalIngested,
@@ -90,11 +90,11 @@ proposalsRouter.post('/api/proposals/:id/apply', requireAuth, requireScope('memo
90
90
  }
91
91
  });
92
92
 
93
- proposalsRouter.post('/api/proposals/:id/reject', requireAuth, requireScope('memory:write'), (req, res) => {
93
+ proposalsRouter.post('/api/proposals/:id/reject', requireAuth, requireScope('memory:write'), async (req, res) => {
94
94
  try {
95
95
  const vaultDir = resolveVaultFromQuery(req);
96
96
  const reason = req.body?.reason || 'Rejected via API.';
97
- res.json(setProposalStatus(vaultDir, req.params.id, 'rejected', {
97
+ res.json(await setProposalStatus(vaultDir, req.params.id, 'rejected', {
98
98
  rejection_reason: reason,
99
99
  reviewed_by: req.body?.actor || 'api',
100
100
  }));
@@ -104,9 +104,9 @@ proposalsRouter.post('/api/proposals/:id/reject', requireAuth, requireScope('mem
104
104
  }
105
105
  });
106
106
 
107
- proposalsRouter.post('/api/proposals/:id/revert', requireAuth, requireScope('memory:write'), (req, res) => {
107
+ proposalsRouter.post('/api/proposals/:id/revert', requireAuth, requireScope('memory:write'), async (req, res) => {
108
108
  try {
109
- res.json(revertProposal(resolveVaultFromQuery(req), req.params.id));
109
+ res.json(await revertProposal(resolveVaultFromQuery(req), req.params.id));
110
110
  } catch (err) {
111
111
  if (/No undo snapshot|not found/i.test(err.message)) return res.status(404).json({ error: err.message });
112
112
  serverError(res, err);
@@ -49,7 +49,7 @@ const router = express.Router();
49
49
 
50
50
  /**
51
51
  * Provider secrets are a single host SSOT — NOT per project-vault brain.
52
- * Selecting project:ultrachat for memory/graph must not empty the Secrets page.
52
+ * Selecting any project:<name> scope for memory/graph must not empty the Secrets page.
53
53
  * Repo binding lives in secret metadata (repos[]), not separate secrets.enc files.
54
54
  *
55
55
  * Override only via TR_SECRETS_BRAIN / TR_BRAIN env (deploy/ops).