diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc26255..697723c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added inclusive `blameUntil` retrieval filters across search, peek, and find-similar tools. Temporal bounds now constrain native vector and keyword candidate retrieval using SQLite blame timestamps instead of filtering only after candidate generation. - Added an Ollama-only representative retrieval evaluation command with expanded coverage for embedding failure recovery, index-lock recovery, and duplicate-definition path disambiguation. - Added `--embedding-model` to the cross-repository benchmark runner, preserving `nomic-embed-text` as the default while recording the selected local Ollama model in its artifacts. - Expanded the frozen mixed-intent cross-repository cohort to 100 reviewed queries across JavaScript, Python, Go, Rust, Java, C#, PHP, and Ruby, with additional hard Ruby and C# cases. diff --git a/commands/peek.md b/commands/peek.md index 75169435..a8c3953f 100644 --- a/commands/peek.md +++ b/commands/peek.md @@ -14,6 +14,7 @@ The first part is the search query. Look for optional parameters: - `author=X` or `blameAuthor=X` → set blameAuthor filter - `sha=X` or `blameSha=X` → set blameSha filter - `since=YYYY-MM-DD` or `blameSince=YYYY-MM-DD` → set blameSince filter +- `until=YYYY-MM-DD` or `blameUntil=YYYY-MM-DD` → set blameUntil filter Call `codebase_peek` with the parsed arguments. @@ -21,7 +22,7 @@ Examples: - `/peek authentication logic` → query="authentication logic" - `/peek error handling limit=5` → query="error handling", limit=5 - `/peek validation functions` → query="validation", chunkType="function" -- `/peek auth logic author=jane@example.com since=2025-01-01` → query="auth logic", blameAuthor="jane@example.com", blameSince="2025-01-01" +- `/peek auth logic author=jane@example.com since=2025-01-01 until=2025-01-31` → query="auth logic", blameAuthor="jane@example.com", blameSince="2025-01-01", blameUntil="2025-01-31" If the index doesn't exist, run `index_codebase` first. diff --git a/commands/search.md b/commands/search.md index 2d171426..e8fdd067 100644 --- a/commands/search.md +++ b/commands/search.md @@ -14,6 +14,7 @@ The first part is the search query. Look for optional parameters: - `author=X` or `blameAuthor=X` → set blameAuthor filter - `sha=X` or `blameSha=X` → set blameSha filter - `since=YYYY-MM-DD` or `blameSince=YYYY-MM-DD` → set blameSince filter +- `until=YYYY-MM-DD` or `blameUntil=YYYY-MM-DD` → set blameUntil filter Call `codebase_search` with the parsed arguments. @@ -21,7 +22,7 @@ Examples: - `/search authentication logic` → query="authentication logic" - `/search error handling limit=5` → query="error handling", limit=5 - `/search validation functions` → query="validation", chunkType="function" -- `/search auth logic author=jane@example.com since=2025-01-01` → query="auth logic", blameAuthor="jane@example.com", blameSince="2025-01-01" +- `/search auth logic author=jane@example.com since=2025-01-01 until=2025-01-31` → query="auth logic", blameAuthor="jane@example.com", blameSince="2025-01-01", blameUntil="2025-01-31" If the index doesn't exist, run `index_codebase` first. diff --git a/native/src/bindings/database.rs b/native/src/bindings/database.rs index badfcc5b..7e1012d4 100644 --- a/native/src/bindings/database.rs +++ b/native/src/bindings/database.rs @@ -417,6 +417,18 @@ impl Database { }) } + #[napi] + pub fn get_chunk_ids_by_blame_date( + &self, + since: Option, + until: Option, + ) -> Result> { + self.with_conn(|conn| { + db::get_chunk_ids_by_blame_date(conn, since, until) + .map_err(|e| Error::from_reason(e.to_string())) + }) + } + #[napi] pub fn get_branch_delta(&self, branch: String, base_branch: String) -> Result { self.with_conn(|conn| { diff --git a/native/src/db.rs b/native/src/db.rs index 932c87f0..98e29593 100644 --- a/native/src/db.rs +++ b/native/src/db.rs @@ -937,6 +937,31 @@ pub fn get_branch_chunk_ids(conn: &Connection, branch: &str) -> DbResult, + until: Option, +) -> DbResult> { + let mut stmt = conn.prepare( + r#" + SELECT chunk_id FROM chunks + WHERE blame_committed_at IS NOT NULL + AND (?1 IS NULL OR blame_committed_at >= ?1) + AND (?2 IS NULL OR blame_committed_at <= ?2) + ORDER BY chunk_id + "#, + )?; + let rows = stmt.query_map(params![since, until], |row| row.get::<_, String>(0))?; + + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) +} + /// Get chunks that exist on branch A but not on branch B (delta) pub fn get_branch_delta( conn: &Connection, diff --git a/native/src/lib.rs b/native/src/lib.rs index 1f28dc66..c0e8e13f 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -113,6 +113,19 @@ impl VectorStore { .map_err(|e| Error::from_reason(e.to_string())) } + #[napi] + pub fn search_filtered( + &self, + query_vector: Vec, + limit: u32, + allowed_ids: Vec, + ) -> Result> { + let query_f32: Vec = query_vector.iter().map(|&x| x as f32).collect(); + self.inner + .search_filtered(&query_f32, limit as usize, &allowed_ids) + .map_err(|e| Error::from_reason(e.to_string())) + } + #[napi] pub fn remove(&mut self, id: String) -> Result { self.inner diff --git a/native/src/store.rs b/native/src/store.rs index 813f4d89..afa028b4 100644 --- a/native/src/store.rs +++ b/native/src/store.rs @@ -1,7 +1,7 @@ use crate::{hasher::xxhash_file, SearchResult}; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; @@ -240,6 +240,32 @@ impl VectorStoreInner { } pub fn search(&self, query_vector: &[f32], limit: usize) -> Result> { + self.search_with_allowed_keys(query_vector, limit, None) + } + + pub fn search_filtered( + &self, + query_vector: &[f32], + limit: usize, + allowed_keys: &[String], + ) -> Result> { + let allowed_ids = allowed_keys + .iter() + .filter_map(|key| self.stored.key_to_id.get(key).copied()) + .collect::>(); + if allowed_ids.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + + self.search_with_allowed_keys(query_vector, limit, Some(&allowed_ids)) + } + + fn search_with_allowed_keys( + &self, + query_vector: &[f32], + limit: usize, + allowed_ids: Option<&HashSet>, + ) -> Result> { if query_vector.len() != self.dimensions { return Err(anyhow!( "Query vector dimension mismatch: expected {}, got {}", @@ -248,7 +274,12 @@ impl VectorStoreInner { )); } - let results = self.index.search(query_vector, limit)?; + let results = match allowed_ids { + Some(ids) => self + .index + .filtered_search(query_vector, limit, |id| ids.contains(&id))?, + None => self.index.search(query_vector, limit)?, + }; let mut search_results = Vec::with_capacity(results.keys.len()); @@ -525,6 +556,26 @@ mod tests { assert_eq!(results[0].id, "vec1"); } + #[test] + fn test_vector_store_filtered_search() { + let dir = tempdir().unwrap(); + let index_path = dir.path().join("test.usearch"); + let mut store = VectorStoreInner::new(index_path, 3).unwrap(); + + store.add("closest", &[1.0, 0.0, 0.0], "meta1").unwrap(); + store.add("allowed", &[0.0, 1.0, 0.0], "meta2").unwrap(); + + let results = store + .search_filtered(&[1.0, 0.0, 0.0], 1, &["allowed".to_string()]) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "allowed"); + assert!(store + .search_filtered(&[1.0, 0.0, 0.0], 1, &[]) + .unwrap() + .is_empty()); + } + #[test] fn test_vector_store_persistence() { let dir = tempdir().unwrap(); diff --git a/skill/SKILL.md b/skill/SKILL.md index a1eb7e5a..1734b362 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -77,14 +77,14 @@ Find code with full content. Use when you need to see implementation. ``` codebase_search(query="error handling middleware", fileType="ts", contextLines=2) -codebase_search(query="rate limiter", blameSince="2025-01-01") +codebase_search(query="rate limiter", blameSince="2025-01-01", blameUntil="2025-01-31") ``` ### `find_similar` Find code similar to a given snippet. Use for duplicate detection, pattern discovery, refactoring. ``` -find_similar(code="function validate(input) { return input.length > 0; }", excludeFile="src/current.ts") +find_similar(code="function validate(input) { return input.length > 0; }", excludeFile="src/current.ts", blameSince="2025-01-01") ``` ### `call_graph` @@ -137,3 +137,4 @@ remove_knowledge_base(path="/path/to/docs") | `blameAuthor` | `"jane@example.com"` or `"Jane Doe"` | | `blameSha` | `"abc1234"` | | `blameSince` | `"2025-01-01"` | +| `blameUntil` | `"2025-01-31"` | diff --git a/src/adapters/mcp/register-tools.ts b/src/adapters/mcp/register-tools.ts index 14c4b3f0..27059916 100644 --- a/src/adapters/mcp/register-tools.ts +++ b/src/adapters/mcp/register-tools.ts @@ -125,6 +125,7 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): blameAuthor: allowNullAsUndefined(z.string().optional()).describe("Filter by git blame author name or email"), blameSha: allowNullAsUndefined(z.string().optional()).describe("Filter by git blame commit SHA or prefix"), blameSince: allowNullAsUndefined(z.string().optional()).describe("Filter to chunks last changed on or after this date"), + blameUntil: allowNullAsUndefined(z.string().optional()).describe("Filter to chunks last changed on or before this date"), }, async (args) => { return searchCodebaseWithEffectiveness(runtime.projectRoot, runtime.host, "search", args.query, { @@ -136,6 +137,7 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): blameAuthor: args.blameAuthor, blameSha: args.blameSha, blameSince: args.blameSince, + blameUntil: args.blameUntil, }, (results) => { const text = results.length === 0 ? "No matching code found. Try a different query or run index_codebase first." @@ -157,6 +159,7 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): blameAuthor: allowNullAsUndefined(z.string().optional()).describe("Filter by git blame author name or email"), blameSha: allowNullAsUndefined(z.string().optional()).describe("Filter by git blame commit SHA or prefix"), blameSince: allowNullAsUndefined(z.string().optional()).describe("Filter to chunks last changed on or after this date"), + blameUntil: allowNullAsUndefined(z.string().optional()).describe("Filter to chunks last changed on or before this date"), }, async (args) => { return searchCodebaseWithEffectiveness(runtime.projectRoot, runtime.host, "peek", args.query, { @@ -168,6 +171,7 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): blameAuthor: args.blameAuthor, blameSha: args.blameSha, blameSince: args.blameSince, + blameUntil: args.blameUntil, }, (results) => { const text = results.length === 0 ? "No matching code found. Try a different query or run index_codebase first." @@ -251,6 +255,8 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): directory: allowNullAsUndefined(z.string().optional()).describe("Filter by directory path (e.g., 'src/utils', 'lib')"), chunkType: allowNullAsUndefined(z.enum(CHUNK_TYPES).optional()).describe("Filter by code chunk type"), excludeFile: allowNullAsUndefined(z.string().optional()).describe("Exclude results from this file path"), + blameSince: allowNullAsUndefined(z.string().optional()).describe("Filter to chunks last changed on or after this date"), + blameUntil: allowNullAsUndefined(z.string().optional()).describe("Filter to chunks last changed on or before this date"), }, async (args) => { const results = await findSimilarCode(runtime.projectRoot, runtime.host, args.code, { @@ -259,6 +265,8 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): directory: args.directory, chunkType: args.chunkType, excludeFile: args.excludeFile, + blameSince: args.blameSince, + blameUntil: args.blameUntil, }); if (results.length === 0) { diff --git a/src/adapters/opencode/tools.ts b/src/adapters/opencode/tools.ts index 4141e57e..9b52f6ea 100644 --- a/src/adapters/opencode/tools.ts +++ b/src/adapters/opencode/tools.ts @@ -166,6 +166,7 @@ export const codebase_peek: ToolDefinition = tool({ blameAuthor: z.string().optional().describe("Filter by git blame author name or email"), blameSha: z.string().optional().describe("Filter by git blame commit SHA or prefix"), blameSince: z.string().optional().describe("Filter to chunks last changed on or after this date (e.g., 2025-01-01)"), + blameUntil: z.string().optional().describe("Filter to chunks last changed on or before this date (e.g., 2025-01-31)"), }, async execute(args, context) { return searchCodebaseWithEffectiveness(context?.worktree, DEFAULT_HOST, "peek", args.query, { @@ -177,6 +178,7 @@ export const codebase_peek: ToolDefinition = tool({ blameAuthor: args.blameAuthor, blameSha: args.blameSha, blameSince: args.blameSince, + blameUntil: args.blameUntil, }, (results) => { const text = formatCodebasePeek(results); return { output: text, text }; @@ -251,6 +253,8 @@ export const find_similar: ToolDefinition = tool({ directory: z.string().optional().describe("Filter by directory path (e.g., 'src/utils', 'lib')"), chunkType: z.enum(CHUNK_TYPE_VALUES).optional().describe("Filter by code chunk type"), excludeFile: z.string().optional().describe("Exclude results from this file path (useful when searching for duplicates of code from a specific file)"), + blameSince: z.string().optional().describe("Filter to chunks last changed on or after this date (e.g., 2025-01-01)"), + blameUntil: z.string().optional().describe("Filter to chunks last changed on or before this date (e.g., 2025-01-31)"), }, async execute(args, context) { const results = await findSimilarCode(context?.worktree, DEFAULT_HOST, args.code, { @@ -259,6 +263,8 @@ export const find_similar: ToolDefinition = tool({ directory: args.directory, chunkType: args.chunkType, excludeFile: args.excludeFile, + blameSince: args.blameSince, + blameUntil: args.blameUntil, }); if (results.length === 0) { @@ -282,6 +288,7 @@ export const codebase_search: ToolDefinition = tool({ blameAuthor: z.string().optional().describe("Filter by git blame author name or email"), blameSha: z.string().optional().describe("Filter by git blame commit SHA or prefix"), blameSince: z.string().optional().describe("Filter to chunks last changed on or after this date (e.g., 2025-01-01)"), + blameUntil: z.string().optional().describe("Filter to chunks last changed on or before this date (e.g., 2025-01-31)"), }, async execute(args, context) { return searchCodebaseWithEffectiveness(context?.worktree, DEFAULT_HOST, "search", args.query, { @@ -293,6 +300,7 @@ export const codebase_search: ToolDefinition = tool({ blameAuthor: args.blameAuthor, blameSha: args.blameSha, blameSince: args.blameSince, + blameUntil: args.blameUntil, }, (results) => { const text = results.length === 0 ? "No matching code found. Try a different query or run index_codebase first." diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index a137e41c..40fdf7b5 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -156,6 +156,7 @@ export default function codebaseIndexPiExtension(pi: ExtensionAPI): void { blameAuthor: Type.Optional(Type.String({ description: "Filter by git blame author name or email" })), blameSha: Type.Optional(Type.String({ description: "Filter by git blame commit SHA or prefix" })), blameSince: Type.Optional(Type.String({ description: "Filter to chunks last changed on or after this date" })), + blameUntil: Type.Optional(Type.String({ description: "Filter to chunks last changed on or before this date" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { return searchCodebaseWithEffectiveness(projectRoot(ctx), HOST, "search", params.query, { @@ -183,6 +184,7 @@ export default function codebaseIndexPiExtension(pi: ExtensionAPI): void { blameAuthor: Type.Optional(Type.String()), blameSha: Type.Optional(Type.String()), blameSince: Type.Optional(Type.String()), + blameUntil: Type.Optional(Type.String()), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { return searchCodebaseWithEffectiveness(projectRoot(ctx), HOST, "peek", params.query, { @@ -206,6 +208,8 @@ export default function codebaseIndexPiExtension(pi: ExtensionAPI): void { directory: Type.Optional(Type.String()), chunkType: Type.Optional(ChunkType), excludeFile: Type.Optional(Type.String()), + blameSince: Type.Optional(Type.String({ description: "Filter to chunks last changed on or after this date" })), + blameUntil: Type.Optional(Type.String({ description: "Filter to chunks last changed on or before this date" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const results = await findSimilarCode(projectRoot(ctx), HOST, params.code, params); diff --git a/src/indexer/index.ts b/src/indexer/index.ts index 136045b5..e3647586 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -401,6 +401,7 @@ interface SearchOptions { blameAuthor?: string; blameSha?: string; blameSince?: string; + blameUntil?: string; trace?: (trace: SearchTrace) => void; } @@ -527,8 +528,18 @@ type SearchFilterOptions = { blameAuthor?: string; blameSha?: string; blameSince?: string; + blameUntil?: string; }; +function parseBlameTimestamp(value: string, endOfDay: boolean): number | null { + let timestampMs = Date.parse(value); + if (Number.isNaN(timestampMs)) return null; + if (endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(value.trim())) { + timestampMs += 24 * 60 * 60 * 1000 - 1; + } + return Math.floor(timestampMs / 1000); +} + function metadataFromBlame(blame: GitBlameMetadata | undefined): Partial { if (!blame) { return {}; @@ -1083,10 +1094,17 @@ function matchesHardSearchFilters( } if (options?.blameSince) { - const sinceMs = Date.parse(options.blameSince); - if (Number.isNaN(sinceMs)) return false; + const since = parseBlameTimestamp(options.blameSince, false); + if (since === null) return false; + const committedAt = candidate.metadata.blameCommittedAt; + if (committedAt === undefined || committedAt < since) return false; + } + + if (options?.blameUntil) { + const until = parseBlameTimestamp(options.blameUntil, true); + if (until === null) return false; const committedAt = candidate.metadata.blameCommittedAt; - if (committedAt === undefined || committedAt < Math.floor(sinceMs / 1000)) return false; + if (committedAt === undefined || committedAt > until) return false; } return true; @@ -4600,41 +4618,66 @@ export class Indexer { }; } - private searchCandidatesWithBranchPrefilter( + private searchCandidatesWithAllowedIds( initialLimit: number, totalCount: number, - branchChunkIds: Set | null, - shouldPrefilterByBranch: boolean, + allowedChunkIds: Set | null, + shouldPrefilter: boolean, search: (limit: number) => T[], getChunkId: (candidate: T) => string, ): T[] { const normalizedLimit = Math.max(0, Math.floor(initialLimit)); if (normalizedLimit === 0) return []; - if (!shouldPrefilterByBranch || !branchChunkIds) { + if (!shouldPrefilter || !allowedChunkIds) { return search(normalizedLimit); } - const targetCount = Math.min(normalizedLimit, branchChunkIds.size); + const targetCount = Math.min(normalizedLimit, allowedChunkIds.size); if (targetCount === 0 || totalCount === 0) return []; let requestedLimit = Math.min(normalizedLimit, totalCount); while (true) { const results = search(requestedLimit); - const branchResults = results.filter((candidate) => branchChunkIds.has(getChunkId(candidate))); + const allowedResults = results.filter((candidate) => allowedChunkIds.has(getChunkId(candidate))); if ( - branchResults.length >= targetCount + allowedResults.length >= targetCount || results.length < requestedLimit || requestedLimit >= totalCount ) { - return branchResults; + return allowedResults; } const nextLimit = Math.min(totalCount, Math.max(requestedLimit + 1, requestedLimit * 2)); - if (nextLimit === requestedLimit) return branchResults; + if (nextLimit === requestedLimit) return allowedResults; requestedLimit = nextLimit; } } + private getTemporalChunkIds( + database: Database, + options: Pick | undefined, + ): Set | null { + if (!options?.blameSince && !options?.blameUntil) return null; + + const since = options.blameSince ? parseBlameTimestamp(options.blameSince, false) : undefined; + const until = options.blameUntil ? parseBlameTimestamp(options.blameUntil, true) : undefined; + if (since === null || until === null) { + return new Set(); + } + + return new Set(database.getChunkIdsByBlameDate(since, until)); + } + + private intersectChunkIdSets( + first: Set | null, + second: Set | null, + ): Set | null { + if (first === null) return second; + if (second === null) return first; + const [smaller, larger] = first.size <= second.size ? [first, second] : [second, first]; + return new Set(Array.from(smaller).filter((chunkId) => larger.has(chunkId))); + } + private buildCandidateSnapshot(candidate: RankedCandidate): CandidateSnapshot { return { id: candidate.id, @@ -4657,13 +4700,17 @@ export class Indexer { initialLimit: number, branchChunkIds: Set | null, shouldPrefilterByBranch: boolean, + temporalChunkIds: Set | null, ): RankedCandidate[] { - return this.searchCandidatesWithBranchPrefilter( - initialLimit, - store.count(), + const availableCount = temporalChunkIds?.size ?? store.count(); + if (availableCount === 0) return []; + const allowedIds = temporalChunkIds === null ? undefined : Array.from(temporalChunkIds); + return this.searchCandidatesWithAllowedIds( + Math.min(initialLimit, availableCount), + availableCount, branchChunkIds, shouldPrefilterByBranch, - (requestedLimit) => store.search(embedding, requestedLimit), + (requestedLimit) => store.search(embedding, requestedLimit, allowedIds), (candidate) => candidate.id, ); } @@ -4737,6 +4784,7 @@ export class Indexer { branchChunkIds = new Set(branchCatalogKeys.flatMap((branchKey) => database.getBranchChunkIds(branchKey))); branchSymbolIds = new Set(branchCatalogKeys.flatMap((branchKey) => database.getBranchSymbolIds(branchKey))); } + const temporalChunkIds = this.getTemporalChunkIds(database, options); const { hasInitializedBranchCatalog, shouldPrefilterByBranch } = this.getBranchPrefilterState(database, branchChunkIds); const prefilterMs = performance.now() - prefilterStartTime; @@ -4749,6 +4797,7 @@ export class Indexer { candidateLimit, branchChunkIds, shouldPrefilterByBranch, + temporalChunkIds, ) : []; const vectorMs = performance.now() - vectorStartTime; @@ -4761,6 +4810,7 @@ export class Indexer { invertedIndex, branchChunkIds, shouldPrefilterByBranch, + temporalChunkIds, ); const keywordMs = performance.now() - keywordStartTime; @@ -4968,15 +5018,20 @@ export class Indexer { invertedIndex: InvertedIndex, branchChunkIds: Set | null = null, shouldPrefilterByBranch = false, + temporalChunkIds: Set | null = null, ): Promise> { const normalizedLimit = Math.max(0, Math.floor(limit)); if (normalizedLimit === 0) return []; - const scoreEntries = this.searchCandidatesWithBranchPrefilter( + const allowedChunkIds = this.intersectChunkIdSets( + shouldPrefilterByBranch ? branchChunkIds : null, + temporalChunkIds, + ); + const scoreEntries = this.searchCandidatesWithAllowedIds( normalizedLimit, invertedIndex.getDocumentCount(), - branchChunkIds, - shouldPrefilterByBranch, + allowedChunkIds, + allowedChunkIds !== null, (requestedLimit) => Array.from(invertedIndex.search(query, requestedLimit)), ([chunkId]) => chunkId, ); @@ -5497,6 +5552,8 @@ export class Indexer { chunkType?: string; excludeFile?: string; filterByBranch?: boolean; + blameSince?: string; + blameUntil?: string; } ): Promise { const { store, provider, database, readIssues, compatibility } = await this.ensureInitialized(); @@ -5539,6 +5596,7 @@ export class Indexer { this.getBranchCatalogKeys().flatMap((branchKey) => database.getBranchChunkIds(branchKey)) ); } + const temporalChunkIds = this.getTemporalChunkIds(database, options); const { hasInitializedBranchCatalog, shouldPrefilterByBranch } = this.getBranchPrefilterState(database, branchChunkIds); const prefilterMs = performance.now() - prefilterStartTime; @@ -5550,6 +5608,7 @@ export class Indexer { limit * 2, branchChunkIds, shouldPrefilterByBranch, + temporalChunkIds, ); const vectorMs = performance.now() - vectorStartTime; diff --git a/src/native/database.ts b/src/native/database.ts index 04a7c173..31edf45e 100644 --- a/src/native/database.ts +++ b/src/native/database.ts @@ -181,6 +181,11 @@ export class Database { return this.inner.getBranchChunkIds(branch); } + getChunkIdsByBlameDate(since?: number, until?: number): string[] { + this.throwIfClosed(); + return this.inner.getChunkIdsByBlameDate(since, until); + } + getBranchDelta(branch: string, baseBranch: string): BranchDelta { this.throwIfClosed(); return this.inner.getBranchDelta(branch, baseBranch); diff --git a/src/native/vector-store.ts b/src/native/vector-store.ts index cf253004..6af5f5f3 100644 --- a/src/native/vector-store.ts +++ b/src/native/vector-store.ts @@ -35,13 +35,15 @@ export class VectorStore { this.inner.addBatch(ids, vectors, metadata); } - search(queryVector: number[], limit: number = 10): SearchResult[] { + search(queryVector: number[], limit: number = 10, allowedIds?: string[]): SearchResult[] { if (queryVector.length !== this.dimensions) { throw new Error( `Query vector dimension mismatch: expected ${this.dimensions}, got ${queryVector.length}` ); } - const results = this.inner.search(queryVector, limit); + const results = allowedIds === undefined + ? this.inner.search(queryVector, limit) + : this.inner.searchFiltered(queryVector, limit, allowedIds); return results.map((r: any) => ({ id: r.id, score: r.score, diff --git a/src/tools/operations.ts b/src/tools/operations.ts index 040fda09..2c1c6f5e 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -233,6 +233,7 @@ export async function searchCodebase( blameAuthor?: string; blameSha?: string; blameSince?: string; + blameUntil?: string; trace?: (trace: SearchTrace) => void; } = {}, ): Promise { @@ -249,6 +250,7 @@ export async function searchCodebase( blameAuthor: options.blameAuthor, blameSha: options.blameSha, blameSince: options.blameSince, + blameUntil: options.blameUntil, trace: options.trace, }); } @@ -306,6 +308,8 @@ export async function findSimilarCode( directory?: string; chunkType?: string; excludeFile?: string; + blameSince?: string; + blameUntil?: string; } = {}, ): Promise>> { await ensureAutoIndexReadyForRetrieval(projectRoot, host); @@ -315,6 +319,8 @@ export async function findSimilarCode( directory: options.directory, chunkType: options.chunkType, excludeFile: options.excludeFile, + blameSince: options.blameSince, + blameUntil: options.blameUntil, }); } diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 6e04130b..acd91c5b 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -222,6 +222,8 @@ Final line.`; expect(searchCmd.template).toContain("blameAuthor"); expect(searchCmd.template).toContain("blameSha"); expect(searchCmd.template).toContain("blameSince"); + expect(searchCmd.template).toContain("blameUntil"); + expect(peekCmd.template).toContain("blameUntil"); }); }); }); diff --git a/tests/database.test.ts b/tests/database.test.ts index 8c1845bc..7b595544 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -315,6 +315,20 @@ describe("Database", () => { expect(db.getChunk("chunk_abc123")).toBeNull(); expect(db.getChunk("chunk_def456")).not.toBeNull(); }); + + it("should return chunk IDs within inclusive blame date bounds", () => { + db.upsertChunksBatch([ + { ...testChunk, chunkId: "older", blameCommittedAt: 100 }, + { ...testChunk, chunkId: "middle", blameCommittedAt: 200 }, + { ...testChunk, chunkId: "newer", blameCommittedAt: 300 }, + { ...testChunk, chunkId: "untracked" }, + ]); + + expect(db.getChunkIdsByBlameDate(200)).toEqual(["middle", "newer"]); + expect(db.getChunkIdsByBlameDate(undefined, 200)).toEqual(["middle", "older"]); + expect(db.getChunkIdsByBlameDate(200, 200)).toEqual(["middle"]); + expect(db.getChunkIdsByBlameDate(301, 400)).toEqual([]); + }); }); describe("branch_chunks", () => { diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index f54dafd2..6459f1b7 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -61,6 +61,7 @@ const indexerMockState = vi.hoisted(() => ({ instances: [] as Array<{ initialize: ReturnType; search: ReturnType; + findSimilar: ReturnType; getStatus: ReturnType; getCallGraphSymbols: ReturnType; getCallersForSymbol: ReturnType; @@ -148,6 +149,7 @@ vi.mock("../src/indexer/index.js", () => { indexerMockState.instances.push({ initialize: this.initialize, search: this.search, + findSimilar: this.findSimilar, getStatus: this.getStatus, getCallGraphSymbols: this.getCallGraphSymbols, getCallersForSymbol: this.getCallersForSymbol, @@ -566,6 +568,7 @@ describe("MCP server tools and prompts", () => { blameAuthor: null, blameSha: null, blameSince: null, + blameUntil: null, }, }); @@ -577,6 +580,26 @@ describe("MCP server tools and prompts", () => { expect(content[0].text).toContain("validateToken"); }); + it("should forward temporal bounds to search", async () => { + await client.callTool({ + name: "codebase_search", + arguments: { + query: "test query", + blameSince: "2025-01-01", + blameUntil: "2025-01-31", + }, + }); + + expect(indexerMockState.instances[0]?.search).toHaveBeenCalledWith( + "test query", + 5, + expect.objectContaining({ + blameSince: "2025-01-01", + blameUntil: "2025-01-31", + }), + ); + }); + it("should execute codebase_peek tool", async () => { const result = await client.callTool({ name: "codebase_peek", @@ -763,6 +786,7 @@ describe("MCP server tools and prompts", () => { blameAuthor: null, blameSha: null, blameSince: null, + blameUntil: null, }, }); @@ -1281,6 +1305,39 @@ describe("MCP server tools and prompts", () => { expect(content[0].text).toContain("Found 1 similar"); }); + it("should execute find_similar with null temporal fields", async () => { + const result = await client.callTool({ + name: "find_similar", + arguments: { + code: "function test() {}", + blameSince: null, + blameUntil: null, + }, + }); + + expect(result.content).toBeDefined(); + }); + + it("should forward temporal bounds to find_similar", async () => { + await client.callTool({ + name: "find_similar", + arguments: { + code: "function test() {}", + blameSince: "2025-01-01", + blameUntil: "2025-01-31", + }, + }); + + expect(indexerMockState.instances[0]?.findSimilar).toHaveBeenCalledWith( + "function test() {}", + 10, + expect.objectContaining({ + blameSince: "2025-01-01", + blameUntil: "2025-01-31", + }), + ); + }); + it("should execute implementation_lookup tool", async () => { const result = await client.callTool({ name: "implementation_lookup", diff --git a/tests/native.test.ts b/tests/native.test.ts index 6ebdff1a..99c3c4ea 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -865,6 +865,30 @@ const values = [1, 2].map(item => item * 2); expect(results[0].id).toBe("chunk1"); }); + it("should search only within allowed vector IDs", () => { + store.add("closest", [1, 0, 0], { + filePath: "closest.ts", + startLine: 1, + endLine: 5, + chunkType: "function", + language: "typescript", + hash: "closest", + }); + store.add("allowed", [0, 1, 0], { + filePath: "allowed.ts", + startLine: 1, + endLine: 5, + chunkType: "function", + language: "typescript", + hash: "allowed", + }); + + expect(store.search([1, 0, 0], 1)[0]?.id).toBe("closest"); + expect(store.search([1, 0, 0], 1, ["allowed"])).toMatchObject([{ id: "allowed" }]); + expect(store.search([1, 0, 0], 1, [])).toEqual([]); + expect(store.search([1, 0, 0], 1, ["missing"])).toEqual([]); + }); + it("should remove vectors", () => { store.add("chunk1", [1, 0, 0], { filePath: "test.ts", diff --git a/tests/pi-package.test.ts b/tests/pi-package.test.ts index 60bc3473..1cda1385 100644 --- a/tests/pi-package.test.ts +++ b/tests/pi-package.test.ts @@ -60,10 +60,14 @@ describe("Pi package integration", () => { const searchParams = JSON.stringify(tools.find((tool) => tool.name === TOOL_NAME.CODEBASE_SEARCH)?.parameters); const peekParams = JSON.stringify(tools.find((tool) => tool.name === TOOL_NAME.CODEBASE_PEEK)?.parameters); + const similarParams = JSON.stringify(tools.find((tool) => tool.name === TOOL_NAME.FIND_SIMILAR)?.parameters); for (const params of [searchParams, peekParams]) { expect(params).toContain("blameAuthor"); expect(params).toContain("blameSha"); expect(params).toContain("blameSince"); + expect(params).toContain("blameUntil"); } + expect(similarParams).toContain("blameSince"); + expect(similarParams).toContain("blameUntil"); }); }); diff --git a/tests/search-integration.test.ts b/tests/search-integration.test.ts index 48bfd020..cddbbbfc 100644 --- a/tests/search-integration.test.ts +++ b/tests/search-integration.test.ts @@ -666,6 +666,45 @@ ${Array.from({ length: 120 }, (_, index) => ` public int Value${index} { get; s }); expect(sinceResults).toHaveLength(1); expect(sinceResults[0]?.filePath).toContain("payments.ts"); + + const untilResults = await indexer.search("session token payment flow", 5, { + metadataOnly: true, + filterByBranch: false, + blameUntil: "2025-03-14", + }); + expect(untilResults).toHaveLength(1); + expect(untilResults[0]?.filePath).toContain("auth.ts"); + + const boundedResults = await indexer.search("session token payment flow", 5, { + metadataOnly: true, + filterByBranch: false, + blameSince: "2025-03-01T00:00:00Z", + blameUntil: "2025-03-31T23:59:59Z", + }); + expect(boundedResults).toHaveLength(1); + expect(boundedResults[0]?.filePath).toContain("auth.ts"); + + const recentSimilar = await indexer.findSimilar( + `export function validateSession() { return "auth session token"; }`, + 5, + { + filterByBranch: false, + blameSince: "2025-03-20T00:00:00Z", + }, + ); + expect(recentSimilar).toHaveLength(1); + expect(recentSimilar[0]?.filePath).toContain("payments.ts"); + + const olderSimilar = await indexer.findSimilar( + `export function chargeCard() { return "payment flow"; }`, + 5, + { + filterByBranch: false, + blameUntil: "2025-03-14", + }, + ); + expect(olderSimilar).toHaveLength(1); + expect(olderSimilar[0]?.filePath).toContain("auth.ts"); } finally { fs.rmSync(authoredDir, { recursive: true, force: true }); } diff --git a/tests/tools-context.test.ts b/tests/tools-context.test.ts index 6d11deaa..11bdd52a 100644 --- a/tests/tools-context.test.ts +++ b/tests/tools-context.test.ts @@ -105,6 +105,7 @@ describe("native OpenCode codebase_context", () => { blameAuthor: undefined, blameSha: undefined, blameSince: undefined, + blameUntil: undefined, }, context); expect(operationMocks.searchCodebaseWithEffectiveness).toHaveBeenLastCalledWith("/repo", "opencode", "peek", "request routing", expect.objectContaining({ metadataOnly: true, @@ -120,6 +121,7 @@ describe("native OpenCode codebase_context", () => { blameAuthor: undefined, blameSha: undefined, blameSince: undefined, + blameUntil: undefined, }, context); expect(operationMocks.searchCodebaseWithEffectiveness).toHaveBeenLastCalledWith( "/repo", @@ -143,6 +145,7 @@ describe("native OpenCode codebase_context", () => { blameAuthor: undefined, blameSha: undefined, blameSince: undefined, + blameUntil: undefined, }, context); expect(operationMocks.recordToolEffectiveness).toHaveBeenCalledTimes(1); expect(operationMocks.recordToolEffectiveness).toHaveBeenLastCalledWith("/repo", "opencode", expect.objectContaining({ @@ -174,6 +177,7 @@ describe("native OpenCode codebase_context", () => { blameAuthor: undefined, blameSha: undefined, blameSince: undefined, + blameUntil: undefined, }, context)).rejects.toThrow("OpenCode formatter failed"); expect(operationMocks.recordToolEffectiveness).toHaveBeenCalledTimes(1); expect(operationMocks.recordToolEffectiveness).toHaveBeenLastCalledWith("/repo", "opencode", expect.objectContaining({