OpenClaw + Hermes Support - #2
Merged
Merged
Conversation
The package is the hosted Cloud API side-channel client (websearch/webfetch/ advisor/BYOK over /v1/*), not a gateway runtime. Reserve internal/gateway/ for the real self-hostable gateway. Updates the two importers (provider/byok.go, provider/wire.go) and the guard-test path assertion; no behavior change, no new dependencies.
The same memcode binary now runs as a long-lived 'memcode gateway': a pluggable channel adapter interface (internal/channels), a Telegram adapter (hand-rolled Bot API long-poll, no SDK), and a runtime/router (internal/gateway/server) that turns each inbound message into a detached agent job (reusing internal/jobs — crash-isolated subprocess) and posts the result back. Self-hosted: the bot token lives in the global .env (MEMCODE_TELEGRAM_BOT_TOKEN). Command hidden from --help while WIP. Proves the external-surface -> objective/agent-spine loop with the simplest channel; Discord/Slack/GitHub/WhatsApp follow on the same interface.
… in gateway.yaml) Configure channels with 'memcode gateway setup': an interactive wizard that routes each answer the way memcode already splits config — bot tokens to the global .env, non-secret knobs to ~/.config/memcode/gateway.yaml. A channel is enabled when its secret is present. Replaces hand-setting MEMCODE_*_BOT_TOKEN env vars by hand.
Real-time gateway websocket via bwmarrin/discordgo (isolated to this adapter, enforced by the vendor-SDK guard test). Skips our own + other bots' messages, splits replies to Discord's 2000-char limit on newline boundaries. Enabled by MEMCODE_DISCORD_BOT_TOKEN in the global .env.
Outbound websocket via slack-go — no public inbound URL needed. Skips bot messages (including our own replies) and message subtypes, acks each request promptly. Enabled by MEMCODE_SLACK_APP_TOKEN + MEMCODE_SLACK_BOT_TOKEN in the global .env. SDK isolated to this adapter (vendor-SDK guard).
GitHub is an event source, not a chat channel: an HMAC-SHA256-verified webhook receiver that turns a failed workflow_run into an agent task and routes the result to a configured chat conversation (github.reply_to, e.g. telegram:123456). Deliveries are de-duplicated on X-GitHub-Delivery; memcode's own bot and memcode/* branches are ignored so a fix run can't trigger itself. The gateway now serves webhooks on :8787 (configurable) alongside the chat channels.
Webhook-in (GET verification handshake + POSTed messages), Graph-API-out for replies. Decouples reply routing from Start-driven channels via a small replySender interface, so WhatsApp registers its Send without pretending to be a long-poll channel. Stays inert until whatsapp.active is set in gateway.yaml — Meta business verification is an external account state, so activation is a manual switch. Tokens in the global .env, phone number id in gateway.yaml.
Bot tokens are third-party platform credentials, not memcode infra — so they take each platform's own conventional variable name (TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, SLACK_APP_TOKEN/SLACK_BOT_TOKEN, GITHUB_WEBHOOK_SECRET, WHATSAPP_*), matching what every platform's docs and both major self-hosted gateways (Hermes, OpenClaw) use. Users can paste values straight from platform docs, and a config exported from another gateway drops in unchanged. Only memcode's own infra keeps the MEMCODE_ prefix.
The #1 failure mode in both Hermes and OpenClaw: at-least-once inbound + in-memory dedup means a restart, reconnect, or provider retry re-runs an old message as a fresh (paid) agent turn. Fix it with a stable per-message identity carried on every Inbound (Telegram update_id, Discord message id, Slack event ts, GitHub delivery, WhatsApp wamid) and a dedicated SQLite state store: MarkProcessed is an atomic INSERT OR IGNORE, so it both survives restarts and guards two concurrent deliveries of the same id. Records prune after 30 days. Kept separate from the core event store so the spine's interface stays clean.
…onfig The gateway ran arbitrary agent tasks in Auto mode for anyone who could message the bot. Both Hermes and OpenClaw gate every channel with an allow-list; so do we now. Config is restructured to a channels.<name> object (matching both, and the import target) holding each channel's settings plus allow_from. The router drops any chat message whose principal isn't allow-listed — default-deny, with a global allow_all escape hatch and a per-inbound Trusted bypass for signature-verified webhooks (GitHub). The setup wizard captures allowed principals per channel.
One shared channels.Chunk (loss-free, newline-preferring) replaces Discord's private copy — both Hermes and OpenClaw grew message-too-long bugs exactly where a side path bypassed the shared splitter. Telegram now: chunks to its 4096 limit; persists the getUpdates offset in the state store so a restart resumes at the last ack instead of replaying (and re-running) the backlog; backs off with exponential + jittered delay capped at 60s so it can't resonate with Telegram's ~30s session TTL; and honors 429 retry_after on send instead of hammering.
Replace the fire-and-forget 'go handle()' per inbound with a dispatcher: each conversation gets a worker that processes its messages one at a time in order (replies can't interleave, a conversation can't double-spend on overlapping turns), while a global semaphore caps concurrent agent jobs so a message flood can't spawn unbounded subprocesses. Different conversations still run in parallel up to the cap.
WhatsApp inbound POSTs were unauthenticated — anyone who knew the URL could inject messages. Meta signs the raw body with the app secret (X-Hub-Signature-256); we now verify it and reject unsigned/forged POSTs, keeping the GET verification handshake as a separate path (it carries no signature). The app secret is a new .env key (WHATSAPP_APP_SECRET), required before the channel will activate. The per-phone allow_list still governs which senders may drive the agent — the signature authenticates the transport, the allow-list authorizes the sender. (GitHub dedup is already durable via the router's state-store dedup.)
`memcode gateway import [openclaw.json]` migrates an existing OpenClaw setup —
maps each supported channel's credentials to memcode's .env keys and its allow
list to channels.<name>.allow_from, merging into the current config. Resolves
OpenClaw's SecretInput forms (literal, $ENV shorthand, {source:env} ref via the
environment); anything it can't carry (external secret providers, unset env refs,
unsupported channels like Signal/iMessage, WhatsApp's non-transferable creds) is
reported as a note, never silently dropped. Finds the config at OpenClaw's own
default locations when no path is given. Uses stdlib JSON (OpenClaw writes plain
JSON) — no JSON5 dependency, which would have dragged a JS VM into the CLI.
Rewrite the gateway README for the channels.<name> config, default-deny allow-lists, the reliability invariants (idempotent dispatch, per-conversation ordering, durable offset, reconnect backoff, one egress, authenticated webhooks), and `gateway import`. Surface the gateway in the main README.
Slack was posting full reply text directly while the docs claimed all outbound goes through one length-aware chunker. Route Slack sends through channels.Chunk like Telegram and Discord, splitting under Slack's message limit.
Telegram and Discord emitted the @username as the principal when present, so a user who allow-listed their numeric id was rejected once they had a username, and a mutable handle is a weaker key anyway. Emit the stable numeric id (Telegram user id, Discord snowflake) as the principal; Slack and WhatsApp already used stable ids. The setup wizard now teaches allow-listing ids.
…ter) An allow-listed user chatting normally in a Discord/Slack/Telegram channel spawned a paid agent job for every message. Match the established convention (Hermes and OpenClaw both default require-mention in groups on all three): a direct message always triggers, but in a group the bot acts only when addressed. Each adapter learns its own identity (Telegram getMe, Discord State.User, Slack auth.test) and detects addressing structurally — Telegram message entities + reply-to-bot (UTF-16-correct offsets), Discord mentions array + reply-to-bot, Slack <@botId>. The router drops a non-direct, non-mentioned message unless channels.<name>. respond_to_all is set. WhatsApp Cloud messages are 1:1 so they count as direct. Identity-fetch failure degrades safe: DMs still work, group mentions just won't match.
Make dispatch actually durable: an adapter now hands each message to a Sink that records it in a durable inbox BEFORE the provider is acked (Telegram advances its offset, Slack acks the socket, GitHub/WhatsApp return 2xx only after the row is written). A worker drains the inbox — replaying anything a crash left pending — and runs each as an agent job, marking it done only after the job COMPLETES. So a redelivery or restart never re-runs a finished job (the inbox (channel,message_id) key is the dedup), and a crash can at worst re-run an interrupted one: at-least-once, not the lost-or-duplicated delivery Codex flagged. Gateway activity is logged to the MAIN event store — gateway_message_received / job_spawned / result_posted / message_dropped / unauthorized, with channel, conversation, principal_id, message_id, job_id — so it's visible to memcode. Per the objective doctrine, an inbound chat message is NOT turned into an objective. Replaces the in-memory dedup + channel with the inbox; the dispatcher is now a generic per-key serial executor.
…, durable inbox) Document the two-check gate (default-deny on stable ids + require-mention in groups with respond_to_all to relax), and correct the reliability section to the durable-inbox / at-least-once model and the main-event-log visibility. Note the chunker covers the chat channels.
A schedules: list in gateway.yaml runs a task on a cadence (every: "24h" or a cron expression) and posts the result to a chat conversation. Each fire builds a Trusted synthetic message and calls the same Deliver the channels use, so it flows through the durable inbox → worker → reply spine unchanged — the scheduler is just another producer of inbox rows. Uses robfig/cron (stdlib-only). This is the parity gap that turns the gateway from purely reactive into autonomous.
A conversation now keeps context across messages instead of a fresh stateless subprocess each time. The gateway derives a deterministic session id per (channel, conversation) and passes it to the job; the --job child pins it and resumes the prior transcript if it exists (resume-or-create via the chat seams), so follow-ups continue the same session. No mapping is stored — the id is derived. Runtime change is minimal and behavior-preserving: a caller can pin a session id (SetSessionID), which StartChat honors verbatim; every existing caller still mints fresh (a leftover sessionID between two chats on one Session is unaffected). jobs.Spawn gains a session arg (empty for TUI/background jobs).
channels.<name>.tier routes a channel's agent runs to a stronger model: "strong" or "frontier", empty stays automatic (cheap for routine work). A code-review channel can run strong while a status channel stays cheap. Reuses the existing --tier force-escalation path through jobs.Spawn; no new signature. A per-channel specific-model pin can follow once SetModel is confirmed to force through automatic routing.
`memcode gateway install` writes an OS-native service unit that runs the gateway in the current project — a launchd LaunchAgent on macOS, a systemd --user unit on Linux — and prints the one command to start it. `gateway uninstall` removes it. It writes the unit but doesn't run launchctl/systemctl itself, since activating a system service is the operator's call. Unit generation is a pure, tested function.
…, and the service installer
timothyerwin
marked this pull request as ready for review
August 14, 2026 10:40
…ow-list Locks the security boundary Kimi probed: an untrusted chat message from an unlisted principal is still dropped, while a Trusted producer (schedule or signature-verified webhook) enqueues by design. Trusted is set only by the HMAC GitHub trigger and fireSchedule (operator config); no chat adapter sets it.
`gateway import` now auto-detects the format: OpenClaw JSON (top-level channels) or Hermes YAML (platforms). Hermes tokens live in ~/.hermes/.env under the same variable names memcode uses, so they carry over directly; allowed_users becomes channels.<name>.allow_from. With no path it checks OpenClaw's default location then Hermes's. Also fixes anyToString to handle YAML integer ids (were dropped).
The audit's headline: the gateway already fails safe — a ModeAuto job has no approver, so the permission gate denies every dangerous/catastrophic command (chat cannot become RCE). These fixes close the residual issues it found: - WARN loudly at startup on open surfaces (allow_all, allow_from "*", respond_to_all) — an autonomous agent for un-allow-listed senders should be a deliberate choice, not a silent one - Importers strip "*" from an allow-list rather than silently inheriting an open channel from OpenClaw/Hermes (reported as a note) - gateway.yaml written 0600 (the allow-list of ids is sensitive on a shared host) - Reject control chars in the service-unit binary/workdir path and XML-escape the plist, closing unit-file directive injection - Session id derivation widened to 128 bits (headroom; it grants nothing anyway)
The path was already optional (import auto-detects OpenClaw's and Hermes's default locations), but advertising it in the usage made it look required. Drop it from Use, and lead the help with 'just run it'. A path is now an escape hatch for a non-standard config location, not the front door.
…turn Instructions (MEMCODE.md) already split user-wide vs project. Memory did not exist as a first-class store at all — learned facts were per-project only, so nothing the agent knew about the user travelled between repos. Add memory.md as the facts counterpart to MEMCODE.md's rules: global ~/.memcode/memory.md AND project .memcode/memory.md, additive (a global fact travels across projects, a project fact stays put), loaded once per session and injected every turn alongside instructions. Framed as background knowledge, never as instructions to act on, so a memory can never steer the agent. The global file is the destination for memories migrated in from another assistant.
Replace the single auto-detecting 'gateway import' with a source-specific
command per assistant. Guessing between two installs was the bug: a user who
moved OpenClaw -> Hermes still has ~/.openclaw around, and auto-detect would
silently import the stale one. Naming the source removes the ambiguity.
Each migrates the full install, not just channels:
- channels -> gateway.yaml + global .env (as before)
- API keys -> provider keys copied to the global .env (same names)
- skills -> SKILL.md dirs copied to ~/.memcode/skills, conflicts kept,
flagged as third-party code to review
- memory -> conversation/history store preserved under
~/.memcode/imported/<source>/, with an idempotent pointer
written into global memory.md
memcode's memory is per-repository, so an assistant's global memory has no
native destination; it is preserved and pointed at rather than dropped or
force-parsed into a store where it does not belong.
The previous cut preserved the memory store as a file and left a pointer.
That is not how OpenClaw and Hermes behave: their memory is global and
actually loaded. So do it properly.
Their memory is markdown, not an opaque DB, so it can be read and used:
- OpenClaw: workspace MEMORY.md, USER.md, SOUL.md, AGENTS.md, and the
daily memory/*.md files, searched across the configured workspace and
OpenClaw's renamed variants (workspace-main, workspace-assistant).
- Hermes: ~/.hermes/memories/*.md, entries delimited by bare § lines.
Port Hermes's own extractor (openclaw_to_hermes.py extract_markdown_entries):
each bullet and paragraph becomes an entry prefixed with its heading context,
fenced code blocks and table rows are dropped, entries are deduped by
whitespace-normalized text and capped to a 20k budget. The result is written
into global ~/.memcode/memory.md as a bounded, marker-delimited block that a
re-run replaces in place, so migrated memory is loaded into every session the
way it was in the source assistant.
Two reliability gaps the durability language was writing checks the code didn't cash (external review): Reply loss: process() marked an item done before sending the reply and never retried a failed send, so a completed job's result could vanish on a transient channel error. Split the flow: a finished job moves pending -> replied with its result stored, so it is never re-run; delivery is a separate step that retries in-process and, if the channel is still down, stays queued and replays after a restart. New 'replied' inbox state + PendingReplies outbound queue. Double-processing: the only guard against running an item twice was an in-memory map, so a second 'memcode gateway' on the same repo would double every job. Take an exclusive flock on the project at startup; a second gateway is refused with a clear message. No-op on platforms without flock (the service installer is macOS/Linux only).
The Linux service unit interpolated the binary and working-directory paths raw, so a path with a space split into two ExecStart arguments and a literal % was read as a systemd specifier. Quote both paths and double %; reject a path containing a double quote rather than emit a broken unit. macOS already XML-escaped its plist values.
The package comment said it read OpenClaw's JSON5, but it parses with encoding/json — which is correct, since OpenClaw writes plain JSON and Hermes's own importer reads openclaw.json the same way. Correct the comment to match.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Self-hostable memcode gateway, plus one-command migration from OpenClaw and Hermes.
Direction: the same
memcodebinary doubles as a long-lived gateway (memcode gateway) — a generic event → objective → action runtime whose first external surface is a chat channel. It listens on the surfaces you already use, turns each inbound message into an agent job, and posts the result back. Coding is one use case, not the thing it's built around; an inbound message is just a task. Hosted Memcode Cloud gateway = managed convenience; self-hosted gateway = trust / privacy / OSS credibility.What's in it
Gateway
event → authorize → dedup → durable inbox → agent job → reply, each job a crash-isolated subprocess so one bad run can't wedge the others.tierrouting; scheduled (cron) tasks; per-conversation session continuity.gateway.yamlfor settings, secrets in the global.envunder each platform's conventional variable name. Runs foreground or as an installed launchd / systemd service.Migration — dedicated
memcode claw(OpenClaw) andmemcode hermes(Hermes), no auto-detect (naming the source avoids importing a stale install). Each moves the full install: channels, provider API keys, skills, and long-term memory.Memory — a global/local split (
~/.memcode/memory.mdplus per-repo.memcode/memory.md), loaded into every session. Migrated memory is read from the source's markdown stores into the global file, so it is actually loaded and used, the way it was in the source assistant, not parked in an unread file.Deliberately NOT in the first slice
22 messenger adapters, WhatsApp/Signal beyond the scaffold, a visual workflow builder, "automate anything" positioning.
Status
Implemented and tested:
gofmt/go vet/go test ./...green across the branch (~60 files, ~5,500 lines). Docs live under/docs/cli/gatewayand/docs/cli/memory. Ready to merge.