memory_search
Search across memories and journal entries using semantic meaning similarity, with automatic fallback to keyword search when needed.
Instructions
Semantic search across all memories and journal entries. Returns results ranked by meaning-similarity. Falls back to keyword search if Ollama is not available.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| query | Yes | What to search for (natural language) | |
| category | No | Filter by category | |
| project | No | Filter by project name | |
| topK | No | Number of results (default: 5) |
Implementation Reference
- The handleMemorySearch function is the main handler for the 'memory_search' tool. It calls searchMemories and formats results with relevance scores, dates, and fallback notes.
import { searchMemories, type SearchResult } from "../memory/search.js"; import { isUsingFallback } from "../memory/embeddings.js"; export async function handleMemorySearch( query: string, options?: { category?: string; project?: string; topK?: number }, ): Promise<string> { const results = await searchMemories(query, { topK: options?.topK ?? 5, category: options?.category, project: options?.project, }); if (results.length === 0) { return `No memories found matching "${query}".`; } const lines = results.map((r, i) => formatResult(r, i + 1)); const fallbackNote = isUsingFallback() ? "\n\n*Using keyword search — install Ollama for semantic search.*" : ""; return `Found ${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}${fallbackNote}`; } function formatResult(r: SearchResult, rank: number): string { const date = new Date(r.created_at).toISOString().slice(0, 10); const score = (r.score * 100).toFixed(0); const project = r.project ? ` [${r.project}]` : ""; const source = r.source === "journal" ? " (journal)" : ` (${r.category})`; return `**${rank}. ${score}% match**${project}${source} — ${date}\n${r.content}`; } - packages/server/src/index.ts:222-245 (schema)Registration of the 'memory_search' tool with Zod schema definitions for query (required string), category (enum filter), project (optional string), and topK (optional number).
server.tool( "memory_search", "Semantic search across all memories and journal entries. Returns results ranked by meaning-similarity. Falls back to keyword search if Ollama is not available.", { query: z.string().describe("What to search for (natural language)"), category: z .enum(["decision", "preference", "fact", "episode", "lesson", "architecture", "framework", "general"]) .optional() .describe("Filter by category"), project: z.string().optional().describe("Filter by project name"), topK: z.number().optional().describe("Number of results (default: 5)"), }, async ({ query, category, project, topK }) => { try { const result = await handleMemorySearch(query, { category, project, topK }); return { content: [{ type: "text", text: result }] }; } catch (err) { return { content: [{ type: "text", text: `Error searching memories: ${err}` }], isError: true, }; } }, ); - The searchMemories function implements the actual search logic with semantic embedding-based matching (via Ollama) and fallback to keyword search. It queries both memories and journal_entries tables.
export async function searchMemories( query: string, options: { topK?: number; category?: string; project?: string; includeArchived?: boolean; minScore?: number; } = {}, ): Promise<SearchResult[]> { const { topK = 5, category, project, includeArchived = false, minScore = 0.3, } = options; // Fall back to keyword search when Ollama is unavailable if (isUsingFallback()) { return keywordSearch(query, { topK, category, project, includeArchived, minScore: 0.2 }); } const db = getDb(); const queryEmbedding = await embed(query); if (!queryEmbedding) { return keywordSearch(query, { topK, category, project, includeArchived, minScore: 0.2 }); } let memorySql = `SELECT id, content, category, project, created_at, embedding FROM memories WHERE embedding IS NOT NULL`; const params: unknown[] = []; if (!includeArchived) { memorySql += ` AND archived = 0`; } if (category) { memorySql += ` AND category = ?`; params.push(category); } if (project) { memorySql += ` AND project = ?`; params.push(project); } const memories = db.prepare(memorySql).all(...params) as Array<{ id: string; content: string; category: string; project: string | null; created_at: number; embedding: Buffer; }>; let journalSql = `SELECT id, content, project, created_at, embedding FROM journal_entries WHERE embedding IS NOT NULL`; const journalParams: unknown[] = []; if (project) { journalSql += ` AND project = ?`; journalParams.push(project); } const journals = db.prepare(journalSql).all(...journalParams) as Array<{ id: string; content: string; project: string | null; created_at: number; embedding: Buffer; }>; const results: SearchResult[] = []; for (const mem of memories) { const memEmbedding = bufferToEmbedding(mem.embedding); const score = cosineSimilarity(queryEmbedding, memEmbedding); if (score >= minScore) { results.push({ id: mem.id, content: mem.content, category: mem.category, project: mem.project, score, created_at: mem.created_at, source: "memory", }); } } for (const entry of journals) { const entryEmbedding = bufferToEmbedding(entry.embedding); const score = cosineSimilarity(queryEmbedding, entryEmbedding); if (score >= minScore) { results.push({ id: entry.id, content: entry.content, category: "journal", project: entry.project, score, created_at: entry.created_at, source: "journal", }); } } results.sort((a, b) => b.score - a.score); const updateAccess = db.prepare( `UPDATE memories SET accessed_at = ?, access_count = access_count + 1 WHERE id = ?`, ); const now = Date.now(); for (const r of results.slice(0, topK)) { if (r.source === "memory") { updateAccess.run(now, r.id); } } db.prepare(`INSERT INTO search_log (query, results_count, created_at) VALUES (?, ?, ?)`).run( query, Math.min(results.length, topK), now, ); return results.slice(0, topK); } - The keywordSearch function provides the fallback search when Ollama is unavailable, using token-based keyword matching against memories and journal entries.
function keywordSearch( query: string, options: { topK: number; category?: string; project?: string; includeArchived: boolean; minScore: number; }, ): SearchResult[] { const db = getDb(); const queryTokens = tokenize(query); if (queryTokens.length === 0) return []; let memorySql = `SELECT id, content, category, project, created_at FROM memories WHERE 1=1`; const params: unknown[] = []; if (!options.includeArchived) { memorySql += ` AND archived = 0`; } if (options.category) { memorySql += ` AND category = ?`; params.push(options.category); } if (options.project) { memorySql += ` AND project = ?`; params.push(options.project); } const memories = db.prepare(memorySql).all(...params) as Array<{ id: string; content: string; category: string; project: string | null; created_at: number; }>; let journalSql = `SELECT id, content, project, created_at FROM journal_entries WHERE 1=1`; const journalParams: unknown[] = []; if (options.project) { journalSql += ` AND project = ?`; journalParams.push(options.project); } const journals = db.prepare(journalSql).all(...journalParams) as Array<{ id: string; content: string; project: string | null; created_at: number; }>; const results: SearchResult[] = []; for (const mem of memories) { const score = keywordScore(queryTokens, mem.content); if (score >= options.minScore) { results.push({ id: mem.id, content: mem.content, category: mem.category, project: mem.project, score, created_at: mem.created_at, source: "memory", }); } } for (const entry of journals) { const score = keywordScore(queryTokens, entry.content); if (score >= options.minScore) { results.push({ id: entry.id, content: entry.content, category: "journal", project: entry.project, score, created_at: entry.created_at, source: "journal", }); } } results.sort((a, b) => b.score - a.score); const now = Date.now(); const updateAccess = db.prepare( `UPDATE memories SET accessed_at = ?, access_count = access_count + 1 WHERE id = ?`, ); for (const r of results.slice(0, options.topK)) { if (r.source === "memory") { updateAccess.run(now, r.id); } } db.prepare(`INSERT INTO search_log (query, results_count, created_at) VALUES (?, ?, ?)`).run( query, Math.min(results.length, options.topK), now, ); return results.slice(0, options.topK); } - The embed function generates embeddings via Ollama (nomic-embed-text model), and cosineSimilarity computes relevance scores for semantic search.
export async function embed(text: string): Promise<Float32Array | null> { const available = await ensureOllama(); if (!available) return null; const client = getClient(); const response = await client.embed({ model: MODEL, input: text }); return new Float32Array(response.embeddings[0]); } export async function embedMany(texts: string[]): Promise<(Float32Array | null)[]> { if (texts.length === 0) return []; const available = await ensureOllama(); if (!available) return texts.map(() => null); const client = getClient(); const response = await client.embed({ model: MODEL, input: texts }); return response.embeddings.map((e) => new Float32Array(e)); } export function cosineSimilarity(a: Float32Array, b: Float32Array): number { let dot = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const denom = Math.sqrt(normA) * Math.sqrt(normB); return denom === 0 ? 0 : dot / denom; } export function embeddingToBuffer(embedding: Float32Array): Buffer { return Buffer.from(embedding.buffer); } export function bufferToEmbedding(buffer: Buffer): Float32Array { return new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4); }