diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef9f3f1..31b816f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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') " diff --git a/CHANGELOG.md b/CHANGELOG.md index cce27fa..9c16619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/clawmes/_version.py b/clawmes/_version.py index 7bf6de4..427e646 100644 --- a/clawmes/_version.py +++ b/clawmes/_version.py @@ -7,4 +7,4 @@ * Tooling that does not want to incur a full package import """ -__version__ = "0.16.4" +__version__ = "0.17.0" diff --git a/clawmes/plugin.yaml b/clawmes/plugin.yaml index 41158ac..c65a8c6 100644 --- a/clawmes/plugin.yaml +++ b/clawmes/plugin.yaml @@ -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 @@ -46,6 +46,7 @@ provides_tools: - market_intel - cost_basis - block_explorer + - clawmes_info - molten - clawnx - hummingbot diff --git a/clawmes/tools/__init__.py b/clawmes/tools/__init__.py index 73e01c3..e140fa0 100644 --- a/clawmes/tools/__init__.py +++ b/clawmes/tools/__init__.py @@ -45,6 +45,7 @@ def register_all(ctx) -> None: bv7x, bv7x_market, bv7x_oracle, + clawmes_info, clawnch_fees, clawnch_launch, clawnchconnect, @@ -114,6 +115,7 @@ def register_all(ctx) -> None: market_intel, cost_basis, block_explorer, + clawmes_info, molten, clawnx, hummingbot, diff --git a/clawmes/tools/clawmes_info.py b/clawmes/tools/clawmes_info.py new file mode 100644 index 0000000..b1bfdf5 --- /dev/null +++ b/clawmes/tools/clawmes_info.py @@ -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: .", + ), + "scan": ( + "clawmes.commands.scan", + "handle_scan", + "Analyze a wallet: holdings, recent activity, risk flags. args: .", + ), + "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) diff --git a/plugin.yaml b/plugin.yaml index 41158ac..c65a8c6 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -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 @@ -46,6 +46,7 @@ provides_tools: - market_intel - cost_basis - block_explorer + - clawmes_info - molten - clawnx - hummingbot diff --git a/pyproject.toml b/pyproject.toml index 5335fc1..83c11f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/commands/test_doctor.py b/tests/commands/test_doctor.py index ffd4ef7..b9d2681 100644 --- a/tests/commands/test_doctor.py +++ b/tests/commands/test_doctor.py @@ -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 diff --git a/tests/tools/test_clawmes_info.py b/tests/tools/test_clawmes_info.py new file mode 100644 index 0000000..0d8e2dc --- /dev/null +++ b/tests/tools/test_clawmes_info.py @@ -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