total-recall-brain 3.20.0 → 3.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.20.0",
3
+ "version": "3.20.1",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -129,6 +129,7 @@ export default async function recall(args) {
129
129
  let allResults = [];
130
130
  let isDegraded = false;
131
131
  const searchErrors = [];
132
+ const degradedReasons = new Map();
132
133
 
133
134
  for (const target of searchTargets) {
134
135
  const vaultDir = path.join(target.brainDir, 'memory-vault');
@@ -163,7 +164,13 @@ export default async function recall(args) {
163
164
  priority
164
165
  });
165
166
  }
166
- if (results.degradedTextSearch) isDegraded = true;
167
+ if (results.degradedTextSearch) {
168
+ isDegraded = true;
169
+ degradedReasons.set(results.degradedReason || 'unknown', {
170
+ label: target.label,
171
+ detail: results.degradedDetail || '',
172
+ });
173
+ }
167
174
  // Tag each result with its layer
168
175
  for (const r of results) {
169
176
  r._layer = target.label;
@@ -215,11 +222,35 @@ export default async function recall(args) {
215
222
  return;
216
223
  }
217
224
  if (isDegraded) {
218
- console.log('\n ⚠️ WARNING: Embedding API unreachable or unconfigured. Cos-similarity vector search is disabled.');
219
- console.log(' Running basic text keyword search instead. Configure GOOGLE_API_KEY or OPENAI_API_KEY.');
225
+ // This block used to print ONE message for two unrelated failures:
226
+ // "Embedding API unreachable or unconfigured … Configure GOOGLE_API_KEY
227
+ // or OPENAI_API_KEY". When the real cause was an unbuilt index, that
228
+ // sent the reader to check credentials that were already working, while
229
+ // a brain with thousands of nodes quietly answered from keyword matching
230
+ // and returned results from the wrong project. Name the actual cause.
231
+ const empty = degradedReasons.get('empty_index');
232
+ const failed = degradedReasons.get('embedding_failed');
233
+
234
+ console.log('\n ⚠️ Vector search is OFF — these results are keyword-only and may miss obvious matches.');
235
+ if (empty) {
236
+ console.log(` Cause: the ${empty.label} brain has NO embeddings indexed. The API is fine; the index was never built.`);
237
+ console.log(' Fix: npx total-recall compile (embeds any unembedded nodes for this brain)');
238
+ }
239
+ if (failed) {
240
+ console.log(` Cause: embedding generation failed for the ${failed.label} brain — ${failed.detail}`);
241
+ console.log(' Fix: set OPENROUTER_API_KEY (preferred), or GOOGLE_API_KEY / OPENAI_API_KEY.');
242
+ }
243
+ if (!empty && !failed) {
244
+ console.log(' Cause: unknown — embeddings unavailable and no reason was reported.');
245
+ }
220
246
  }
221
247
  const layerSuffix = searchTargets.length > 1 ? ' (merged)' : ` (${searchTargets[0].label})`;
222
- console.log(`\n 🔍 Semantic search results for "${query}"${layerSuffix}:\n`);
248
+ // Never call it "Semantic search results" when semantic search did not run.
249
+ // The old header said exactly that regardless, so degraded keyword output
250
+ // was indistinguishable from real vector results — same header, same
251
+ // percentage scores. If the label doesn't change, nobody can tell.
252
+ const modeLabel = isDegraded ? '🔤 KEYWORD-ONLY results' : '🔍 Semantic search results';
253
+ console.log(`\n ${modeLabel} for "${query}"${layerSuffix}:\n`);
223
254
  for (let i = 0; i < allResults.length; i++) {
224
255
  const match = allResults[i];
225
256
  const rank = i + 1;
@@ -285,7 +285,13 @@ export function detectProjectBrain(startDir = process.cwd()) {
285
285
  if (fs.existsSync(path.join(candidate, 'SKILL.md'))) {
286
286
  return {
287
287
  agentDir: path.join(dir, '.agent'),
288
- brainDir: globalBrainDir,
288
+ // The project brain lives at the detected candidate — NEVER alias it to
289
+ // globalBrainDir. This returned `globalBrainDir` while the sibling
290
+ // implementation in src/cli/agent-dir.mjs returned `candidate`, so the
291
+ // two disagreed about where the project brain is: anything routed
292
+ // through this function silently operated on the GLOBAL vault while
293
+ // reporting itself as the 'project' layer.
294
+ brainDir: candidate,
289
295
  projectRoot: dir,
290
296
  };
291
297
  }
@@ -132,12 +132,22 @@ export async function semanticSearch(query, {
132
132
  const filteredSlugs = new Set(filteredNodes.map(n => n.slug));
133
133
  const results = [];
134
134
 
135
- // Get query embedding
135
+ // Get query embedding.
136
+ //
137
+ // Falling back to lexical-only search is fine. Losing the REASON is not:
138
+ // "the embedding API failed" and "the index for this brain was never built"
139
+ // are completely different problems with completely different fixes, and
140
+ // this used to report both as the former. That mislabelling cost real time —
141
+ // festech.live's project brain had 2602 vault nodes and 0 embeddings, and
142
+ // every recall against it printed "Embedding API unreachable or
143
+ // unconfigured. Configure GOOGLE_API_KEY or OPENAI_API_KEY", which sent the
144
+ // reader off to check credentials that were working perfectly.
136
145
  let queryEmbedding = null;
146
+ let embeddingError = null;
137
147
  try {
138
148
  queryEmbedding = await getEmbedding(String(query));
139
149
  } catch (err) {
140
- // Gracefully ignore embedding generation errors
150
+ embeddingError = String(err?.message || err);
141
151
  }
142
152
 
143
153
  const vaultIndex = loadEmbeddingsIndex(derivedDir);
@@ -279,6 +289,13 @@ export async function semanticSearch(query, {
279
289
  const finalResults = results.slice(0, k);
280
290
  if (!queryEmbedding || vaultEntries.length === 0) {
281
291
  finalResults.degradedTextSearch = true;
292
+ // Say WHICH failure this is. The caller renders remediation from it, and
293
+ // the two causes need opposite fixes: one is credentials, the other is an
294
+ // unbuilt index for this specific brain.
295
+ finalResults.degradedReason = !queryEmbedding ? 'embedding_failed' : 'empty_index';
296
+ finalResults.degradedDetail = !queryEmbedding
297
+ ? (embeddingError || 'embedding provider returned nothing')
298
+ : `no embeddings indexed for this brain (${derivedDir})`;
282
299
  }
283
300
 
284
301
  return finalResults;
@@ -187,8 +187,55 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
187
187
  // Check daemon status
188
188
  const daemonStatus = getDaemonStatus();
189
189
 
190
+ // Embedding coverage per brain.
191
+ //
192
+ // This existed nowhere before, and its absence is exactly why a brain with
193
+ // 2602 vault nodes and 0 embeddings looked healthy for weeks: compile
194
+ // reported `compiled: true`, recall returned keyword hits that read like
195
+ // real results, and nothing anywhere printed "N of M nodes embedded".
196
+ // Vector search silently off is a CRITICAL failure, not a cosmetic one —
197
+ // recall answers from the wrong brain and the caller cannot tell.
198
+ const embeddingCoverage = [];
199
+ try {
200
+ const { loadEmbeddingsIndex } = await import('../core/embeddings.mjs');
201
+ const { getActiveBrains } = await import('../core/config.mjs');
202
+ const brains = getActiveBrains();
203
+ for (const b of Object.values(brains)) {
204
+ if (!b?.brainDir) continue;
205
+ const vaultPath = path.join(b.brainDir, 'memory-vault');
206
+ if (!fs.existsSync(vaultPath)) continue;
207
+ let nodeCount = 0;
208
+ const walk = (d) => {
209
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
210
+ if (e.name.startsWith('.')) continue;
211
+ const p = path.join(d, e.name);
212
+ if (e.isDirectory()) walk(p);
213
+ else if (e.name.endsWith('.md')) nodeCount++;
214
+ }
215
+ };
216
+ try { walk(vaultPath); } catch { /* unreadable vault */ }
217
+ let embedded = 0;
218
+ try {
219
+ embedded = Object.keys(loadEmbeddingsIndex(path.join(b.brainDir, 'memory-derived'))).length;
220
+ } catch { /* index unreadable — reported as 0, which is the honest value */ }
221
+ embeddingCoverage.push({
222
+ layer: b.layer || 'unknown',
223
+ nodes: nodeCount,
224
+ embedded,
225
+ vector_search: embedded > 0 ? 'on' : 'OFF — keyword-only',
226
+ });
227
+ }
228
+ } catch (err) {
229
+ embeddingCoverage.push({ error: String(err?.message || err) });
230
+ }
231
+
232
+ // A brain with nodes but no vectors answers every query from keyword
233
+ // matching. Surface it at the same severity as a dead daemon.
234
+ const unembeddedBrains = embeddingCoverage.filter(c => c.nodes > 0 && c.embedded === 0);
235
+
190
236
  // Determine overall status
191
- const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead' || cliAgents.length === 0;
237
+ const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead' || cliAgents.length === 0
238
+ || unembeddedBrains.length > 0;
192
239
 
193
240
  // Check Caddy and Cloudflare status
194
241
  let caddyStatus = 'inactive';
@@ -223,6 +270,12 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
223
270
  disk,
224
271
  cli_agents: cliAgents,
225
272
  daemon: daemonStatus,
273
+ embedding_coverage: embeddingCoverage,
274
+ // Explicit, greppable signal. A brain with nodes and no vectors serves
275
+ // keyword-only results that look exactly like real ones.
276
+ vector_search_degraded: unembeddedBrains.length > 0
277
+ ? unembeddedBrains.map(b => `${b.layer}: ${b.embedded}/${b.nodes} embedded`)
278
+ : null,
226
279
  caddy: caddyStatus,
227
280
  cloudflare: cloudflareStatus,
228
281
  emergency_alerts: emergencyAlerts || null,
@@ -15,6 +15,7 @@ import {
15
15
  import { compileSurface } from '../../core/surface.mjs';
16
16
  import { getNodes, invalidate } from '../../core/vault-cache.mjs';
17
17
  import { loadEmbeddingsIndex, loadSessionEmbeddingsIndex } from '../../core/embeddings.mjs';
18
+ import { logger } from '../../core/logger.mjs';
18
19
 
19
20
  const router = Router();
20
21
 
@@ -32,20 +33,30 @@ router.post('/api/vault/compile', requireAuth, requireScope('memory:recompile'),
32
33
  force: true,
33
34
  });
34
35
  // Incrementally embed any new vault nodes and sessions for THIS brain
35
- let vaultEmbed = null, sessionEmbed = null;
36
+ let vaultEmbed = null, sessionEmbed = null, embedError = null;
36
37
  try {
37
38
  const { buildEmbeddingsIndex, buildSessionEmbeddingsIndex } = await import('../../core/embeddings.mjs');
38
39
  invalidate(vaultDir);
39
40
  const vaultNodes = getNodes(vaultDir);
40
41
  vaultEmbed = await buildEmbeddingsIndex(vaultNodes, paths.derivedDir);
41
42
  sessionEmbed = await buildSessionEmbeddingsIndex(paths.sessionsDir, paths.derivedDir);
42
- } catch { /* embedding service may be unavailable — non-fatal */ }
43
+ } catch (err) {
44
+ // Still non-fatal — a compile that produced surfaces is worth keeping —
45
+ // but NEVER silent. This was a bare `catch {}`, and it meant every
46
+ // compile could fail to embed a single node while reporting
47
+ // `compiled: true`. A brain with thousands of nodes and zero vectors
48
+ // looked perfectly healthy, and recall silently answered from keyword
49
+ // matching for weeks.
50
+ embedError = String(err?.message || err);
51
+ logger.error('vault', `Embedding build FAILED during compile (surfaces still compiled): ${embedError}`, { vaultDir });
52
+ }
43
53
  res.json({
44
54
  compiled: true,
45
55
  elapsed_ms: Date.now() - start,
46
56
  vault_dir: vaultDir,
47
57
  vault_embeddings: vaultEmbed,
48
58
  session_embeddings: sessionEmbed,
59
+ embedding_error: embedError,
49
60
  });
50
61
  } catch (err) {
51
62
  serverError(res, err);