src / workspace / discovery.ts
src / workspace / discovery.ts
import { readFile, stat } from "node:fs/promises";
import type { WorkspaceBoundary } from "./boundary";
import { appearsBinary } from "./text";
import { walkWorkspace, type WalkEntry } from "./walk";
function normalize(value: string): string {
return value.toLowerCase().replaceAll("\\", "/");
}
function tokenize(value: string): string[] {
return [...new Set(
normalize(value)
.split(/[^a-z0-9_.$@/-]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2),
)];
}
function levenshtein(a: string, b: string, maxDistance = 64): number {
if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
for (let i = 1; i <= a.length; i++) {
const current = [i];
let rowMin = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
const value = Math.min(
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + cost,
);
current.push(value);
rowMin = Math.min(rowMin, value);
}
if (rowMin > maxDistance) return maxDistance + 1;
previous = current;
}
return previous[b.length];
}
function pathScore(path: string, query: string): number {
const p = normalize(path);
const q = normalize(query);
const base = p.split("/").pop() ?? p;
if (p === q || base === q) return 1000;
if (base.startsWith(q)) return 900 - (base.length - q.length);
if (base.includes(q)) return 800 - base.indexOf(q);
if (p.includes(q)) return 700 - p.indexOf(q) * 0.1;
const distance = levenshtein(base, q, Math.max(8, Math.floor(q.length * 0.7)));
return distance <= Math.max(3, Math.floor(q.length * 0.45))
? 500 - distance * 20
: 0;
}
export async function findWorkspacePaths(
boundary: WorkspaceBoundary,
input: {
query: string;
path?: string;
maxResults?: number;
includeDirectories?: boolean;
includeHidden?: boolean;
},
): Promise<{ matches: Array<WalkEntry & { score: number }>; scanned: number; truncated: boolean }> {
const listing = await walkWorkspace(boundary, input.path ?? ".", {
maxDepth: 30,
maxEntries: 10_000,
includeHidden: input.includeHidden ?? false,
});
const matches = listing.entries
.filter((entry) => input.includeDirectories || entry.type === "file")
.map((entry) => ({ ...entry, score: pathScore(entry.path, input.query) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, Math.max(1, Math.min(input.maxResults ?? 50, 200)));
return { matches, scanned: listing.entries.length, truncated: listing.truncated };
}
export interface SemanticSearchMatch {
path: string;
startLine: number;
endLine: number;
score: number;
preview: string;
}
export async function semanticSearchWorkspace(
boundary: WorkspaceBoundary,
input: {
query: string;
path?: string;
maxResults?: number;
maxFiles?: number;
maxFileBytes?: number;
},
): Promise<{
matches: SemanticSearchMatch[];
filesScanned: number;
filesSkipped: number;
chunksScored: number;
truncated: boolean;
}> {
const query = input.query.trim();
const queryTokens = tokenize(query);
if (queryTokens.length === 0) {
return { matches: [], filesScanned: 0, filesSkipped: 0, chunksScored: 0, truncated: false };
}
const maxFiles = Math.max(1, Math.min(input.maxFiles ?? 500, 2000));
const maxFileBytes = Math.max(1000, Math.min(input.maxFileBytes ?? 1_500_000, 10_000_000));
const listing = await walkWorkspace(boundary, input.path ?? ".", {
maxDepth: 30,
maxEntries: maxFiles * 5,
includeHidden: false,
});
const candidates = listing.entries.filter((entry) => entry.type === "file");
const chunks: Array<{
path: string;
startLine: number;
endLine: number;
text: string;
tokens: string[];
}> = [];
let filesScanned = 0;
let filesSkipped = 0;
for (const file of candidates) {
if (filesScanned >= maxFiles || chunks.length >= 10_000) break;
const absolute = await boundary.resolveRead(file.path);
const info = await stat(absolute);
if (info.size > maxFileBytes) {
filesSkipped++;
continue;
}
const buffer = await readFile(absolute);
if (appearsBinary(buffer)) {
filesSkipped++;
continue;
}
const content = buffer.toString("utf8");
if (!Buffer.from(content, "utf8").equals(buffer)) {
filesSkipped++;
continue;
}
filesScanned++;
const lines = content.split(/\r?\n/);
const chunkSize = 80;
const overlap = 12;
for (let start = 0; start < lines.length; start += chunkSize - overlap) {
const end = Math.min(lines.length, start + chunkSize);
const text = lines.slice(start, end).join("\n");
const tokens = tokenize(text);
if (tokens.some((token) => queryTokens.includes(token)) || normalize(text).includes(normalize(query))) {
chunks.push({ path: file.path, startLine: start + 1, endLine: end, text, tokens });
}
if (end === lines.length || chunks.length >= 10_000) break;
}
}
const documentFrequency = new Map<string, number>();
for (const token of queryTokens) {
documentFrequency.set(
token,
chunks.reduce((count, chunk) => count + (chunk.tokens.includes(token) ? 1 : 0), 0),
);
}
const phrase = normalize(query);
const scored = chunks.map((chunk): SemanticSearchMatch => {
const normalizedText = normalize(chunk.text);
let score = normalizedText.includes(phrase) ? 12 : 0;
for (const token of queryTokens) {
const occurrences = normalizedText.split(token).length - 1;
if (occurrences === 0) continue;
const df = documentFrequency.get(token) ?? 0;
const idf = Math.log(1 + (chunks.length + 1) / (df + 1));
score += (1 + Math.log(occurrences)) * idf;
}
const pathTokens = tokenize(chunk.path);
score += queryTokens.filter((token) => pathTokens.some((pathToken) => pathToken.includes(token))).length * 1.5;
return {
path: chunk.path,
startLine: chunk.startLine,
endLine: chunk.endLine,
score: Number(score.toFixed(3)),
preview: chunk.text.slice(0, 2400),
};
});
const maxResults = Math.max(1, Math.min(input.maxResults ?? 20, 100));
return {
matches: scored
.filter((match) => match.score > 0)
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, maxResults),
filesScanned,
filesSkipped,
chunksScored: chunks.length,
truncated: listing.truncated || filesScanned >= maxFiles || chunks.length >= 10_000,
};
}
import { readFile, stat } from "node:fs/promises";
import type { WorkspaceBoundary } from "./boundary";
import { appearsBinary } from "./text";
import { walkWorkspace, type WalkEntry } from "./walk";
function normalize(value: string): string {
return value.toLowerCase().replaceAll("\\", "/");
}
function tokenize(value: string): string[] {
return [...new Set(
normalize(value)
.split(/[^a-z0-9_.$@/-]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2),
)];
}
function levenshtein(a: string, b: string, maxDistance = 64): number {
if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
for (let i = 1; i <= a.length; i++) {
const current = [i];
let rowMin = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
const value = Math.min(
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + cost,
);
current.push(value);
rowMin = Math.min(rowMin, value);
}
if (rowMin > maxDistance) return maxDistance + 1;
previous = current;
}
return previous[b.length];
}
function pathScore(path: string, query: string): number {
const p = normalize(path);
const q = normalize(query);
const base = p.split("/").pop() ?? p;
if (p === q || base === q) return 1000;
if (base.startsWith(q)) return 900 - (base.length - q.length);
if (base.includes(q)) return 800 - base.indexOf(q);
if (p.includes(q)) return 700 - p.indexOf(q) * 0.1;
const distance = levenshtein(base, q, Math.max(8, Math.floor(q.length * 0.7)));
return distance <= Math.max(3, Math.floor(q.length * 0.45))
? 500 - distance * 20
: 0;
}
export async function findWorkspacePaths(
boundary: WorkspaceBoundary,
input: {
query: string;
path?: string;
maxResults?: number;
includeDirectories?: boolean;
includeHidden?: boolean;
},
): Promise<{ matches: Array<WalkEntry & { score: number }>; scanned: number; truncated: boolean }> {
const listing = await walkWorkspace(boundary, input.path ?? ".", {
maxDepth: 30,
maxEntries: 10_000,
includeHidden: input.includeHidden ?? false,
});
const matches = listing.entries
.filter((entry) => input.includeDirectories || entry.type === "file")
.map((entry) => ({ ...entry, score: pathScore(entry.path, input.query) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, Math.max(1, Math.min(input.maxResults ?? 50, 200)));
return { matches, scanned: listing.entries.length, truncated: listing.truncated };
}
export interface SemanticSearchMatch {
path: string;
startLine: number;
endLine: number;
score: number;
preview: string;
}
export async function semanticSearchWorkspace(
boundary: WorkspaceBoundary,
input: {
query: string;
path?: string;
maxResults?: number;
maxFiles?: number;
maxFileBytes?: number;
},
): Promise<{
matches: SemanticSearchMatch[];
filesScanned: number;
filesSkipped: number;
chunksScored: number;
truncated: boolean;
}> {
const query = input.query.trim();
const queryTokens = tokenize(query);
if (queryTokens.length === 0) {
return { matches: [], filesScanned: 0, filesSkipped: 0, chunksScored: 0, truncated: false };
}
const maxFiles = Math.max(1, Math.min(input.maxFiles ?? 500, 2000));
const maxFileBytes = Math.max(1000, Math.min(input.maxFileBytes ?? 1_500_000, 10_000_000));
const listing = await walkWorkspace(boundary, input.path ?? ".", {
maxDepth: 30,
maxEntries: maxFiles * 5,
includeHidden: false,
});
const candidates = listing.entries.filter((entry) => entry.type === "file");
const chunks: Array<{
path: string;
startLine: number;
endLine: number;
text: string;
tokens: string[];
}> = [];
let filesScanned = 0;
let filesSkipped = 0;
for (const file of candidates) {
if (filesScanned >= maxFiles || chunks.length >= 10_000) break;
const absolute = await boundary.resolveRead(file.path);
const info = await stat(absolute);
if (info.size > maxFileBytes) {
filesSkipped++;
continue;
}
const buffer = await readFile(absolute);
if (appearsBinary(buffer)) {
filesSkipped++;
continue;
}
const content = buffer.toString("utf8");
if (!Buffer.from(content, "utf8").equals(buffer)) {
filesSkipped++;
continue;
}
filesScanned++;
const lines = content.split(/\r?\n/);
const chunkSize = 80;
const overlap = 12;
for (let start = 0; start < lines.length; start += chunkSize - overlap) {
const end = Math.min(lines.length, start + chunkSize);
const text = lines.slice(start, end).join("\n");
const tokens = tokenize(text);
if (tokens.some((token) => queryTokens.includes(token)) || normalize(text).includes(normalize(query))) {
chunks.push({ path: file.path, startLine: start + 1, endLine: end, text, tokens });
}
if (end === lines.length || chunks.length >= 10_000) break;
}
}
const documentFrequency = new Map<string, number>();
for (const token of queryTokens) {
documentFrequency.set(
token,
chunks.reduce((count, chunk) => count + (chunk.tokens.includes(token) ? 1 : 0), 0),
);
}
const phrase = normalize(query);
const scored = chunks.map((chunk): SemanticSearchMatch => {
const normalizedText = normalize(chunk.text);
let score = normalizedText.includes(phrase) ? 12 : 0;
for (const token of queryTokens) {
const occurrences = normalizedText.split(token).length - 1;
if (occurrences === 0) continue;
const df = documentFrequency.get(token) ?? 0;
const idf = Math.log(1 + (chunks.length + 1) / (df + 1));
score += (1 + Math.log(occurrences)) * idf;
}
const pathTokens = tokenize(chunk.path);
score += queryTokens.filter((token) => pathTokens.some((pathToken) => pathToken.includes(token))).length * 1.5;
return {
path: chunk.path,
startLine: chunk.startLine,
endLine: chunk.endLine,
score: Number(score.toFixed(3)),
preview: chunk.text.slice(0, 2400),
};
});
const maxResults = Math.max(1, Math.min(input.maxResults ?? 20, 100));
return {
matches: scored
.filter((match) => match.score > 0)
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, maxResults),
filesScanned,
filesSkipped,
chunksScored: chunks.length,
truncated: listing.truncated || filesScanned >= maxFiles || chunks.length >= 10_000,
};
}