| 1 | import { |
| 2 | insertRankedSearchResult, |
| 3 | normalizeSearchQuery, |
| 4 | scoreQueryMatch, |
| 5 | } from "@t3tools/shared/searchRanking"; |
| 6 | |
| 7 | import type { ComposerCommandItem } from "./ComposerCommandMenu"; |
| 8 | import { scoreProviderSkill } from "../../providerSkillSearch"; |
| 9 | |
| 10 | type SlashSearchItem = Extract< |
| 11 | ComposerCommandItem, |
| 12 | { type: "slash-command" | "provider-slash-command" | "skill" } |
| 13 | >; |
| 14 | |
| 15 | /** |
| 16 | * A provider expands a slash command only when it opens the whole message; |
| 17 | * anywhere else it reaches the agent as literal text, so it is not offered |
| 18 | * there. Built-ins apply locally on selection and skills insert a `$` mention |
| 19 | * the server dispatches from any position, so both stay available. |
| 20 | */ |
| 21 | export function slashCommandItemsForPromptPosition( |
| 22 | items: ReadonlyArray<SlashSearchItem>, |
| 23 | isAtPromptStart: boolean, |
| 24 | ): SlashSearchItem[] { |
| 25 | if (isAtPromptStart) { |
| 26 | return [...items]; |
| 27 | } |
| 28 | return items.filter((item) => item.type !== "provider-slash-command"); |
| 29 | } |
| 30 | |
| 31 | function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { |
| 32 | if (item.type === "skill") { |
| 33 | if (query === "skill") { |
| 34 | return 0; |
| 35 | } |
| 36 | const skillQuery = query.startsWith("skill:") ? query.slice("skill:".length) : query; |
| 37 | const skillScore = skillQuery ? scoreProviderSkill(item.skill, skillQuery) : 0; |
| 38 | if (skillScore !== null) { |
| 39 | return skillScore; |
| 40 | } |
| 41 | return "skill".startsWith(query) ? Number.MAX_SAFE_INTEGER : null; |
| 42 | } |
| 43 | |
| 44 | const primaryValue = |
| 45 | item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); |
| 46 | const description = item.description.toLowerCase(); |
| 47 | |
| 48 | const scores = [ |
| 49 | scoreQueryMatch({ |
| 50 | value: primaryValue, |
| 51 | query, |
| 52 | exactBase: 0, |
| 53 | prefixBase: 2, |
| 54 | boundaryBase: 4, |
| 55 | includesBase: 6, |
| 56 | fuzzyBase: 100, |
| 57 | boundaryMarkers: ["-", "_", "/"], |
| 58 | }), |
| 59 | scoreQueryMatch({ |
| 60 | value: description, |
| 61 | query, |
| 62 | exactBase: 20, |
| 63 | prefixBase: 22, |
| 64 | boundaryBase: 24, |
| 65 | includesBase: 26, |
| 66 | }), |
| 67 | ].filter((score): score is number => score !== null); |
| 68 | |
| 69 | if (scores.length === 0) { |
| 70 | return null; |
| 71 | } |
| 72 | |
| 73 | return Math.min(...scores); |
| 74 | } |
| 75 | |
| 76 | export function searchSlashCommandItems( |
| 77 | items: ReadonlyArray<SlashSearchItem>, |
| 78 | query: string, |
| 79 | ): SlashSearchItem[] { |
| 80 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); |
| 81 | if (!normalizedQuery) { |
| 82 | return [...items]; |
| 83 | } |
| 84 | |
| 85 | const ranked: Array<{ |
| 86 | item: SlashSearchItem; |
| 87 | score: number; |
| 88 | tieBreaker: string; |
| 89 | }> = []; |
| 90 | |
| 91 | for (const item of items) { |
| 92 | const score = scoreSlashCommandItem(item, normalizedQuery); |
| 93 | if (score === null) { |
| 94 | continue; |
| 95 | } |
| 96 | |
| 97 | insertRankedSearchResult( |
| 98 | ranked, |
| 99 | { |
| 100 | item, |
| 101 | score, |
| 102 | tieBreaker: |
| 103 | item.type === "slash-command" |
| 104 | ? `0\u0000${item.command}` |
| 105 | : item.type === "provider-slash-command" |
| 106 | ? `1\u0000${item.command.name}\u0000${item.provider}` |
| 107 | : `2\u0000${item.skill.name}\u0000${item.provider}`, |
| 108 | }, |
| 109 | Number.POSITIVE_INFINITY, |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | return ranked.map((entry) => entry.item); |
| 114 | } |