Skip to content
Open
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
72 changes: 67 additions & 5 deletions openvibe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,16 +266,23 @@ def update_session_config(self, overrides: dict[str, Any]) -> None:
# ------------------------------------------------------------------

def _try_command(self, text: str) -> Response | None:
"""If *text* is a slash command, execute it and return a Response."""
from openvibe.commands import (CommandContext, execute, get_command,
is_command)
"""If *text* is a registered slash command, execute it and return a Response.

Returns ``None`` for unrecognised names so that ``_try_skill`` can
handle skill invocations before we fall through to the LLM.
"""
from openvibe.commands import CommandContext, _COMMANDS, execute, get_command, is_command # noqa: PLC2701

if not is_command(text):
return None
parsed = get_command(text)
if parsed is None:
return None
name, args = parsed
# Only handle names that are registered as slash commands; unknown
# names may be skill invocations — let _try_skill decide.
if name not in _COMMANDS:
return None
ctx = CommandContext(session=self, args=args)
result = execute(name, ctx)
return Response(
Expand All @@ -284,6 +291,40 @@ def _try_command(self, text: str) -> Response | None:
command_result=result,
)

def _try_skill(self, text: str) -> str | None:
"""If *text* is a skill invocation (``/name args``), return the expanded prompt.

Returns ``None`` when the text is not a skill invocation so that the
caller can fall through to the normal LLM path.
"""
from openvibe.commands import is_command
from openvibe.skill.registry import get_registry

if not is_command(text):
return None
parts = text[1:].split(None, 1)
name = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
skill = get_registry().get(name)
if skill is None:
return None
return skill.get_prompt(args)

def _send_raw(
self,
text: str,
on_token: Callable[[str], None] | None = None,
) -> Response:
"""Send *text* directly to the LLM without command/skill interception.

Used internally by the :class:`~openvibe.skill.executor.SkillExecutor`
so that retry prompts bypass the skill expansion layer. Assumes the
FSM is already in THINKING state when called from within the skill
executor loop.
"""
self._launch_worker(text, on_token, callback=None)
return self._collect()

def send(
self,
text: str,
Expand All @@ -299,16 +340,22 @@ def send(
* an error occurs → Response(state=ERROR)

Slash commands (``/help``, ``/cost``, etc.) are handled locally and
never reach the LLM.
never reach the LLM. Skill invocations (``/simplify``, ``/debug``,
etc.) are expanded into full LLM prompts before being sent.

*on_message(msg_id, role)* — called when a new message is created.
*on_tool(msg_id, part_index, state_dict)* — called on tool state changes.
"""
# Slash commands bypass the LLM entirely.
# 1. Slash commands bypass the LLM entirely.
cmd_response = self._try_command(text)
if cmd_response is not None:
return cmd_response

# 2. Skill invocations: expand prompt before sending to LLM.
expanded = self._try_skill(text)
if expanded is not None:
text = expanded

with self._lock:
if self._state not in (SessionState.IDLE, SessionState.ERROR):
raise InvalidStateError(
Expand Down Expand Up @@ -368,6 +415,11 @@ def send_nowait(
callback(cmd_response)
return

# Skill invocations: expand prompt before sending to LLM.
expanded = self._try_skill(text)
if expanded is not None:
text = expanded

with self._lock:
if self._state not in (SessionState.IDLE, SessionState.ERROR):
raise InvalidStateError(
Expand Down Expand Up @@ -606,6 +658,8 @@ def start(self) -> "OpenVibe":
from openvibe.config import load_config
from openvibe.db import create_database
from openvibe.project import project as _project_module
from openvibe.skill.bundled import init_bundled_skills
from openvibe.skill.loader import load_skills_dir
from openvibe.tool.base import create_default_registry

if self._config is None:
Expand All @@ -616,6 +670,9 @@ def start(self) -> "OpenVibe":
self._registry = create_default_registry()
self._project = _project_module.get_or_create(self._db, self._project_dir)

init_bundled_skills()
load_skills_dir(self._project_dir / "skills")

if self._config.mcp:
self._init_mcp()

Expand Down Expand Up @@ -648,13 +705,18 @@ async def start_async(self) -> "OpenVibe":
from openvibe.permission.permission import PermissionService
from openvibe.project import project as _project_module
from openvibe.session.processor import SessionProcessor
from openvibe.skill.bundled import init_bundled_skills
from openvibe.skill.loader import load_skills_dir
from openvibe.tool.base import create_default_registry

if self._config is None:
self._config = load_config(self._project_dir)
if self._db is None:
self._db = create_database()

init_bundled_skills()
load_skills_dir(self._project_dir / "skills")

llm = self._llm or create_default_backend()
self._bus = EventBus()
self._registry = create_default_registry()
Expand Down
58 changes: 56 additions & 2 deletions openvibe/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def _config(ctx: CommandContext):
# ---------------------------------------------------------------------------


@command("help", "Show available commands")
@command("help", "Show available commands and skills")
def cmd_help(ctx: CommandContext) -> CommandResult:
lines = ["[bold]Available commands:[/bold]\n"]
for name in sorted(_COMMANDS):
Expand All @@ -166,9 +166,63 @@ def cmd_help(ctx: CommandContext) -> CommandResult:
f" [bold cyan]/{name}[/bold cyan] [dim]{entry.description}[/dim]"
)
for sub_name, (_, sub_desc) in sorted(entry.subcommands.items()):
lines.append(f" [bold cyan]/{name} {sub_name}[/bold cyan] [dim]{sub_desc}[/dim]")

# Append skills section
try:
from rich.markup import escape

from openvibe.skill.registry import get_registry
skills = get_registry().user_invocable()
if skills:
lines.append("\n[bold]Skills[/bold] [dim](route through the LLM):[/dim]\n")
for skill in skills:
aliases = (
f" [dim]alias: {', '.join(f'/{a}' for a in skill.aliases)}[/dim]"
if skill.aliases
else ""
)
hint = f" [dim]{escape(skill.argument_hint)}[/dim]" if skill.argument_hint else ""
lines.append(
f" [bold cyan]/{escape(skill.name)}[/bold cyan]{hint}"
f" [dim]{escape(skill.description)}[/dim]{aliases}"
)
except Exception:
pass

return CommandResult(output="\n".join(lines))


@command("skills", "List available skills")
def cmd_skills(ctx: CommandContext) -> CommandResult:
"""Show all user-invocable skills with metadata."""
try:
from openvibe.skill.registry import get_registry
except ImportError:
return CommandResult(output="[dim]Skills system not available.[/dim]")

skills = get_registry().user_invocable()
if not skills:
return CommandResult(output="[dim]No skills registered.[/dim]")

from rich.markup import escape

lines = ["[bold]Available skills:[/bold]\n"]
for skill in skills:
lines.append(f"[bold cyan]/{escape(skill.name)}[/bold cyan]")
if skill.aliases:
lines[-1] += f" [dim](aliases: {', '.join(f'/{a}' for a in skill.aliases)})[/dim]"
lines.append(f" [dim]{escape(skill.description)}[/dim]")
if skill.when_to_use:
lines.append(f" [yellow]When to use:[/yellow] [dim]{escape(skill.when_to_use)}[/dim]")
if skill.argument_hint:
lines.append(
f" [bold cyan]/{name} {sub_name}[/bold cyan] [dim]{sub_desc}[/dim]"
f" [yellow]Usage:[/yellow] [dim]/{escape(skill.name)} {escape(skill.argument_hint)}[/dim]"
)
if skill.tags:
lines.append(f" [yellow]Tags:[/yellow] [dim]{escape(', '.join(skill.tags))}[/dim]")
lines.append("")

return CommandResult(output="\n".join(lines))


Expand Down
73 changes: 73 additions & 0 deletions openvibe/skill/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""openvibe skills — discoverable prompt-templates that route through the LLM.

Public surface::

from openvibe.skill import (
SkillDefinition,
SkillResult,
SkillStatus,
CostTier,
SkillValidator,
ValidationResult,
SkillExample,
get_registry,
register_skill,
SkillExecutor,
ExecutionContext,
get_skill_log,
init_skill_log,
load_skills_dir,
SkillLoader,
)
"""

from openvibe.skill.base import (
CostTier,
SkillDefinition,
SkillExample,
SkillResult,
SkillStatus,
SkillValidator,
ValidationResult,
)
from openvibe.skill.executor import ExecutionContext, SkillExecutor
from openvibe.skill.loader import FileSkill, SkillLoader, load_skills_dir
from openvibe.skill.log import get_skill_log, init_skill_log
from openvibe.skill.registry import get_registry, register_skill
from openvibe.skill.verifier import (
KeywordValidator,
MinLengthValidator,
NoErrorValidator,
NonEmptyValidator,
SkillVerifier,
)

__all__ = [
# base
"CostTier",
"SkillDefinition",
"SkillExample",
"SkillResult",
"SkillStatus",
"SkillValidator",
"ValidationResult",
# registry
"get_registry",
"register_skill",
# executor
"ExecutionContext",
"SkillExecutor",
# log
"get_skill_log",
"init_skill_log",
# loader
"FileSkill",
"SkillLoader",
"load_skills_dir",
# validators
"KeywordValidator",
"MinLengthValidator",
"NoErrorValidator",
"NonEmptyValidator",
"SkillVerifier",
]
Loading
Loading