Files
hermes-agent/apps/desktop/src/lib/slash-completion-cache.ts
T
Brooklyn Nicholson 341f95b249 feat(desktop): cache slash completions and list skills on a bare /
The composer re-asked the gateway for the command catalog on every open
and for complete.slash on every keystroke. Both scan the skills dir on
the backend, so opening the menu cost a round trip against data that
only moves when a skill is added, removed, or toggled.

Hold both behind a one-hour cache keyed per query, and invalidate it
where the skill set actually changes: hub install/uninstall/update, a
Capabilities toggle, bulk toggle, archive, the agent's own skill_manage
call, and a profile switch. A cached query also skips the debounce and
the spinner, so a warm menu opens in the same frame.

The bare-slash list showed no skills at all: the backend categorizes
registry commands but appends skills to the flat pairs list only, and
the popover prefers the categorized layout. Re-add the uncategorized
leftovers under a Skills header, so /clean and /work are visible on /
and not just after typing enough of the name to match.
2026-07-28 03:19:51 -05:00

72 lines
3.0 KiB
TypeScript

import { atom } from 'nanostores'
import { queryClient } from '@/lib/query-client'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
// Root for every cached `/` completion response — the bare-slash catalog and
// each typed query. Not in PROFILE_INDEPENDENT_QUERY_ROOTS, so a profile or
// gateway switch drops it with the rest of the profile-scoped cache.
const SLASH_COMPLETIONS_KEY = 'slash-completions'
// The command catalog and its completions are a scan of the command registry
// plus the skills on disk: they change when a skill is added, removed, or
// toggled — not while the user types. Both gateway calls are expensive on the
// backend (a full skills-dir scan per request), so hold the answer for an hour
// and let the mutation sites invalidate it when the truth actually moves.
const SLASH_COMPLETIONS_TTL_MS = 60 * 60_000
/** Serve a `/` completion response from cache, fetching only when stale. */
export function cachedSlashCompletion<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
return queryClient.fetchQuery({
queryKey: [SLASH_COMPLETIONS_KEY, key],
queryFn: fetcher,
gcTime: SLASH_COMPLETIONS_TTL_MS,
staleTime: SLASH_COMPLETIONS_TTL_MS,
// A completion is only worth having while the popover is open. Retrying a
// failed lookup with backoff would spend seconds answering a keystroke the
// user has already typed past; the caller falls back to an empty list and
// the next keystroke asks again.
retry: false
})
}
/** True when `cachedSlashCompletion(key)` will resolve without a round trip. */
export function hasCachedSlashCompletion(key: string): boolean {
const state = queryClient.getQueryState([SLASH_COMPLETIONS_KEY, key])
return state?.data !== undefined && Date.now() - state.dataUpdatedAt < SLASH_COMPLETIONS_TTL_MS
}
/**
* 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
* instead to know the answer it's holding is no longer current.
*/
export const $slashCompletionsEpoch = atom(0)
/**
* Drop cached `/` completions. Called from every site that changes which
* skills exist or are enabled — install/uninstall/update from the hub, a
* skill toggle or delete in Capabilities — so the composer's list matches
* the backend without waiting out the TTL.
*/
export function invalidateSlashCompletions(): void {
void queryClient.invalidateQueries({ queryKey: [SLASH_COMPLETIONS_KEY] })
$slashCompletionsEpoch.set($slashCompletionsEpoch.get() + 1)
}
// Each profile has its own skills directory, so a cached catalog is only valid
// for the profile that produced it. Dropped at the source rather than in the
// composer so it holds whether or not a chat is mounted at switch time.
let cachedProfile: null | string = null
$activeGatewayProfile.subscribe(value => {
const key = normalizeProfileKey(value)
if (cachedProfile !== null && cachedProfile !== key) {
invalidateSlashCompletions()
}
cachedProfile = key
})