diff --git a/AGENTS.md b/AGENTS.md index 4721963..35e6266 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,9 @@ - Run the E2E suite with `bash scripts/e2e_test.sh` or `make test`. - Validate harness module manifests with `make harness-validate` when changing harness module assets. +- Treat `harness/` as an experimental, not-yet-released harness layer. Do not + use it as an implementation dependency for release-path commands such as + `mnemon setup`; formal integrations belong under `cmd/` and `internal/`. - Treat `.claude/`, `.codex/`, `.openclaw/`, and similar host directories as local projection surfaces, not canonical project state. diff --git a/README.md b/README.md index 9424eed..0032648 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,15 @@ mnemon setup `mnemon setup` auto-detects Claude Code, then interactively deploys skill, hooks, and behavioral guide. Start a new session — memory just works. +### [Codex](https://github.com/openai/codex) + +```bash +mnemon setup --target codex --yes +``` + +One command deploys the mnemon skill, prompt files, and Codex lifecycle hooks +(`SessionStart`, `UserPromptSubmit`, `Stop`) in `.codex/hooks.json`. + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash diff --git a/cmd/setup.go b/cmd/setup.go index a016325..efe6728 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -22,10 +22,10 @@ var setupCmd = &cobra.Command{ Short: "Deploy mnemon into LLM CLI environments", Long: `Detect installed LLM CLIs and deploy mnemon integration. -By default, installs to project-local config (.claude/, .openclaw/, .nanobot/). -Use --global to install to user-wide config (~/.claude/, ~/.openclaw/, ~/.nanobot/workspace/). +By default, installs to project-local config (.claude/, .codex/, .openclaw/, .nanobot/). +Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.openclaw/, ~/.nanobot/workspace/). -Supported environments: Claude Code, OpenClaw, Nanobot. +Supported environments: Claude Code, Codex, OpenClaw, Nanobot. Examples: mnemon setup # Interactive: project-local install @@ -38,16 +38,16 @@ Examples: } func init() { - setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, openclaw, nanobot)") + setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, openclaw, nanobot)") setupCmd.Flags().BoolVar(&setupEject, "eject", false, "remove mnemon integrations") setupCmd.Flags().BoolVar(&setupYes, "yes", false, "auto-confirm all prompts (CI-friendly)") - setupCmd.Flags().BoolVar(&setupGlobal, "global", false, "install to user-wide config (~/.claude/) instead of project-local (.claude/)") + setupCmd.Flags().BoolVar(&setupGlobal, "global", false, "install to user-wide config instead of project-local config") rootCmd.AddCommand(setupCmd) } func runSetup(cmd *cobra.Command, args []string) error { - if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "openclaw" && setupTarget != "nanobot" { - return fmt.Errorf("invalid target %q (must be claude-code, openclaw, or nanobot)", setupTarget) + if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "openclaw" && setupTarget != "nanobot" { + return fmt.Errorf("invalid target %q (must be claude-code, codex, openclaw, or nanobot)", setupTarget) } envs := setup.DetectEnvironments(setupGlobal) @@ -83,7 +83,7 @@ func runInstallFlow(envs []setup.Environment) error { if len(detected) == 0 { fmt.Println("\nNo supported LLM CLI environments detected.") - fmt.Println("Install Claude Code, OpenClaw, or Nanobot, then run 'mnemon setup' again.") + fmt.Println("Install Claude Code, Codex, OpenClaw, or Nanobot, then run 'mnemon setup' again.") return nil } @@ -125,6 +125,8 @@ func installEnv(env *setup.Environment) error { switch env.Name { case "claude-code": err = installClaudeCode(env) + case "codex": + err = installCodex(env) case "openclaw": err = installOpenClaw(env) case "nanobot": @@ -282,6 +284,85 @@ func selectOptionalHooks() setup.HookSelection { return sel } +// ─── Codex ────────────────────────────────────────────────────────── + +func installCodex(env *setup.Environment) error { + configDir := env.ConfigDir + + if !setupGlobal && !setupYes && setup.IsInteractive() { + home := setup.HomeDir() + localDir := ".codex" + globalDir := home + "/.codex" + idx := setup.SelectOne("Install scope", + []string{ + fmt.Sprintf("Local — this project only (%s/)", localDir), + fmt.Sprintf("Global — all projects (%s/)", globalDir), + }, 0) + if idx == 1 { + configDir = globalDir + } else { + configDir = localDir + } + } + + fmt.Printf("\nSetting up Codex (%s)...\n", configDir) + + fmt.Println("\n[1/4] Skill") + if path, err := setup.CodexWriteSkill(configDir); err != nil { + setup.StatusError(0, 0, "Skill", err) + return err + } else { + setup.StatusOK(0, 0, "Skill", path) + } + + fmt.Println("\n[2/4] Prompts") + if path, err := setup.WritePromptFiles(); err != nil { + setup.StatusError(0, 0, "Prompts", err) + return err + } else { + setup.StatusOK(0, 0, "Prompts", path) + } + + fmt.Println("\n[3/4] Hooks") + if path, err := setup.CodexWriteHook(configDir, "prime.sh", assets.CodexPrimeHook); err != nil { + setup.StatusError(0, 0, "Hook: prime", err) + return err + } else { + setup.StatusOK(0, 0, "Hook: prime", path) + } + if path, err := setup.CodexWriteHook(configDir, "user_prompt.sh", assets.CodexUserPromptHook); err != nil { + setup.StatusError(0, 0, "Hook: remind", err) + return err + } else { + setup.StatusOK(0, 0, "Hook: remind", path) + } + if path, err := setup.CodexWriteHook(configDir, "stop.sh", assets.CodexStopHook); err != nil { + setup.StatusError(0, 0, "Hook: stop", err) + return err + } else { + setup.StatusOK(0, 0, "Hook: stop", path) + } + + fmt.Println("\n[4/4] Config") + if path, err := setup.CodexRegisterHooks(configDir); err != nil { + setup.StatusError(0, 0, "Hooks config", err) + return err + } else { + setup.StatusUpdated(0, 0, "Hooks config", path) + } + + fmt.Println() + fmt.Println("Setup complete!") + fmt.Printf(" Skill %s/skills/mnemon/SKILL.md\n", configDir) + fmt.Printf(" Hooks %s/hooks.json (SessionStart, UserPromptSubmit, Stop)\n", configDir) + fmt.Printf(" Prompts ~/.mnemon/prompt/ (guide.md, skill.md)\n") + fmt.Println() + fmt.Println("Start a new Codex session to activate.") + fmt.Println("Run 'mnemon setup --eject --target codex' to remove.") + + return nil +} + // ─── OpenClaw ─────────────────────────────────────────────────────── func installOpenClaw(env *setup.Environment) error { @@ -526,6 +607,13 @@ func ejectEnv(env *setup.Environment) error { return errs[0] } + case "codex": + errs := setup.CodexEject(env.ConfigDir) + ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") + if len(errs) > 0 { + return errs[0] + } + case "openclaw": errs := setup.OpenClawEject(env.ConfigDir) ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") diff --git a/internal/setup/assets/assets.go b/internal/setup/assets/assets.go index 9df0d2d..3c245ae 100644 --- a/internal/setup/assets/assets.go +++ b/internal/setup/assets/assets.go @@ -20,6 +20,18 @@ var ClaudeSkill []byte //go:embed claude/guide.md var ClaudeGuide []byte +//go:embed codex/SKILL.md +var CodexSkill []byte + +//go:embed codex/prime.sh +var CodexPrimeHook []byte + +//go:embed codex/user_prompt.sh +var CodexUserPromptHook []byte + +//go:embed codex/stop.sh +var CodexStopHook []byte + //go:embed openclaw/SKILL.md var OpenClawSkill []byte @@ -49,5 +61,5 @@ var NanobotSkill []byte // All returns the embedded filesystem for inspection. // -//go:embed claude openclaw nanoclaw nanobot +//go:embed claude codex openclaw nanoclaw nanobot var All embed.FS diff --git a/internal/setup/assets/codex/SKILL.md b/internal/setup/assets/codex/SKILL.md new file mode 100644 index 0000000..0e7d78d --- /dev/null +++ b/internal/setup/assets/codex/SKILL.md @@ -0,0 +1,44 @@ +--- +name: mnemon +description: Persistent memory CLI for LLM agents. Store facts, recall past knowledge, link related memories, manage lifecycle. +--- + +# mnemon + +## Workflow + +1. **Remember**: `mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent` + - Diff is built in: duplicates are skipped, conflicts are auto-replaced. + - Output includes `action` (added/updated/skipped), `semantic_candidates`, and `causal_candidates`. +2. **Link** (evaluate candidates from step 1 using judgment): + - Review `causal_candidates`: link only when the memories are genuinely causally related. + - Review `semantic_candidates`: high `similarity` alone is not enough; skip unrelated keyword matches. + - Syntax: `mnemon link --type --weight <0-1> [--meta '']` +3. **Recall**: `mnemon recall "" --limit 10` + +## Commands + +```bash +mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent +mnemon link --type --weight <0-1> [--meta ''] +mnemon recall "" --limit 10 +mnemon search "" --limit 10 +mnemon forget +mnemon related --edge causal +mnemon gc --threshold 0.4 +mnemon gc --keep +mnemon status +mnemon log +mnemon store list +mnemon store create +mnemon store set +mnemon store remove +``` + +## Guardrails + +- Use memory only when it can materially improve continuity or task quality. +- Do not store secrets, passwords, tokens, private keys, or short-lived operational noise. +- Categories: `preference` · `decision` · `insight` · `fact` · `context` +- Edge types: `temporal` · `semantic` · `causal` · `entity` +- Max 8,000 chars per insight. diff --git a/internal/setup/assets/codex/prime.sh b/internal/setup/assets/codex/prime.sh new file mode 100644 index 0000000..6fc49d8 --- /dev/null +++ b/internal/setup/assets/codex/prime.sh @@ -0,0 +1,19 @@ +#!/bin/bash +PROMPT_DIR="${HOME}/.mnemon/prompt" + +if ! command -v mnemon >/dev/null 2>&1; then + echo "[mnemon] Warning: mnemon not found in PATH." + [ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" + exit 0 +fi + +STATS=$(mnemon status 2>/dev/null) +if [ -n "$STATS" ]; then + INSIGHTS=$(echo "$STATS" | sed -n 's/.*"total_insights": *\([0-9]*\).*/\1/p' | head -1) + EDGES=$(echo "$STATS" | sed -n 's/.*"edge_count": *\([0-9]*\).*/\1/p' | head -1) + echo "[mnemon] Memory active (${INSIGHTS:-0} insights, ${EDGES:-0} edges)." +else + echo "[mnemon] Memory active." +fi + +[ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" diff --git a/internal/setup/assets/codex/stop.sh b/internal/setup/assets/codex/stop.sh new file mode 100644 index 0000000..e87833e --- /dev/null +++ b/internal/setup/assets/codex/stop.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# mnemon Codex Stop hook +# Stop requires JSON on stdout. Keep this non-blocking; do not force continuation. +INPUT=$(cat) +python3 - "$INPUT" <<'PY' +import json +import sys + +try: + payload = json.loads(sys.argv[1]) +except Exception: + payload = {} + +if payload.get("stop_hook_active"): + sys.exit(0) + +last_message = (payload.get("last_assistant_message") or "").lower() +if "mnemon" in last_message or "durable memory" in last_message: + sys.exit(0) + +print(json.dumps({ + "continue": True, + "systemMessage": "[mnemon] Consider: does this exchange warrant durable memory?", +})) +PY diff --git a/internal/setup/assets/codex/user_prompt.sh b/internal/setup/assets/codex/user_prompt.sh new file mode 100644 index 0000000..802e009 --- /dev/null +++ b/internal/setup/assets/codex/user_prompt.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# mnemon Codex UserPromptSubmit hook +# Plain stdout is added as extra developer context by Codex. +cat >/dev/null || true +echo "[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?" diff --git a/internal/setup/codex.go b/internal/setup/codex.go new file mode 100644 index 0000000..01357e6 --- /dev/null +++ b/internal/setup/codex.go @@ -0,0 +1,164 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/mnemon-dev/mnemon/internal/setup/assets" +) + +// CodexWriteSkill writes the mnemon skill to the Codex skills directory. +func CodexWriteSkill(configDir string) (string, error) { + skillDir := filepath.Join(configDir, "skills", "mnemon") + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.MkdirAll(skillDir, 0755); err != nil { + return "", err + } + if err := os.WriteFile(skillPath, assets.CodexSkill, 0644); err != nil { + return "", err + } + return skillPath, nil +} + +// CodexWriteHook writes a hook script to the Codex hooks directory. +func CodexWriteHook(configDir, filename string, content []byte) (string, error) { + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + if err := os.MkdirAll(hooksDir, 0755); err != nil { + return "", err + } + hookPath := filepath.Join(hooksDir, filename) + if err := os.WriteFile(hookPath, content, 0755); err != nil { + return "", err + } + return hookPath, nil +} + +// CodexRegisterHooks registers Mnemon lifecycle hooks in hooks.json. +func CodexRegisterHooks(configDir string) (string, error) { + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + absHooksDir, err := filepath.Abs(hooksDir) + if err != nil { + return "", err + } + hooksPath := filepath.Join(configDir, "hooks.json") + data, err := ReadJSONFile(hooksPath) + if err != nil { + return "", err + } + addCodexHooks(data, absHooksDir) + if err := WriteJSONFile(hooksPath, data); err != nil { + return "", err + } + return hooksPath, nil +} + +// CodexEject removes mnemon integration from the given Codex config dir. +func CodexEject(configDir string) []error { + var errs []error + + fmt.Printf("\nRemoving Codex integration (%s)...\n", configDir) + + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + if err := os.RemoveAll(hooksDir); err != nil { + StatusError(1, 3, "Hooks", err) + errs = append(errs, err) + } else { + StatusOK(1, 3, "Hooks", hooksDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "hooks")) + + hooksPath := filepath.Join(configDir, "hooks.json") + data, err := ReadJSONFile(hooksPath) + if err != nil { + StatusError(2, 3, "Hooks config", err) + errs = append(errs, err) + } else { + removeCodexHooks(data) + if err := WriteOrRemoveJSONFile(hooksPath, data); err != nil { + StatusError(2, 3, "Hooks config", err) + errs = append(errs, err) + } else { + StatusOK(2, 3, "Hooks config", hooksPath+" cleaned") + } + } + + skillDir := filepath.Join(configDir, "skills", "mnemon") + if err := os.RemoveAll(skillDir); err != nil { + StatusError(3, 3, "Skill", err) + errs = append(errs, err) + } else { + StatusOK(3, 3, "Skill", skillDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "skills")) + removeIfEmpty(configDir) + + return errs +} + +func addCodexHooks(data map[string]interface{}, hooksDir string) { + removeCodexHooks(data) + hooks := ensureHooksMap(data) + + primeEntry := map[string]interface{}{ + "matcher": "startup|resume|clear", + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": filepath.Join(hooksDir, "prime.sh"), + "timeout": 30, + "statusMessage": "Loading Mnemon context", + }, + }, + } + sessionArr, _ := hooks["SessionStart"].([]interface{}) + hooks["SessionStart"] = append(sessionArr, primeEntry) + + remindEntry := map[string]interface{}{ + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": filepath.Join(hooksDir, "user_prompt.sh"), + "timeout": 30, + "statusMessage": "Checking Mnemon recall guidance", + }, + }, + } + promptArr, _ := hooks["UserPromptSubmit"].([]interface{}) + hooks["UserPromptSubmit"] = append(promptArr, remindEntry) + + stopEntry := map[string]interface{}{ + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": filepath.Join(hooksDir, "stop.sh"), + "timeout": 30, + "statusMessage": "Checking Mnemon writeback guidance", + }, + }, + } + stopArr, _ := hooks["Stop"].([]interface{}) + hooks["Stop"] = append(stopArr, stopEntry) +} + +func removeCodexHooks(data map[string]interface{}) { + hooks, ok := data["hooks"].(map[string]interface{}) + if !ok { + return + } + for _, key := range []string{"SessionStart", "UserPromptSubmit", "Stop"} { + arr, ok := hooks[key].([]interface{}) + if !ok { + continue + } + filtered := filterHookArray(arr) + if len(filtered) == 0 { + delete(hooks, key) + } else { + hooks[key] = filtered + } + } + if len(hooks) == 0 { + delete(data, "hooks") + } +} diff --git a/internal/setup/detect.go b/internal/setup/detect.go index dea468b..c385bf1 100644 --- a/internal/setup/detect.go +++ b/internal/setup/detect.go @@ -9,8 +9,8 @@ import ( // Environment describes a detected LLM CLI environment. type Environment struct { - Name string // "claude-code", "openclaw" - Display string // "Claude Code", "OpenClaw" + Name string // "claude-code", "codex", "openclaw" + Display string // "Claude Code", "Codex", "OpenClaw" Detected bool // CLI binary or global config dir found BinPath string // exec.LookPath result Installed bool // mnemon integration present at ConfigDir @@ -30,6 +30,7 @@ func HomeDir() string { func DetectEnvironments(global bool) []Environment { return []Environment{ detectClaude(global), + detectCodex(global), detectOpenClaw(global), detectNanobot(global), } @@ -76,6 +77,45 @@ func detectClaude(global bool) Environment { return env } +func detectCodex(global bool) Environment { + home := HomeDir() + globalDir := filepath.Join(home, ".codex") + localDir := ".codex" + + configDir := localDir + if global { + configDir = globalDir + } + + env := Environment{ + Name: "codex", + Display: "Codex", + ConfigDir: configDir, + } + + // CLI detection is always global. + if binPath, err := exec.LookPath("codex"); err == nil { + env.Detected = true + env.BinPath = binPath + } + if _, err := os.Stat(globalDir); err == nil { + env.Detected = true + } + + skillPath := filepath.Join(configDir, "skills", "mnemon", "SKILL.md") + if _, err := os.Stat(skillPath); err == nil { + env.Installed = true + } + + if env.BinPath != "" { + if out, err := exec.Command(env.BinPath, "--version").Output(); err == nil { + env.Version = cleanVersion(strings.TrimSpace(string(out))) + } + } + + return env +} + func detectOpenClaw(global bool) Environment { home := HomeDir() globalDir := filepath.Join(home, ".openclaw") diff --git a/internal/setup/settings_test.go b/internal/setup/settings_test.go index 798ce1e..6229dad 100644 --- a/internal/setup/settings_test.go +++ b/internal/setup/settings_test.go @@ -62,3 +62,36 @@ func TestAddClaudeHooksSelectiveReplacesExistingMnemonHooks(t *testing.T) { t.Fatal("enabled compact hook should be registered") } } + +func TestAddCodexHooksReplacesExistingMnemonHooks(t *testing.T) { + data := map[string]any{ + "hooks": map[string]any{ + "SessionStart": []any{ + map[string]any{"hooks": []any{map[string]any{"command": "/old/mnemon/prime.sh"}}}, + map[string]any{"hooks": []any{map[string]any{"command": "/keep/custom.sh"}}}, + }, + "UserPromptSubmit": []any{ + map[string]any{"hooks": []any{map[string]any{"command": "/old/mnemon/user_prompt.sh"}}}, + }, + "Stop": []any{ + map[string]any{"hooks": []any{map[string]any{"command": "/old/mnemon/stop.sh"}}}, + }, + }, + } + + addCodexHooks(data, "/new/hooks") + + hooks := data["hooks"].(map[string]any) + sessionStart := hooks["SessionStart"].([]any) + if len(sessionStart) != 2 { + t.Fatalf("expected kept custom hook plus new prime hook: %#v", sessionStart) + } + userPrompt := hooks["UserPromptSubmit"].([]any) + if len(userPrompt) != 1 { + t.Fatalf("expected one new remind hook: %#v", userPrompt) + } + stop := hooks["Stop"].([]any) + if len(stop) != 1 { + t.Fatalf("expected one new stop hook: %#v", stop) + } +}