| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; |
| 2 | import type { ServerProviderSkill } from "@t3tools/contracts"; |
| 3 | import { |
| 4 | dedupeProviderSkillsByName, |
| 5 | isProviderSkillUserInvocable, |
| 6 | } from "@t3tools/client-runtime/providerSkills"; |
| 7 | import { |
| 8 | insertRankedSearchResult, |
| 9 | normalizeSearchQuery, |
| 10 | scoreQueryMatch, |
| 11 | } from "@t3tools/shared/searchRanking"; |
| 12 | |
| 13 | export function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null { |
| 14 | const normalizedName = skill.name.toLowerCase(); |
| 15 | const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase(); |
| 16 | const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? ""; |
| 17 | const normalizedDescription = skill.description?.toLowerCase() ?? ""; |
| 18 | const normalizedScope = skill.scope?.toLowerCase() ?? ""; |
| 19 | |
| 20 | const scores = [ |
| 21 | scoreQueryMatch({ |
| 22 | value: normalizedName, |
| 23 | query, |
| 24 | exactBase: 0, |
| 25 | prefixBase: 2, |
| 26 | boundaryBase: 4, |
| 27 | includesBase: 6, |
| 28 | fuzzyBase: 100, |
| 29 | boundaryMarkers: ["-", "_", "/"], |
| 30 | }), |
| 31 | scoreQueryMatch({ |
| 32 | value: normalizedLabel, |
| 33 | query, |
| 34 | exactBase: 1, |
| 35 | prefixBase: 3, |
| 36 | boundaryBase: 5, |
| 37 | includesBase: 7, |
| 38 | fuzzyBase: 110, |
| 39 | }), |
| 40 | scoreQueryMatch({ |
| 41 | value: normalizedShortDescription, |
| 42 | query, |
| 43 | exactBase: 20, |
| 44 | prefixBase: 22, |
| 45 | boundaryBase: 24, |
| 46 | includesBase: 26, |
| 47 | }), |
| 48 | scoreQueryMatch({ |
| 49 | value: normalizedDescription, |
| 50 | query, |
| 51 | exactBase: 30, |
| 52 | prefixBase: 32, |
| 53 | boundaryBase: 34, |
| 54 | includesBase: 36, |
| 55 | }), |
| 56 | scoreQueryMatch({ |
| 57 | value: normalizedScope, |
| 58 | query, |
| 59 | exactBase: 40, |
| 60 | prefixBase: 42, |
| 61 | includesBase: 44, |
| 62 | }), |
| 63 | ].filter((score): score is number => score !== null); |
| 64 | |
| 65 | if (scores.length === 0) { |
| 66 | return null; |
| 67 | } |
| 68 | |
| 69 | return Math.min(...scores); |
| 70 | } |
| 71 | |
| 72 | export function searchProviderSkills( |
| 73 | skills: ReadonlyArray<ServerProviderSkill>, |
| 74 | query: string, |
| 75 | limit = Number.POSITIVE_INFINITY, |
| 76 | ): ServerProviderSkill[] { |
| 77 | const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); |
| 78 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\p{Sc}+/u }); |
| 79 | |
| 80 | if (!normalizedQuery) { |
| 81 | return enabledSkills; |
| 82 | } |
| 83 | |
| 84 | const ranked: Array<{ |
| 85 | item: ServerProviderSkill; |
| 86 | score: number; |
| 87 | tieBreaker: string; |
| 88 | }> = []; |
| 89 | |
| 90 | for (const skill of enabledSkills) { |
| 91 | const score = scoreProviderSkill(skill, normalizedQuery); |
| 92 | if (score === null) { |
| 93 | continue; |
| 94 | } |
| 95 | |
| 96 | insertRankedSearchResult( |
| 97 | ranked, |
| 98 | { |
| 99 | item: skill, |
| 100 | score, |
| 101 | tieBreaker: `${formatProviderSkillDisplayName(skill).toLowerCase()}\u0000${skill.name}`, |
| 102 | }, |
| 103 | limit, |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | return ranked.map((entry) => entry.item); |
| 108 | } |