Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jobs:
def register_tool(self, **kw):
self.n += 1
c = Ctx(); reg(c)
assert c.n == 52, f'expected 52 tools, got {c.n}'
assert c.n == 53, f'expected 53 tools, got {c.n}'
print(f'OK: clawmes {clawmes.__version__} \u2014 {c.n} tools register')
"

Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

## 0.17.0 — 2026-06-02

### Added — `clawmes_info`: agent-callable bridge for the read-only command surface

The Hermes Desktop app curates its slash-command autocomplete to a built-in
allowlist (`apps/desktop` → `desktop-slash-commands.ts`), so clawmes's plugin
slash commands don't appear in the `/` menu and their output renders as a
status line instead of a selectable chat bubble. Verified against the desktop
source.

To make clawmes usable from natural language in the desktop (and render as a
proper tool card), this adds a single read-only **tool** that bridges the key
informational commands:

- **`clawmes_info`** (tool 53, toolset `clawmes-trading`) — `op` + optional
`args`. Ops: `wallet`, `balance`, `portfolio`, `research`, `scan`,
`trending`, `leaderboard`, `my_launches`. The agent invokes it from phrases
like "what's my wallet balance" or "research CLAWNCH"; the result renders as
a normal tool card, and any HTML card the underlying command generates
(e.g. `/research`) is surfaced as a preview attachment via
`json_result(preview=...)`.
- Includes an async-safe runner so the sync tool can drive the `async` command
handlers whether or not an event loop is already running.

Read-only by design — write actions keep their own gated tools (`defi_swap`,
`transfer`, …). Tool count: 52 → 53.

## 0.16.4 — 2026-06-02

### Fixed — preview cards now actually reach the desktop
Expand Down
2 changes: 1 addition & 1 deletion clawmes/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
* Tooling that does not want to incur a full package import
"""

__version__ = "0.16.4"
__version__ = "0.17.0"
3 changes: 2 additions & 1 deletion clawmes/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: clawmes
version: 0.16.4
version: 0.17.0
description: Hermes Agent for crypto. Wallet, swaps, DeFi, launches, automation.
author: Clawnch
kind: standalone
Expand Down Expand Up @@ -46,6 +46,7 @@ provides_tools:
- market_intel
- cost_basis
- block_explorer
- clawmes_info
- molten
- clawnx
- hummingbot
Expand Down
2 changes: 2 additions & 0 deletions clawmes/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def register_all(ctx) -> None:
bv7x,
bv7x_market,
bv7x_oracle,
clawmes_info,
clawnch_fees,
clawnch_launch,
clawnchconnect,
Expand Down Expand Up @@ -114,6 +115,7 @@ def register_all(ctx) -> None:
market_intel,
cost_basis,
block_explorer,
clawmes_info,
molten,
clawnx,
hummingbot,
Expand Down
155 changes: 155 additions & 0 deletions clawmes/tools/clawmes_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""``clawmes_info`` — agent-callable bridge to clawmes's read-only command surface.

The Hermes Desktop app curates its slash-command autocomplete to a built-in
allowlist (``apps/desktop`` → ``desktop-slash-commands.ts``), so clawmes's
plugin slash commands don't appear in the menu and their output renders as a
status line rather than a chat bubble. This tool re-exposes the key
*informational* commands as a single **agent-callable tool**: the agent invokes
it from natural language ("research CLAWNCH", "what's my wallet balance"), and
the result renders as a normal, selectable tool card. Any HTML card the
underlying command generates (e.g. ``/research``) is surfaced as a preview
attachment via ``json_result(preview=...)``.

Read-only by design — every bridged op only reads. Write actions keep their own
gated tools (``defi_swap``, ``transfer``, …) and slash commands; they are
intentionally NOT reachable here.
"""

from __future__ import annotations

import asyncio
import concurrent.futures
import importlib
import re
from typing import Any

from clawmes.lib.params import read_str
from clawmes.lib.tool_result import error_result, json_result
from clawmes.tools.registry import read_tool, register_with_ctx

# op -> (module path, async handler name, one-line description for the schema)
_OPS: dict[str, tuple[str, str, str]] = {
"wallet": (
"clawmes.commands.wallet",
"handle_wallet",
"Connected wallet: address, chain, balance, policies.",
),
"balance": (
"clawmes.commands.balance",
"handle_balance",
"Native-token balance. args: optional chain.",
),
"portfolio": (
"clawmes.commands.balance",
"handle_portfolio",
"Native + common ERC-20 balances. args: optional chain.",
),
"research": (
"clawmes.commands.research",
"handle_research",
"Token research: price, liquidity, volume, risk flags, links. args: <token>.",
),
"scan": (
"clawmes.commands.scan",
"handle_scan",
"Analyze a wallet: holdings, recent activity, risk flags. args: <wallet address>.",
),
"trending": (
"clawmes.commands.trending",
"handle_trending",
"Top tokens by 24h volume on Base. args: optional [--clawnch|--all] [limit].",
),
"leaderboard": (
"clawmes.commands.leaderboard",
"handle_leaderboard",
"Top on Clawnch. args: optional tokens|launchers|burners.",
),
"my_launches": (
"clawmes.commands.my_launches",
"handle_my_launches",
"Tokens you've launched. args: optional [--clawnch|--all].",
),
}

# Pull a generated card path out of a command's text output so it renders as a
# desktop preview attachment. Matches the absolute card path written by
# ``clawmes.lib.ui_cards.write_card`` (``${HERMES_HOME}/clawmes/cards/*.html``).
_CARD_RE = re.compile(r"(/[^\s'\"]+/clawmes/cards/[^\s'\"]+\.html)")

_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": sorted(_OPS),
"description": "Which read to run. "
+ " ".join(f"{k}: {v[2]}" for k, v in _OPS.items()),
},
"args": {
"type": "string",
"description": (
"Optional argument string for the op — e.g. a token symbol for "
"research (CLAWNCH), a wallet address for scan (0x…), or a chain "
"for balance (base)."
),
},
},
"required": ["op"],
}


def _run_coro(coro: Any) -> str:
"""Drive an async command handler to completion from a sync tool.

Handles both invocation contexts: no running event loop (the common
tool-call path → ``asyncio.run``) and an already-running loop (run in a
worker thread with its own loop so we never call ``asyncio.run`` inside a
live loop).
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(asyncio.run, coro).result()


@read_tool(
name="clawmes_info",
toolset="clawmes-trading",
description=(
"Query clawmes for crypto info from natural language: wallet status, "
"balances, portfolio, token research, wallet scan, trending tokens, "
"Clawnch leaderboard, and your launches. Pick `op` and optional `args` "
"(e.g. op=research args=CLAWNCH, or op=scan args=0xWallet). Read-only — "
"use defi_swap / transfer for actions."
),
schema=_SCHEMA,
emoji="\U0001f50e",
)
def clawmes_info(args: dict[str, Any], **_kwargs: Any) -> str:
op = (read_str(args, "op", required=True) or "").strip().lower()
arg_str = read_str(args, "args") or ""

entry = _OPS.get(op)
if entry is None:
return error_result(
f"Unknown op {op!r}. Choose one of: {', '.join(sorted(_OPS))}.",
code="param_error",
)

module_path, handler_name, _desc = entry
handler = getattr(importlib.import_module(module_path), handler_name)
output = _run_coro(handler(arg_str))

match = _CARD_RE.search(output)
preview = match.group(1) if match else None
return json_result(
{"op": op, "args": arg_str, "output": output},
summary=output,
preview=preview,
)


def register(ctx) -> None:
register_with_ctx(ctx, clawmes_info)
3 changes: 2 additions & 1 deletion plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: clawmes
version: 0.16.4
version: 0.17.0
description: Hermes Agent for crypto. Wallet, swaps, DeFi, launches, automation.
author: Clawnch
kind: standalone
Expand Down Expand Up @@ -46,6 +46,7 @@ provides_tools:
- market_intel
- cost_basis
- block_explorer
- clawmes_info
- molten
- clawnx
- hummingbot
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "clawmes"
version = "0.16.4"
version = "0.17.0"
description = "Hermes Agent plugin for crypto: wallets, DEX trading, lending and staking, governance, on-chain automation."
readme = "README.md"
license = { text = "MIT" }
Expand Down
4 changes: 2 additions & 2 deletions tests/commands/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ def test_real_manifest_counts(self):
section = _plugin_section()
assert "Tools registered:" in section.body
# 45 at 0.1.0 + policy_manage + agent_identity + bv7x + bv7x_oracle
# + bv7x_market + eas_attestation + a2a_call = 52
assert "52" in section.body
# + bv7x_market + eas_attestation + a2a_call = 52; + clawmes_info = 53
assert "53" in section.body
assert "Hooks registered:" in section.body
assert "11" in section.body # 11 hooks
assert "Commands registered:" in section.body
Expand Down
122 changes: 122 additions & 0 deletions tests/tools/test_clawmes_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Tests for clawmes.tools.clawmes_info — the agent-callable read bridge."""

from __future__ import annotations

import json

import pytest

from clawmes.tools.clawmes_info import _OPS, clawmes_info, register


def _set_handler(monkeypatch, op, fn):
"""Monkeypatch the async handler that ``op`` dispatches to."""
module_path, handler_name, _desc = _OPS[op]
monkeypatch.setattr(f"{module_path}.{handler_name}", fn)


class TestDispatch:
def test_dispatches_and_returns_output(self, monkeypatch):
async def _stub(raw_args, *a, **k):
return "WALLET STATUS OK"

_set_handler(monkeypatch, "wallet", _stub)
out = json.loads(clawmes_info({"op": "wallet"}))
assert out["details"]["op"] == "wallet"
assert out["details"]["output"] == "WALLET STATUS OK"
assert out["content"][0]["text"] == "WALLET STATUS OK"
assert "isError" not in out

def test_passes_args_through(self, monkeypatch):
async def _stub(raw_args, *a, **k):
return f"researching {raw_args}"

_set_handler(monkeypatch, "research", _stub)
out = json.loads(clawmes_info({"op": "research", "args": "CLAWNCH"}))
assert out["details"]["args"] == "CLAWNCH"
assert "CLAWNCH" in out["details"]["output"]

def test_op_is_normalized(self, monkeypatch):
async def _stub(raw_args, *a, **k):
return "ok"

_set_handler(monkeypatch, "trending", _stub)
out = json.loads(clawmes_info({"op": " TRENDING "}))
assert out["details"]["op"] == "trending"

def test_missing_args_defaults_empty(self, monkeypatch):
captured = {}

async def _stub(raw_args, *a, **k):
captured["raw"] = raw_args
return "ok"

_set_handler(monkeypatch, "balance", _stub)
clawmes_info({"op": "balance"})
assert captured["raw"] == ""


class TestPreview:
def test_preview_extracted_from_card_path(self, monkeypatch):
async def _stub(raw_args, *a, **k):
return "Report\n\nResearch card: /home/u/.hermes/clawmes/cards/research-FOO-1700000000.html\n"

_set_handler(monkeypatch, "research", _stub)
out = json.loads(clawmes_info({"op": "research", "args": "FOO"}))
assert out["preview"] == "/home/u/.hermes/clawmes/cards/research-FOO-1700000000.html"

def test_no_preview_when_no_card(self, monkeypatch):
async def _stub(raw_args, *a, **k):
return "just text, no card path here"

_set_handler(monkeypatch, "scan", _stub)
out = json.loads(clawmes_info({"op": "scan", "args": "0xabc"}))
assert "preview" not in out


class TestErrors:
def test_unknown_op(self):
out = json.loads(clawmes_info({"op": "definitely_not_an_op"}))
assert out["isError"] is True
assert out["details"]["error_code"] == "param_error"

def test_op_required(self):
# read_str(required=True) raises ParamError → read_tool maps to param_error
out = json.loads(clawmes_info({}))
assert out["isError"] is True
assert out["details"]["error_code"] == "param_error"

def test_handler_exception_is_caught(self, monkeypatch):
async def _boom(raw_args, *a, **k):
raise RuntimeError("handler blew up")

_set_handler(monkeypatch, "wallet", _boom)
out = json.loads(clawmes_info({"op": "wallet"}))
assert out["isError"] is True
assert out["details"]["error_code"] == "tool_error"


class TestRunningLoopBranch:
@pytest.mark.asyncio
async def test_dispatch_from_running_loop(self, monkeypatch):
# Inside an async test a loop is already running, so _run_coro must take
# the worker-thread branch instead of asyncio.run.
async def _stub(raw_args, *a, **k):
return "VIA WORKER THREAD"

_set_handler(monkeypatch, "wallet", _stub)
out = json.loads(clawmes_info({"op": "wallet"}))
assert out["details"]["output"] == "VIA WORKER THREAD"


class TestRegister:
def test_registers_tool(self, mock_ctx):
register(mock_ctx)
names = [t["name"] for t in mock_ctx.tools]
assert "clawmes_info" in names

def test_schema_lists_all_ops(self):
# The tool's own metadata enum should match the bridged op set.
meta = clawmes_info._clawmes_meta
assert set(meta["schema"]["properties"]["op"]["enum"]) == set(_OPS)
assert meta["is_write"] is False
Loading