mirror of
https://github.com/rohitg00/agentmemory.git
synced 2026-09-14 20:16:33 +08:00
1a035383de
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.
98 lines
3.1 KiB
TypeScript
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");
|
|
});
|
|
});
|