m9k_search
Find relevant past conversations and indexed content quickly. Apply filters for project, date, source, and sort order. Results prioritize current session and project.
Instructions
Search indexed past conversations. Returns compact results with snippets. Results from the current project and session are boosted by default. Use m9k_context or m9k_full to drill down.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| query | Yes | Search query (keywords or natural language) | |
| project | No | Filter by project path. Omit for cross-project search. | |
| limit | No | Max results | |
| since | No | ISO-8601 date. Only results after this date. | |
| until | No | ISO-8601 date. Only results before this date (exclusive). | |
| order | No | Sort order: score (relevance), date_asc (oldest first), date_desc (newest first) | score |
| source | No | Filter by source type. Default: all sources. |
Implementation Reference
- src/tools/search.ts:11-67 (handler)The 'm9k_search' tool registration and handler. registerSearchTools registers 'm9k_search' with input schema (query, project, limit, since, until, order, source) and calls the core search() function, returning JSON-stringified results.
export function registerSearchTools(server: McpServer, ctx: ToolContext): void { server.registerTool( 'm9k_search', { description: 'Search indexed past conversations. Returns compact results with snippets. Results from the current project and session are boosted by default. Use m9k_context or m9k_full to drill down.', inputSchema: { query: z.string().describe('Search query (keywords or natural language)'), project: z .string() .optional() .describe('Filter by project path. Omit for cross-project search.'), limit: z.number().int().min(1).max(50).default(10).describe('Max results'), since: z.string().optional().describe('ISO-8601 date. Only results after this date.'), until: z .string() .optional() .describe('ISO-8601 date. Only results before this date (exclusive).'), order: z .enum(['score', 'date_asc', 'date_desc']) .default('score') .describe( 'Sort order: score (relevance), date_asc (oldest first), date_desc (newest first)', ), source: z .enum(['conversations', 'git', 'files']) .optional() .describe('Filter by source type. Default: all sources.'), }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async ({ query, project, limit, since, until, order }) => { const currentSession = getStat(ctx.db, 'current_session_id') || undefined; const results = await search( ctx.db, { query, project, currentProject: ctx.currentProject, currentSession, limit, since, until, order, }, ctx.searchContext, ); return { content: [{ type: 'text' as const, text: JSON.stringify(results) }], }; }, ); - src/tools/search.ts:11-46 (registration)Registration of 'm9k_search' tool via server.registerTool with its input schema (zod definitions for query, project, limit, since, until, order, source) and annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint).
export function registerSearchTools(server: McpServer, ctx: ToolContext): void { server.registerTool( 'm9k_search', { description: 'Search indexed past conversations. Returns compact results with snippets. Results from the current project and session are boosted by default. Use m9k_context or m9k_full to drill down.', inputSchema: { query: z.string().describe('Search query (keywords or natural language)'), project: z .string() .optional() .describe('Filter by project path. Omit for cross-project search.'), limit: z.number().int().min(1).max(50).default(10).describe('Max results'), since: z.string().optional().describe('ISO-8601 date. Only results after this date.'), until: z .string() .optional() .describe('ISO-8601 date. Only results before this date (exclusive).'), order: z .enum(['score', 'date_asc', 'date_desc']) .default('score') .describe( 'Sort order: score (relevance), date_asc (oldest first), date_desc (newest first)', ), source: z .enum(['conversations', 'git', 'files']) .optional() .describe('Filter by source type. Default: all sources.'), }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, - src/tools/search.ts:17-39 (schema)Input schema for the m9k_search tool: query (string, required), project (optional string), limit (number, 1-50, default 10), since (optional ISO-8601 string), until (optional ISO-8601 string), order (enum: score/date_asc/date_desc, default score), source (optional enum: conversations/git/files).
inputSchema: { query: z.string().describe('Search query (keywords or natural language)'), project: z .string() .optional() .describe('Filter by project path. Omit for cross-project search.'), limit: z.number().int().min(1).max(50).default(10).describe('Max results'), since: z.string().optional().describe('ISO-8601 date. Only results after this date.'), until: z .string() .optional() .describe('ISO-8601 date. Only results before this date (exclusive).'), order: z .enum(['score', 'date_asc', 'date_desc']) .default('score') .describe( 'Sort order: score (relevance), date_asc (oldest first), date_desc (newest first)', ), source: z .enum(['conversations', 'git', 'files']) .optional() .describe('Filter by source type. Default: all sources.'), }, - src/search.ts:180-341 (helper)Core search() function executed by m9k_search. Performs hybrid search: BM25 (FTS5) + optional text/code vector search (via sqlite-vec), fused via Reciprocal Rank Fusion (RRF). Supports filtering by project/date, auto-fuzzy fallback, cross-encoder reranker, project/session affinity boosts, and date-based sorting.
export async function search( db: Database.Database, options: SearchOptions, ctx?: SearchContext, ): Promise<SearchResult[]> { if (!options.query.trim()) return []; const t0 = performance.now(); const autoFuzzyThreshold = ctx?.autoFuzzyThreshold ?? 3; // BM25 search (always available) const bm25Results = searchBM25(db, options.query, options.limit * 2); const t1 = performance.now(); logger.debug('search', ` BM25: ${(t1 - t0).toFixed(0)}ms (${bm25Results.length} results)`); let results: SearchResult[]; // Triple vector search: BM25 + text vectors + code vectors, fused via RRF const vecLists: SearchResult[][] = [bm25Results]; if (ctx?.vecTextEnabled && ctx.embedderText) { try { const t2 = performance.now(); const embedFn = ctx.embedderText.embedQuery ?? ctx.embedderText.embed; const emb = await embedFn.call(ctx.embedderText, options.query); const t3 = performance.now(); logger.debug('search', ` text embed: ${(t3 - t2).toFixed(0)}ms`); vecLists.push(searchVectorized(db, emb, options.limit * 2, MAX_VECTOR_DISTANCE, '_text')); logger.debug('search', ` text vec search: ${(performance.now() - t3).toFixed(0)}ms`); } catch (err) { logger.warn('search', 'Text vector search failed — degrading gracefully', err); } } if (ctx?.vecCodeEnabled && ctx.embedderCode) { try { const t4 = performance.now(); const embedFn = ctx.embedderCode.embedQuery ?? ctx.embedderCode.embed; const emb = await embedFn.call(ctx.embedderCode, options.query); const t5 = performance.now(); logger.debug('search', ` code embed: ${(t5 - t4).toFixed(0)}ms`); vecLists.push(searchVectorized(db, emb, options.limit * 2, MAX_VECTOR_DISTANCE, '_code')); logger.debug('search', ` code vec search: ${(performance.now() - t5).toFixed(0)}ms`); } catch (err) { logger.warn('search', 'Code vector search failed — degrading gracefully', err); } } results = vecLists.length > 1 ? fusionRRF(vecLists) : bm25Results; logger.debug('search', ` RRF: ${(performance.now() - t1).toFixed(0)}ms total`); // Filter by project if specified if (options.project) { results = results.filter((r) => r.project === options.project); } // Filter by date if specified if (options.since) { results = results.filter((r) => r.timestamp >= options.since!); } if (options.until) { results = results.filter((r) => r.timestamp < options.until!); } // Auto-fuzzy fallback: if fewer than threshold results, try wildcard search if (results.length < autoFuzzyThreshold) { const fuzzyResults = searchFuzzy(db, options.query, options.limit * 2); // Merge: add fuzzy results that aren't already in exact results const existingIds = new Set(results.map((r) => r.chunkId)); for (const fr of fuzzyResults) { if (!existingIds.has(fr.chunkId)) { // Apply same filters if (options.project && fr.project !== options.project) continue; if (options.since && fr.timestamp < options.since) continue; if (options.until && fr.timestamp >= options.until) continue; results.push(fr); existingIds.add(fr.chunkId); } } } // Reranker: cross-encoder reorders pre-limit results by relevance const tRerank0 = performance.now(); if (ctx?.reranker && results.length > 1) { try { const documents = results.map((r) => { const chunk = db .prepare('SELECT user_content, assistant_content FROM conv_chunks WHERE id = ?') .get(r.chunkId) as { user_content: string; assistant_content: string } | undefined; return { id: r.chunkId, content: chunk ? (chunk.user_content + ' ' + chunk.assistant_content).slice(0, 512) : r.snippet, }; }); const reranked = await ctx.reranker.rerank(options.query, documents, options.limit); const rerankedMap = new Map(reranked.map((rr, i) => [rr.id, { score: rr.score, rank: i }])); results = results .filter((r) => rerankedMap.has(r.chunkId)) .sort((a, b) => { const ra = rerankedMap.get(a.chunkId)!; const rb = rerankedMap.get(b.chunkId)!; return ra.rank - rb.rank; }) .map((r) => ({ ...r, score: rerankedMap.get(r.chunkId)!.score })); } catch (err) { logger.warn('search', 'Reranker failed — keeping original order', err); } } logger.debug('search', ` reranker: ${(performance.now() - tRerank0).toFixed(0)}ms`); // Project affinity boost: promote current project results when no strict project filter if (!options.project && options.currentProject) { for (const r of results) { if (r.project === options.currentProject) { // Handle both positive (RRF/reranker) and negative (raw BM25) scores r.score = r.score >= 0 ? r.score * PROJECT_BOOST_FACTOR : r.score / PROJECT_BOOST_FACTOR; } } results.sort((a, b) => b.score - a.score); } // Session affinity boost: promote current session results (weaker than project boost) if (options.currentSession) { for (const r of results) { if (r.sessionId === options.currentSession) { r.score = r.score >= 0 ? r.score * SESSION_BOOST_FACTOR : r.score / SESSION_BOOST_FACTOR; } } results.sort((a, b) => b.score - a.score); } // Re-sort if order is date-based (default is score from BM25/RRF/reranker) if (options.order === 'date_asc') { results.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } else if (options.order === 'date_desc') { results.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); } const finalResults = results.slice(0, options.limit); // Update usage counters (best-effort, don't fail the search) try { incrementStat(db, 'search_count'); if (finalResults.length > 0) { incrementStat(db, 'hit_count'); const tokensServed = finalResults.reduce( (sum, r) => sum + Math.ceil(r.snippet.length / 4), 0, ); incrementStat(db, 'tokens_served', tokensServed); } setStat(db, 'last_search_at', new Date().toISOString()); } catch { // Non-critical — don't fail searches if stats write fails } return finalResults; } - src/tools/context.ts:14-23 (helper)ToolContext interface used by m9k_search handler, containing db, config, searchContext (embedders, reranker), currentProject, orchestrator, embeddingState, version, and mode.
export interface ToolContext { db: DatabaseType; cfg: MelchizedekConfig; searchContext: SearchContext; currentProject?: string; orchestrator: EmbedOrchestrator | null; embeddingState: EmbeddingState; version: string; mode: 'daemon' | 'local'; }