feat(server): memory v2 tests and search_memory tool
This commit is contained in:
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,3 +13,15 @@ describe('rankByRelevance', () => {
|
|||||||
expect(result[0].title).toBe('Style');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
40
apps/server/src/services/tools/search_memory.ts
Normal file
40
apps/server/src/services/tools/search_memory.ts
Normal file
@@ -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<typeof SearchMemoryInput>;
|
||||||
|
|
||||||
|
export const searchMemoryTool: ToolDef<InputT> = {
|
||||||
|
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<unknown> {
|
||||||
|
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')}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user