From 6344105877ab5fed1046e3f8ff46c0f5a18bf418 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 21:55:47 +0000 Subject: [PATCH] feat(server): memory v2 tests and search_memory tool --- .../services/memory/__tests__/bm25.test.ts | 37 +++++++++++++++++ .../services/memory/__tests__/recall.test.ts | 12 ++++++ .../src/services/tools/search_memory.ts | 40 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 apps/server/src/services/memory/__tests__/bm25.test.ts create mode 100644 apps/server/src/services/tools/search_memory.ts diff --git a/apps/server/src/services/memory/__tests__/bm25.test.ts b/apps/server/src/services/memory/__tests__/bm25.test.ts new file mode 100644 index 0000000..08b27e1 --- /dev/null +++ b/apps/server/src/services/memory/__tests__/bm25.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { Bm25Ranker } from '../bm25.js'; + +describe('Bm25Ranker', () => { + it('scores documents by term frequency', () => { + const ranker = new Bm25Ranker(); + ranker.fit(['the cat sat on the mat', 'the dog chased the cat', 'the bird flew over the mat']); + const results = ranker.rank('cat mat'); + expect(results.length).toBeGreaterThan(0); + expect(results[0]!.score).toBeGreaterThan(0); + }); + + it('returns empty for no matches', () => { + const ranker = new Bm25Ranker(); + ranker.fit(['aaa bbb', 'ccc ddd']); + const results = ranker.rank('zzz'); + expect(results).toHaveLength(0); + }); + + it('handles single document corpus', () => { + const ranker = new Bm25Ranker(); + ranker.fit(['only document here']); + const results = ranker.rank('document'); + expect(results).toHaveLength(1); + }); + + it('ranks relevant docs higher', () => { + const ranker = new Bm25Ranker(); + ranker.fit([ + 'javascript is a programming language', + 'python is also a programming language', + 'the weather is nice today', + ]); + const results = ranker.rank('javascript programming'); + expect(results[0]!.index).toBe(0); + }); +}); diff --git a/apps/server/src/services/memory/__tests__/recall.test.ts b/apps/server/src/services/memory/__tests__/recall.test.ts index bc345e0..ff81f00 100644 --- a/apps/server/src/services/memory/__tests__/recall.test.ts +++ b/apps/server/src/services/memory/__tests__/recall.test.ts @@ -13,3 +13,15 @@ describe('rankByRelevance', () => { expect(result[0].title).toBe('Style'); }); }); + +describe('rankByHybrid', () => { + it('falls back to BM25 when embeddings unavailable', async () => { + const entries: MemoryEntry[] = [ + { id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] }, + { id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] }, + ]; + const { rankByHybrid } = await import('../recall.js'); + const result = await rankByHybrid('indentation style', entries); + expect(result.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/server/src/services/tools/search_memory.ts b/apps/server/src/services/tools/search_memory.ts new file mode 100644 index 0000000..8ef6ae7 --- /dev/null +++ b/apps/server/src/services/tools/search_memory.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import type { ToolDef } from '../tools/types.js'; +import { scanProjectMemory } from '../memory/scan.js'; +import { rankByHybrid } from '../memory/recall.js'; + +const SearchMemoryInput = z.object({ + query: z.string().min(1).describe('Search query to match against memory entries'), +}); + +type InputT = z.infer; + +export const searchMemoryTool: ToolDef = { + name: 'search_memory', + description: 'Search the .boocode/memory/ store for relevant entries. Returns ranked results matching the query. Use before asking about project conventions or preferences.', + inputSchema: SearchMemoryInput, + jsonSchema: { + type: 'function', + function: { + name: 'search_memory', + description: 'Search memory store for relevant entries', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + }, + required: ['query'], + }, + }, + }, + async execute(input: InputT, projectRoot: string): Promise { + const entries = await scanProjectMemory(projectRoot); + if (entries.length === 0) return { result: 'No memory entries found.' }; + + const relevant = await rankByHybrid(input.query, entries); + if (relevant.length === 0) return { result: 'No matching memory entries.' }; + + const lines = relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`); + return { result: `Found ${relevant.length} entry(ies):\n${lines.join('\n')}` }; + }, +};