mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(desktop): rank the slash menu by the skills you actually use
The `/` popover listed skills alphabetically, so on a 200-skill install the ones invoked daily sat below a wall of skills that shipped with Hermes and were never opened — /research-paper-writing (never used) outranked /research (60 invocations). Sort the Skills section most-used first, A-Z within a tie, and on a bare `/` drop bundled skills with no recorded activity. Typing a query keeps every match: a search that hides a result is broken, so a typed `/re` only reorders.
This commit is contained in:
parent
75bf13e032
commit
959d2993ab
5 changed files with 212 additions and 7 deletions
|
|
@ -18,6 +18,29 @@ const CATALOG = {
|
|||
]
|
||||
}
|
||||
|
||||
// A catalog shaped like a real install: a couple of skills the user lives in,
|
||||
// a bundled one they have never opened, and one of their own they haven't
|
||||
// either.
|
||||
const RANKED_CATALOG = {
|
||||
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/docx', 'Edit Word documents'],
|
||||
['/research', 'Look it up before answering'],
|
||||
['/research-paper-writing', 'Write an academic paper'],
|
||||
['/work', 'Kick off a task in a fresh worktree']
|
||||
],
|
||||
skills: {
|
||||
'/docx': { usage: 0, origin: 'local' },
|
||||
'/research': { usage: 60, origin: 'local' },
|
||||
'/research-paper-writing': { usage: 0, origin: 'bundled' },
|
||||
'/work': { usage: 172, origin: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
const commandsOf = (items: readonly Unstable_TriggerItem[]) =>
|
||||
items.map(item => (item.metadata as { command?: string })?.command)
|
||||
|
||||
function harness(gateway: HermesGateway) {
|
||||
const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {}
|
||||
|
||||
|
|
@ -95,4 +118,40 @@ describe('useSlashCompletions', () => {
|
|||
|
||||
expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work'])
|
||||
})
|
||||
|
||||
// An alphabetical `/` menu buries the skills someone runs daily under the
|
||||
// ones that shipped with Hermes and were never opened.
|
||||
it('orders skills by use and hides never-used built-ins on a bare slash', async () => {
|
||||
const request = vi.fn().mockResolvedValue(RANKED_CATALOG)
|
||||
const api = harness({ request } as unknown as HermesGateway)
|
||||
|
||||
const skills = commandsOf((await completions(api, '')).filter(isSkillItem))
|
||||
|
||||
expect(skills).toEqual(['/work', '/research', '/docx'])
|
||||
})
|
||||
|
||||
// Typing is a search, and a search that hides a match is broken — the
|
||||
// never-used built-in still shows, just below the one she actually uses.
|
||||
it('ranks a typed query by use without hiding anything', async () => {
|
||||
const request = vi.fn().mockImplementation((method: string) =>
|
||||
Promise.resolve(
|
||||
method === 'commands.catalog'
|
||||
? RANKED_CATALOG
|
||||
: {
|
||||
items: [
|
||||
{ text: '/research-paper-writing', display: '/research-paper-writing', meta: 'Write a paper' },
|
||||
{ text: '/research', display: '/research', meta: 'Look it up' }
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const api = harness({ request } as unknown as HermesGateway)
|
||||
|
||||
// Warm the catalog first: the popover always opens on a bare `/` before a
|
||||
// query is typed, which is where the usage map comes from.
|
||||
await completions(api, '')
|
||||
|
||||
expect(commandsOf(await completions(api, 'research'))).toEqual(['/research', '/research-paper-writing'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ import {
|
|||
type DesktopThemeCommandOption,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
isDesktopSlashSuggestion,
|
||||
rankSkillCommands
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache'
|
||||
import {
|
||||
$slashCompletionsEpoch,
|
||||
cachedSlashCompletion,
|
||||
hasCachedSlashCompletion,
|
||||
peekCachedSlashCompletion
|
||||
} from '@/lib/slash-completion-cache'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
|
|
@ -145,7 +151,7 @@ export function useSlashCompletions(options: {
|
|||
// backend didn't categorize.
|
||||
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const items = sections.flatMap(section =>
|
||||
const items = sections.flatMap<CompletionEntry>(section =>
|
||||
section.pairs.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
|
|
@ -161,13 +167,19 @@ export function useSlashCompletions(options: {
|
|||
// Re-add the leftovers under one Skills header (which also gives them
|
||||
// the skill pill accent and makes them offerable mid-message).
|
||||
const categorized = new Set(items.map(item => item.text.toLowerCase()))
|
||||
const skillRows: CompletionEntry[] = []
|
||||
|
||||
for (const [command, meta] of catalog.pairs ?? []) {
|
||||
if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) {
|
||||
items.push({ text: command, display: command, group: 'Skills', meta })
|
||||
skillRows.push({ text: command, display: command, group: 'Skills', meta })
|
||||
}
|
||||
}
|
||||
|
||||
// Browsing, not searching: rank the skills the user actually reaches
|
||||
// for to the top and drop never-used built-ins entirely. Typing a
|
||||
// query takes the other branch, where nothing is hidden.
|
||||
items.push(...rankSkillCommands(skillRows, catalog.skills, { pruneUnusedBuiltins: true }))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
|
|
@ -210,9 +222,27 @@ export function useSlashCompletions(options: {
|
|||
// Skills (stable within a group, preserving backend relevance order).
|
||||
const groupOrder = ['Commands', 'Skills', 'Options']
|
||||
|
||||
const items = isArgCompletion
|
||||
? decorated
|
||||
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
if (isArgCompletion) {
|
||||
return { items: decorated, query }
|
||||
}
|
||||
|
||||
// Rank the matched skills by use — `/re` should lead with the /research
|
||||
// the user lives in, not the /research-paper-writing they've never
|
||||
// opened. Nothing is pruned here: a typed query is a search, and a
|
||||
// search that hides a match is broken. Usage rides along on the catalog
|
||||
// response, which the popover has already fetched by the time anyone
|
||||
// types; if it somehow hasn't, order falls back to the backend's.
|
||||
const catalogSkills = peekCachedSlashCompletion<CommandsCatalogLike>('catalog')?.skills
|
||||
|
||||
const ranked = [
|
||||
...decorated.filter(item => item.group !== 'Skills'),
|
||||
...rankSkillCommands(
|
||||
decorated.filter(item => item.group === 'Skills'),
|
||||
catalogSkills
|
||||
)
|
||||
]
|
||||
|
||||
const items = [...ranked].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
isDesktopSlashSuggestion,
|
||||
isModelPickerCommand,
|
||||
isPickerCommand,
|
||||
rankSkillCommands,
|
||||
resolveDesktopCommand
|
||||
} from './desktop-slash-commands'
|
||||
|
||||
|
|
@ -292,3 +293,56 @@ describe('desktop slash command curation', () => {
|
|||
expect(resolveDesktopCommand('/gif-search')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rankSkillCommands', () => {
|
||||
const rows = [
|
||||
{ text: '/research' },
|
||||
{ text: '/research-paper-writing' },
|
||||
{ text: '/work' },
|
||||
{ text: '/ship-it' },
|
||||
{ text: '/manim-video' },
|
||||
{ text: '/docx' }
|
||||
]
|
||||
|
||||
const skills = {
|
||||
'/research': { usage: 60, origin: 'local' as const },
|
||||
'/research-paper-writing': { usage: 0, origin: 'bundled' as const },
|
||||
'/work': { usage: 172, origin: 'local' as const },
|
||||
'/manim-video': { usage: 0, origin: 'bundled' as const },
|
||||
'/docx': { usage: 0, origin: 'local' as const }
|
||||
}
|
||||
|
||||
it('puts the most-used skill first and breaks ties alphabetically', () => {
|
||||
expect(rankSkillCommands(rows, skills).map(row => row.text)).toEqual([
|
||||
'/work',
|
||||
'/research',
|
||||
'/docx',
|
||||
'/manim-video',
|
||||
'/research-paper-writing',
|
||||
'/ship-it'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops never-used built-ins when browsing, keeping everything else', () => {
|
||||
const browsing = rankSkillCommands(rows, skills, { pruneUnusedBuiltins: true }).map(row => row.text)
|
||||
|
||||
expect(browsing).toEqual(['/work', '/research', '/docx', '/ship-it'])
|
||||
// A user's own unused skill survives — only shipped-and-ignored goes.
|
||||
expect(browsing).toContain('/docx')
|
||||
// Unclassified rows (quick commands, skills newer than the map) survive too.
|
||||
expect(browsing).toContain('/ship-it')
|
||||
})
|
||||
|
||||
it('leaves the backend order untouched when the catalog carries no usage', () => {
|
||||
expect(rankSkillCommands(rows, undefined, { pruneUnusedBuiltins: true })).toEqual(rows)
|
||||
})
|
||||
|
||||
it('ranks an alias by the canonical command it resolves to', () => {
|
||||
const ranked = rankSkillCommands([{ text: '/sessions' }, { text: '/research' }], {
|
||||
'/research': { usage: 5, origin: 'local' },
|
||||
'/resume': { usage: 900, origin: 'local' }
|
||||
})
|
||||
|
||||
expect(ranked.map(row => row.text)).toEqual(['/sessions', '/research'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,9 +7,24 @@ export interface CommandsCatalogLike {
|
|||
categories?: CommandsCatalogSection[]
|
||||
pairs?: [string, string][]
|
||||
skill_count?: number
|
||||
skills?: SkillCatalogMap
|
||||
warning?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-skill ranking data from `commands.catalog`, keyed by slash command.
|
||||
* Absent on older backends — every helper below degrades to "no ranking,
|
||||
* hide nothing".
|
||||
*/
|
||||
export interface SkillCatalogEntry {
|
||||
/** Where the skill came from; matches `/api/skills` provenance ('agent' = 'local'). */
|
||||
origin?: 'bundled' | 'hub' | 'local'
|
||||
/** Observed activity (use + view + patch) — the same number Capabilities shows. */
|
||||
usage?: number
|
||||
}
|
||||
|
||||
export type SkillCatalogMap = Record<string, SkillCatalogEntry>
|
||||
|
||||
export interface DesktopSlashCompletion {
|
||||
display: string
|
||||
meta: string
|
||||
|
|
@ -518,6 +533,43 @@ export function desktopSkinSlashCompletions(
|
|||
return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Order skill rows by how much the user actually uses them, most-used first,
|
||||
* A–Z within a tie. A `/` menu sorted alphabetically buries the handful of
|
||||
* skills someone reaches for daily under a hundred they have never opened.
|
||||
*
|
||||
* `pruneUnusedBuiltins` additionally drops bundled skills with no recorded
|
||||
* activity — the ones that ship with Hermes and were never asked for. It is
|
||||
* for BROWSING (a bare `/`) only: typing a query is a search, and a search
|
||||
* must never hide a match.
|
||||
*
|
||||
* Older backends send no `skills` map; then nothing is reordered or dropped.
|
||||
*/
|
||||
export function rankSkillCommands<T extends { text: string }>(
|
||||
rows: readonly T[],
|
||||
skills: SkillCatalogMap | undefined,
|
||||
{ pruneUnusedBuiltins = false }: { pruneUnusedBuiltins?: boolean } = {}
|
||||
): T[] {
|
||||
if (!skills) {
|
||||
return [...rows]
|
||||
}
|
||||
|
||||
const entryOf = (row: T): SkillCatalogEntry | undefined => skills[canonicalDesktopSlashCommand(row.text)]
|
||||
const usageOf = (row: T): number => entryOf(row)?.usage ?? 0
|
||||
|
||||
const kept = pruneUnusedBuiltins
|
||||
? rows.filter(row => {
|
||||
const entry = entryOf(row)
|
||||
|
||||
// Unknown to the map (a quick command, a newer skill the catalog
|
||||
// hasn't classified) stays — only a confirmed never-used built-in goes.
|
||||
return !entry || entry.origin !== 'bundled' || (entry.usage ?? 0) > 0
|
||||
})
|
||||
: [...rows]
|
||||
|
||||
return kept.sort((a, b) => usageOf(b) - usageOf(a) || a.text.localeCompare(b.text))
|
||||
}
|
||||
|
||||
export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike {
|
||||
const categories = catalog.categories
|
||||
?.map(section => ({
|
||||
|
|
|
|||
|
|
@ -37,6 +37,16 @@ export function hasCachedSlashCompletion(key: string): boolean {
|
|||
return state?.data !== undefined && Date.now() - state.dataUpdatedAt < SLASH_COMPLETIONS_TTL_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cached completion response without fetching. For data that improves a
|
||||
* response but must not cost a round trip to get — the catalog's per-skill
|
||||
* usage map, which refines the ordering of a typed query but is not worth
|
||||
* delaying that query for.
|
||||
*/
|
||||
export function peekCachedSlashCompletion<T>(key: string): T | undefined {
|
||||
return hasCachedSlashCompletion(key) ? queryClient.getQueryData<T>([SLASH_COMPLETIONS_KEY, key]) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped on every invalidation. The composer's completion adapter de-dupes by
|
||||
* query, so an unchanged `/` would never re-ask on its own — it watches this
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue