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
41 changes: 39 additions & 2 deletions cmd/agent_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,53 @@ import (
"encoding/json"
"os"

"github.com/memcode-ai/memcode/internal/agent/input"
"github.com/memcode-ai/memcode/internal/agent/runtime"
"github.com/memcode-ai/memcode/internal/channels"
gwconfig "github.com/memcode-ai/memcode/internal/gateway/config"
)

// jobContext mirrors the envelope the gateway persists at gwconfig.ContextPath —
// the persona's supplemental context plus its extra skill roots. The JSON shape
// is the contract with internal/gateway/server.
// the persona's supplemental context, its extra skill roots, and this task's
// media as spool IDs. The JSON shape is the contract with internal/gateway/server.
type jobContext struct {
Items []runtime.ContextItem `json:"items,omitempty"`
SkillRoots []string `json:"skill_roots,omitempty"`
// Attachments are media spool IDs — bare <sha256>.<ext> filenames, resolved
// STRICTLY inside the gateway media spool (see resolveJobAttachments). Never
// paths: the spool is the trust boundary, so a corrupted context file cannot
// point this job at arbitrary local files.
Attachments []string `json:"attachments,omitempty"`
}

// resolveJobAttachments turns spool IDs into engine attachments. Each ID must
// resolve inside the media spool; anything else — separators, dot-files, a
// missing file, an unsupported kind — is skipped. Audio never reaches the
// engine (the gateway transcribes it before spawning the job).
func resolveJobAttachments(ids []string) []input.Attachment {
if len(ids) == 0 {
return nil
}
spool, err := gwconfig.MediaDir()
if err != nil {
return nil
}
var out []input.Attachment
for _, id := range ids {
path, err := channels.ResolveSpoolID(spool, id)
if err != nil {
continue
}
att, ok := input.Resolve(path, spool, "channel")
if !ok {
continue
}
switch att.Kind {
case input.KindImage, input.KindPDF, input.KindText:
out = append(out, att)
}
}
return out
}

// loadJobContext reads the job context the gateway persisted for this session
Expand Down
42 changes: 40 additions & 2 deletions cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ var gatewaySetupCmd = &cobra.Command{
} else {
cmd.Printf("Configured: %s\n", strings.Join(enabled, ", "))
}
choice := strings.ToLower(strings.TrimSpace(prompt(in, cmd, "Channel to add/update [telegram/discord/slack/github/whatsapp] (blank to finish): ")))
choice := strings.ToLower(strings.TrimSpace(prompt(in, cmd, "Channel to add/update [telegram/discord/slack/email/signal/matrix/mattermost/msteams/googlechat/sms/github/whatsapp] (blank to finish): ")))

secrets := map[string]string{}
if settings.Channels == nil {
Expand Down Expand Up @@ -136,8 +136,46 @@ var gatewaySetupCmd = &cobra.Command{
secrets[gwconfig.EnvWhatsAppVerify] = secret(cmd, "Webhook verify token: ")
secrets[gwconfig.EnvWhatsAppSecret] = secret(cmd, "App secret (verifies inbound; required to activate): ")
ch.AllowFrom = allowList(in, cmd)
case "email":
cmd.Println("Use a DEDICATED mailbox (an app password for Gmail/Outlook), never your personal inbox.")
secrets[gwconfig.EnvEmailAddress] = strings.TrimSpace(prompt(in, cmd, "Email address: "))
secrets[gwconfig.EnvEmailPassword] = secret(cmd, "App password: ")
secrets[gwconfig.EnvEmailIMAPHost] = strings.TrimSpace(prompt(in, cmd, "IMAP host (e.g. imap.gmail.com): "))
secrets[gwconfig.EnvEmailSMTPHost] = strings.TrimSpace(prompt(in, cmd, "SMTP host (e.g. smtp.gmail.com): "))
ch.AllowFrom = allowList(in, cmd)
case "signal":
cmd.Println("Requires a running signal-cli daemon in HTTP mode (see the docs); use a dedicated number.")
secrets[gwconfig.EnvSignalNumber] = strings.TrimSpace(prompt(in, cmd, "Your Signal number (+E.164): "))
if u := strings.TrimSpace(prompt(in, cmd, "signal-cli daemon URL (blank = http://127.0.0.1:8080): ")); u != "" {
secrets[gwconfig.EnvSignalCLIURL] = u
}
ch.AllowFrom = allowList(in, cmd)
case "matrix":
cmd.Println("Plain rooms only for now (no end-to-end-encrypted rooms).")
secrets[gwconfig.EnvMatrixHomeserver] = strings.TrimSpace(prompt(in, cmd, "Homeserver URL (e.g. https://matrix.org): "))
secrets[gwconfig.EnvMatrixToken] = secret(cmd, "Access token: ")
ch.AllowFrom = allowList(in, cmd)
case "mattermost":
secrets[gwconfig.EnvMattermostURL] = strings.TrimSpace(prompt(in, cmd, "Server URL (e.g. https://mm.example.com): "))
secrets[gwconfig.EnvMattermostToken] = secret(cmd, "Bot access token: ")
ch.AllowFrom = allowList(in, cmd)
case "msteams":
secrets[gwconfig.EnvTeamsAppID] = strings.TrimSpace(prompt(in, cmd, "Azure app (bot) ID: "))
secrets[gwconfig.EnvTeamsAppPassword] = secret(cmd, "Client secret: ")
secrets[gwconfig.EnvTeamsTenantID] = strings.TrimSpace(prompt(in, cmd, "Tenant ID: "))
ch.AllowFrom = allowList(in, cmd)
case "googlechat":
secrets[gwconfig.EnvGoogleChatSAKey] = strings.TrimSpace(prompt(in, cmd, "Path to the service-account JSON key: "))
ch.Audience = strings.TrimSpace(prompt(in, cmd, "Project number (JWT audience): "))
ch.AllowFrom = allowList(in, cmd)
case "sms":
secrets[gwconfig.EnvTwilioAccountSID] = strings.TrimSpace(prompt(in, cmd, "Twilio Account SID: "))
secrets[gwconfig.EnvTwilioAuthToken] = secret(cmd, "Auth token: ")
secrets[gwconfig.EnvTwilioFromNumber] = strings.TrimSpace(prompt(in, cmd, "Your Twilio number (+E.164): "))
ch.WebhookURL = strings.TrimSpace(prompt(in, cmd, "Exact public webhook URL (e.g. https://gw.example.com/webhook/sms): "))
ch.AllowFrom = allowList(in, cmd)
default:
cmd.Println("Unknown channel; pick one of telegram/discord/slack/github/whatsapp.")
cmd.Println("Unknown channel; pick one of telegram/discord/slack/email/signal/matrix/mattermost/msteams/googlechat/sms/github/whatsapp.")
continue
}
settings.Channels[choice] = ch
Expand Down
7 changes: 4 additions & 3 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ for local gateway development. Never store keys in .memcode.`,
// Uses the chat seams (which load + save the transcript) instead of Run.
if sessionID, _ := cmd.Flags().GetString("session"); sessionID != "" {
sess.SetSessionID(sessionID)
if jc := loadJobContext(sessionID); len(jc.Items) > 0 || len(jc.SkillRoots) > 0 {
sess.SetContext(jc.Items) // gateway-supplied persona/user context for this run
sess.SetSkillRoots(jc.SkillRoots) // persona's own skills join discovery
if jc := loadJobContext(sessionID); len(jc.Items) > 0 || len(jc.SkillRoots) > 0 || len(jc.Attachments) > 0 {
sess.SetContext(jc.Items) // gateway-supplied persona/user context for this run
sess.SetSkillRoots(jc.SkillRoots) // persona's own skills join discovery
sess.SetTaskAttachments(resolveJobAttachments(jc.Attachments)) // channel media rides this turn
}
if _, err := runtime.ResolveSession(cfg.Root, sessionID); err == nil {
sess.SetResume(sessionID)
Expand Down
54 changes: 45 additions & 9 deletions docs/gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

The same `memcode` binary that runs the interactive agent can run as a
long-lived, self-hosted **gateway**: it listens on the surfaces people already
use (Telegram, Discord, Slack, GitHub, WhatsApp), turns each inbound message
into an agent job, and posts the result back. Coding is one use of this loop,
use (Telegram, Discord, Slack, Email, Signal, Matrix, Mattermost, Microsoft
Teams, Google Chat, SMS, GitHub, WhatsApp), turns each inbound message into an
agent job, and posts the result back. Coding is one use of this loop,
not what it's built around — an inbound message is just a task.

```
Expand All @@ -30,13 +31,31 @@ It routes each answer the way memcode splits configuration:

A channel is enabled when its secret is present.

| Channel | Secret(s) in `.env` | Transport |
|----------|------------------------------------------------------------|-------------------|
| Telegram | `TELEGRAM_BOT_TOKEN` | Bot API long-poll |
| Discord | `DISCORD_BOT_TOKEN` | gateway websocket |
| Slack | `SLACK_APP_TOKEN`, `SLACK_BOT_TOKEN` | Socket Mode |
| GitHub | `GITHUB_WEBHOOK_SECRET` | inbound webhook |
| WhatsApp | `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET` | Meta Cloud API |
| Channel | Secret(s) in `.env` | Transport |
|-------------|------------------------------------------------------------|-------------------|
| Telegram | `TELEGRAM_BOT_TOKEN` | Bot API long-poll |
| Discord | `DISCORD_BOT_TOKEN` | gateway websocket |
| Slack | `SLACK_APP_TOKEN`, `SLACK_BOT_TOKEN` | Socket Mode |
| Email | `EMAIL_ADDRESS`, `EMAIL_PASSWORD`, `EMAIL_IMAP_HOST`, `EMAIL_SMTP_HOST` | IMAP poll + SMTP |
| Signal | `SIGNAL_NUMBER` (+ optional `SIGNAL_CLI_URL`) | signal-cli daemon (SSE + JSON-RPC) |
| Matrix | `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` | client-server /sync (no E2EE v1) |
| Mattermost | `MATTERMOST_URL`, `MATTERMOST_TOKEN` | websocket + REST v4 |
| MS Teams | `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_TENANT_ID` | Bot Framework webhook |
| Google Chat | `GOOGLE_CHAT_SA_KEY` (path) + `googlechat.audience` | signed webhook + Chat REST |
| SMS | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER` + `sms.webhook_url` | Twilio webhook + Messages API |
| GitHub | `GITHUB_WEBHOOK_SECRET` | inbound webhook |
| WhatsApp | `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET` | Meta Cloud API |

Webhook-driven surfaces (Teams, Google Chat, SMS, GitHub, WhatsApp) mount on
the shared listener (`webhook.addr`, default `:8787`) at
`/webhook/{teams,googlechat,sms,github,whatsapp}` — expose it over HTTPS.
Email dedup is keyed on `<mailbox>/<UIDVALIDITY>/<UID>` (the provider-side ack
identity); Message-ID serves threading only. Email's sender identity is the
RFC From address — weaker than the other channels' platform-verified ids, so
its allow-list depends on your mailbox provider rejecting spoofed mail
(SPF/DKIM/DMARC); use a mainstream provider and a dedicated account. Signal requires a signal-cli
daemon in native HTTP mode; Matrix v1 is plain rooms only (E2EE is a known
follow-up).

### gateway.yaml

Expand Down Expand Up @@ -136,6 +155,23 @@ projects, agents, channel knobs) on change, so an approval takes effect within
seconds — no restart. Channel connections and schedules are wired at startup and
do not hot-reload.

## Media and voice

Inbound attachments (photos, PDFs, documents) are downloaded into a
content-addressed media spool (`~/.config/memcode/media`, pruned with the
inbox) and ride the task into the engine as native image/document blocks.
Everything downstream of the adapter addresses media by spool ID, never by
path — the spool is the trust boundary.

Voice notes are transcribed gateway-side (OpenAI `gpt-4o-mini-transcribe`
falling back to `whisper-1`, or Gemini — picked by whichever key is present)
and the transcript becomes the task text; audio never reaches the engine.
Without either key a voice-only message gets an honest "not configured" reply.
Optionally, `channels.<name>.voice_replies: in_kind|always` (default `off`)
synthesizes an OGG/Opus voice reply (OpenAI `gpt-4o-mini-tts` — the full text
is always sent alongside, code blocks are never spoken, synthesis failures
degrade to text).

## Import from OpenClaw

Already running OpenClaw? Bring your channels over with one command:
Expand Down
8 changes: 6 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ require (
github.com/charmbracelet/x/term v0.2.2
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-runewidth v0.0.24
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/openai/openai-go/v3 v3.41.1
Expand All @@ -24,6 +27,7 @@ require (
go.yaml.in/yaml/v4 v4.0.0-rc.2
golang.org/x/image v0.43.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
google.golang.org/genai v1.63.0
Expand All @@ -45,6 +49,8 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2/v2 v2.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logr/logr v1.4.3 // indirect
Expand All @@ -58,7 +64,6 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
Expand All @@ -84,7 +89,6 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/api v0.287.1 // indirect
Expand Down
Loading
Loading