Merge pull request #6934 from nesquena/release/stage-6932-1786470719

Release: search skills by keyword in slash-command autocomplete (#6932)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-08-11 10:57:28 -07:00
commit e9f1e2a459
5 files changed
+126 -5

No files matched your search

+2
View File
@@ -9,6 +9,8 @@
### Changed
- **Slash-command autocomplete now searches skills by keyword.** Typing `/` and a term now matches plain skills on a case-insensitive keyword in their **name or description** (previously only a name prefix), so a skill is discoverable by what it does, not just its exact slug. Built-in, agent/plugin, and bundle commands keep prefix matching and always take precedence over a same-slug skill, and skill suggestions are held until the bundle-metadata request settles so a slow response can't briefly surface a shadowed skill. Thanks @MinhoJJang. (#6932)
- **Extensions can observe turn lifecycle events via `handle.events.on(...)`.** A registered extension (see `window.hermesExt.register`) can now subscribe to session turn-lifecycle events — `turn:start`, `turn:complete`, `turn:error`, and `turn:cancel` — through a frozen `events` accessor on its handle, each delivering a frozen scalar event (`sessionId`, `streamId`, `status`, and start/end timestamps; no message content). Core owns the authoritative live-session stream and emits each event exactly once per real transition (a per-session/stream state machine suppresses duplicate starts and post-terminal events, including on reconnect/replay), with per-listener error isolation so a misbehaving extension cannot break streaming. Subscriptions return an idempotent unsubscribe. This is the B1 observer surface on top of the E0 identity handle — a stable page-local facade instead of extensions polling private DOM state. Thanks @franksong2702. (#6924)
- **Extensions can claim a cooperative page identity via `window.hermesExt.register(id)`.** An injected extension can now claim a stable browser-page identity together with its existing settings and storage accessors: `register(id)` returns a frozen `{id, settings, storage}` handle for an ID present in the effective-enabled, Core-sanitized manifest inventory captured at page boot, and returns `null` (fail-closed, no namespace side effects) for empty, malformed, unknown, or untrusted IDs. Re-registering the same ID in one page returns the same handle; boot-time trust is immutable across status refreshes (a refresh cannot elevate an ID absent at boot). This is cooperative attribution for extensions sharing the page — explicitly not a sandbox, capability, permission, isolation mechanism, or security boundary. Thanks @franksong2702. (#6795)
+1
View File
@@ -294,6 +294,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
### Slash commands
- Type `/` in the composer for autocomplete dropdown
- Plain skills match case-insensitive keywords in their name or description; built-in, agent/plugin, and bundle commands keep prefix matching and take precedence over a same-slug skill
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
- Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent
+12 -4
View File
@@ -187,6 +187,7 @@ function getMatchingCommands(prefix){
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
const seen=new Set(matches.map(c=>c.name));
const reserved=_getReservedSlashCommandSlugs();
const bundleSlugs=new Set(_bundleCommandCache.map(bundle=>bundle.name));
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
if(!name.startsWith(q)||seen.has(name))continue;
matches.push({
@@ -227,10 +228,16 @@ function getMatchingCommands(prefix){
seen.add(bundle.name);
}
}
// A same-slug bundle owns dispatch. Hold plain skills until the independent
// bundle metadata request settles so a slow bundle response cannot briefly
// expose a selectable, shadowed skill.
if(!_bundleCommandCacheReady)return matches;
for(const skill of _skillCommandCache){
if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;
const name=String(skill&&skill.name||'').toLowerCase();
const description=String(skill&&skill.desc||'').toLowerCase();
if((!name.includes(q)&&!description.includes(q))||seen.has(name)||reserved.has(name)||bundleSlugs.has(name))continue;
matches.push(skill);
seen.add(skill.name);
seen.add(name);
}
return matches;
}
@@ -2086,8 +2093,9 @@ function refreshSlashCommandDropdown(){
});
}
function ensureSkillCommandsLoadedForAutocomplete(){
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
if(!_skillCommandCacheReady&&!_skillCommandLoadPromise){
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
}
if(!_bundleCommandCacheReady&&!_bundleCommandLoadPromise){
loadBundleCommands().then(()=>{refreshSlashCommandDropdown();});
}
+108
View File
@@ -215,6 +215,18 @@ def _run_commands_js(script_body: str) -> dict:
{{
name: 'plugin review',
description: 'Plugin collisions should stay hidden from slash autocomplete'
}},
{{
name: 'hermes-upgrade',
description: 'Safely upgrade and verify a Hermes installation'
}},
{{
name: 'maintenance-guide',
description: 'Plan a safe upgrade and rollback path'
}},
{{
name: 'reinstall-helper',
description: 'Recover from a failed reinstall'
}}
]
}};
@@ -352,6 +364,102 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace():
assert "use" in result["use_names"]
def test_skill_autocomplete_matches_keyword_in_name_or_description():
result = _run_commands_js(
"""
await loadBundleCommands(true);
await loadSkillCommands(true);
const upgrade = await getSlashAutocompleteMatches('/upgrade');
const reinstall = await getSlashAutocompleteMatches('/reinstall');
return {
upgrade_names: upgrade.map(item => item.name),
upgrade_sources: upgrade.map(item => item.source),
reinstall_names: reinstall.map(item => item.name),
reinstall_sources: reinstall.map(item => item.source)
};
"""
)
assert result["upgrade_names"] == ["hermes-upgrade", "maintenance-guide"]
assert result["upgrade_sources"] == ["skill", "skill"]
assert result["reinstall_names"] == ["reinstall-helper"]
assert result["reinstall_sources"] == ["skill"]
def test_skill_autocomplete_hides_keyword_match_shadowed_by_bundle():
result = _run_commands_js(
"""
await loadAgentCommandMetadata(true);
await loadBundleCommands(true);
await loadSkillCommands(true);
const matches = await getSlashAutocompleteMatches('/non-colliding');
return matches.map(item => ({ name: item.name, source: item.source }));
"""
)
assert result == []
def test_skill_autocomplete_waits_for_bundle_metadata_before_showing_colliding_keyword_match():
script = textwrap.dedent(
f"""
const vm = require('vm');
let releaseBundles;
const bundlesReady = new Promise(resolve => {{ releaseBundles = resolve; }});
const ctx = {{
console,
localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }},
t: (key) => key,
api: async (path) => {{
if (path === '/api/commands') return {{ commands: [] }};
if (path === '/api/commands/bundles') return bundlesReady;
if (path === '/api/skills') return {{ skills: [
{{
name: 'incident-review',
description: 'Handle a non-colliding keyword',
category: 'ops'
}}
] }};
throw new Error('unexpected api path: ' + path);
}}
}};
vm.createContext(ctx);
vm.runInContext({json.dumps(COMMANDS_JS)}, ctx);
(async () => {{
await vm.runInContext('loadSkillCommands(true)', ctx);
const bundleLoad = vm.runInContext('loadBundleCommands(true)', ctx);
const before = await vm.runInContext("getSlashAutocompleteMatches('/non-colliding')", ctx);
releaseBundles({{ bundles: [
{{
name: 'incident-review',
description: 'Incident review bundle',
skill_count: 3,
source: 'bundle'
}}
] }});
await bundleLoad;
const after = await vm.runInContext("getSlashAutocompleteMatches('/non-colliding')", ctx);
process.stdout.write(JSON.stringify({{
before: before.map(item => ({{ name: item.name, source: item.source }})),
after: after.map(item => ({{ name: item.name, source: item.source }}))
}}));
}})().catch(err => {{
console.error(err && err.stack || err);
process.exit(1);
}});
"""
)
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle:
handle.write(script)
script_path = Path(handle.name)
try:
proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True)
finally:
script_path.unlink(missing_ok=True)
assert json.loads(proc.stdout) == {"before": [], "after": []}
def test_bundle_collisions_stay_hidden_until_agent_metadata_is_ready():
script = textwrap.dedent(
f"""
+3 -1
View File
@@ -63,7 +63,9 @@ def test_skill_mutations_invalidate_slash_skill_caches():
def test_builtin_commands_take_precedence_over_skill_slug_collisions():
assert "_getReservedSlashCommandSlugs" in COMMANDS_JS
assert "if(_getReservedSlashCommandSlugs().has(slug)) return null;" in COMMANDS_JS
assert "if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;" in COMMANDS_JS
command_matching = _function_body(COMMANDS_JS, "getMatchingCommands")
assert "reserved.has(name)" in command_matching
assert "!name.includes(q)&&!description.includes(q)" in command_matching
def test_bundle_entries_merge_before_plain_skill_entries():