Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } = {}

Expand Down Expand Up @@ -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'])
})
})
44 changes: 37 additions & 7 deletions apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand All @@ -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 }
}

Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isDesktopSlashSuggestion,
isModelPickerCommand,
isPickerCommand,
rankSkillCommands,
resolveDesktopCommand
} from './desktop-slash-commands'

Expand Down Expand Up @@ -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'])
})
})
52 changes: 52 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => ({
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/lib/slash-completion-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7338,6 +7338,66 @@ def test_commands_catalog_surfaces_quick_commands(monkeypatch):
assert resp["result"]["canon"]["/notes"] == "/notes"


def test_commands_catalog_ranks_skill_commands_by_recorded_usage(monkeypatch):
"""Skill entries carry the usage + origin the `/` menu ranks on.

Without it the menu is alphabetical, so a bundled skill the user has never
opened outranks the one they invoke daily.
"""
monkeypatch.setattr(
server,
"_skill_usage_lookup",
lambda: (
lambda name: {"research": 60, "work": 172}.get(name, 0),
lambda name: "bundled" if name == "research-paper-writing" else "local",
),
)
monkeypatch.setattr(
"agent.skill_commands.scan_skill_commands",
lambda: {
"/research": {"name": "research", "description": "Look it up"},
"/research-paper-writing": {
"name": "research-paper-writing",
"description": "Write a paper",
},
"/work": {"name": "work", "description": "Fresh worktree"},
},
)

resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}
)

skills = resp["result"]["skills"]
assert skills["/work"] == {"usage": 172, "origin": "local"}
assert skills["/research"] == {"usage": 60, "origin": "local"}
assert skills["/research-paper-writing"] == {"usage": 0, "origin": "bundled"}

# Every advertised skill command is rankable — a missing entry silently
# sorts that skill to the bottom of the menu.
advertised = {name for name, _ in resp["result"]["pairs"]}
assert set(skills) <= advertised
assert resp["result"]["skill_count"] == len(skills)


def test_commands_catalog_survives_an_unreadable_usage_sidecar(monkeypatch):
"""A broken/absent .usage.json degrades to no ranking, never a broken menu."""
monkeypatch.setattr(
"tools.skill_usage.load_usage",
lambda: (_ for _ in ()).throw(OSError("sidecar is gone")),
)

resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}
)

assert "error" not in resp
assert all(
entry == {"usage": 0, "origin": "local"}
for entry in resp["result"]["skills"].values()
)


def test_commands_catalog_includes_tui_mouse_command():
resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}
Expand Down
Loading
Loading