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 +1 -1
- package/src/cli/recall.mjs +35 -4
- package/src/core/config.mjs +7 -1
- package/src/core/search.mjs +19 -2
- package/src/server/index.mjs +54 -1
- package/src/server/routes/vault.mjs +13 -2
package/package.json
CHANGED
package/src/cli/recall.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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;
|
package/src/core/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/core/search.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/server/index.mjs
CHANGED
|
@@ -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 {
|
|
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);
|