Skip to content

Commit 27a29ea

Browse files
committed
gateway: schedules run as a persona; MCP + SOUL.md migrate; cron timezones
Closing the gaps a config-surface audit of OpenClaw and Hermes surfaced: - schedules gain agent: (--agent) — the job runs as that persona, bringing its instructions, memory, and pinned model; this is memcode's answer to both products' per-job model pin, routed through the persona - schedules gain tz: (--tz) — cron evaluated in a named zone (CRON_TZ); OpenClaw job timezones now carry through migration - MCP servers migrate: Hermes mcp_servers and OpenClaw mcp.servers map to the user-scope .mcp.json (same command/args/env/url shape); cwd and tool include/exclude filters become notes, never silent drops - Hermes SOUL.md (agent identity, at the install root, previously missed) folds into global memory alongside memories/*
1 parent 27290b7 commit 27a29ea

11 files changed

Lines changed: 236 additions & 22 deletions

File tree

cmd/admin_tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ func adminSchedule(input json.RawMessage) (string, error) {
474474
At string `json:"at"`
475475
Task string `json:"task"`
476476
DeliverTo string `json:"deliver_to"`
477+
Agent string `json:"agent"`
477478
}
478479
if err := json.Unmarshal(input, &in); err != nil {
479480
return "", err

cmd/gateway_schedule.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ var (
4141
scheduleEvery string
4242
scheduleAt string
4343
scheduleTo string
44+
scheduleTZ string
45+
scheduleAgent string
4446
)
4547

4648
// parseAt accepts the ways people naturally write a one-shot time — a duration
@@ -137,7 +139,8 @@ Examples:
137139
}
138140
}
139141
settings.Schedules = append(settings.Schedules, gwconfig.Schedule{
140-
Name: name, Cron: scheduleCron, Every: scheduleEvery, At: at, Task: task, DeliverTo: to,
142+
Name: name, Cron: scheduleCron, Every: scheduleEvery, At: at, TZ: scheduleTZ,
143+
Task: task, DeliverTo: to, Agent: strings.TrimSpace(scheduleAgent),
141144
})
142145
if err := gwconfig.Save(settings); err != nil {
143146
return err
@@ -171,6 +174,12 @@ var gatewayScheduleShowCmd = &cobra.Command{
171174
cmd.Printf("at: %s (one-shot)\n", sc.At)
172175
}
173176
cmd.Printf("deliver_to: %s\n", sc.DeliverTo)
177+
if sc.Agent != "" {
178+
cmd.Printf("agent: %s\n", sc.Agent)
179+
}
180+
if sc.TZ != "" {
181+
cmd.Printf("tz: %s\n", sc.TZ)
182+
}
174183
cmd.Printf("task: %s\n", sc.Task)
175184
if sc.Disabled {
176185
cmd.Println("state: disabled")
@@ -206,6 +215,12 @@ var gatewayScheduleEditCmd = &cobra.Command{
206215
}
207216
sc.DeliverTo = scheduleTo
208217
}
218+
if scheduleTZ != "" {
219+
sc.TZ = scheduleTZ
220+
}
221+
if scheduleAgent != "" {
222+
sc.Agent = strings.TrimSpace(scheduleAgent)
223+
}
209224
if len(args) > 1 {
210225
sc.Task = strings.TrimSpace(strings.Join(args[1:], " "))
211226
}
@@ -372,6 +387,8 @@ func scheduleSpecFlags(c *cobra.Command) {
372387
c.Flags().StringVar(&scheduleEvery, "every", "", "interval as a Go duration, e.g. 30m or 24h")
373388
c.Flags().StringVar(&scheduleAt, "at", "", "one-shot: a duration from now (30m) or a date-time (2026-03-01T09:00)")
374389
c.Flags().StringVar(&scheduleTo, "to", "", "where the result is delivered: \"channel:conversation\"")
390+
c.Flags().StringVar(&scheduleTZ, "tz", "", "evaluate --cron in this zone, e.g. America/Los_Angeles (default: local)")
391+
c.Flags().StringVar(&scheduleAgent, "agent", "", "run as this persona (its pinned model and instructions apply)")
375392
}
376393

377394
func init() {

cmd/migrate.go

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/memcode-ai/memcode/internal/authflow"
1717
gwconfig "github.com/memcode-ai/memcode/internal/gateway/config"
1818
"github.com/memcode-ai/memcode/internal/gateway/importer"
19+
"github.com/memcode-ai/memcode/internal/mcp"
1920
"github.com/memcode-ai/memcode/internal/provider"
2021
)
2122

@@ -59,12 +60,13 @@ directory:
5960
return fmt.Errorf("no OpenClaw install found (looked in %s)", strings.Join(searched, ", "))
6061
}
6162
return runMigration(cmd, migrationSource{
62-
display: "OpenClaw",
63-
slug: "openclaw",
64-
dir: dir,
65-
channels: openClawChannels,
66-
memory: openClawMemory,
67-
schedules: importer.ImportOpenClawSchedules,
63+
display: "OpenClaw",
64+
slug: "openclaw",
65+
dir: dir,
66+
channels: openClawChannels,
67+
memory: openClawMemory,
68+
schedules: importer.ImportOpenClawSchedules,
69+
mcpServers: openClawMCP,
6870
})
6971
},
7072
}
@@ -97,12 +99,13 @@ directory:
9799
return fmt.Errorf("no Hermes install found (looked in %s)", filepath.Join(home, ".hermes"))
98100
}
99101
return runMigration(cmd, migrationSource{
100-
display: "Hermes",
101-
slug: "hermes",
102-
dir: dir,
103-
channels: hermesChannels,
104-
memory: hermesMemory,
105-
schedules: importer.ImportHermesSchedules,
102+
display: "Hermes",
103+
slug: "hermes",
104+
dir: dir,
105+
channels: hermesChannels,
106+
memory: hermesMemory,
107+
schedules: importer.ImportHermesSchedules,
108+
mcpServers: hermesMCP,
106109
})
107110
},
108111
}
@@ -119,6 +122,8 @@ type migrationSource struct {
119122
// schedules reads the source's cron jobs into memcode schedules plus notes
120123
// for anything that couldn't be carried.
121124
schedules func(dir string) ([]gwconfig.Schedule, []string)
125+
// mcpServers reads the source's MCP server config into memcode's shape.
126+
mcpServers func(dir string) (map[string]mcp.ServerConfig, []string)
122127
}
123128

124129
// runMigration performs the full migration for a source: channels, provider API
@@ -196,7 +201,26 @@ func runMigration(cmd *cobra.Command, src migrationSource) error {
196201
skills, skillNotes := copySkills(filepath.Join(src.dir, "skills"))
197202
res.Notes = append(res.Notes, skillNotes...)
198203

199-
// 4. Memory → extracted from the source's markdown stores into global memory.md.
204+
// 4. MCP servers → user scope (~/.memcode/mcp.json), shared by all projects.
205+
var mcpCount int
206+
if src.mcpServers != nil {
207+
servers, mcpNotes := src.mcpServers(src.dir)
208+
res.Notes = append(res.Notes, mcpNotes...)
209+
existing := mcp.UserServers()
210+
for name, sc := range servers {
211+
if _, ok := existing[name]; ok {
212+
res.Notes = append(res.Notes, fmt.Sprintf("mcp: server %q already configured — kept yours, skipped the import", name))
213+
continue
214+
}
215+
if err := mcp.AddServer("", mcp.ScopeUser, name, sc); err != nil {
216+
res.Notes = append(res.Notes, fmt.Sprintf("mcp: server %q could not be written: %v", name, err))
217+
continue
218+
}
219+
mcpCount++
220+
}
221+
}
222+
223+
// 5. Memory → extracted from the source's markdown stores into global memory.md.
200224
memCount, err := migrateMemories(src)
201225
if err != nil {
202226
return err
@@ -212,6 +236,9 @@ func runMigration(cmd *cobra.Command, src migrationSource) error {
212236
cmd.Printf(" API keys: %d provider key(s) → global .env\n", len(keys))
213237
cmd.Printf(" secrets: %d credential(s) written\n", len(res.Secrets))
214238
cmd.Printf(" skills: %d imported → ~/.memcode/skills\n", len(skills))
239+
if mcpCount > 0 {
240+
cmd.Printf(" mcp: %d server(s) → ~/.memcode/mcp.json (user scope, all projects)\n", mcpCount)
241+
}
215242
if memCount > 0 {
216243
cmd.Printf(" memory: %d entries → ~/.memcode/memory.md (global, loaded every session)\n", memCount)
217244
}
@@ -249,6 +276,23 @@ func hermesChannels(dir string, env map[string]string) (importer.Result, error)
249276
return importer.FromHermes(data, env)
250277
}
251278

279+
// openClawMCP / hermesMCP read each source's MCP server block.
280+
func openClawMCP(dir string) (map[string]mcp.ServerConfig, []string) {
281+
data, err := os.ReadFile(filepath.Join(dir, "openclaw.json"))
282+
if err != nil {
283+
return nil, nil
284+
}
285+
return importer.OpenClawMCPServers(data)
286+
}
287+
288+
func hermesMCP(dir string) (map[string]mcp.ServerConfig, []string) {
289+
data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
290+
if err != nil {
291+
return nil, nil
292+
}
293+
return importer.HermesMCPServers(data)
294+
}
295+
252296
// openClawDir resolves the OpenClaw state directory: an explicit arg, then
253297
// OpenClaw's own default locations (honoring OPENCLAW_STATE_DIR and the legacy
254298
// ~/.clawdbot). Returns the found dir (or "") and the locations searched.
@@ -427,10 +471,15 @@ func openClawMemory(dir string) []string {
427471
// separated by bare § lines. Split on that delimiter rather than re-parsing the
428472
// markdown, matching Hermes's own destination parser.
429473
func hermesMemory(dir string) []string {
474+
var entries []string
475+
// SOUL.md at the install root is the agent's identity file — carry it too.
476+
if data, err := os.ReadFile(filepath.Join(dir, "SOUL.md")); err == nil {
477+
entries = append(entries, extractMarkdownEntries(string(data))...)
478+
}
430479
memDir := filepath.Join(dir, "memories")
431480
files, err := os.ReadDir(memDir)
432481
if err != nil {
433-
return nil
482+
return entries
434483
}
435484
var names []string
436485
for _, e := range files {
@@ -439,7 +488,6 @@ func hermesMemory(dir string) []string {
439488
}
440489
}
441490
sort.Strings(names)
442-
var entries []string
443491
for _, n := range names {
444492
data, err := os.ReadFile(filepath.Join(memDir, n))
445493
if err != nil {

docs/gateway/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,18 @@ memcode gateway schedule disable standup # pause; enable resumes
218218
memcode gateway schedule remove standup
219219
```
220220

221+
A schedule can run as a specific persona (`--agent`, or `agent:` in yaml) —
222+
bringing that persona's instructions, memory, and pinned model
223+
(`agents.<name>.model`) — and evaluate cron in a named zone (`--tz`).
224+
221225
`cron` and `automations` are accepted aliases for `schedule` (OpenClaw/Hermes
222226
muscle memory), as are `create`/`rm`/`ls`/`get`/`pause`/`resume` for the verbs.
223227
`memcode claw migrate` and `memcode hermes migrate` carry existing cron jobs
224228
over where the source stores them readably (Hermes jobs.json, OpenClaw's legacy
225229
cron file); jobs in OpenClaw's internal database are reported with exact
226-
recreate instructions — never silently dropped.
230+
recreate instructions — never silently dropped. MCP server configs
231+
(`mcp_servers` / `mcp.servers`) migrate into the user-scope .mcp.json, and
232+
Hermes's SOUL.md identity file is folded into global memory.
227233

228234
## Run
229235

internal/agent/tools/admin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func AdminDefs() []wire.ToolDef {
7171
"at": str("add only: one-shot RFC3339 time, e.g. \"2026-03-01T09:00:00Z\""),
7272
"task": str("add only: the task to run, in plain language"),
7373
"deliver_to": str("add only: where the result goes, channel:conversation"),
74+
"agent": str("add only: run as this persona (its pinned model and instructions apply)"),
7475
}, "action", "name"),
7576
},
7677
{

internal/channels/channels.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ type Inbound struct {
2525
// router's per-channel allow-list doesn't apply. Chat messages leave this
2626
// false and are gated by the allow-list; a signed GitHub delivery sets it.
2727
Trusted bool
28+
// Agent optionally forces the persona for this task. Honored only on Trusted
29+
// inbounds (schedules, verified webhooks) — a chat sender picks personas via
30+
// /agent, never through a message field.
31+
Agent string
2832
// IsDirect is true for a 1:1 direct message. A DM always triggers the agent;
2933
// a message in a group/channel triggers only when the bot is addressed (see
3034
// Mentioned) or the channel is configured to respond to all.

internal/gateway/config/config.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,18 @@ func CanonicalRoot(path string) (string, error) {
176176
// fires). Disabled pauses a schedule without deleting it. This is what turns
177177
// the gateway from purely reactive into autonomous.
178178
type Schedule struct {
179-
Name string `yaml:"name"`
180-
Every string `yaml:"every,omitempty"`
181-
Cron string `yaml:"cron,omitempty"`
182-
At string `yaml:"at,omitempty"`
179+
Name string `yaml:"name"`
180+
Every string `yaml:"every,omitempty"`
181+
Cron string `yaml:"cron,omitempty"`
182+
At string `yaml:"at,omitempty"`
183+
// TZ evaluates Cron in a named zone ("America/Los_Angeles"); empty = local.
184+
TZ string `yaml:"tz,omitempty"`
183185
Task string `yaml:"task"`
184186
DeliverTo string `yaml:"deliver_to"`
185-
Disabled bool `yaml:"disabled,omitempty"`
187+
// Agent runs this task as a specific persona — which also decides the model
188+
// when that persona pins one. Empty = the conversation's current persona.
189+
Agent string `yaml:"agent,omitempty"`
190+
Disabled bool `yaml:"disabled,omitempty"`
186191
}
187192

188193
// Webhook is the inbound HTTP listener shared by GitHub/WhatsApp. Defaults to
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// MCP server migration. Both Hermes (config.yaml mcp_servers) and OpenClaw
2+
// (openclaw.json mcp.servers) configure MCP servers with the same essential
3+
// shape memcode's .mcp.json uses — command/args/env for stdio, url for remote —
4+
// so they carry straight over into the user scope. Fields memcode doesn't have
5+
// a home for (cwd, per-server tool include/exclude filters) become notes, never
6+
// silent drops.
7+
package importer
8+
9+
import (
10+
"encoding/json"
11+
"fmt"
12+
"strings"
13+
14+
yaml "go.yaml.in/yaml/v4"
15+
16+
"github.com/memcode-ai/memcode/internal/mcp"
17+
)
18+
19+
// srcMCPServer covers the fields both products use for one server.
20+
type srcMCPServer struct {
21+
Command string `yaml:"command" json:"command"`
22+
Args []string `yaml:"args" json:"args"`
23+
Env map[string]string `yaml:"env" json:"env"`
24+
Cwd string `yaml:"cwd" json:"cwd"`
25+
URL string `yaml:"url" json:"url"`
26+
Headers map[string]string `yaml:"headers" json:"headers"`
27+
Tools struct {
28+
Include []string `yaml:"include" json:"include"`
29+
Exclude []string `yaml:"exclude" json:"exclude"`
30+
} `yaml:"tools" json:"toolFilter"`
31+
}
32+
33+
// convertMCP maps one source server to memcode's config, noting what didn't map.
34+
func convertMCP(name string, s srcMCPServer, notes *[]string) (mcp.ServerConfig, bool) {
35+
out := mcp.ServerConfig{Command: s.Command, Args: s.Args, Env: s.Env, URL: s.URL, Headers: s.Headers}
36+
if strings.TrimSpace(s.Command) == "" && strings.TrimSpace(s.URL) == "" {
37+
*notes = append(*notes, fmt.Sprintf("mcp: server %q has neither a command nor a url — skipped", name))
38+
return out, false
39+
}
40+
if strings.TrimSpace(s.Cwd) != "" {
41+
*notes = append(*notes, fmt.Sprintf("mcp: server %q set a working directory (%s), which memcode doesn't carry — it runs from the project root", name, s.Cwd))
42+
}
43+
if len(s.Tools.Include) > 0 || len(s.Tools.Exclude) > 0 {
44+
*notes = append(*notes, fmt.Sprintf("mcp: server %q had a tool include/exclude filter, which memcode doesn't carry — all its tools are available (gated by approval)", name))
45+
}
46+
return out, true
47+
}
48+
49+
// HermesMCPServers reads the mcp_servers block of a Hermes config.yaml.
50+
func HermesMCPServers(configYAML []byte) (map[string]mcp.ServerConfig, []string) {
51+
var hc struct {
52+
Servers map[string]srcMCPServer `yaml:"mcp_servers"`
53+
}
54+
if err := yaml.Unmarshal(configYAML, &hc); err != nil || len(hc.Servers) == 0 {
55+
return nil, nil
56+
}
57+
return convertAll(hc.Servers)
58+
}
59+
60+
// OpenClawMCPServers reads the mcp.servers block of an openclaw.json.
61+
func OpenClawMCPServers(data []byte) (map[string]mcp.ServerConfig, []string) {
62+
var oc struct {
63+
MCP struct {
64+
Servers map[string]srcMCPServer `json:"servers"`
65+
} `json:"mcp"`
66+
}
67+
if err := json.Unmarshal(data, &oc); err != nil || len(oc.MCP.Servers) == 0 {
68+
return nil, nil
69+
}
70+
return convertAll(oc.MCP.Servers)
71+
}
72+
73+
func convertAll(servers map[string]srcMCPServer) (map[string]mcp.ServerConfig, []string) {
74+
out := map[string]mcp.ServerConfig{}
75+
var notes []string
76+
for name, s := range servers {
77+
if sc, ok := convertMCP(name, s, &notes); ok {
78+
out[name] = sc
79+
}
80+
}
81+
return out, notes
82+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package importer
2+
3+
import "testing"
4+
5+
func TestHermesMCPServers(t *testing.T) {
6+
yaml := `
7+
mcp_servers:
8+
github:
9+
command: gh-mcp
10+
args: ["--stdio"]
11+
env: {GH_TOKEN: x}
12+
docs:
13+
url: https://mcp.example.com/sse
14+
broken:
15+
cwd: /tmp
16+
`
17+
servers, notes := HermesMCPServers([]byte(yaml))
18+
if len(servers) != 2 {
19+
t.Fatalf("want 2 servers, got %d (%+v)", len(servers), servers)
20+
}
21+
if s := servers["github"]; s.Command != "gh-mcp" || len(s.Args) != 1 || s.Env["GH_TOKEN"] != "x" {
22+
t.Errorf("stdio server mapped wrong: %+v", s)
23+
}
24+
if s := servers["docs"]; s.URL != "https://mcp.example.com/sse" {
25+
t.Errorf("remote server mapped wrong: %+v", s)
26+
}
27+
if len(notes) == 0 {
28+
t.Error("the command-less server must produce a note")
29+
}
30+
}
31+
32+
func TestOpenClawMCPServers(t *testing.T) {
33+
data := `{"mcp":{"servers":{"linear":{"command":"linear-mcp","toolFilter":{"include":["issues"]}}}}}`
34+
servers, notes := OpenClawMCPServers([]byte(data))
35+
if len(servers) != 1 || servers["linear"].Command != "linear-mcp" {
36+
t.Fatalf("mapped wrong: %+v", servers)
37+
}
38+
if len(notes) == 0 {
39+
t.Error("a dropped tool filter must produce a note")
40+
}
41+
}

0 commit comments

Comments
 (0)