diff --git a/README.md b/README.md index d3f24ff..0085f4c 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ sessiontrove ~/Backups/agent-sessions --machine macbookpro-m4 | --- | --- | | Claude Code | Project transcripts and `history.jsonl` under `$CLAUDE_CONFIG_DIR` or `~/.claude` | | Codex | `~/.codex/sessions`, `~/.codex/archived_sessions`, and `~/.codex/history.jsonl` | +| OpenClaw | Every agent's `sessions` directory under `$OPENCLAW_STATE_DIR/agents` or `~/.openclaw/agents` | | Pi | `~/.pi/agent/sessions` | -Claude and Codex history files hold long-lived user prompts; project and rollout files hold the full transcripts. +Claude and Codex history files hold long-lived user prompts; project and rollout files hold the full transcripts. OpenClaw archives include its session index, transcripts, reset and deleted sessions, trajectories, caches, and migration or repair artifacts. Temporary and lock files are skipped. ### Keep Claude Code transcripts diff --git a/src/sessiontrove/archive.py b/src/sessiontrove/archive.py index f213cf7..7cdce7d 100644 --- a/src/sessiontrove/archive.py +++ b/src/sessiontrove/archive.py @@ -16,6 +16,7 @@ class Source: path: Path target: Path patterns: tuple[str, ...] + exclude_patterns: tuple[str, ...] = () _SAFE_MACHINE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") @@ -29,6 +30,28 @@ def default_sources( home = (home or Path.home()).expanduser() environ = os.environ if environ is None else environ claude_home = Path(environ.get("CLAUDE_CONFIG_DIR", home / ".claude")).expanduser() + openclaw_override = environ.get("OPENCLAW_STATE_DIR", "").strip() + openclaw_home = Path(openclaw_override or home / ".openclaw").expanduser() + openclaw_agents = openclaw_home / "agents" + openclaw_sources = [] + if openclaw_agents.is_dir() and not openclaw_agents.is_symlink(): + for agent_path in sorted(openclaw_agents.iterdir(), key=lambda path: path.name): + sessions = agent_path / "sessions" + if ( + agent_path.is_dir() + and not agent_path.is_symlink() + and sessions.is_dir() + and not sessions.is_symlink() + ): + openclaw_sources.append( + Source( + "openclaw", + sessions, + Path("agents") / agent_path.name / "sessions", + ("*",), + ("*.lock", "*.tmp"), + ) + ) return ( Source( @@ -62,6 +85,7 @@ def default_sources( Path("sessions"), ("*.jsonl",), ), + *openclaw_sources, ) @@ -70,7 +94,11 @@ def _absolute(path: Path) -> Path: def _matches(relative: Path, source: Source) -> bool: - return any(fnmatch.fnmatch(relative.name, pattern) for pattern in source.patterns) + return any( + fnmatch.fnmatch(relative.name, pattern) for pattern in source.patterns + ) and not any( + fnmatch.fnmatch(relative.name, pattern) for pattern in source.exclude_patterns + ) def _files(source: Source) -> list[tuple[Path, Path]]: diff --git a/tests/test_archive.py b/tests/test_archive.py index 43ad1bd..d752f53 100644 --- a/tests/test_archive.py +++ b/tests/test_archive.py @@ -109,6 +109,82 @@ def test_archives_complete_agent_layouts_without_unrelated_state( } +def test_discovers_and_archives_openclaw_session_directories(tmp_path: Path) -> None: + home = tmp_path / "home" + state = tmp_path / "openclaw-state" + main_sessions = state / "agents/main/sessions" + worker_sessions = state / "agents/worker/sessions" + + write(main_sessions / "sessions.json") + write(main_sessions / "session.jsonl") + write(main_sessions / "session.jsonl.reset.2026-08-24T18-00-00.000Z") + write(main_sessions / "session.jsonl.deleted.2026-08-24T18-00-00.000Z") + write(main_sessions / "session.trajectory.jsonl") + write(main_sessions / "session.trajectory-path.json") + write(main_sessions / ".usage-cost-cache.json") + write(main_sessions / "sessions.json.bak.repair") + write(main_sessions / "session.jsonl.migrated") + write(main_sessions / "session.jsonl.lock", "ephemeral") + write(main_sessions / ".usage-cost-cache.json.123.tmp", "ephemeral") + write(state / "agents/main/agent/auth-profiles.json", "secret") + write(state / "agents/main/agent/codex-home/sessions/rollout.jsonl", "duplicate") + write(worker_sessions / "worker.jsonl") + write(state / "agents/no-sessions/agent/runtime.json", "unrelated") + + sources = default_sources( + home, + {"OPENCLAW_STATE_DIR": str(state)}, + ) + assert tuple(source for source in sources if source.agent == "openclaw") == ( + Source( + "openclaw", + main_sessions, + Path("agents/main/sessions"), + ("*",), + ("*.lock", "*.tmp"), + ), + Source( + "openclaw", + worker_sessions, + Path("agents/worker/sessions"), + ("*",), + ("*.lock", "*.tmp"), + ), + ) + + destination = tmp_path / "archive" + assert archive(destination, MACHINE, sources) == {"openclaw": 10} + openclaw_archive = destination / MACHINE / "openclaw" + archived = { + path.relative_to(openclaw_archive).as_posix() + for path in openclaw_archive.rglob("*") + if path.is_file() + } + assert archived == { + "agents/main/sessions/.usage-cost-cache.json", + "agents/main/sessions/session.jsonl", + "agents/main/sessions/session.jsonl.deleted.2026-08-24T18-00-00.000Z", + "agents/main/sessions/session.jsonl.migrated", + "agents/main/sessions/session.jsonl.reset.2026-08-24T18-00-00.000Z", + "agents/main/sessions/session.trajectory-path.json", + "agents/main/sessions/session.trajectory.jsonl", + "agents/main/sessions/sessions.json", + "agents/main/sessions/sessions.json.bak.repair", + "agents/worker/sessions/worker.jsonl", + } + + +def test_openclaw_defaults_to_the_state_directory_under_home(tmp_path: Path) -> None: + sessions = tmp_path / ".openclaw/agents/main/sessions" + sessions.mkdir(parents=True) + + assert [ + source.path + for source in default_sources(tmp_path, {}) + if source.agent == "openclaw" + ] == [sessions] + + def test_machine_directories_keep_shared_archives_separate(tmp_path: Path) -> None: session = tmp_path / "source/session.jsonl" write(session, "from mac")