97 lines
2.4 KiB
TypeScript
97 lines
2.4 KiB
TypeScript
/**
|
|
* searchTools — pure lexical scoring over tool names + descriptions.
|
|
*
|
|
* Anti-ReDoS: never compiles `new RegExp(query)`.
|
|
* Uses only `String.prototype.indexOf` loops (same pattern as
|
|
* `src/lib/memory/retrieval.ts::getRelevanceScore`).
|
|
*/
|
|
|
|
export interface ToolCatalogEntry {
|
|
name: string;
|
|
description: string;
|
|
scopes: readonly string[];
|
|
inputSchema?: unknown;
|
|
}
|
|
|
|
export interface ScoredTool {
|
|
name: string;
|
|
description: string;
|
|
scopes: readonly string[];
|
|
inputSchema?: unknown;
|
|
score: number;
|
|
}
|
|
|
|
const NAME_PHRASE_BONUS = 25;
|
|
const NAME_TOKEN_BONUS = 6;
|
|
const DESC_PHRASE_BONUS = 20;
|
|
const DESC_TOKEN_BONUS = 3;
|
|
const MIN_LIMIT = 1;
|
|
const MAX_LIMIT = 25;
|
|
|
|
function countOccurrences(haystack: string, needle: string): number {
|
|
if (!needle) return 0;
|
|
let count = 0;
|
|
let pos = 0;
|
|
while ((pos = haystack.indexOf(needle, pos)) !== -1) {
|
|
count++;
|
|
pos += needle.length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function scoreEntry(entry: ToolCatalogEntry, normalizedQuery: string, tokens: string[]): number {
|
|
const nameLower = entry.name.toLowerCase();
|
|
const descLower = entry.description.toLowerCase();
|
|
|
|
let score = 0;
|
|
|
|
// Name scoring (higher weight)
|
|
if (nameLower.includes(normalizedQuery)) {
|
|
score += NAME_PHRASE_BONUS;
|
|
}
|
|
for (const token of tokens) {
|
|
score += countOccurrences(nameLower, token) * NAME_TOKEN_BONUS;
|
|
}
|
|
|
|
// Description scoring
|
|
if (descLower.includes(normalizedQuery)) {
|
|
score += DESC_PHRASE_BONUS;
|
|
}
|
|
for (const token of tokens) {
|
|
score += countOccurrences(descLower, token) * DESC_TOKEN_BONUS;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
/**
|
|
* Search tool catalog entries lexically (no RegExp on user input).
|
|
* Returns top-K results ordered by score desc, name asc for ties.
|
|
*/
|
|
export function searchTools(
|
|
entries: ToolCatalogEntry[],
|
|
query: string,
|
|
limit = 8
|
|
): ScoredTool[] {
|
|
const clampedLimit = Math.max(MIN_LIMIT, Math.min(MAX_LIMIT, limit));
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
if (!normalizedQuery) return [];
|
|
|
|
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
|
|
const scored: ScoredTool[] = [];
|
|
for (const entry of entries) {
|
|
const score = scoreEntry(entry, normalizedQuery, tokens);
|
|
if (score > 0) {
|
|
scored.push({ ...entry, score });
|
|
}
|
|
}
|
|
|
|
scored.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
});
|
|
|
|
return scored.slice(0, clampedLimit);
|
|
}
|