mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
* perf(chat): remove repeated scans and array copies from the skill picker * perf(chat): keep picker grouping within the module line limit --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import type { DiscoveredSkill, SkillSourceKind } from './skills'
|
|
import { locatedPickerSkill } from './native-chat-reported-skill'
|
|
import type { NativeChatSessionSkill, SlashCommandSuggestion } from './native-chat-slash-commands'
|
|
import { isSafeDisplayCharacter, stripUnsafeDisplayCharacters } from './skill-display-text'
|
|
import { compareBaseSensitivityLocaleText } from './locale-text-collators'
|
|
|
|
export type NativeChatPickerItem =
|
|
| {
|
|
kind: 'command'
|
|
id: string
|
|
name: string
|
|
/** Exactly what a pick inserts — the form the agent invokes. */
|
|
token: string
|
|
description?: string
|
|
/** How the provider says the command is invoked, e.g. `<objective>`. */
|
|
argumentHint?: string
|
|
skillCollision: boolean
|
|
orcaOwned?: true
|
|
}
|
|
| {
|
|
kind: 'skill'
|
|
id: string
|
|
name: string
|
|
token: string
|
|
description: string | null
|
|
sources: { sourceKind: SkillSourceKind; skillFilePath: string }[]
|
|
/** The file the agent selects this skill by, when the agent reported it; a pick sends it. */
|
|
path?: string
|
|
/** Distinguishes same-named reported files independently of description or scanner scope. */
|
|
location?: string
|
|
}
|
|
|
|
export type NativeChatSkillDiscoverySnapshot = {
|
|
status: 'idle' | 'loading' | 'ready' | 'error'
|
|
skills: readonly DiscoveredSkill[]
|
|
errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown'
|
|
}
|
|
|
|
/**
|
|
* Which commands a scanned skill of the same name may stand in for, by marking them unclassified.
|
|
* Never one the chat's own send handles (`/compact`), which runs that whatever the row says. Where
|
|
* the agent's report lists skills among its commands (OpenCode 1.x), only a name reported under
|
|
* that skill's own description; any other is the agent's command, which OpenCode runs first.
|
|
*/
|
|
export function classifyPickerCommands(
|
|
commands: readonly SlashCommandSuggestion[],
|
|
skills: readonly DiscoveredSkill[],
|
|
options: { reportMayListSkills: boolean; isSendCommand: (name: string) => boolean }
|
|
): readonly SlashCommandSuggestion[] {
|
|
const skillDescriptions = options.reportMayListSkills
|
|
? new Set(skills.map((skill) => `${skill.name}\0${normalizedDescription(skill.description)}`))
|
|
: null
|
|
return commands.map((command) => {
|
|
const unclassified =
|
|
(command.kindUnspecified === true ||
|
|
skillDescriptions?.has(`${command.name}\0${normalizedDescription(command.description)}`) ===
|
|
true) &&
|
|
!options.isSendCommand(command.name)
|
|
if (unclassified === (command.kindUnspecified === true)) {
|
|
return command
|
|
}
|
|
const { kindUnspecified: _dropped, ...classified } = command
|
|
return unclassified ? { ...command, kindUnspecified: true } : classified
|
|
})
|
|
}
|
|
|
|
function normalizedDescription(description: string | null | undefined): string {
|
|
return description?.replace(/\s+/g, ' ').trim() ?? ''
|
|
}
|
|
|
|
const PICKER_RESULT_LIMIT = 50
|
|
const SCOPE_PRIORITY: Record<SkillSourceKind, number> = {
|
|
repo: 0,
|
|
home: 1,
|
|
bundled: 2,
|
|
plugin: 3
|
|
}
|
|
|
|
export function buildNativeChatPickerItems(
|
|
commands: readonly SlashCommandSuggestion[],
|
|
skills: readonly DiscoveredSkill[],
|
|
query: string,
|
|
skillSigil: '/' | '$',
|
|
sessionSkills?: readonly NativeChatSessionSkill[],
|
|
/** Rows kept per group; a caller that needs every skill passes Infinity. */
|
|
limit = PICKER_RESULT_LIMIT,
|
|
/** `$` matches skill names only, so a shell word like `$CODEX_HOME` never hits a description. */
|
|
trigger: '/' | '$' = '/'
|
|
): NativeChatPickerItem[] {
|
|
// A name can only collide when both kinds invoke through the same sigil;
|
|
// where skills carry their own, `/review` and `$review` are distinct entries.
|
|
const sharedSigil = skillSigil === '/'
|
|
const unclassifiedNames = new Set(
|
|
commands.filter((command) => command.kindUnspecified).map((command) => command.name)
|
|
)
|
|
const mergedSkills = mergeNativeChatSkills(skills, sessionSkills, unclassifiedNames, skillSigil)
|
|
const skillNames = new Set(mergedSkills.map((skill) => skill.name))
|
|
const orcaNames = new Set(commands.filter((c) => c.orcaOwned).map((command) => command.name))
|
|
const resolvedCommands = commands.filter(
|
|
(command) =>
|
|
command.orcaOwned ||
|
|
!(
|
|
orcaNames.has(command.name) ||
|
|
(sharedSigil && command.kindUnspecified && skillNames.has(command.name))
|
|
)
|
|
)
|
|
// Orca runs its own rows whatever the skills are called, so a namesake skill keeps its row.
|
|
const commandNames = new Set(
|
|
resolvedCommands.filter((command) => !command.orcaOwned).map((command) => command.name)
|
|
)
|
|
const commandItems = rankItems(
|
|
resolvedCommands.map((command, index) => ({
|
|
item: {
|
|
kind: 'command' as const,
|
|
// Why: the name is the dispatch token and the catalog is curated, so
|
|
// it is inserted verbatim; only untrusted skill text gets sanitized.
|
|
id: `command:${command.name}`,
|
|
name: command.name,
|
|
token: `/${command.name}`,
|
|
description: command.description ? sanitizePickerText(command.description, 240) : undefined,
|
|
argumentHint: command.argumentHint
|
|
? sanitizePickerText(command.argumentHint, 80)
|
|
: undefined,
|
|
skillCollision: sharedSigil && !command.orcaOwned && skillNames.has(command.name),
|
|
...(command.orcaOwned ? { orcaOwned: true as const } : {})
|
|
},
|
|
stableOrder: index
|
|
})),
|
|
query
|
|
)
|
|
const skillItems = rankItems(
|
|
mergedSkills
|
|
.filter((skill) => !(sharedSigil && commandNames.has(skill.name)))
|
|
.map((item, index) => ({ item, stableOrder: index })),
|
|
query,
|
|
trigger === '/'
|
|
)
|
|
return [...commandItems.slice(0, limit), ...skillItems.slice(0, limit)]
|
|
}
|
|
|
|
function mergeNativeChatSkills(
|
|
skills: readonly DiscoveredSkill[],
|
|
sessionSkills: readonly NativeChatSessionSkill[] | undefined,
|
|
unclassifiedNames: ReadonlySet<string>,
|
|
skillSigil: '/' | '$'
|
|
): Extract<NativeChatPickerItem, { kind: 'skill' }>[] {
|
|
const exactPaths = new Map<string, DiscoveredSkill>()
|
|
for (const skill of skills) {
|
|
if (skill.installed && !exactPaths.has(skill.skillFilePath)) {
|
|
exactPaths.set(skill.skillFilePath, skill)
|
|
}
|
|
}
|
|
const byName = new Map<string, DiscoveredSkill[]>()
|
|
for (const skill of exactPaths.values()) {
|
|
const safeName = getSafeSkillName(skill)
|
|
if (!safeName) {
|
|
continue
|
|
}
|
|
const namedSkills = byName.get(safeName) ?? []
|
|
namedSkills.push({ ...skill, name: safeName })
|
|
byName.set(safeName, namedSkills)
|
|
}
|
|
const discovered = new Map(
|
|
[...byName.entries()].map(([name, namedSkills]) => [
|
|
name,
|
|
pickerSkill(name, namedSkills, skillSigil)
|
|
])
|
|
)
|
|
// Why: when the running session reports its own skills, that report is the
|
|
// authority on which ones exist — a disk scan cannot see what the session
|
|
// actually loaded (plugin roots, setting-source filters), and a scanned root
|
|
// the session ignored must not be offered. The scan stays the source of
|
|
// description and scope for the names both know about.
|
|
// A reported skill with its file is its own row: two skills may share a name, and the pick
|
|
// tells the agent which file it means.
|
|
const nameCounts = new Map<string, number>()
|
|
const firstReportedByName = new Map<string, NativeChatSessionSkill>()
|
|
for (const skill of sessionSkills ?? []) {
|
|
nameCounts.set(skill.name, (nameCounts.get(skill.name) ?? 0) + 1)
|
|
if (!firstReportedByName.has(skill.name)) {
|
|
firstReportedByName.set(skill.name, skill)
|
|
}
|
|
}
|
|
const located = (sessionSkills ?? []).flatMap((skill) =>
|
|
skill.path && isTokenSafe(skill.name)
|
|
? [
|
|
locatedPickerSkill(
|
|
skill,
|
|
skill.path,
|
|
exactPaths.get(skill.path),
|
|
skillSigil,
|
|
(nameCounts.get(skill.name) ?? 0) > 1
|
|
)
|
|
]
|
|
: []
|
|
)
|
|
const names =
|
|
sessionSkills !== undefined
|
|
? [
|
|
...sessionSkills
|
|
.filter((skill) => !skill.path)
|
|
.map((skill) => skill.name)
|
|
.filter(isTokenSafe),
|
|
...[...discovered.keys()].filter((name) => unclassifiedNames.has(name))
|
|
]
|
|
: [...discovered.keys()]
|
|
return [
|
|
...located,
|
|
...[...new Set(names)].map((name) => {
|
|
const skill = discovered.get(name) ?? pickerSkill(name, [], skillSigil)
|
|
const reported = firstReportedByName.get(name)?.description
|
|
return skill.description === null && reported
|
|
? { ...skill, description: sanitizePickerText(reported, 240) }
|
|
: skill
|
|
})
|
|
].sort(comparePickerSkills)
|
|
}
|
|
|
|
function pickerSkill(
|
|
name: string,
|
|
namedSkills: readonly DiscoveredSkill[],
|
|
skillSigil: '/' | '$'
|
|
): Extract<NativeChatPickerItem, { kind: 'skill' }> {
|
|
const sorted = [...namedSkills].sort(compareDiscoveredSkills)
|
|
return {
|
|
kind: 'skill' as const,
|
|
id: `skill:${name}`,
|
|
name,
|
|
token: `${skillSigil}${name}`,
|
|
description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null,
|
|
sources: sorted.map((skill) => ({
|
|
sourceKind: skill.sourceKind,
|
|
skillFilePath: skill.skillFilePath
|
|
}))
|
|
}
|
|
}
|
|
|
|
function rankItems<T extends NativeChatPickerItem>(
|
|
entries: { item: T; stableOrder: number }[],
|
|
query: string,
|
|
matchDescriptions = true
|
|
): T[] {
|
|
if (!query) {
|
|
return entries.map((entry) => entry.item)
|
|
}
|
|
return entries
|
|
.map((entry) => ({ ...entry, rank: getMatchRank(entry.item, query, matchDescriptions) }))
|
|
.filter((entry) => entry.rank !== null)
|
|
.sort((a, b) => a.rank! - b.rank! || a.stableOrder - b.stableOrder)
|
|
.map((entry) => entry.item)
|
|
}
|
|
|
|
function getMatchRank(
|
|
item: Pick<NativeChatPickerItem, 'name' | 'description'>,
|
|
query: string,
|
|
matchDescriptions: boolean
|
|
): number | null {
|
|
const normalizedQuery = query.toLocaleLowerCase()
|
|
const name = item.name.toLocaleLowerCase()
|
|
if (name === normalizedQuery) {
|
|
return 0
|
|
}
|
|
if (name.startsWith(normalizedQuery)) {
|
|
return 1
|
|
}
|
|
if (name.includes(normalizedQuery)) {
|
|
return 2
|
|
}
|
|
if (isSubsequence(normalizedQuery, name)) {
|
|
return 3
|
|
}
|
|
if (matchDescriptions && item.description?.toLocaleLowerCase().includes(normalizedQuery)) {
|
|
return 4
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isSubsequence(query: string, value: string): boolean {
|
|
let queryIndex = 0
|
|
for (const character of value) {
|
|
if (character === query[queryIndex]) {
|
|
queryIndex += 1
|
|
}
|
|
if (queryIndex === query.length) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Why: the row's visual truncation is CSS; the name IS the inserted PTY token,
|
|
// so it must never be sliced. Token safety instead rejects absurd lengths.
|
|
const MAX_TOKEN_SAFE_NAME_LENGTH = 200
|
|
|
|
function getSafeSkillName(skill: DiscoveredSkill): string | null {
|
|
if (isTokenSafe(skill.name)) {
|
|
return skill.name
|
|
}
|
|
const directoryName = skill.directoryPath.split(/[\\/]/).findLast(Boolean) ?? ''
|
|
return isTokenSafe(directoryName) ? directoryName : null
|
|
}
|
|
|
|
function isTokenSafe(value: string): boolean {
|
|
return (
|
|
value.length > 0 &&
|
|
value.length <= MAX_TOKEN_SAFE_NAME_LENGTH &&
|
|
!/\s/u.test(value) &&
|
|
[...value].every(isSafeDisplayCharacter)
|
|
)
|
|
}
|
|
|
|
function sanitizePickerText(value: string, maxLength: number): string {
|
|
return stripUnsafeDisplayCharacters(value).slice(0, maxLength)
|
|
}
|
|
|
|
function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number {
|
|
return (
|
|
SCOPE_PRIORITY[a.sourceKind] - SCOPE_PRIORITY[b.sourceKind] ||
|
|
compareBaseSensitivityLocaleText(a.name, b.name) ||
|
|
a.skillFilePath.localeCompare(b.skillFilePath)
|
|
)
|
|
}
|
|
|
|
// A session-reported skill this host could not locate on disk sorts last: it is
|
|
// real and invocable, but carries no scope or description to rank on.
|
|
const UNLOCATED_SCOPE_PRIORITY = Object.keys(SCOPE_PRIORITY).length
|
|
|
|
function skillScopePriority(item: Extract<NativeChatPickerItem, { kind: 'skill' }>): number {
|
|
const sourceKind = item.sources[0]?.sourceKind
|
|
return sourceKind === undefined ? UNLOCATED_SCOPE_PRIORITY : SCOPE_PRIORITY[sourceKind]
|
|
}
|
|
|
|
function comparePickerSkills(
|
|
a: Extract<NativeChatPickerItem, { kind: 'skill' }>,
|
|
b: Extract<NativeChatPickerItem, { kind: 'skill' }>
|
|
): number {
|
|
return (
|
|
skillScopePriority(a) - skillScopePriority(b) ||
|
|
compareBaseSensitivityLocaleText(a.name, b.name)
|
|
)
|
|
}
|
|
|
|
export {
|
|
LEADING_SLASH_TRIGGER,
|
|
MID_PROMPT_SLASH_TRIGGER,
|
|
applyPickerSuggestion,
|
|
matchSkillSigil
|
|
} from './native-chat-picker-triggers'
|