diff --git a/internal/setup/assets/codex/prime.sh b/internal/setup/assets/codex/prime.sh index ced108ba..60c18e1a 100644 --- a/internal/setup/assets/codex/prime.sh +++ b/internal/setup/assets/codex/prime.sh @@ -1,4 +1,8 @@ #!/bin/bash +# mnemon Codex SessionStart hook +# Same context as before (memory-active status / missing-binary warning / guide), +# wrapped in one SessionStart JSON object so Codex does not mis-parse the leading +# "[mnemon]". python3 is already required by the sibling stop.sh. PROMPT_DIR="${MNEMON_DATA_DIR:-$HOME/.mnemon}/prompt" # Fall back to legacy location if the resolved path has no guide yet but the # legacy ~/.mnemon/prompt/ does — preserves existing installs that pre-date @@ -7,19 +11,19 @@ if [ ! -f "${PROMPT_DIR}/guide.md" ] && [ -f "${HOME}/.mnemon/prompt/guide.md" ] PROMPT_DIR="${HOME}/.mnemon/prompt" fi -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 +{ + if ! command -v mnemon >/dev/null 2>&1; then + echo "[mnemon] Warning: mnemon not found in PATH." + else + 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 + 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" + [ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" +} | python3 -c 'import json, sys; print(json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": sys.stdin.read()}}))' diff --git a/internal/setup/assets/codex/user_prompt.sh b/internal/setup/assets/codex/user_prompt.sh index 802e0095..4ad552c1 100644 --- a/internal/setup/assets/codex/user_prompt.sh +++ b/internal/setup/assets/codex/user_prompt.sh @@ -1,5 +1,7 @@ #!/bin/bash # mnemon Codex UserPromptSubmit hook -# Plain stdout is added as extra developer context by Codex. +# Codex classifies stdout beginning with "[" or "{" as attempted JSON; the reminder +# legitimately begins "[mnemon]", so it must ride inside additionalContext or Codex +# reports invalid JSON. Static payload; no new runtime dependency. cat >/dev/null || true -echo "[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?" +printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?"}}' diff --git a/internal/setup/assets/codex_hooks_test.go b/internal/setup/assets/codex_hooks_test.go new file mode 100644 index 00000000..74a85269 --- /dev/null +++ b/internal/setup/assets/codex_hooks_test.go @@ -0,0 +1,215 @@ +package assets + +// These tests execute the actual embedded Codex hook byte slices (the exact bytes +// `mnemon setup` installs) as real subprocesses and assert only their external +// contract: Codex requires each hook's stdout to be exactly one +// hookSpecificOutput JSON object. They deliberately do not inspect the scripts' +// internal shell logic. Both tests fail against the pre-patch plain-text hooks +// and pass once the hooks emit structured JSON. + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// hookOutput is the Codex hook contract shape. +type hookOutput struct { + HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + AdditionalContext string `json:"additionalContext"` + } `json:"hookSpecificOutput"` +} + +// writeExecutable writes body to dir/name as an executable and returns the path. +func writeExecutable(t *testing.T, dir, name string, body []byte) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, body, 0o755); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return p +} + +// runHook runs the embedded asset the way Codex does: the script reads stdin and +// writes the hook object to stdout. env fully replaces the environment; nil +// inherits the parent's. +func runHook(t *testing.T, script, stdin string, env []string) []byte { + t.Helper() + cmd := exec.Command("bash", script) + cmd.Stdin = strings.NewReader(stdin) + if env != nil { + cmd.Env = env + } + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + t.Fatalf("hook exited non-zero: %v\nstderr: %s", err, stderr.String()) + } + return out +} + +// mustDecodeOne asserts out is exactly one JSON object (encoding/json rejects any +// non-whitespace before or after the value) carrying wantEvent, and returns it. +func mustDecodeOne(t *testing.T, out []byte, wantEvent string) hookOutput { + t.Helper() + var o hookOutput + if err := json.Unmarshal(out, &o); err != nil { + t.Fatalf("stdout is not a single JSON object: %v\nraw: %q", err, out) + } + if o.HookSpecificOutput.HookEventName != wantEvent { + t.Fatalf("hookEventName = %q, want %q", o.HookSpecificOutput.HookEventName, wantEvent) + } + return o +} + +// shSingleQuote wraps s for safe embedding in a single-quoted shell word. +func shSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// isolatedPythonBin returns a bin dir containing only a python3 symlink. A PATH +// built from it exposes python3 (the hook's sole hard dependency, already shared +// with stop.sh) while keeping the real `mnemon` off PATH, so the missing-binary +// branch is deterministic regardless of the host's install. +func isolatedPythonBin(t *testing.T) string { + t.Helper() + py, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not found; Codex hooks require it (same dependency as stop.sh)") + } + bin := t.TempDir() + if err := os.Symlink(py, filepath.Join(bin, "python3")); err != nil { + t.Fatalf("symlink python3: %v", err) + } + return bin +} + +// fakeMnemon writes a stub `mnemon` whose `status` prints statusStdout (empty => +// prints nothing) and returns the bin dir holding it. +func fakeMnemon(t *testing.T, statusStdout string) string { + t.Helper() + bin := t.TempDir() + body := "#!/bin/bash\n" + if statusStdout != "" { + body += "printf '%s' " + shSingleQuote(statusStdout) + "\n" + } + if err := os.WriteFile(filepath.Join(bin, "mnemon"), []byte(body), 0o755); err != nil { + t.Fatalf("write fake mnemon: %v", err) + } + return bin +} + +func newPromptHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".mnemon", "prompt"), 0o755); err != nil { + t.Fatal(err) + } + return home +} + +func writeGuide(t *testing.T, promptDir, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(promptDir, "guide.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestCodexUserPromptHook(t *testing.T) { + t.Parallel() + const wantContext = "[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?" + script := writeExecutable(t, t.TempDir(), "user_prompt.sh", CodexUserPromptHook) + + // Codex may deliver an empty body or a JSON prompt payload; the hook must + // consume stdin and emit the same static object either way. + cases := []struct{ name, stdin string }{ + {"empty stdin", ""}, + {"json stdin", `{"prompt":"hello"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := mustDecodeOne(t, runHook(t, script, tc.stdin, nil), "UserPromptSubmit") + if o.HookSpecificOutput.AdditionalContext != wantContext { + t.Errorf("additionalContext = %q, want %q", o.HookSpecificOutput.AdditionalContext, wantContext) + } + }) + } +} + +func TestCodexPrimeHook(t *testing.T) { + t.Parallel() + script := writeExecutable(t, t.TempDir(), "prime.sh", CodexPrimeHook) + basePATH := isolatedPythonBin(t) + ":/usr/bin:/bin" + + // A guide fixture exercising every escaping hazard: quotes, backslash, tab, + // newline, and non-ASCII. It must survive round-trip byte-for-byte. + const guideFixture = "first \"quoted\" line\nback\\slash\ttab\nunicode: café ✓\n" + + t.Run("missing mnemon: warning + guide preserved byte-exact", func(t *testing.T) { + home := newPromptHome(t) + writeGuide(t, filepath.Join(home, ".mnemon", "prompt"), guideFixture) + o := mustDecodeOne(t, runHook(t, script, "{}", []string{"HOME=" + home, "PATH=" + basePATH}), "SessionStart") + ac := o.HookSpecificOutput.AdditionalContext + if !strings.Contains(ac, "[mnemon] Warning: mnemon not found in PATH.") { + t.Errorf("missing-binary warning absent: %q", ac) + } + if !strings.HasSuffix(ac, guideFixture) { + t.Errorf("guide not preserved byte-exact.\n got: %q\nwant suffix: %q", ac, guideFixture) + } + }) + + t.Run("no guide: still one valid object", func(t *testing.T) { + home := newPromptHome(t) // prompt dir exists, no guide.md + o := mustDecodeOne(t, runHook(t, script, "{}", []string{"HOME=" + home, "PATH=" + basePATH}), "SessionStart") + if !strings.Contains(o.HookSpecificOutput.AdditionalContext, "mnemon not found in PATH") { + t.Errorf("expected warning, got %q", o.HookSpecificOutput.AdditionalContext) + } + }) + + t.Run("mnemon present, stats -> count line", func(t *testing.T) { + home := newPromptHome(t) + path := fakeMnemon(t, `{"total_insights": 42, "edge_count": 99}`) + ":" + basePATH + o := mustDecodeOne(t, runHook(t, script, "{}", []string{"HOME=" + home, "PATH=" + path}), "SessionStart") + const want = "[mnemon] Memory active (42 insights, 99 edges)." + if !strings.Contains(o.HookSpecificOutput.AdditionalContext, want) { + t.Errorf("stats line = %q, want contains %q", o.HookSpecificOutput.AdditionalContext, want) + } + }) + + t.Run("mnemon present, empty status -> plain fallback", func(t *testing.T) { + home := newPromptHome(t) + path := fakeMnemon(t, "") + ":" + basePATH + o := mustDecodeOne(t, runHook(t, script, "{}", []string{"HOME=" + home, "PATH=" + path}), "SessionStart") + if !strings.Contains(o.HookSpecificOutput.AdditionalContext, "[mnemon] Memory active.") { + t.Errorf("fallback line absent: %q", o.HookSpecificOutput.AdditionalContext) + } + }) + + t.Run("MNEMON_DATA_DIR override selects that prompt dir", func(t *testing.T) { + home := newPromptHome(t) // legacy dir exists but has no guide + dataDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dataDir, "prompt"), 0o755); err != nil { + t.Fatal(err) + } + writeGuide(t, filepath.Join(dataDir, "prompt"), "DATADIR-GUIDE\n") + o := mustDecodeOne(t, runHook(t, script, "{}", []string{"HOME=" + home, "MNEMON_DATA_DIR=" + dataDir, "PATH=" + basePATH}), "SessionStart") + if !strings.Contains(o.HookSpecificOutput.AdditionalContext, "DATADIR-GUIDE") { + t.Errorf("MNEMON_DATA_DIR guide not used: %q", o.HookSpecificOutput.AdditionalContext) + } + }) + + t.Run("legacy fallback when data dir has no guide", func(t *testing.T) { + home := newPromptHome(t) + writeGuide(t, filepath.Join(home, ".mnemon", "prompt"), "LEGACY-GUIDE\n") + dataDir := t.TempDir() // set, but no prompt/guide.md inside + o := mustDecodeOne(t, runHook(t, script, "{}", []string{"HOME=" + home, "MNEMON_DATA_DIR=" + dataDir, "PATH=" + basePATH}), "SessionStart") + if !strings.Contains(o.HookSpecificOutput.AdditionalContext, "LEGACY-GUIDE") { + t.Errorf("legacy fallback guide not used: %q", o.HookSpecificOutput.AdditionalContext) + } + }) +}