Files
rohitg00__agentmemory/test/remember-bm25-index.test.ts
Rohit Ghumare 1a035383de fix(memory): index memories into BM25 + backfill on startup (closes #257) (#258)
memory_save returned 200 with a valid memory ID, but every subsequent
memory_smart_search and memory_recall returned empty results. Direct
REST /agentmemory/smart-search showed the same — confirming the bug was
not in the MCP layer.

Root cause: mem::remember in src/functions/remember.ts wrote the new
Memory to KV.memories but never called getSearchIndex().add(). The same
pattern that #228 fixed for vectors-on-observe was missing here for
BM25-on-remember. Compounded by rebuildIndex() walking only sessions/
observations and skipping KV.memories, so even a restart-rebuild
couldn't recover indexed memories.

Three changes:

1. src/functions/remember.ts — synthesize a CompressedObservation-shaped
   record from the saved Memory (title + content + concepts + files) and
   add it to BM25 right after kv.set(). Wrapped in try/catch so an
   indexing hiccup never blocks the durable save.

2. src/functions/search.ts — rebuildIndex() now walks KV.memories before
   sessions/observations, so a fresh rebuild covers the full corpus.
   Skips memory.isLatest === false so superseded entries don't pollute
   results.

3. src/index.ts — startup backfill for users upgrading from <0.9.5: when
   the persisted BM25 is non-empty (no rebuild triggered) but legacy
   memories were never indexed, walk KV.memories and add anything
   missing. SearchIndex.has(id) is the new idempotency gate.
   indexPersistence.scheduleSave() persists the augmented index.

Tests in test/remember-bm25-index.test.ts cover:
- SearchIndex.has() before/after add
- A saved memory is findable by keyword (the case the bug broke)
- The exact repro from the issue (mem_moy3u6ua_..., query "BM25 test")
- Concept-only matches (no title/content overlap)

Out of scope (file as follow-up):
- Removing superseded memories from BM25 when mem::cascade-update fires.
  Currently relies on isLatest filter at result time; not perfect but
  doesn't regress recall.
- Memory restore via IndexPersistence (covered transparently by the
  startup backfill, but a dedicated round-trip test would be tighter).

Reported by @Nizar-BenHamida with full repro + log capture.
2026-05-09 14:23:45 +01:00

98 lines
3.1 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { SearchIndex } from "../src/state/search-index.js";
import type { CompressedObservation, Memory } from "../src/types.js";
// Mirrors the helper used by remember.ts and rebuildIndex(). Kept inline
// here rather than exporting from src/ so the test asserts the contract,
// not the implementation.
function memoryAsIndexable(memory: Memory): CompressedObservation {
return {
id: memory.id,
sessionId: memory.sessionIds[0] ?? "memory",
timestamp: memory.createdAt,
type: "decision",
title: memory.title,
facts: [memory.content],
narrative: memory.content,
concepts: memory.concepts,
files: memory.files,
importance: memory.strength,
};
}
function makeMemory(overrides: Partial<Memory> = {}): Memory {
return {
id: "mem_test_001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "fact",
title: "BM25 test memory",
content: "BM25 search returns this memory by keyword match",
concepts: ["bm25", "search", "test"],
files: [],
sessionIds: [],
strength: 7,
version: 1,
isLatest: true,
...overrides,
};
}
describe("SearchIndex.has()", () => {
it("returns false for unknown ids", () => {
expect(new SearchIndex().has("mem_unknown")).toBe(false);
});
it("returns true after add()", () => {
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory()));
expect(idx.has("mem_test_001")).toBe(true);
});
});
describe("memory indexing into SearchIndex (closes #257)", () => {
it("makes a saved memory findable by keyword search", () => {
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory({
id: "mem_user_001",
title: "JWT middleware uses jose for Edge compatibility",
content: "Chose jose over jsonwebtoken because Cloudflare Workers don't ship Node crypto",
concepts: ["auth", "jose", "edge"],
})));
const hits = idx.search("jose middleware", 5);
expect(hits).toHaveLength(1);
expect(hits[0].obsId).toBe("mem_user_001");
});
it("returns the memory when the issue's reproduction query is run", () => {
// From issue #257: user saved a memory containing 'BM25 test'
// keywords and the search returned empty — recall failure.
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory({
id: "mem_moy3u6ua_8c6962b668e7",
title: "BM25 test",
content: "Confirmed BM25 indexing works for memories saved via memory_save",
concepts: [],
})));
const hits = idx.search("BM25 test", 5);
expect(hits.length).toBeGreaterThan(0);
expect(hits[0].obsId).toBe("mem_moy3u6ua_8c6962b668e7");
});
it("matches concepts as well as title and content", () => {
const idx = new SearchIndex();
idx.add(memoryAsIndexable(makeMemory({
id: "mem_concept_001",
title: "Generic title",
content: "Generic content",
concepts: ["unique-concept-marker"],
})));
const hits = idx.search("unique-concept-marker", 5);
expect(hits.length).toBeGreaterThan(0);
expect(hits[0].obsId).toBe("mem_concept_001");
});
});