From 8c8b89d24e00bab07b63dbd48343c5fe30e14aff Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 10 Feb 2026 22:37:14 +0000 Subject: [PATCH 1/2] feat: add OpenClaw agent integration Add OpenClaw as a third supported agent alongside Claude Code and Gemini CLI. OpenClaw is an AI agent orchestration platform that uses git hooks exclusively (no agent-side lifecycle hooks). Sessions are JSONL-formatted transcripts with role/content/tool_calls/timestamp fields. New files: - cmd/entire/cli/agent/openclaw/openclaw.go - Main agent implementation - cmd/entire/cli/agent/openclaw/transcript.go - JSONL transcript parsing - cmd/entire/cli/agent/openclaw/types.go - OpenClaw-specific types - cmd/entire/cli/agent/openclaw/openclaw_test.go - Comprehensive tests Modified files: - cmd/entire/cli/agent/registry.go - Add AgentNameOpenClaw/AgentTypeOpenClaw constants - cmd/entire/cli/hooks_cmd.go - Import openclaw for registration - cmd/entire/cli/setup.go - Support agents without HookSupport in enable flow - cmd/entire/cli/setup_test.go - Import openclaw for registration Key design decisions: - SupportsHooks() returns false - uses git hooks exclusively - DetectPresence checks .openclaw/ dir or OPENCLAW_SESSION env var - GetSessionDir returns ~/.openclaw/sessions/ or OPENCLAW_SESSION_DIR - JSONL format with TranscriptAnalyzer and TranscriptChunker interfaces - setup.go updated to gracefully handle agents without HookSupport --- cmd/entire/cli/agent/openclaw/openclaw.go | 326 +++++++ .../cli/agent/openclaw/openclaw_test.go | 846 ++++++++++++++++++ cmd/entire/cli/agent/openclaw/transcript.go | 101 +++ cmd/entire/cli/agent/openclaw/types.go | 42 + cmd/entire/cli/agent/registry.go | 2 + cmd/entire/cli/hooks_cmd.go | 1 + cmd/entire/cli/setup.go | 45 +- cmd/entire/cli/setup_test.go | 1 + 8 files changed, 1345 insertions(+), 19 deletions(-) create mode 100644 cmd/entire/cli/agent/openclaw/openclaw.go create mode 100644 cmd/entire/cli/agent/openclaw/openclaw_test.go create mode 100644 cmd/entire/cli/agent/openclaw/transcript.go create mode 100644 cmd/entire/cli/agent/openclaw/types.go diff --git a/cmd/entire/cli/agent/openclaw/openclaw.go b/cmd/entire/cli/agent/openclaw/openclaw.go new file mode 100644 index 000000000..a934a54f0 --- /dev/null +++ b/cmd/entire/cli/agent/openclaw/openclaw.go @@ -0,0 +1,326 @@ +// Package openclaw implements the Agent interface for OpenClaw. +package openclaw + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/sessionid" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameOpenClaw, NewOpenClawAgent) +} + +// OpenClawAgent implements the Agent interface for OpenClaw. +// +//nolint:revive // OpenClawAgent is clearer than Agent in this context +type OpenClawAgent struct{} + +// NewOpenClawAgent creates a new OpenClaw agent instance. +func NewOpenClawAgent() agent.Agent { + return &OpenClawAgent{} +} + +// Name returns the agent registry key. +func (o *OpenClawAgent) Name() agent.AgentName { + return agent.AgentNameOpenClaw +} + +// Type returns the agent type identifier. +func (o *OpenClawAgent) Type() agent.AgentType { + return agent.AgentTypeOpenClaw +} + +// Description returns a human-readable description. +func (o *OpenClawAgent) Description() string { + return "OpenClaw - AI agent orchestration platform" +} + +// DetectPresence checks if OpenClaw is configured in the repository. +func (o *OpenClawAgent) DetectPresence() (bool, error) { + // Check for OPENCLAW_SESSION env var + if os.Getenv("OPENCLAW_SESSION") != "" { + return true, nil + } + + // Get repo root to check for .openclaw directory + repoRoot, err := paths.RepoRoot() + if err != nil { + // Not in a git repo, fall back to CWD-relative check + repoRoot = "." + } + + // Check for .openclaw directory + openclawDir := filepath.Join(repoRoot, ".openclaw") + if _, err := os.Stat(openclawDir); err == nil { + return true, nil + } + + return false, nil +} + +// GetHookConfigPath returns empty since OpenClaw uses git hooks, not agent-side hooks. +func (o *OpenClawAgent) GetHookConfigPath() string { + return "" +} + +// SupportsHooks returns false as OpenClaw uses git hooks exclusively. +// OpenClaw sessions are captured via prepare-commit-msg, post-commit, and pre-push +// hooks that `entire enable` already installs. +func (o *OpenClawAgent) SupportsHooks() bool { + return false +} + +// ParseHookInput parses OpenClaw hook input from stdin. +// OpenClaw doesn't use agent-side hooks, but this is required by the interface. +// It handles the case where git hooks pass session context. +func (o *OpenClawAgent) ParseHookInput(_ agent.HookType, reader io.Reader) (*agent.HookInput, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + if len(data) == 0 { + return nil, errors.New("empty input") + } + + input := &agent.HookInput{ + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + // Try to parse as JSON with session info + var raw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse input: %w", err) + } + + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (o *OpenClawAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// TransformSessionID converts an OpenClaw session ID to an Entire session ID. +// This is an identity function - the agent session ID IS the Entire session ID. +func (o *OpenClawAgent) TransformSessionID(agentSessionID string) string { + return agentSessionID +} + +// ExtractAgentSessionID extracts the OpenClaw session ID from an Entire session ID. +// Since Entire session ID = agent session ID (identity), this returns the input unchanged. +// For backwards compatibility with legacy date-prefixed IDs, it strips the prefix if present. +func (o *OpenClawAgent) ExtractAgentSessionID(entireSessionID string) string { + return sessionid.ModelSessionID(entireSessionID) +} + +// GetSessionDir returns the directory where OpenClaw stores session transcripts. +// OpenClaw stores sessions at ~/.openclaw/sessions/ by default, +// or respects the OPENCLAW_SESSION_DIR env var. +func (o *OpenClawAgent) GetSessionDir(_ string) (string, error) { + // Check for test environment override + if override := os.Getenv("ENTIRE_TEST_OPENCLAW_SESSION_DIR"); override != "" { + return override, nil + } + + // Check for OpenClaw session dir env var + if sessionDir := os.Getenv("OPENCLAW_SESSION_DIR"); sessionDir != "" { + return sessionDir, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(homeDir, ".openclaw", "sessions"), nil +} + +// ReadSession reads a session from OpenClaw's storage (JSONL transcript file). +// The session data is stored in NativeData as raw JSONL bytes. +// ModifiedFiles is computed by parsing the transcript. +func (o *OpenClawAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + // Read the raw JSONL file + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + // Parse to extract computed fields + messages, err := ParseTranscript(data) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: o.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: ExtractModifiedFiles(messages), + }, nil +} + +// WriteSession writes a session to OpenClaw's storage (JSONL transcript file). +// Uses the NativeData field which contains raw JSONL bytes. +func (o *OpenClawAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + // Verify this session belongs to OpenClaw + if session.AgentName != "" && session.AgentName != o.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, o.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + // Ensure parent directory exists + dir := filepath.Dir(session.SessionRef) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + // Write the raw JSONL data + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an OpenClaw session. +func (o *OpenClawAgent) FormatResumeCommand(sessionID string) string { + return "openclaw resume " + sessionID +} + +// TranscriptAnalyzer interface implementation + +// GetTranscriptPosition returns the current line count of an OpenClaw transcript. +// OpenClaw uses JSONL format, so position is the number of lines. +// Returns 0 if the file doesn't exist or is empty. +func (o *OpenClawAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec // Path comes from OpenClaw transcript location + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to open transcript file: %w", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + lineCount := 0 + + for { + _, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line number. +// For OpenClaw (JSONL format), offset is the starting line number. +// Returns: +// - files: list of file paths modified by OpenClaw (from write/edit tools) +// - currentPosition: total number of lines in the file +// - error: any error encountered during reading +func (o *OpenClawAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + file, openErr := os.Open(path) //nolint:gosec // Path comes from OpenClaw transcript location + if openErr != nil { + return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) + } + defer file.Close() + + reader := bufio.NewReader(file) + var messages []OpenClawMessage + lineNum := 0 + + for { + lineData, readErr := reader.ReadBytes('\n') + if readErr != nil && readErr != io.EOF { + return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr) + } + + if len(lineData) > 0 { + lineNum++ + if lineNum > startOffset { + var msg OpenClawMessage + if parseErr := json.Unmarshal(lineData, &msg); parseErr == nil { + messages = append(messages, msg) + } + // Skip malformed lines silently + } + } + + if readErr == io.EOF { + break + } + } + + return ExtractModifiedFiles(messages), lineNum, nil +} + +// TranscriptChunker interface implementation + +// ChunkTranscript splits a JSONL transcript at line boundaries. +// OpenClaw uses JSONL format (one JSON object per line), so chunking +// is done at newline boundaries to preserve message integrity. +func (o *OpenClawAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk JSONL transcript: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript concatenates JSONL chunks with newlines. +// +//nolint:unparam // error return is required by interface, kept for consistency +func (o *OpenClawAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} diff --git a/cmd/entire/cli/agent/openclaw/openclaw_test.go b/cmd/entire/cli/agent/openclaw/openclaw_test.go new file mode 100644 index 000000000..ba5933ab9 --- /dev/null +++ b/cmd/entire/cli/agent/openclaw/openclaw_test.go @@ -0,0 +1,846 @@ +package openclaw + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestNewOpenClawAgent(t *testing.T) { + ag := NewOpenClawAgent() + if ag == nil { + t.Fatal("NewOpenClawAgent() returned nil") + } + + oc, ok := ag.(*OpenClawAgent) + if !ok { + t.Fatal("NewOpenClawAgent() didn't return *OpenClawAgent") + } + if oc == nil { + t.Fatal("NewOpenClawAgent() returned nil agent") + } +} + +func TestName(t *testing.T) { + ag := &OpenClawAgent{} + if name := ag.Name(); name != agent.AgentNameOpenClaw { + t.Errorf("Name() = %q, want %q", name, agent.AgentNameOpenClaw) + } +} + +func TestType(t *testing.T) { + ag := &OpenClawAgent{} + if agType := ag.Type(); agType != agent.AgentTypeOpenClaw { + t.Errorf("Type() = %q, want %q", agType, agent.AgentTypeOpenClaw) + } +} + +func TestDescription(t *testing.T) { + ag := &OpenClawAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() returned empty string") + } +} + +func TestDetectPresence_NoOpenClawDir(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Clear env var + t.Setenv("OPENCLAW_SESSION", "") + + ag := &OpenClawAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } +} + +func TestDetectPresence_WithOpenClawDir(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Clear env var + t.Setenv("OPENCLAW_SESSION", "") + + // Create .openclaw directory + if err := os.Mkdir(".openclaw", 0o755); err != nil { + t.Fatalf("failed to create .openclaw: %v", err) + } + + ag := &OpenClawAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } +} + +func TestDetectPresence_WithEnvVar(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + t.Setenv("OPENCLAW_SESSION", "test-session-123") + + ag := &OpenClawAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true (env var set)") + } +} + +func TestGetHookConfigPath(t *testing.T) { + ag := &OpenClawAgent{} + path := ag.GetHookConfigPath() + if path != "" { + t.Errorf("GetHookConfigPath() = %q, want empty (OpenClaw uses git hooks)", path) + } +} + +func TestSupportsHooks(t *testing.T) { + ag := &OpenClawAgent{} + if ag.SupportsHooks() { + t.Error("SupportsHooks() = true, want false (OpenClaw uses git hooks)") + } +} + +func TestParseHookInput(t *testing.T) { + ag := &OpenClawAgent{} + + input := `{"session_id":"test-123","transcript_path":"/tmp/transcript.jsonl"}` + + result, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionID != "test-123" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "test-123") + } + if result.SessionRef != "/tmp/transcript.jsonl" { + t.Errorf("SessionRef = %q, want %q", result.SessionRef, "/tmp/transcript.jsonl") + } +} + +func TestParseHookInput_Empty(t *testing.T) { + ag := &OpenClawAgent{} + + _, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader("")) + if err == nil { + t.Error("ParseHookInput() should error on empty input") + } +} + +func TestParseHookInput_InvalidJSON(t *testing.T) { + ag := &OpenClawAgent{} + + _, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader("not json")) + if err == nil { + t.Error("ParseHookInput() should error on invalid JSON") + } +} + +func TestGetSessionID(t *testing.T) { + ag := &OpenClawAgent{} + input := &agent.HookInput{SessionID: "test-session-123"} + + id := ag.GetSessionID(input) + if id != "test-session-123" { + t.Errorf("GetSessionID() = %q, want test-session-123", id) + } +} + +func TestTransformSessionID(t *testing.T) { + ag := &OpenClawAgent{} + + // TransformSessionID is an identity function + result := ag.TransformSessionID("abc123") + if result != "abc123" { + t.Errorf("TransformSessionID() = %q, want abc123 (identity function)", result) + } +} + +func TestExtractAgentSessionID(t *testing.T) { + ag := &OpenClawAgent{} + + tests := []struct { + name string + input string + want string + }{ + { + name: "with date prefix", + input: "2025-01-09-abc123", + want: "abc123", + }, + { + name: "without date prefix", + input: "abc123", + want: "abc123", + }, + { + name: "longer session id", + input: "2025-12-31-session-id-here", + want: "session-id-here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ag.ExtractAgentSessionID(tt.input) + if got != tt.want { + t.Errorf("ExtractAgentSessionID(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestGetSessionDir(t *testing.T) { + ag := &OpenClawAgent{} + + // Test with override env var + t.Setenv("ENTIRE_TEST_OPENCLAW_SESSION_DIR", "/test/override") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != "/test/override" { + t.Errorf("GetSessionDir() = %q, want /test/override", dir) + } +} + +func TestGetSessionDir_WithOpenClawEnv(t *testing.T) { + ag := &OpenClawAgent{} + + t.Setenv("ENTIRE_TEST_OPENCLAW_SESSION_DIR", "") + t.Setenv("OPENCLAW_SESSION_DIR", "/custom/sessions") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != "/custom/sessions" { + t.Errorf("GetSessionDir() = %q, want /custom/sessions", dir) + } +} + +func TestGetSessionDir_DefaultPath(t *testing.T) { + ag := &OpenClawAgent{} + + // Make sure env vars are not set + t.Setenv("ENTIRE_TEST_OPENCLAW_SESSION_DIR", "") + t.Setenv("OPENCLAW_SESSION_DIR", "") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + + // Should be an absolute path ending with .openclaw/sessions + if !filepath.IsAbs(dir) { + t.Errorf("GetSessionDir() should return absolute path, got %q", dir) + } + if !strings.HasSuffix(dir, filepath.Join(".openclaw", "sessions")) { + t.Errorf("GetSessionDir() = %q, want suffix .openclaw/sessions", dir) + } +} + +func TestFormatResumeCommand(t *testing.T) { + ag := &OpenClawAgent{} + + cmd := ag.FormatResumeCommand("abc123") + expected := "openclaw resume abc123" + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} + +func TestReadSession(t *testing.T) { + tempDir := t.TempDir() + + // Create a transcript file + transcriptPath := filepath.Join(tempDir, "transcript.jsonl") + transcriptContent := `{"role":"user","content":"hello","timestamp":"2025-01-01T00:00:00Z"} +{"role":"assistant","content":"hi there","timestamp":"2025-01-01T00:00:01Z"} +` + if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &OpenClawAgent{} + input := &agent.HookInput{ + SessionID: "test-session", + SessionRef: transcriptPath, + } + + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if session.SessionID != "test-session" { + t.Errorf("SessionID = %q, want test-session", session.SessionID) + } + if session.AgentName != agent.AgentNameOpenClaw { + t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameOpenClaw) + } + if len(session.NativeData) == 0 { + t.Error("NativeData is empty") + } +} + +func TestReadSession_NoSessionRef(t *testing.T) { + ag := &OpenClawAgent{} + input := &agent.HookInput{SessionID: "test-session"} + + _, err := ag.ReadSession(input) + if err == nil { + t.Error("ReadSession() should error when SessionRef is empty") + } +} + +func TestReadSession_WithModifiedFiles(t *testing.T) { + tempDir := t.TempDir() + + transcriptPath := filepath.Join(tempDir, "transcript.jsonl") + transcriptContent := `{"role":"user","content":"write a file","timestamp":"2025-01-01T00:00:00Z"} +{"role":"assistant","content":"writing...","tool_calls":[{"name":"write","params":{"file_path":"main.go"}}],"timestamp":"2025-01-01T00:00:01Z"} +{"role":"assistant","content":"editing...","tool_calls":[{"name":"edit","params":{"file_path":"utils.go"}}],"timestamp":"2025-01-01T00:00:02Z"} +` + if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &OpenClawAgent{} + input := &agent.HookInput{ + SessionID: "test-session", + SessionRef: transcriptPath, + } + + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if len(session.ModifiedFiles) != 2 { + t.Fatalf("ModifiedFiles count = %d, want 2", len(session.ModifiedFiles)) + } + + hasFile := func(name string) bool { + for _, f := range session.ModifiedFiles { + if f == name { + return true + } + } + return false + } + + if !hasFile("main.go") { + t.Error("ModifiedFiles missing main.go") + } + if !hasFile("utils.go") { + t.Error("ModifiedFiles missing utils.go") + } +} + +func TestWriteSession(t *testing.T) { + tempDir := t.TempDir() + transcriptPath := filepath.Join(tempDir, "transcript.jsonl") + + ag := &OpenClawAgent{} + session := &agent.AgentSession{ + SessionID: "test-session", + AgentName: agent.AgentNameOpenClaw, + SessionRef: transcriptPath, + NativeData: []byte(`{"role":"user","content":"hello"}` + "\n"), + } + + err := ag.WriteSession(session) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file was written + data, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read transcript: %v", err) + } + + if string(data) != `{"role":"user","content":"hello"}`+"\n" { + t.Errorf("transcript content = %q, want JSONL data", string(data)) + } +} + +func TestWriteSession_CreatesDirectory(t *testing.T) { + tempDir := t.TempDir() + transcriptPath := filepath.Join(tempDir, "nested", "dir", "transcript.jsonl") + + ag := &OpenClawAgent{} + session := &agent.AgentSession{ + SessionID: "test-session", + AgentName: agent.AgentNameOpenClaw, + SessionRef: transcriptPath, + NativeData: []byte(`{"role":"user","content":"hello"}` + "\n"), + } + + err := ag.WriteSession(session) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file exists + if _, err := os.Stat(transcriptPath); err != nil { + t.Errorf("transcript file not created: %v", err) + } +} + +func TestWriteSession_Nil(t *testing.T) { + ag := &OpenClawAgent{} + + err := ag.WriteSession(nil) + if err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + ag := &OpenClawAgent{} + session := &agent.AgentSession{ + AgentName: "claude-code", + SessionRef: "/path/to/file", + NativeData: []byte("{}"), + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should error for wrong agent") + } +} + +func TestWriteSession_NoSessionRef(t *testing.T) { + ag := &OpenClawAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameOpenClaw, + NativeData: []byte("{}"), + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should error when SessionRef is empty") + } +} + +func TestWriteSession_NoNativeData(t *testing.T) { + ag := &OpenClawAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameOpenClaw, + SessionRef: "/path/to/file", + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should error when NativeData is empty") + } +} + +// Transcript parsing tests + +func TestParseTranscript(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"user","content":"hello","timestamp":"2025-01-01T00:00:00Z"} +{"role":"assistant","content":"hi there","timestamp":"2025-01-01T00:00:01Z"} +`) + + messages, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(messages) != 2 { + t.Errorf("ParseTranscript() got %d messages, want 2", len(messages)) + } + + if messages[0].Role != "user" || messages[0].Content != "hello" { + t.Errorf("First message = %+v, want role=user, content=hello", messages[0]) + } + + if messages[1].Role != "assistant" || messages[1].Content != "hi there" { + t.Errorf("Second message = %+v, want role=assistant, content=hi there", messages[1]) + } +} + +func TestParseTranscript_SkipsMalformed(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"user","content":"hello"} +not valid json +{"role":"assistant","content":"hi"} +`) + + messages, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(messages) != 2 { + t.Errorf("ParseTranscript() got %d messages, want 2 (skipping malformed)", len(messages)) + } +} + +func TestSerializeTranscript(t *testing.T) { + t.Parallel() + + messages := []OpenClawMessage{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi"}, + } + + data, err := SerializeTranscript(messages) + if err != nil { + t.Fatalf("SerializeTranscript() error = %v", err) + } + + // Parse back to verify round-trip + parsed, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript(serialized) error = %v", err) + } + + if len(parsed) != 2 { + t.Errorf("Round-trip got %d messages, want 2", len(parsed)) + } + + if parsed[0].Role != "user" || parsed[0].Content != "hello" { + t.Errorf("Round-trip first message = %+v", parsed[0]) + } +} + +func TestExtractModifiedFiles(t *testing.T) { + t.Parallel() + + messages := []OpenClawMessage{ + {Role: "assistant", Content: "writing...", ToolCalls: []OpenClawToolCall{ + {Name: "write", Params: map[string]interface{}{"file_path": "foo.go"}}, + }}, + {Role: "assistant", Content: "editing...", ToolCalls: []OpenClawToolCall{ + {Name: "edit", Params: map[string]interface{}{"file_path": "bar.go"}}, + }}, + {Role: "assistant", Content: "running...", ToolCalls: []OpenClawToolCall{ + {Name: "exec", Params: map[string]interface{}{"command": "ls"}}, + }}, + {Role: "assistant", Content: "writing again...", ToolCalls: []OpenClawToolCall{ + {Name: "write", Params: map[string]interface{}{"file_path": "foo.go"}}, + }}, + } + + files := ExtractModifiedFiles(messages) + + // Should have foo.go and bar.go (deduplicated, exec not included) + if len(files) != 2 { + t.Errorf("ExtractModifiedFiles() got %d files, want 2", len(files)) + } + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("foo.go") { + t.Error("ExtractModifiedFiles() missing foo.go") + } + if !hasFile("bar.go") { + t.Error("ExtractModifiedFiles() missing bar.go") + } +} + +func TestExtractModifiedFiles_PathParam(t *testing.T) { + t.Parallel() + + messages := []OpenClawMessage{ + {Role: "assistant", ToolCalls: []OpenClawToolCall{ + {Name: "write", Params: map[string]interface{}{"path": "main.go"}}, + }}, + } + + files := ExtractModifiedFiles(messages) + if len(files) != 1 || files[0] != "main.go" { + t.Errorf("ExtractModifiedFiles() = %v, want [main.go]", files) + } +} + +func TestExtractLastUserPrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + messages []OpenClawMessage + want string + }{ + { + name: "multiple messages", + messages: []OpenClawMessage{ + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "response"}, + {Role: "user", Content: "second"}, + }, + want: "second", + }, + { + name: "empty transcript", + messages: nil, + want: "", + }, + { + name: "no user messages", + messages: []OpenClawMessage{ + {Role: "assistant", Content: "hello"}, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ExtractLastUserPrompt(tt.messages) + if got != tt.want { + t.Errorf("ExtractLastUserPrompt() = %q, want %q", got, tt.want) + } + }) + } +} + +// TranscriptAnalyzer tests + +func TestGetTranscriptPosition(t *testing.T) { + t.Parallel() + + ag := &OpenClawAgent{} + + t.Run("empty path", func(t *testing.T) { + pos, err := ag.GetTranscriptPosition("") + if err != nil { + t.Fatalf("error = %v", err) + } + if pos != 0 { + t.Errorf("position = %d, want 0", pos) + } + }) + + t.Run("nonexistent file", func(t *testing.T) { + pos, err := ag.GetTranscriptPosition("/nonexistent/path") + if err != nil { + t.Fatalf("error = %v", err) + } + if pos != 0 { + t.Errorf("position = %d, want 0", pos) + } + }) + + t.Run("file with lines", func(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "transcript.jsonl") + content := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi"} +{"role":"user","content":"bye"} +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + pos, err := ag.GetTranscriptPosition(path) + if err != nil { + t.Fatalf("error = %v", err) + } + if pos != 3 { + t.Errorf("position = %d, want 3", pos) + } + }) +} + +func TestExtractModifiedFilesFromOffset(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "transcript.jsonl") + + content := `{"role":"user","content":"write foo"} +{"role":"assistant","tool_calls":[{"name":"write","params":{"file_path":"foo.go"}}]} +{"role":"user","content":"write bar"} +{"role":"assistant","tool_calls":[{"name":"write","params":{"file_path":"bar.go"}}]} +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + ag := &OpenClawAgent{} + + // From offset 0 - should get both files + files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + if err != nil { + t.Fatalf("error = %v", err) + } + if pos != 4 { + t.Errorf("position = %d, want 4", pos) + } + if len(files) != 2 { + t.Errorf("files count = %d, want 2", len(files)) + } + + // From offset 2 - should get only bar.go + files, pos, err = ag.ExtractModifiedFilesFromOffset(path, 2) + if err != nil { + t.Fatalf("error = %v", err) + } + if pos != 4 { + t.Errorf("position = %d, want 4", pos) + } + if len(files) != 1 || files[0] != "bar.go" { + t.Errorf("files = %v, want [bar.go]", files) + } +} + +// Chunking tests + +func TestChunkTranscript_SmallContent(t *testing.T) { + ag := &OpenClawAgent{} + + content := []byte(`{"role":"user","content":"hello"} +{"role":"assistant","content":"hi"} +`) + + chunks, err := ag.ChunkTranscript(content, agent.MaxChunkSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) != 1 { + t.Errorf("Expected 1 chunk, got %d", len(chunks)) + } +} + +func TestChunkTranscript_LargeContent(t *testing.T) { + ag := &OpenClawAgent{} + + // Create content that exceeds maxSize + var lines []string + for i := range 100 { + msg := OpenClawMessage{ + Role: "user", + Content: fmt.Sprintf("message %d: %s", i, strings.Repeat("x", 500)), + } + data, _ := json.Marshal(msg) + lines = append(lines, string(data)) + } + content := []byte(strings.Join(lines, "\n") + "\n") + + maxSize := 5000 + chunks, err := ag.ChunkTranscript(content, maxSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + if len(chunks) < 2 { + t.Errorf("Expected at least 2 chunks for large content, got %d", len(chunks)) + } +} + +func TestChunkTranscript_RoundTrip(t *testing.T) { + ag := &OpenClawAgent{} + + messages := []OpenClawMessage{ + {Role: "user", Content: "Write a hello world program"}, + {Role: "assistant", Content: "Sure!", ToolCalls: []OpenClawToolCall{ + {Name: "write", Params: map[string]interface{}{"file_path": "main.go", "content": "package main"}}, + }}, + {Role: "user", Content: "Now add a function"}, + {Role: "assistant", Content: "Done!", ToolCalls: []OpenClawToolCall{ + {Name: "edit", Params: map[string]interface{}{"file_path": "main.go"}}, + }}, + } + + content, err := SerializeTranscript(messages) + if err != nil { + t.Fatalf("SerializeTranscript() error = %v", err) + } + + // Small maxSize to force chunking + maxSize := 200 + chunks, err := ag.ChunkTranscript(content, maxSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + // Parse and verify + result, err := ParseTranscript(reassembled) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(result) != len(messages) { + t.Fatalf("Message count mismatch: got %d, want %d", len(result), len(messages)) + } + + for i, msg := range result { + if msg.Role != messages[i].Role { + t.Errorf("Message %d role = %q, want %q", i, msg.Role, messages[i].Role) + } + if msg.Content != messages[i].Content { + t.Errorf("Message %d content = %q, want %q", i, msg.Content, messages[i].Content) + } + } +} + +func TestReassembleTranscript_EmptyChunks(t *testing.T) { + ag := &OpenClawAgent{} + + result, err := ag.ReassembleTranscript([][]byte{}) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + if len(result) != 0 { + t.Errorf("Expected empty result for empty chunks, got %d bytes", len(result)) + } +} + +// Registration test + +func TestAgentRegistered(t *testing.T) { + ag, err := agent.Get(agent.AgentNameOpenClaw) + if err != nil { + t.Fatalf("agent.Get(openclaw) error = %v", err) + } + if ag == nil { + t.Fatal("agent.Get(openclaw) returned nil") + } + if ag.Name() != agent.AgentNameOpenClaw { + t.Errorf("registered agent name = %q, want %q", ag.Name(), agent.AgentNameOpenClaw) + } + if ag.Type() != agent.AgentTypeOpenClaw { + t.Errorf("registered agent type = %q, want %q", ag.Type(), agent.AgentTypeOpenClaw) + } +} diff --git a/cmd/entire/cli/agent/openclaw/transcript.go b/cmd/entire/cli/agent/openclaw/transcript.go new file mode 100644 index 000000000..0375b194b --- /dev/null +++ b/cmd/entire/cli/agent/openclaw/transcript.go @@ -0,0 +1,101 @@ +package openclaw + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" +) + +// Scanner buffer size for large transcript files (10MB) +const scannerBufferSize = 10 * 1024 * 1024 + +// ParseTranscript parses raw JSONL content into OpenClaw messages +func ParseTranscript(data []byte) ([]OpenClawMessage, error) { + var messages []OpenClawMessage + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 0, scannerBufferSize), scannerBufferSize) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var msg OpenClawMessage + if err := json.Unmarshal(line, &msg); err != nil { + continue // Skip malformed lines + } + messages = append(messages, msg) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to scan transcript: %w", err) + } + return messages, nil +} + +// SerializeTranscript converts messages back to JSONL bytes +func SerializeTranscript(messages []OpenClawMessage) ([]byte, error) { + var buf bytes.Buffer + for _, msg := range messages { + data, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("failed to marshal message: %w", err) + } + buf.Write(data) + buf.WriteByte('\n') + } + return buf.Bytes(), nil +} + +// ExtractModifiedFiles extracts file paths modified by tool calls from messages +func ExtractModifiedFiles(messages []OpenClawMessage) []string { + fileSet := make(map[string]bool) + var files []string + + for _, msg := range messages { + if msg.Role != "assistant" { + continue + } + + for _, toolCall := range msg.ToolCalls { + // Check if it's a file modification tool + isModifyTool := false + for _, name := range FileModificationTools { + if toolCall.Name == name { + isModifyTool = true + break + } + } + + if !isModifyTool { + continue + } + + // Extract file path from params + var file string + if fp, ok := toolCall.Params["file_path"].(string); ok && fp != "" { + file = fp + } else if p, ok := toolCall.Params["path"].(string); ok && p != "" { + file = p + } + + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + } + + return files +} + +// ExtractLastUserPrompt extracts the last user message from the transcript +func ExtractLastUserPrompt(messages []OpenClawMessage) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" && messages[i].Content != "" { + return messages[i].Content + } + } + return "" +} diff --git a/cmd/entire/cli/agent/openclaw/types.go b/cmd/entire/cli/agent/openclaw/types.go new file mode 100644 index 000000000..ceb0d01bf --- /dev/null +++ b/cmd/entire/cli/agent/openclaw/types.go @@ -0,0 +1,42 @@ +package openclaw + +// OpenClaw session transcript types. +// OpenClaw stores sessions as JSONL files where each line is a JSON object +// representing a message in the conversation. + +// Tool names used in OpenClaw transcripts that modify files +const ( + ToolWrite = "write" + ToolEdit = "edit" +) + +// FileModificationTools lists tools that create or modify files +var FileModificationTools = []string{ + ToolWrite, + ToolEdit, +} + +// OpenClawToolCall represents a tool call in an assistant message +type OpenClawToolCall struct { + Name string `json:"name"` + Params map[string]interface{} `json:"params,omitempty"` +} + +// OpenClawMessage represents a single JSONL entry in an OpenClaw transcript. +// Each line in the JSONL file is one of: +// - {"role":"user","content":"...","timestamp":"..."} +// - {"role":"assistant","content":"...","tool_calls":[...],"timestamp":"..."} +type OpenClawMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []OpenClawToolCall `json:"tool_calls,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +// SessionMetadata holds OpenClaw session metadata. +// OpenClaw stores session data at ~/.openclaw/sessions//transcript.jsonl +type SessionMetadata struct { + SessionID string `json:"session_id"` + RepoPath string `json:"repo_path,omitempty"` + StartedAt string `json:"started_at,omitempty"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 901300196..bafdfacb7 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -80,12 +80,14 @@ type AgentType string const ( AgentNameClaudeCode AgentName = "claude-code" AgentNameGemini AgentName = "gemini" + AgentNameOpenClaw AgentName = "openclaw" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeOpenClaw AgentType = "OpenClaw" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..36c3823bc 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -5,6 +5,7 @@ import ( // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/openclaw" "github.com/spf13/cobra" ) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 435881ac9..64b4b3688 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -539,18 +539,19 @@ func printWrongAgentError(w io.Writer, name string) { // If strategyName is provided, it sets the strategy; otherwise uses default. func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName string, localDev, forceHooks, skipPushSessions, telemetry bool) error { agentName := ag.Name() - // Check if agent supports hooks - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return fmt.Errorf("agent %s does not support hooks", agentName) - } fmt.Fprintf(w, "Agent: %s\n\n", ag.Type()) - // Install agent hooks (agent hooks don't depend on settings) - installedHooks, err := hookAgent.InstallHooks(localDev, forceHooks) - if err != nil { - return fmt.Errorf("failed to install hooks for %s: %w", agentName, err) + // Install agent hooks if agent supports them + // Some agents (e.g., OpenClaw) use git hooks exclusively and don't have agent-side hooks + var installedHooks int + hookAgent, ok := ag.(agent.HookSupport) + if ok { + var err error + installedHooks, err = hookAgent.InstallHooks(localDev, forceHooks) + if err != nil { + return fmt.Errorf("failed to install hooks for %s: %w", agentName, err) + } } // Setup .entire directory @@ -607,18 +608,24 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str return fmt.Errorf("failed to install git hooks: %w", err) } - if installedHooks == 0 { - msg := fmt.Sprintf("Hooks for %s already installed", ag.Description()) - if agentName == agent.AgentNameGemini { - msg += " (Preview)" + if ok { + // Agent supports hooks - show hook installation status + if installedHooks == 0 { + msg := fmt.Sprintf("Hooks for %s already installed", ag.Description()) + if agentName == agent.AgentNameGemini { + msg += " (Preview)" + } + fmt.Fprintf(w, "%s\n", msg) + } else { + msg := fmt.Sprintf("Installed %d hooks for %s", installedHooks, ag.Description()) + if agentName == agent.AgentNameGemini { + msg += " (Preview)" + } + fmt.Fprintf(w, "%s\n", msg) } - fmt.Fprintf(w, "%s\n", msg) } else { - msg := fmt.Sprintf("Installed %d hooks for %s", installedHooks, ag.Description()) - if agentName == agent.AgentNameGemini { - msg += " (Preview)" - } - fmt.Fprintf(w, "%s\n", msg) + // Agent uses git hooks only (e.g., OpenClaw) + fmt.Fprintf(w, "✓ %s configured (uses git hooks)\n", ag.Description()) } fmt.Fprintf(w, "✓ Project configured (%s)\n", configDisplayProject) diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 41c5bb719..77b05bf20 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -11,6 +11,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/openclaw" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/go-git/go-git/v5" From 94060998f92dd63e28f7e15ea64ed869e50f5c77 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 10 Feb 2026 22:52:21 +0000 Subject: [PATCH 2/2] feat: add save-session hook for OpenClaw checkpoint creation OpenClaw agents don't have Claude-style lifecycle hooks (session-start/stop), so the shadow branch data was never written before commits. This adds: 1. HookHandler interface on OpenClawAgent with 'save-session' hook verb 2. hooks_openclaw_handlers.go - handles 'entire hooks openclaw save-session' which reads the transcript, extracts modified files, and calls SaveChanges to persist session data to the shadow branch Usage flow: # Before committing, save the session: echo '{"session_id":"...","transcript_path":"..."}' | entire hooks openclaw save-session # Then commit normally - checkpoint trailer added automatically git commit -m 'your message' Tested end-to-end: checkpoint created, transcript stored, files tracked, commit linked via Entire-Checkpoint trailer. --- cmd/entire/cli/agent/openclaw/openclaw.go | 16 ++- cmd/entire/cli/hooks_openclaw_handlers.go | 165 ++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 cmd/entire/cli/hooks_openclaw_handlers.go diff --git a/cmd/entire/cli/agent/openclaw/openclaw.go b/cmd/entire/cli/agent/openclaw/openclaw.go index a934a54f0..ce628562e 100644 --- a/cmd/entire/cli/agent/openclaw/openclaw.go +++ b/cmd/entire/cli/agent/openclaw/openclaw.go @@ -69,6 +69,19 @@ func (o *OpenClawAgent) DetectPresence() (bool, error) { return false, nil } +// HookNameSaveSession is the hook verb for saving session data to the shadow branch. +// Called before committing to persist the transcript: `entire hooks openclaw save-session` +const HookNameSaveSession = "save-session" + +// Ensure OpenClawAgent implements HookHandler +var _ agent.HookHandler = (*OpenClawAgent)(nil) + +// GetHookNames returns the hook verbs OpenClaw supports. +// These become subcommands: entire hooks openclaw +func (o *OpenClawAgent) GetHookNames() []string { + return []string{HookNameSaveSession} +} + // GetHookConfigPath returns empty since OpenClaw uses git hooks, not agent-side hooks. func (o *OpenClawAgent) GetHookConfigPath() string { return "" @@ -76,7 +89,8 @@ func (o *OpenClawAgent) GetHookConfigPath() string { // SupportsHooks returns false as OpenClaw uses git hooks exclusively. // OpenClaw sessions are captured via prepare-commit-msg, post-commit, and pre-push -// hooks that `entire enable` already installs. +// hooks that `entire enable` already installs. The save-session hook is called +// explicitly by the agent before committing. func (o *OpenClawAgent) SupportsHooks() bool { return false } diff --git a/cmd/entire/cli/hooks_openclaw_handlers.go b/cmd/entire/cli/hooks_openclaw_handlers.go new file mode 100644 index 000000000..4a2b18b32 --- /dev/null +++ b/cmd/entire/cli/hooks_openclaw_handlers.go @@ -0,0 +1,165 @@ +// hooks_openclaw_handlers.go contains OpenClaw specific hook handler implementations. +// These are called by the hook registry in hook_registry.go. +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + openclawagent "github.com/entireio/cli/cmd/entire/cli/agent/openclaw" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +//nolint:gochecknoinits // Hook handler registration at startup is the intended pattern +func init() { + // Register OpenClaw handlers + RegisterHookHandler(agent.AgentNameOpenClaw, openclawagent.HookNameSaveSession, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenClawSaveSession() + }) +} + +// handleOpenClawSaveSession saves the current OpenClaw session transcript to +// the shadow branch so that the next git commit can create a checkpoint. +// +// This is the OpenClaw equivalent of Claude Code's "stop" hook - it takes the +// session transcript and persists it to Entire's metadata branch. +// +// Input (stdin JSON): +// +// { +// "session_id": "...", +// "transcript_path": "/path/to/transcript.jsonl" +// } +func handleOpenClawSaveSession() error { + // Parse input + ag, err := agent.Get(agent.AgentNameOpenClaw) + if err != nil { + return fmt.Errorf("failed to get openclaw agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookSessionEnd, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse input: %w", err) + } + + sessionID := ag.GetSessionID(input) + if sessionID == "" { + return fmt.Errorf("session_id is required") + } + + transcriptPath := input.SessionRef + if transcriptPath == "" { + return fmt.Errorf("transcript_path is required") + } + + // Read the transcript + transcript, err := os.ReadFile(transcriptPath) //nolint:gosec // Path comes from OpenClaw + if err != nil { + return fmt.Errorf("failed to read transcript: %w", err) + } + + // Parse transcript to extract modified files + messages, err := openclawagent.ParseTranscript(transcript) + if err != nil { + return fmt.Errorf("failed to parse transcript: %w", err) + } + + modifiedFiles := openclawagent.ExtractModifiedFiles(messages) + + // Get repo root for path normalization + repoRoot, err := paths.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + // Normalize file paths to be repo-relative + relModifiedFiles := FilterAndNormalizePaths(modifiedFiles, repoRoot) + + if len(relModifiedFiles) == 0 { + fmt.Fprintf(os.Stderr, "[entire] No file modifications found in transcript\n") + } else { + fmt.Fprintf(os.Stderr, "[entire] Files modified during session (%d):\n", len(relModifiedFiles)) + for _, file := range relModifiedFiles { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + } + + // Create session metadata directory + sessionDir := filepath.Join(paths.EntireMetadataDir, sessionID) + sessionDirAbs := filepath.Join(repoRoot, sessionDir) + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create session dir: %w", err) + } + + // Write transcript to metadata dir + transcriptDest := filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := os.WriteFile(transcriptDest, transcript, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + // Create minimal context file + contextData := map[string]interface{}{ + "session_id": sessionID, + "agent": "openclaw", + "files": relModifiedFiles, + } + contextJSON, _ := json.MarshalIndent(contextData, "", " ") + contextFile := filepath.Join(sessionDirAbs, paths.ContextFileName) + if err := os.WriteFile(contextFile, contextJSON, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "[entire] Warning: failed to write context file: %v\n", err) + } + + // Get git author + author, err := GetGitAuthor() + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + // Get the strategy and save changes + strat := GetStrategy() + if err := strat.EnsureSetup(); err != nil { + fmt.Fprintf(os.Stderr, "[entire] Warning: failed to ensure strategy setup: %v\n", err) + } + + // Extract last user prompt for commit message + lastPrompt := openclawagent.ExtractLastUserPrompt(messages) + commitMsg := "OpenClaw session" + if lastPrompt != "" { + if len(lastPrompt) > 72 { + commitMsg = lastPrompt[:72] + } else { + commitMsg = lastPrompt + } + } + + ctx := strategy.SaveContext{ + SessionID: sessionID, + ModifiedFiles: relModifiedFiles, + MetadataDir: sessionDir, + MetadataDirAbs: sessionDirAbs, + CommitMessage: commitMsg, + TranscriptPath: transcriptPath, + AuthorName: author.Name, + AuthorEmail: author.Email, + AgentType: agent.AgentTypeOpenClaw, + } + + if err := strat.SaveChanges(ctx); err != nil { + return fmt.Errorf("failed to save changes: %w", err) + } + + fmt.Fprintf(os.Stderr, "[entire] Session saved to shadow branch\n") + + // Transition to turn end + transitionSessionTurnEnd(sessionID) + + return nil +}