Преглед на файлове

feat: bundle chatto-platform-plugin skill via ctx.register_skill

- skills/chatto-platform-plugin/SKILL.md: tool usage guide (chatto_send_dm/
  chatto_send_room/chatto_list_rooms), cron deliver patterns, plugin tool
  contract, Chatto API quirks, dev workflow (protobuf split, async chattolib,
  isolated cron-scope verification)
- register() walks skills/ and registers every <dir>/SKILL.md (best-effort)
- plugin.yaml: bump to 1.0.1, declare provides_tools for the 3 client tools
  (fixes plugin-doctor WARNs; opt-in for CLI/TUI tool pre-registration)
Liv Klumpp преди 2 седмици
родител
ревизия
a5e8335f31
променени са 3 файла, в които са добавени 94 реда и са изтрити 1 реда
  1. 16 0
      adapter.py
  2. 5 1
      plugin.yaml
  3. 73 0
      skills/chatto-platform-plugin/SKILL.md

+ 16 - 0
adapter.py

@@ -4066,6 +4066,22 @@ def register(ctx) -> None:
         description=CHATTO_LIST_ROOMS_SCHEMA["description"],
     )
 
+    # --- Bundled skills ---
+    # Ship the plugin's own usage/development knowledge with the code, so any
+    # agent using this plugin knows when/how to use its tools and how to
+    # extend it. Loaded via skill_view("chatto-platform:chatto-platform-plugin").
+    try:
+        from pathlib import Path
+
+        skills_dir = Path(__file__).parent / "skills"
+        for child in sorted(skills_dir.iterdir()):
+            skill_md = child / "SKILL.md"
+            if child.is_dir() and skill_md.exists():
+                ctx.register_skill(child.name, str(skill_md))
+                logger.info("Chatto plugin skill registered: %s", child.name)
+    except Exception as exc:  # pragma: no cover — skill loading is best-effort
+        logger.warning("Chatto plugin skill registration failed: %s", exc)
+
     for capability in _capabilities():
         logger.info("Chatto capability: %s", capability)
 

+ 5 - 1
plugin.yaml

@@ -1,7 +1,7 @@
 name: chatto-platform
 label: Chatto
 kind: platform
-version: 1.0.0
+version: 1.0.1
 description: >
   Chatto gateway adapter for Hermes Agent.
   Connects to a Chatto server (cloud-hosted or self-hosted team chat) and relays messages
@@ -10,6 +10,10 @@ description: >
   protocol (binary protobuf) for inbound events.  Nothing to install:
   chattolib[realtime] and its dependencies are vendored into the plugin.
 author: Paul Klumpp
+provides_tools:
+  - chatto_send_dm
+  - chatto_send_room
+  - chatto_list_rooms
 requires_env:
   - name: CHATTO_BASE_URL
     description: "Base URL of the Chatto server (e.g. https://chat.example.com) leave blank for ChattoHQ (https://chat.chatto.run)"

+ 73 - 0
skills/chatto-platform-plugin/SKILL.md

@@ -0,0 +1,73 @@
+---
+name: chatto-platform-plugin
+description: "Use when working with the Hermes Chatto platform plugin: its tools, the Chatto API quirks, or extending the plugin."
+version: 1.1.0
+author: Paul Klumpp
+license: MIT
+metadata:
+  hermes:
+    tags: [chatto, hermes, plugin, gateway, adapter, chattolib, tools]
+---
+
+# Hermes Chatto Platform Plugin
+
+## When to Use
+
+Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`), the Chatto API, the vendored chattolib, or adding new tools to the plugin.
+
+## The plugin tools and when to use them
+
+### chatto_list_rooms(name?) — resolve room names to IDs
+
+Lists every room the bot is a member of: `room_id`, `name`, `kind` (channel/DM), `threading_mode`, `archived`. Optional `name` filters case-insensitively by substring (unnamed DM rooms are skipped when a filter is set; without a filter they appear with an empty name and label `(DM room <id>)`).
+
+**Primary use case: cron `deliver` targets.** The Hermes cron `deliver` field takes only `platform:chat_id` strings (e.g. `chatto-platform:RbmwiHk4vs9a3Ql`) and does NO name resolution. When a scheduled job's report should go to a channel, call this tool first, take the `room_id`, and use it as the `chat_id` part. Never trust chat names in session/origin metadata as room IDs — resolve live.
+
+### chatto_send_room(room_id|room_name, message, create_thread?, thread_root_event_id?) — post into a channel
+
+Resolves the room case-insensitively by name OR verifies the ID, then posts. Works for any room the bot is a member of.
+
+- `create_thread=true` posts the message as the ROOT of a NEW thread (first chunk = root, further chunks inside), then best-effort `follow_thread`.
+- `thread_root_event_id` posts INSIDE an existing thread. Mutually exclusive with `create_thread`.
+- Response carries `thread_root_event_id` + `created_thread`.
+
+**Mentions instead of DMs:** to notify a user inside a channel, mention them in the message TEXT (`@login, hier ist …`) — Chatto renders room-text mentions. A bot cannot deliver a personal notification any other way (see DM limitation below).
+
+### chatto_send_dm(user_login|user_id, message) — DM a user
+
+Looks up the user by login or ID and posts into the DM room.
+
+**Precondition: the recipient must have DMed the bot once.** Bots can NEVER start a DM — by design (Chatto dev-docs, guides/operations/permissions: "Only people can start a DM... A bot cannot start a DM, even with message.post."). No permission or role change lifts this. On `permission_denied`: the recipient must message the bot first, then the existing-room path works.
+
+## Cron delivery patterns
+
+- **Split content destination from report destination.** Content goes out via `chatto_send_room`/`chatto_send_dm` called BY the job prompt; the cron `deliver` field routes only the final confirmation/report (e.g. `deliver: "chatto-platform:<room_id>""` — resolve the ID via `chatto_list_rooms`). A `deliver` target without a `:thread_id` segment lands in the channel's main chat.
+- Pattern: prompt says "post X to room Y via chatto_send_room, mention @user, then answer with a short confirmation" — `deliver` stays `origin` or points at a separate admin channel.
+- Test any redesigned flow with `cronjob_manage action='run'` before the next scheduled fire.
+
+## Plugin tool contract (ctx.register_tool) — for extending the plugin
+
+- **Async handlers need `is_async=True`** — the default is sync; an `async def` handler registered without it returns an unawaited coroutine and the dispatcher fails with `tool_result_contract: unsupported result type: coroutine` (`tools/registry.py: dispatch()`).
+- **Handlers must return JSON strings** (`json.dumps({...})`), never plain dicts — `_normalize_handler_result` accepts only str or the multimodal envelope.
+- **Config access: never read `os.environ` in handlers.** `.env` credentials like `CHATTO_TOKEN` are profile-scoped and NOT visible in cron fires' os.environ, and `hermes_validate_config` never runs in a fresh cron session. Resolve config through `_chatto_config_for_tools()` in adapter.py: (1) module-level `_resolved_chato_config` set by `hermes_validate_config` in a running gateway; (2) empty `PlatformConfig(enabled=True, extra={})` wrapped in `ChattoConfiguration`, re-filled from `build_profile_secret_scope(get_hermes_home())` (`agent.secret_scope`) — re-reads the profile's own `.env`, works in gateway, cron and CLI alike; (3) clean error. Do NOT use `load_gateway_config().platforms[name]` as fallback (returns None in fresh processes) and do NOT leak a `token` parameter into the tool schema.
+- **Reuse `_chatto_tool_client(args, cfg)`** for client construction in new handlers instead of re-inlining token/base_url resolution.
+- **A field resolving to its declared default is a HOLE, not a value**: `base_url` defaults to `chat.chatto.run` — non-empty but wrong when the profile `.env` says otherwise. `ConfigValue.explicit` (commit 8b95ae0) distinguishes user-set values from default leakage; the `.env` override tier checks `explicit`.
+- **Verify in BOTH contexts:** a gateway-session `tool_call` is not enough — also fire a `cronjob_manage action='run'` test when the tool will run from cron, because cron sessions exercise the config-fallback tier gateway sessions never hit. Debug order: `tool_result_contract` error → contract bug (async/return type); `AttributeError` on client → wrong stub/service name; `permission_denied` → server-side limit.
+
+## Chatto API quirks
+
+- **protoc splits consecutive capitals in RPC names**: `StartDM` → stub method `start_d_m` (`_pb/chatto/api/v1/*_connect.py`). Always grep the generated stub for the exact method name — never guess snake_case from the RPC.
+- **Bot tokens have no admin rights**: `admin_get_member` etc. return `permission_denied`. Resolve users by login via `list_room_members(room_id, search=login)` + `batch_get_room_members(room_id, ids)` across the bot's rooms, matching `user.login` case-insensitively.
+- `post_message` lives on the messages service (`client.post_message`), not the rooms stub.
+- **There is no "create thread" RPC** — a thread is created by posting a root message, then posting a reply to the same room with `thread_root_event_id=<root id>`; verify the reply's own `thread_root_event_id` echoes the root.
+- **chattolib client methods are async** (`list_rooms()`, `post_message()` … return coroutines); `list_rooms()` yields `RoomWithViewerState` wrappers — `id`, `name`, `kind` sit on the nested `.room` attribute, NOT top-level.
+
+## Development workflow
+
+1. Edit `adapter.py` or vendored chattolib (`vendor/common/chattolib/`, platform-split via `vendor_path.py`).
+2. Syntax gate: `python3 -m py_compile adapter.py` before every commit.
+3. Commit + push on the feature branch — the plugin directory IS a git repo.
+4. **Gateway restart is REQUIRED after plugin code changes** — the running session keeps the old module in memory. Ask the user to restart, then verify via `tool_call`.
+5. **Before requesting a restart, verify config-dependent fixes in an isolated subprocess** simulating the cron scope: pop the relevant env vars (`CHATTO_TOKEN`, `CHATTO_BASE_URL`), `sys.path.insert` the Hermes kernel and plugin dir, set `HERMES_HOME`, call the handler directly. A green isolated test proves the cron path before burning a restart cycle.
+6. **Importing vendored chattolib standalone: honor the protobuf split.** The vendored `_pb` gencode (7.35.x) needs a protobuf runtime at least that new; Hermes venvs can ship an older one (6.x) → `VersionError: gencode 7.x runtime 6.x`. Fix: fresh subprocess with `vendor/platform/<tag>` PREPENDED to sys.path and `vendor/common` APPENDED (same order as `vendor_path.setup_vendor_path()`). Platform tags: `linux-x86_64`, `linux-aarch64`, `macos-arm64`. Read `CHATTO_TOKEN`/`CHATTO_BASE_URL` from the profile `.env` for standalone tests. In a persistent kernel an already-imported old protobuf stays in `sys.modules` — always go through a fresh subprocess.
+7. **Bundled skills:** register additional skills in `register(ctx)` via `ctx.register_skill(name, path_to_SKILL.md)` — see the `skills/` directory of this plugin.