Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d4b87f6
gateway: add docs placeholder for the self-hostable gateway prototype
timothyerwin Aug 13, 2026
be8e5fe
refactor: rename internal/gateway/client -> internal/cloudclient
timothyerwin Aug 13, 2026
24a85b9
gateway: spine + Telegram channel (message -> agent job -> reply)
timothyerwin Aug 13, 2026
8abd669
gateway: one config, not a pile of env vars (tokens in .env, settings…
timothyerwin Aug 14, 2026
698d9b3
gateway: Discord channel adapter
timothyerwin Aug 14, 2026
4e2a478
gateway: Slack channel adapter (Socket Mode)
timothyerwin Aug 14, 2026
f454e3e
gateway: inbound webhook server + GitHub CI-failure trigger
timothyerwin Aug 14, 2026
9a69165
gateway: WhatsApp adapter (Meta Cloud API), inert until verified
timothyerwin Aug 14, 2026
70c6ec4
gateway: document config model and all channels in the README
timothyerwin Aug 14, 2026
a189959
gateway: use platform-conventional env var names, drop MEMCODE_ prefix
timothyerwin Aug 14, 2026
abc681a
gateway: durable idempotent dispatch (no re-run of redelivered messages)
timothyerwin Aug 14, 2026
0e42ac1
gateway: per-channel authorization (default-deny) + channels.<name> c…
timothyerwin Aug 14, 2026
3ca7045
gateway: shared chunker + Telegram transport hardening
timothyerwin Aug 14, 2026
13b0195
gateway: per-conversation ordering + bounded job concurrency
timothyerwin Aug 14, 2026
d4ce406
gateway: authenticate WhatsApp inbound (app-secret HMAC signature)
timothyerwin Aug 14, 2026
1cf2217
gateway: import channels from an OpenClaw config
timothyerwin Aug 14, 2026
7579356
gateway: document the hardened model, config schema, and OpenClaw import
timothyerwin Aug 14, 2026
0beb550
gateway: Slack replies go through the shared chunker
timothyerwin Aug 14, 2026
f0b6486
gateway: authorize on stable user ids, not mutable @handles
timothyerwin Aug 14, 2026
6df93fd
gateway: require a mention in group channels (no jobs on ambient chat…
timothyerwin Aug 14, 2026
e2dcacc
gateway: durable inbox + minimal event-spine logging
timothyerwin Aug 14, 2026
a66554d
gateway: docs match hardened behavior (stable-id auth, mention gating…
timothyerwin Aug 14, 2026
b99b80a
gateway: scheduled (cron) tasks — autonomous, not just reactive
timothyerwin Aug 14, 2026
c9a8f3a
gateway: per-conversation session continuity
timothyerwin Aug 14, 2026
ac617a7
gateway: per-channel model tier routing
timothyerwin Aug 14, 2026
6732169
gateway: install as a background service (launchd/systemd)
timothyerwin Aug 14, 2026
edc97db
gateway: document schedules, stateful conversations, per-channel tier…
timothyerwin Aug 14, 2026
f32b08c
gateway: regression test — Trusted bypass doesn't weaken the chat all…
timothyerwin Aug 14, 2026
7850a35
gateway: import from Hermes too (symmetry with OpenClaw)
timothyerwin Aug 14, 2026
cea632f
gateway: security hardening from the adversarial audit
timothyerwin Aug 14, 2026
9976c33
gateway: make import zero-arg by default (like Hermes's migrate)
timothyerwin Aug 14, 2026
1773f22
runtime: durable memory.md with a global/local split, injected every …
timothyerwin Aug 14, 2026
8cde4da
cli: dedicated 'memcode claw' / 'memcode hermes' full-install migration
timothyerwin Aug 14, 2026
9a78689
cli: extract OpenClaw/Hermes memory into global memory.md
timothyerwin Aug 14, 2026
fe10b63
gateway: durable outbound replies + single-instance project lock
timothyerwin Aug 14, 2026
9baad2d
gateway: quote and escape systemd unit paths
timothyerwin Aug 14, 2026
a036561
importer: fix stale JSON5 comment on the OpenClaw reader
timothyerwin Aug 14, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ One Go binary, a full terminal UI, and it runs against whatever models you alrea
<tr><td><b>A real terminal UI</b></td><td>Multiline editing, slash commands with autocomplete, streaming tool output, interrupt and redirect mid-turn, themes, and a live context meter.</td></tr>
<tr><td><b>Plans before it builds</b></td><td><code>/plan</code> researches with parallel scouts, drafts, gets a cross-model review, and turns the approved plan into a binding contract for execution.</td></tr>
<tr><td><b>Delegates and parallelizes</b></td><td>Spawn read-only explorers or full sub-agents, run detached background jobs, and manage them with <code>/jobs</code>, <code>/tail</code>, <code>/kill</code>.</td></tr>
<tr><td><b>Runs where you chat</b></td><td>The same binary runs as a self-hosted <a href="docs/gateway/README.md">gateway</a>: Telegram, Discord, Slack, GitHub, and WhatsApp messages become agent jobs in your repo. Durable idempotent dispatch, per-channel allow-lists, and one-command import from OpenClaw. Coding is one use of the loop, not what it is built around.</td></tr>
<tr><td><b>Table stakes, done properly</b></td><td>MCP client, Agent Skills, hooks (<a href="HOOKS.md">HOOKS.md</a>), resident LSP for diagnostics and navigation, a sandboxed shell with a real command classifier, vision and PDF input, prompt caching, and context compaction that respects the model's actual window (<a href="COMPACTION.md">COMPACTION.md</a>).</td></tr>
<tr><td><b>Self-updating</b></td><td>Stages updates in the background and applies them on the next launch. <code>MEMCODE_AUTO_UPDATE=off</code> keeps it manual.</td></tr>
</table>
Expand Down
26 changes: 25 additions & 1 deletion cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ for local gateway development. Never store keys in .memcode.`,
mode = permissions.ModeAuto // a backgrounded job can't answer prompts
fmt.Println("note: background jobs run in --auto (can't prompt); pass --allow-all to widen")
}
job, err := jobs.Spawn(cfg.Root, task, string(mode), "", chrome, false)
job, err := jobs.Spawn(cfg.Root, task, string(mode), "", chrome, false, "")
if err != nil {
return err
}
Expand Down Expand Up @@ -136,6 +136,29 @@ for local gateway development. Never store keys in .memcode.`,
case "strong":
sess.SetForceEscalate(true) // strong-tier background agent → strong vendor's balanced tier
}
// --session: a gateway conversation job. Pin the id and resume the prior
// transcript if it exists, so follow-up messages continue the same session.
// Uses the chat seams (which load + save the transcript) instead of Run.
if sessionID, _ := cmd.Flags().GetString("session"); sessionID != "" {
sess.SetSessionID(sessionID)
if _, err := runtime.ResolveSession(cfg.Root, sessionID); err == nil {
sess.SetResume(sessionID)
}
fmt.Printf("memcode job %s · model %s · mode %s · session %s\n", jobID, model, mode, sessionID)
chat := sess.StartChat(ctx)
sess.Submit(ctx, chat, task)
sess.EndChat(ctx)
code := 0
if sess.LastError() != nil {
code = 1
}
result := ""
if rb, _ := cmd.Flags().GetBool("report-back"); rb {
result = sess.LastText()
}
_ = jobs.Finish(cfg.Root, jobID, code, result)
return sess.LastError()
}
fmt.Printf("memcode job %s · model %s · mode %s\n", jobID, model, mode)
_, runErr := sess.Run(ctx, task)
code := 0
Expand Down Expand Up @@ -233,5 +256,6 @@ func init() {
agentCmd.Flags().String("protocol", "", "machine control protocol: stream-json (newline-delimited JSON on stdio, for SDK wrappers)")
agentCmd.Flags().BoolP("continue", "c", false, "resume the most recent session with its full conversation")
agentCmd.Flags().String("resume", "", "resume a session by id or prefix (see `memcode session recent`)")
agentCmd.Flags().String("session", "", "run a --job in this session id, resuming it if it exists (gateway conversation continuity)")
rootCmd.AddCommand(agentCmd)
}
174 changes: 174 additions & 0 deletions cmd/gateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package cmd

import (
"bufio"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/memcode-ai/memcode/internal/authflow"
gwconfig "github.com/memcode-ai/memcode/internal/gateway/config"
gwserver "github.com/memcode-ai/memcode/internal/gateway/server"
"github.com/memcode-ai/memcode/internal/provider"
)

// gatewayCmd runs memcode as a long-lived gateway: the same binary that runs the
// interactive agent also hosts channel adapters (Telegram/Discord/Slack/GitHub/
// WhatsApp) that turn inbound messages into agent work and post the results back.
// Self-hosted, configured once with `memcode gateway setup` — bot tokens land in
// the global .env, non-secret settings in ~/.config/memcode/gateway.yaml.
var gatewayCmd = &cobra.Command{
Use: "gateway",
Short: "Run memcode as a self-hosted gateway (chat channels → agent → reply)",
Long: `Run memcode as a long-lived gateway.

Channels are configured once with 'memcode gateway setup'. Bot tokens are stored
in the global .env; non-secret settings in ~/.config/memcode/gateway.yaml. Each
inbound message runs as a detached agent job in the current project and the
result is posted back to the channel it came from. Runs until interrupted.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
provider.LoadDotEnv() // pull bot tokens from the global .env into the environment
settings, err := gwconfig.Load()
if err != nil {
return err
}
if len(gwconfig.EnabledChannels()) == 0 {
return fmt.Errorf("no channels configured — run `memcode gateway setup` first")
}
st, cfg, err := openProject(ctx)
if err != nil {
return err
}
defer st.Close()

cmd.Printf("memcode gateway — %s (channels: %s)\n", cfg.Root, strings.Join(gwconfig.EnabledChannels(), ", "))
return gwserver.Run(ctx, cfg.Root, st, settings, cmd.OutOrStdout())
},
}

// gatewaySetupCmd is the interactive wizard that replaces hand-setting a pile of
// environment variables. It routes each answer the way memcode (and Hermes)
// split config: bot tokens go to the global .env, non-secret knobs to
// gateway.yaml.
var gatewaySetupCmd = &cobra.Command{
Use: "setup",
Short: "Configure gateway channels (Telegram/Discord/Slack/GitHub/WhatsApp)",
RunE: func(cmd *cobra.Command, args []string) error {
provider.LoadDotEnv()
settings, err := gwconfig.Load()
if err != nil {
return err
}
in := bufio.NewReader(os.Stdin)

for {
if enabled := gwconfig.EnabledChannels(); len(enabled) == 0 {
cmd.Println("No channels configured yet.")
} 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): ")))

secrets := map[string]string{}
if settings.Channels == nil {
settings.Channels = map[string]gwconfig.Channel{}
}
switch choice {
case "":
p, _ := gwconfig.Path()
cmd.Printf("Done. Tokens in the global .env; settings in %s\n", p)
return nil
case "telegram":
secrets[gwconfig.EnvTelegramToken] = secret(cmd, "Bot token (from @BotFather): ")
settings.Channels["telegram"] = gwconfig.Channel{AllowFrom: allowList(in, cmd)}
case "discord":
secrets[gwconfig.EnvDiscordToken] = secret(cmd, "Bot token (Discord developer portal): ")
settings.Channels["discord"] = gwconfig.Channel{AllowFrom: allowList(in, cmd)}
case "slack":
secrets[gwconfig.EnvSlackAppToken] = secret(cmd, "App-level token (xapp-…): ")
secrets[gwconfig.EnvSlackBotToken] = secret(cmd, "Bot token (xoxb-…): ")
settings.Channels["slack"] = gwconfig.Channel{AllowFrom: allowList(in, cmd)}
case "github":
secrets[gwconfig.EnvGitHubSecret] = secret(cmd, "Webhook secret: ")
// GitHub deliveries are HMAC-authenticated, so no allow-list here.
settings.Channels["github"] = gwconfig.Channel{
ReplyTo: strings.TrimSpace(prompt(in, cmd, "Route results to (e.g. telegram:123456, blank for none): ")),
}
case "whatsapp":
cmd.Println("Note: WhatsApp stays inactive until your Meta business is verified.")
cmd.Println("Once verified, set `whatsapp.active: true` in gateway.yaml to enable it.")
wa := gwconfig.Channel{
PhoneNumberID: strings.TrimSpace(prompt(in, cmd, "Phone number ID: ")),
}
secrets[gwconfig.EnvWhatsAppToken] = secret(cmd, "Access token: ")
secrets[gwconfig.EnvWhatsAppVerify] = secret(cmd, "Webhook verify token: ")
secrets[gwconfig.EnvWhatsAppSecret] = secret(cmd, "App secret (verifies inbound; required to activate): ")
wa.AllowFrom = allowList(in, cmd)
settings.Channels["whatsapp"] = wa
default:
cmd.Println("Unknown channel; pick one of telegram/discord/slack/github/whatsapp.")
continue
}

if len(secrets) > 0 {
if err := authflow.SetGlobalEnv(secrets); err != nil {
return err
}
// Reflect the just-written tokens so EnabledChannels sees them this loop.
for k, v := range secrets {
_ = os.Setenv(k, v)
}
}
if err := gwconfig.Save(settings); err != nil {
return err
}
cmd.Printf("Saved %s.\n", choice)
}
},
}

// allowList prompts for the principals allowed to drive the agent through this
// channel. The gateway is default-deny, so an empty answer means no one can use
// the channel yet; "*" allows anyone who can reach it.
func allowList(in *bufio.Reader, cmd *cobra.Command) []string {
raw := prompt(in, cmd, "Allowed users — comma-separated stable user ids (not @handles), or * for anyone (blank = no one yet): ")
var out []string
for _, p := range strings.Split(raw, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

// prompt writes a prompt and reads one line.
func prompt(in *bufio.Reader, cmd *cobra.Command, label string) string {
cmd.Print(label)
line, _ := in.ReadString('\n')
return strings.TrimRight(line, "\r\n")
}

// secret reads a value without echoing it when stdin is a terminal, falling back
// to a plain read when it isn't (piped input).
func secret(cmd *cobra.Command, label string) string {
cmd.Print(label)
fd := int(os.Stdin.Fd())
if term.IsTerminal(fd) {
b, err := term.ReadPassword(fd)
cmd.Println()
if err == nil {
return strings.TrimSpace(string(b))
}
}
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
return strings.TrimSpace(line)
}

func init() {
gatewayCmd.AddCommand(gatewaySetupCmd)
rootCmd.AddCommand(gatewayCmd)
}
161 changes: 161 additions & 0 deletions cmd/gateway_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/spf13/cobra"

"github.com/memcode-ai/memcode/internal/atomicfile"
)

// gatewayInstallCmd installs the gateway as a managed background service so it
// survives logout/reboot instead of living in a foreground terminal. It writes an
// OS-native unit (launchd on macOS, systemd --user on Linux) that runs
// `memcode gateway` in the current project, then prints how to start it.
var gatewayInstallCmd = &cobra.Command{
Use: "install",
Short: "Install the gateway as a background service (launchd/systemd)",
RunE: func(cmd *cobra.Command, args []string) error {
bin, err := os.Executable()
if err != nil {
return fmt.Errorf("locating memcode binary: %w", err)
}
workDir, err := os.Getwd()
if err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
path, content, start, err := gatewayUnit(runtime.GOOS, home, bin, workDir)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := atomicfile.WriteFile(path, []byte(content), 0o644); err != nil {
return err
}
cmd.Printf("Installed gateway service for %s\n", workDir)
cmd.Printf("Unit: %s\n", path)
cmd.Printf("Start it with:\n %s\n", start)
return nil
},
}

// gatewayUninstallCmd removes the service unit. It doesn't stop a running service
// (the user unloads it with the printed command); it just deletes the unit.
var gatewayUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Remove the installed gateway service unit",
RunE: func(cmd *cobra.Command, args []string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
path, _, _, err := gatewayUnit(runtime.GOOS, home, "", "")
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
cmd.Printf("Removed %s\n", path)
switch runtime.GOOS {
case "darwin":
cmd.Printf("Stop it (if running) with:\n launchctl unload %s\n", path)
case "linux":
cmd.Printf("Stop it (if running) with:\n systemctl --user disable --now memcode-gateway\n")
}
return nil
},
}

// hasControlChars rejects paths carrying newlines/NULs, which could inject extra
// unit directives (a systemd ExecStart= line, a plist element) via the binary or
// working-directory path.
func hasControlChars(s string) bool { return strings.ContainsAny(s, "\n\r\x00") }

// xmlEscape escapes a value for safe interpolation into the plist XML.
var xmlEscape = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;").Replace

// systemdQuote renders a path as a single double-quoted systemd argument,
// escaping the % specifier introducer. The caller has already rejected paths
// containing a double quote, so the value inside the quotes is safe.
func systemdQuote(s string) string {
return `"` + strings.ReplaceAll(s, "%", "%%") + `"`
}

// gatewayUnit builds the service unit for goos: its file path, contents, and the
// command to start it. bin/workDir may be empty when only the path is needed
// (uninstall). Returns an error for an unsupported OS or a path with control
// characters.
func gatewayUnit(goos, home, bin, workDir string) (path, content, start string, err error) {
if hasControlChars(bin) || hasControlChars(workDir) {
return "", "", "", fmt.Errorf("binary or working-directory path contains control characters; refusing to write a service unit")
}
switch goos {
case "darwin":
bin, workDir := xmlEscape(bin), xmlEscape(workDir)
path = filepath.Join(home, "Library", "LaunchAgents", "ai.memcode.gateway.plist")
content = fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>ai.memcode.gateway</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>gateway</string>
</array>
<key>WorkingDirectory</key><string>%s</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>%s/.memcode/gateway.log</string>
<key>StandardErrorPath</key><string>%s/.memcode/gateway.log</string>
</dict>
</plist>
`, bin, workDir, workDir, workDir)
start = "launchctl load " + path
return path, content, start, nil
case "linux":
// systemd parses ExecStart with its own quoting rules and treats % as a
// specifier introducer, so a path with a space or a % would corrupt the
// unit. Quote both paths and double any % (systemd's escape for a literal
// percent). A literal double-quote in a path can't be represented safely
// here, so reject it rather than emit a broken unit.
if strings.ContainsAny(bin, "\"") || strings.ContainsAny(workDir, "\"") {
return "", "", "", fmt.Errorf("binary or working-directory path contains a double quote; refusing to write a systemd unit")
}
qBin, qWork := systemdQuote(bin), systemdQuote(workDir)
path = filepath.Join(home, ".config", "systemd", "user", "memcode-gateway.service")
content = fmt.Sprintf(`[Unit]
Description=memcode gateway
After=network-online.target

[Service]
ExecStart=%s gateway
WorkingDirectory=%s
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
`, qBin, qWork)
start = "systemctl --user daemon-reload && systemctl --user enable --now memcode-gateway"
return path, content, start, nil
default:
return "", "", "", fmt.Errorf("gateway install is not supported on %s (run `memcode gateway` directly)", goos)
}
}

func init() {
gatewayCmd.AddCommand(gatewayInstallCmd)
gatewayCmd.AddCommand(gatewayUninstallCmd)
}
Loading
Loading