mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
3 Commits
feat/apify
...
worktree-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a926a45a7 | ||
|
|
b2968bbd70 | ||
|
|
cde41f3140 |
@@ -67,10 +67,30 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
const result = await 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`
|
||||
// with replace_from = 14. Rewrite those entries so the popover inserts
|
||||
// the full `/personality alice` token instead of stranding `/alice`.
|
||||
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
|
||||
const isArgCompletion = replaceFrom > 1
|
||||
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
|
||||
|
||||
const items = (result.items ?? [])
|
||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
||||
.map(item => {
|
||||
if (!isArgCompletion) {
|
||||
return item
|
||||
}
|
||||
|
||||
const argText = typeof item.text === 'string' ? item.text : ''
|
||||
|
||||
return { ...item, text: `${prefix}${argText}` }
|
||||
})
|
||||
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
|
||||
.map(item => ({
|
||||
...item,
|
||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
||||
|
||||
@@ -22,6 +22,33 @@ describe('detectTrigger', () => {
|
||||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the slash trigger live while typing args', () => {
|
||||
expect(detectTrigger('/personality ')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality ',
|
||||
tokenLength: 13
|
||||
})
|
||||
expect(detectTrigger('/personality alic')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
tokenLength: 17
|
||||
})
|
||||
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||
kind: '/',
|
||||
query: 'tools enable foo',
|
||||
tokenLength: 17
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat file-style paths as slash triggers', () => {
|
||||
expect(detectTrigger('src/foo/bar')).toBeNull()
|
||||
expect(detectTrigger('/path/to/file')).toBeNull()
|
||||
})
|
||||
|
||||
it('still anchors at-mention triggers strictly at the token edge', () => {
|
||||
expect(detectTrigger('@file:path with space')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractClipboardImageBlobs', () => {
|
||||
|
||||
@@ -6,7 +6,13 @@ export interface TriggerState {
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||
// single tokens. `/` triggers keep going so the popover stays live while the
|
||||
// user types args (`/personality alic` → arg completer suggests `alice`).
|
||||
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
|
||||
// paths like `src/foo/bar`.
|
||||
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
@@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
const slash = SLASH_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
if (slash) {
|
||||
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
|
||||
}
|
||||
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (at) {
|
||||
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ describe('desktop slash command curation', () => {
|
||||
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces /tools, /save, and /personality on the desktop', () => {
|
||||
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/save')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/tools')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/save')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/personality')).toBe(true)
|
||||
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
|
||||
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
|
||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows aliases to execute without cluttering the popover', () => {
|
||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||
@@ -74,6 +86,24 @@ describe('desktop slash command curation', () => {
|
||||
['/new', 'Start a new desktop chat'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
])
|
||||
// skill_count is recomputed from the filtered output (only /ship-it is an
|
||||
// extension command — /new is a built-in) so the /help footer matches what
|
||||
// the user actually sees rather than echoing the unfiltered backend total.
|
||||
expect(filtered.skill_count).toBe(1)
|
||||
})
|
||||
|
||||
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
|
||||
const filtered = filterDesktopCommandsCatalog({
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/clear', 'Clear terminal screen'],
|
||||
['/gif-search', 'Search for a gif'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
],
|
||||
skill_count: 12
|
||||
})
|
||||
|
||||
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
|
||||
expect(filtered.skill_count).toBe(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -31,16 +31,19 @@ const DESKTOP_COMMAND_META = [
|
||||
['/goal', 'Manage the standing goal for this session'],
|
||||
['/help', 'Show desktop slash commands'],
|
||||
['/new', 'Start a new desktop chat'],
|
||||
['/personality', 'Switch personality for this session'],
|
||||
['/profile', 'Switch the active Hermes profile'],
|
||||
['/queue', 'Queue a prompt for the next turn'],
|
||||
['/resume', 'Resume a saved session'],
|
||||
['/retry', 'Retry the last user message'],
|
||||
['/rollback', 'List or restore filesystem checkpoints'],
|
||||
['/save', 'Save the current transcript to JSON'],
|
||||
['/skin', 'Switch desktop theme or cycle to the next one'],
|
||||
['/status', 'Show current session status'],
|
||||
['/steer', 'Steer the current run after the next tool call'],
|
||||
['/stop', 'Stop running background processes'],
|
||||
['/title', 'Rename the current session'],
|
||||
['/tools', 'List or toggle tools available to the agent'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session'],
|
||||
['/version', 'Show Hermes Agent version'],
|
||||
@@ -90,7 +93,6 @@ const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/redraw',
|
||||
'/reload',
|
||||
'/restart',
|
||||
'/save',
|
||||
'/sb',
|
||||
'/set-home',
|
||||
'/sethome',
|
||||
@@ -98,7 +100,6 @@ const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/snapshot',
|
||||
'/statusbar',
|
||||
'/toolsets',
|
||||
'/tools',
|
||||
'/update',
|
||||
'/verbose'
|
||||
])
|
||||
@@ -112,7 +113,6 @@ const ADVANCED_COMMANDS = new Set([
|
||||
'/fast',
|
||||
'/insights',
|
||||
'/kanban',
|
||||
'/personality',
|
||||
'/reasoning',
|
||||
'/reload-mcp',
|
||||
'/reload-skills',
|
||||
@@ -274,10 +274,32 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
|
||||
?.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||
|
||||
// Recount skill commands from the filtered output so /help's footer reflects
|
||||
// what the user actually sees. Backend's skill_count includes commands the
|
||||
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
|
||||
// like "60 skill commands available" while only ~29 appear in the list.
|
||||
const filteredCommands = new Set<string>()
|
||||
for (const section of categories ?? []) {
|
||||
for (const [command] of section.pairs) {
|
||||
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||
}
|
||||
}
|
||||
for (const [command] of pairs ?? []) {
|
||||
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||
}
|
||||
let skillCount = 0
|
||||
for (const command of filteredCommands) {
|
||||
if (isDesktopSlashExtensionCommand(command)) {
|
||||
skillCount += 1
|
||||
}
|
||||
}
|
||||
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
|
||||
|
||||
return {
|
||||
...catalog,
|
||||
...(categories ? { categories } : {}),
|
||||
...(pairs ? { pairs } : {})
|
||||
...(pairs ? { pairs } : {}),
|
||||
...(hasSkillCount ? { skill_count: skillCount } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1538,6 +1538,91 @@ class SlashCommandCompleter(Completer):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _tools_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
|
||||
|
||||
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
|
||||
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
|
||||
keys and MCP server prefixes, filtered by current enable state so the
|
||||
user only sees actionable options).
|
||||
"""
|
||||
SUBS = ("list", "disable", "enable")
|
||||
parts = sub_text.split()
|
||||
trailing_space = sub_text.endswith(" ")
|
||||
|
||||
# Subcommand stage: zero words typed, or completing the first word.
|
||||
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
|
||||
partial = sub_text if not trailing_space else ""
|
||||
for sub in SUBS:
|
||||
if sub.startswith(partial.lower()) and sub != partial.lower():
|
||||
yield Completion(sub, start_position=-len(partial), display=sub)
|
||||
return
|
||||
|
||||
subcommand = parts[0].lower()
|
||||
if subcommand not in ("enable", "disable"):
|
||||
return
|
||||
|
||||
partial = "" if trailing_space else parts[-1]
|
||||
partial_lower = partial.lower()
|
||||
already = set(parts[1:] if trailing_space else parts[1:-1])
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import (
|
||||
CONFIGURABLE_TOOLSETS,
|
||||
_get_platform_tools,
|
||||
_get_plugin_toolset_keys,
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||
|
||||
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta=label,
|
||||
)
|
||||
|
||||
for ts_key in sorted(_get_plugin_toolset_keys()):
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta="plugin toolset",
|
||||
)
|
||||
|
||||
mcp_servers = config.get("mcp_servers") or {}
|
||||
if isinstance(mcp_servers, dict):
|
||||
for server in sorted(mcp_servers):
|
||||
prefix = f"{server}:"
|
||||
if prefix in already or not prefix.startswith(partial_lower):
|
||||
continue
|
||||
yield Completion(
|
||||
prefix,
|
||||
start_position=-len(partial),
|
||||
display=prefix,
|
||||
display_meta=f"MCP server '{server}'",
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _personality_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /personality from configured personalities."""
|
||||
@@ -1596,6 +1681,13 @@ class SlashCommandCompleter(Completer):
|
||||
yield from self._personality_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# /tools needs multi-word completion (subcommand + toolset name)
|
||||
# so it handles both stages itself, bypassing the single-word
|
||||
# SUBCOMMANDS branch below.
|
||||
if base_cmd == "/tools":
|
||||
yield from self._tools_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
|
||||
@@ -687,6 +687,105 @@ class TestSubcommandCompletion:
|
||||
completions = _completions(SlashCommandCompleter(), "/help ")
|
||||
assert completions == []
|
||||
|
||||
def test_tools_subcommand_completion(self):
|
||||
"""`/tools ` should suggest list, disable, enable."""
|
||||
completions = _completions(SlashCommandCompleter(), "/tools ")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"list", "disable", "enable"}
|
||||
|
||||
def test_tools_subcommand_prefix_filters(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/tools en")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"enable"}
|
||||
|
||||
def test_tools_enable_completes_toolset_names(self, monkeypatch):
|
||||
"""`/tools enable ` should suggest currently-disabled toolsets."""
|
||||
from hermes_cli import commands as commands_mod
|
||||
|
||||
# `web` is enabled, `spotify` is disabled — enabling should only offer
|
||||
# the disabled ones.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: {"web", "file"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable ")
|
||||
texts = {c.text for c in completions}
|
||||
# Should include disabled toolsets, exclude already-enabled ones.
|
||||
assert "web" not in texts
|
||||
assert "file" not in texts
|
||||
assert "spotify" in texts
|
||||
|
||||
def test_tools_disable_completes_enabled_toolsets_only(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: {"web", "file"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools disable ")
|
||||
texts = {c.text for c in completions}
|
||||
# Should include enabled toolsets, exclude disabled ones.
|
||||
assert texts == {"web", "file"}
|
||||
|
||||
def test_tools_enable_partial_filters(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: set(),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable sp")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"spotify"}
|
||||
|
||||
def test_tools_enable_skips_already_listed(self, monkeypatch):
|
||||
"""If the user already typed a name, don't suggest it again."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: set(),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable spotify ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "spotify" not in texts
|
||||
|
||||
def test_tools_suggests_mcp_server_prefixes(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
lambda *_a, **_k: set(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"mcp_servers": {"github": {}, "linear": {}}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
completions = _completions(SlashCommandCompleter(), "/tools enable git")
|
||||
texts = {c.text for c in completions}
|
||||
assert "github:" in texts
|
||||
|
||||
|
||||
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user