mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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.
This commit is contained in:
parent
1cd8f8c37e
commit
341f95b249
7 changed files with 245 additions and 10 deletions
|
|
@ -25,9 +25,18 @@ export function useLiveCompletionAdapter(options: {
|
|||
enabled: boolean
|
||||
debounceMs?: number
|
||||
fetcher: (query: string) => Promise<CompletionPayload>
|
||||
/** True when `fetcher` will answer this query from cache. Such a query skips
|
||||
* both the debounce and the loading state — the debounce exists to avoid a
|
||||
* request per keystroke, and a spinner over an answer we already hold reads
|
||||
* as latency the user isn't actually paying. */
|
||||
isCached?: (query: string) => boolean
|
||||
/** Bump to declare the held answer stale. Without it a popover left open on
|
||||
* an unchanged query would keep serving what it fetched before the source
|
||||
* changed, because the adapter de-dupes on the query alone. */
|
||||
epoch?: number
|
||||
toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem
|
||||
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
|
||||
const { enabled, debounceMs = 60, fetcher, toItem } = options
|
||||
const { enabled, debounceMs = 60, epoch = 0, fetcher, isCached, toItem } = options
|
||||
|
||||
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
|
||||
query: EMPTY_QUERY,
|
||||
|
|
@ -62,6 +71,16 @@ export function useLiveCompletionAdapter(options: {
|
|||
setState({ query: EMPTY_QUERY, items: [] })
|
||||
}, [cancelTimer, enabled])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
// Invalidate by forgetting which query the held items answer, so the next
|
||||
// search() re-fetches. The items themselves stay until the new answer
|
||||
// lands — an open popover must not blink empty on a background refresh.
|
||||
// On mount this is already the state, so the first run is a no-op.
|
||||
pendingQueryRef.current = null
|
||||
setState(current => (current.query === EMPTY_QUERY ? current : { ...current, query: EMPTY_QUERY }))
|
||||
}, [epoch])
|
||||
|
||||
const scheduleFetch = useCallback(
|
||||
(query: string) => {
|
||||
if (!enabled) {
|
||||
|
|
@ -75,9 +94,13 @@ export function useLiveCompletionAdapter(options: {
|
|||
pendingQueryRef.current = query
|
||||
cancelTimer()
|
||||
const token = ++tokenRef.current
|
||||
setLoading(true)
|
||||
const cached = isCached?.(query) ?? false
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (!cached) {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
timerRef.current = null
|
||||
|
||||
fetcher(query)
|
||||
|
|
@ -103,9 +126,13 @@ export function useLiveCompletionAdapter(options: {
|
|||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
// A cached answer resolves in a microtask, so debouncing it would only
|
||||
// add a frame of empty popover on every keystroke.
|
||||
cached ? run() : (timerRef.current = window.setTimeout(run, debounceMs))
|
||||
},
|
||||
[cancelTimer, debounceMs, enabled, fetcher, toItem]
|
||||
[cancelTimer, debounceMs, enabled, fetcher, isCached, toItem]
|
||||
)
|
||||
|
||||
const adapter = useMemo<Unstable_TriggerAdapter>(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
||||
|
||||
import { useSlashCompletions } from './use-slash-completions'
|
||||
|
||||
const CATALOG = {
|
||||
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/work', 'Kick off a task in a fresh worktree']
|
||||
]
|
||||
}
|
||||
|
||||
function harness(gateway: HermesGateway) {
|
||||
const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {}
|
||||
|
||||
function Probe() {
|
||||
const { adapter } = useSlashCompletions({ gateway })
|
||||
api.search = adapter.search
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
render(<Probe />)
|
||||
|
||||
return api as { search: (query: string) => readonly Unstable_TriggerItem[] }
|
||||
}
|
||||
|
||||
/** Drive the adapter until its async fetch has settled into `search`'s result. */
|
||||
async function completions(api: { search: (query: string) => readonly Unstable_TriggerItem[] }, query: string) {
|
||||
await act(async () => {
|
||||
api.search(query)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// The debounce is skipped only for cached queries; give the timer a beat.
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 120))
|
||||
})
|
||||
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
describe('useSlashCompletions', () => {
|
||||
it('serves the bare-slash catalog from cache instead of re-requesting it', async () => {
|
||||
const request = vi.fn().mockResolvedValue(CATALOG)
|
||||
const api = harness({ request } as unknown as HermesGateway)
|
||||
|
||||
await completions(api, '')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Reopening `/` must not hit the gateway again.
|
||||
queryClient.setQueryData(['unrelated'], 1)
|
||||
await completions(api, '')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// …until something that changes the command set invalidates it.
|
||||
await act(async () => invalidateSlashCompletions())
|
||||
await completions(api, '')
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('offers skill commands on a bare slash, not just built-ins', async () => {
|
||||
const request = vi.fn().mockResolvedValue(CATALOG)
|
||||
const api = harness({ request } as unknown as HermesGateway)
|
||||
|
||||
const items = await completions(api, '')
|
||||
const work = items.find(item => (item.metadata as { command?: string })?.command === '/work')
|
||||
|
||||
expect((work?.metadata as { group?: string })?.group).toBe('Skills')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
|
|
@ -63,6 +65,7 @@ export function useSlashCompletions(options: {
|
|||
} {
|
||||
const { gateway, skinThemes, activeSkin } = options
|
||||
const enabled = Boolean(gateway)
|
||||
const epoch = useStore($slashCompletionsEpoch)
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (query: string): Promise<CompletionPayload> => {
|
||||
|
|
@ -133,7 +136,9 @@ export function useSlashCompletions(options: {
|
|||
|
||||
try {
|
||||
if (!query) {
|
||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
const catalog = filterDesktopCommandsCatalog(
|
||||
await cachedSlashCompletion('catalog', () => gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
)
|
||||
|
||||
// Prefer the categorized layout so the popover renders section headers
|
||||
// (Session, Tools & Skills, ...). Fall back to the flat list when the
|
||||
|
|
@ -149,12 +154,26 @@ export function useSlashCompletions(options: {
|
|||
}))
|
||||
)
|
||||
|
||||
// Skill commands reach us only through the flat `pairs` list — the
|
||||
// backend categorizes registry commands but appends skills
|
||||
// uncategorized, so the categorized layout alone drops every skill
|
||||
// from the bare `/` list even though typing `/wo` offers them.
|
||||
// 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()))
|
||||
|
||||
for (const [command, meta] of catalog.pairs ?? []) {
|
||||
if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) {
|
||||
items.push({ text: command, display: command, group: 'Skills', meta })
|
||||
}
|
||||
}
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
|
||||
text
|
||||
})
|
||||
const result = await cachedSlashCompletion(`slash:${text.toLowerCase()}`, () =>
|
||||
gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { text })
|
||||
)
|
||||
|
||||
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||
|
|
@ -231,5 +250,21 @@ export function useSlashCompletions(options: {
|
|||
}
|
||||
}, [])
|
||||
|
||||
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
|
||||
// Mirrors the fetcher's branching: the `/skin` and `/resume` arg stages are
|
||||
// answered from client-side state, so they never wait on the network; every
|
||||
// other query is served from the completion cache when it's still warm.
|
||||
const isCached = useCallback(
|
||||
(query: string) => {
|
||||
const text = `/${query}`
|
||||
|
||||
if ((skinThemes && /^\/skin\s+/is.test(text)) || /^\/(?:resume|sessions|switch)\s+/is.test(text)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return hasCachedSlashCompletion(query ? `slash:${text.toLowerCase()}` : 'catalog')
|
||||
},
|
||||
[skinThemes]
|
||||
)
|
||||
|
||||
return useLiveCompletionAdapter({ enabled, epoch, fetcher, isCached, toItem })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
||||
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
|
|
@ -756,6 +757,13 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
}
|
||||
}
|
||||
|
||||
// The agent just created/deleted/renamed a skill, which adds or removes
|
||||
// its `/name` command. Drop the composer's cached `/` list so the new
|
||||
// skill is offerable now rather than after the hour-long TTL.
|
||||
if (payload?.name === 'skill_manage') {
|
||||
invalidateSlashCompletions()
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { useI18n } from '@/i18n'
|
|||
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import { queryClient, writeCache } from '@/lib/query-client'
|
||||
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -222,6 +223,8 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY })
|
||||
])
|
||||
|
||||
invalidateSlashCompletions()
|
||||
|
||||
// An explicit refresh is the one time we bypass the analytics TTL — but
|
||||
// only if the badges are already on screen; otherwise let the lazy load
|
||||
// pick it up when Toolsets is first shown. Guard the async set against a
|
||||
|
|
@ -336,6 +339,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
|
||||
try {
|
||||
await toggleSkill(skill.name, enabled)
|
||||
// A disabled skill loses its `/name` command, so the composer's cached
|
||||
// `/` list has to be dropped along with the row repaint.
|
||||
invalidateSlashCompletions()
|
||||
} catch (err) {
|
||||
setSkills(
|
||||
current => current?.map(row => (row.name === skill.name ? { ...row, enabled: !enabled } : row)) ?? current
|
||||
|
|
@ -390,6 +396,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
} catch (err) {
|
||||
notifyError(err, t.skills.failedToUpdate(mode === 'skills' ? t.skills.tabSkills : t.skills.tabToolsets))
|
||||
} finally {
|
||||
invalidateSlashCompletions()
|
||||
setBulkBusy(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -674,6 +681,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
const snapshot = skills
|
||||
|
||||
setSkills(current => current?.filter(skill => skill.name !== name) ?? current)
|
||||
invalidateSlashCompletions()
|
||||
|
||||
if (skillEditor?.name === name) {
|
||||
setSkillEditor(null)
|
||||
|
|
|
|||
71
apps/desktop/src/lib/slash-completion-cache.ts
Normal file
71
apps/desktop/src/lib/slash-completion-cache.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import { atom, map } from 'nanostores'
|
|||
|
||||
import { getActionStatus, installSkillFromHub, uninstallSkillFromHub, updateSkillsFromHub } from '@/hermes'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
||||
|
|
@ -104,6 +105,9 @@ async function runHubAction(key: string, kind: HubActionKind, spawn: () => Promi
|
|||
// (un)install adds/removes a skill, so its count/rows must update too.
|
||||
void queryClient.invalidateQueries({ queryKey: HUB_SOURCES_KEY })
|
||||
void queryClient.invalidateQueries({ queryKey: SKILLS_LIST_KEY })
|
||||
// …and the composer's `/` list, which caches the command catalog for an
|
||||
// hour and would otherwise keep offering the skill we just removed.
|
||||
invalidateSlashCompletions()
|
||||
} catch (err) {
|
||||
// A profile switch points the next poll at the new backend, which 404s the
|
||||
// old action name — that's an abandonment, not a failure, so swallow it
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue