fix(sf): enforceMemoryCap sweeps orphaned embeddings too

Same orphan-cleanup as 1b71ddd17 but for the batch path. enforceMemoryCap
calls supersedeLowestRankedMemories, which marks N lowest memories
superseded in one UPDATE — bypassing the per-memory supersede embedding
cleanup. The result was that capping a project at 50 memories left dead
embedding rows for everything that got demoted.

Now: a single DELETE-IN-SUBQUERY removes embedding rows for any memory
that no longer has superseded_by IS NULL — covers both the cap path
and any historical orphans from before the per-row cleanup landed.
Best-effort; cap enforcement is load-bearing, embedding cleanup is not.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 23:23:37 +02:00
parent 1b71ddd178
commit 05a326a294

View file

@ -434,6 +434,12 @@ export function decayStaleMemories(thresholdUnits = 20): void {
/**
* Supersede lowest-ranked memories when count exceeds cap.
*
* After superseding, sweeps memory_embeddings for rows whose memory is now
* superseded keeps the embeddings table aligned with active memories so
* loadAllEmbeddings doesn't carry dead vectors and storage doesn't grow
* unbounded. Best-effort cleanup; the cap enforcement is the load-bearing
* step.
*/
export function enforceMemoryCap(max = 50): void {
if (!isDbAvailable()) return;
@ -451,6 +457,21 @@ export function enforceMemoryCap(max = 50): void {
const excess = count - max;
supersedeLowestRankedMemories(excess, new Date().toISOString());
// Sweep orphaned embeddings for newly-superseded memories.
try {
adapter
.prepare(
`DELETE FROM memory_embeddings WHERE memory_id IN (
SELECT id FROM memories WHERE superseded_by IS NOT NULL
)`,
)
.run();
} catch {
// Orphaned rows are harmless to queries (loadAllEmbeddings filters
// by superseded_by IS NULL); skip-on-error keeps cap enforcement
// load-bearing without coupling to embedding cleanup.
}
} catch {
// non-fatal
}