File size: 5,673 Bytes
f76c374 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | // Lightweight fuzzy subsequence scorer for picker filtering.
//
// Matches a query as an ordered subsequence of the target (so `g4o` matches
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
// score is a better match. Returns the matched character indices so callers
// can highlight them.
//
// The scoring favours, in rough order: exact full match, prefix match, matches
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
// lower→upper case transition), contiguous runs, and earlier matches. This is
// intentionally simple — no external dependency — but good enough to make
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
//
// Separators (`-`, `_`, `.`) fold to a space on BOTH sides before matching, so
// `gpt.4o`, `claude_3` and `qwen3-8` hit `gpt-4o`, `claude-3-opus` and
// `qwen3.8-flash` the way a user typing from memory expects. The fold is one
// char in, one char out, so returned positions still index the original
// target and a highlighter can apply the same fold without drift.
//
// Shared by the desktop, web and TUI pickers via `@hermes/shared/fuzzy` so a
// query ranks identically on every surface.
export interface FuzzyMatch {
/** Total score; higher is better. */
score: number
/** Indices into the original (non-lowercased) target that were matched. */
positions: number[]
}
const WORD_BOUNDARY = /[-_/.\s]/
/** Length-preserving search fold: lower-case and `[-_.]` → space. */
export function searchFold(value: string): string {
return value.toLowerCase().replace(/[-_.]/g, ' ')
}
function isBoundary(target: string, index: number): boolean {
if (index === 0) {
return true
}
const prev = target[index - 1]
if (WORD_BOUNDARY.test(prev)) {
return true
}
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
const cur = target[index]
return prev === prev.toLowerCase() && cur !== cur.toLowerCase() && cur === cur.toUpperCase()
}
/**
* Score a single query token against a target. Returns null when the token is
* not a subsequence of the target. An empty query scores 0 with no positions.
*/
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
if (!query) {
return { score: 0, positions: [] }
}
const lowerTarget = searchFold(target)
const lowerQuery = searchFold(query)
const positions: number[] = []
let score = 0
let prevIndex = -1
let searchFrom = 0
for (const ch of lowerQuery) {
const idx = lowerTarget.indexOf(ch, searchFrom)
if (idx < 0) {
return null
}
positions.push(idx)
// Base point for the matched character.
score += 1
// Contiguous with the previous match → strong bonus.
if (prevIndex >= 0 && idx === prevIndex + 1) {
score += 5
} else if (prevIndex >= 0) {
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
score -= Math.min(idx - prevIndex - 1, 3)
}
// Word-boundary / start-of-string matches are meaningful.
if (isBoundary(target, idx)) {
score += 3
}
// Matching the very first character of the target is the strongest signal.
if (idx === 0) {
score += 5
}
prevIndex = idx
searchFrom = idx + 1
}
// Prefix bonus: the query matched a contiguous prefix of the target.
if (positions.length && positions[0] === 0 && positions[positions.length - 1] === positions.length - 1) {
score += 8
}
// Exact full match dominates everything else.
if (lowerTarget === lowerQuery) {
score += 20
}
// Slightly prefer shorter targets when scores are otherwise close, so a
// query that fully prefixes a short id beats the same prefix on a long one.
score -= lowerTarget.length * 0.01
return { score, positions }
}
/**
* Score a target against a whitespace-separated, multi-token query. Every token
* must match (AND semantics); the result aggregates per-token scores and the
* union of matched positions. Returns null if any token fails to match.
*/
export function fuzzyScoreMulti(target: string, query: string): FuzzyMatch | null {
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (!tokens.length) {
return { score: 0, positions: [] }
}
let score = 0
const positionSet = new Set<number>()
for (const token of tokens) {
const match = fuzzyScore(target, token)
if (!match) {
return null
}
score += match.score
for (const pos of match.positions) {
positionSet.add(pos)
}
}
return { score, positions: [...positionSet].sort((a, b) => a - b) }
}
export interface RankedItem<T> {
item: T
score: number
positions: number[]
}
/**
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
* items are dropped; matches are sorted by score (descending), ties broken by
* the original index so ordering is stable for equal scores. An empty query
* returns every item in original order with no positions.
*/
export function fuzzyRank<T>(items: readonly T[], query: string, toText: (item: T) => string): RankedItem<T>[] {
const trimmed = query.trim()
if (!trimmed) {
return items.map(item => ({ item, score: 0, positions: [] }))
}
const ranked: Array<RankedItem<T> & { index: number }> = []
items.forEach((item, index) => {
const match = fuzzyScoreMulti(toText(item), trimmed)
if (match) {
ranked.push({ item, score: match.score, positions: match.positions, index })
}
})
ranked.sort((a, b) => b.score - a.score || a.index - b.index)
return ranked.map(({ item, score, positions }) => ({ item, score, positions }))
}
|