import type { MemoryEntry } from './entries.js'; import { scanProjectMemory } from './scan.js'; import { Bm25Ranker } from './bm25.js'; import { embed, isEmbeddingAvailable } from './embeddings.js'; const SEARCH_MODE = process.env['MEMORY_SEARCH'] ?? 'hybrid'; function extractKeywords(query: string): string[] { return query .toLowerCase() .replace(/[^a-z0-9\s]/g, '') .split(/\s+/) .filter((w) => w.length > 2); } export function rankByRelevance(query: string, entries: MemoryEntry[]): MemoryEntry[] { const keywords = extractKeywords(query); if (keywords.length === 0) return entries.slice(0, 5); const scored = entries.map((entry) => { let score = 0; const searchText = `${entry.title} ${entry.content} ${entry.tags.join(' ')}`.toLowerCase(); for (const kw of keywords) { if (entry.title.toLowerCase().includes(kw)) score += 3; if (entry.tags.some((t) => t.toLowerCase().includes(kw))) score += 2; if (entry.content.toLowerCase().includes(kw)) score += 1; } return { entry, score }; }); return scored .filter((s) => s.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 10) .map((s) => s.entry); } export async function rankByHybrid( query: string, entries: MemoryEntry[], ): Promise { if (entries.length === 0) return []; const texts = entries.map((e) => `${e.title} ${e.content} ${e.tags.join(' ')}`); const bm25 = new Bm25Ranker(); bm25.fit(texts); const bm25Scores = texts.map((_, i) => bm25.score(query, i)); const maxBm25 = Math.max(...bm25Scores, 1); const normBm25 = bm25Scores.map((s) => s / maxBm25); let cosineScores: number[] = []; if (isEmbeddingAvailable()) { const vectors = await embed([query, ...texts]); if (vectors) { const queryVec = vectors[0]!; cosineScores = texts.map((_, i) => { const vec = vectors[i + 1]; if (!vec) return 0; let dot = 0, nA = 0, nB = 0; for (let j = 0; j < queryVec.length; j++) { dot += queryVec[j]! * vec[j]!; nA += queryVec[j]! * queryVec[j]!; nB += vec[j]! * vec[j]!; } const denom = Math.sqrt(nA) * Math.sqrt(nB); return denom === 0 ? 0 : dot / denom; }); } } const scored = entries.map((entry, i) => { const combined = (normBm25[i]! * 0.3) + ((cosineScores[i] ?? 0) * 0.7); return { entry, score: combined }; }); return scored .filter((s) => s.score >= 0.15) .sort((a, b) => b.score - a.score) .slice(0, 10) .map((s) => s.entry); } export async function loadMemoryForSession( projectRoot: string, _sessionId?: string, query?: string, ): Promise { const entries = await scanProjectMemory(projectRoot); if (entries.length === 0) return []; const relevant = query ? SEARCH_MODE === 'keyword' ? rankByRelevance(query, entries) : await rankByHybrid(query, entries) : entries.slice(0, 5); return relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`); } export { initEmbeddings } from './embeddings.js';