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 }))
}