diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f95ef4a9..a8785a13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Session history and transcripts (`sessions/`, `transcripts/`) now honor + `$CLAWCODEX_CONFIG_DIR`, consistent with config, memory, skills, and auth. + Default users are unaffected (still under `~/.clawcodex/`). If you set the + override *after* accumulating sessions, `/resume` and session search look + under the new root — move the old `~/.clawcodex/sessions` and + `~/.clawcodex/transcripts` if you want them to carry over. The + `# Environment` system-prompt hint now points at the resolved root so the + model finds session history in either configuration. + ## [1.1.0] - 2026-07-12 ### Added diff --git a/src/agent/session.py b/src/agent/session.py index f678aef3c..89afc5f01 100644 --- a/src/agent/session.py +++ b/src/agent/session.py @@ -16,11 +16,11 @@ import json import time -from pathlib import Path from datetime import datetime from typing import Optional from dataclasses import dataclass, field +from src.utils.clawcodex_dirs import get_sessions_dir from src.bootstrap.state import ( get_model_usage, get_session_id, @@ -56,7 +56,7 @@ def save(self): usage). Previously this method emitted no cost block; the restore reader hit defaults of 0 unconditionally. """ - session_dir = Path.home() / ".clawcodex" / "sessions" + session_dir = get_sessions_dir() session_dir.mkdir(parents=True, exist_ok=True) session_file = session_dir / f"{self.session_id}.json" @@ -81,7 +81,7 @@ def save(self): @classmethod def load(cls, session_id: str) -> Optional['Session']: """Load session from disk.""" - session_file = Path.home() / ".clawcodex" / "sessions" / f"{session_id}.json" + session_file = get_sessions_dir() / f"{session_id}.json" if not session_file.exists(): return None diff --git a/src/agent/transcript.py b/src/agent/transcript.py index 1687b17c2..b993202f4 100644 --- a/src/agent/transcript.py +++ b/src/agent/transcript.py @@ -51,6 +51,8 @@ from pathlib import Path from typing import Any, Iterator +from src.utils.clawcodex_dirs import get_transcripts_dir + logger = logging.getLogger(__name__) @@ -60,14 +62,14 @@ def _transcripts_root() -> Path: - """Return ``~/.clawcodex/transcripts/`` (created if absent). + """Return the transcripts dir (created if absent). - Mirrors the directory convention that ``typescript/src/utils/sessionStorage.ts`` - uses for sidechain transcripts; the Python repo's home-relative - ``~/.clawcodex`` matches the bash background dir at - ``/clawcodex-bg`` philosophically (per-user, not per-workspace). + Resolved through ``get_transcripts_dir()`` so it honors + ``$CLAWCODEX_CONFIG_DIR`` (default ``~/.clawcodex/transcripts``). Mirrors + the directory convention that ``typescript/src/utils/sessionStorage.ts`` + uses for sidechain transcripts; per-user, not per-workspace. """ - root = Path.home() / ".clawcodex" / "transcripts" + root = get_transcripts_dir() root.mkdir(parents=True, exist_ok=True) return root diff --git a/src/context_system/prompt_assembly.py b/src/context_system/prompt_assembly.py index 2eee16f66..d23ad60b5 100644 --- a/src/context_system/prompt_assembly.py +++ b/src/context_system/prompt_assembly.py @@ -369,24 +369,19 @@ def _clawcodex_data_dir_line() -> str | None: ``~/.Claude Code/sessions``), landing on the wrong tool's data or nothing at all. - Anchored on ``Path.home() / ".clawcodex"`` rather than - ``get_user_config_dir()``: the ``sessions/`` and ``transcripts/`` stores - are hardcoded to ``~/.clawcodex`` in every writer (``agent/session.py``, - ``agent/transcript.py``, ``services/session_storage.py``, …) and - deliberately do NOT honor ``$CLAWCODEX_CONFIG_DIR``, so this is their - invariant location in both the default and the override configuration. - (Config/memory/skills DO relocate under the override — but the model - doesn't need those to find session history, so we don't name them and - avoid emitting a path we can't back up. Anchoring on the override root - instead would authoritatively misdirect the model whenever the override - is set — strictly worse than the guessing this line replaces.) The value - is constant per process, so it is safe to embed in the REQUEST-scoped + Anchored on ``get_user_config_dir()`` (``$CLAWCODEX_CONFIG_DIR`` or + ``~/.clawcodex``): the ``sessions/`` and ``transcripts/`` stores now + resolve through ``get_sessions_dir()``/``get_transcripts_dir()`` (same + resolver), so they relocate WITH the override — naming that root points + the model at the real location in both default and override configs. + The value is constant per process (the override is an env var fixed + before start), so it is safe to embed in the REQUEST-scoped # Environment block without busting the cache prefix. """ try: - from pathlib import Path + from src.utils.clawcodex_dirs import get_user_config_dir - root = str(Path.home() / ".clawcodex") + root = str(get_user_config_dir()) except Exception: return None return ( diff --git a/src/memdir/memdir.py b/src/memdir/memdir.py index c35f5eea7..dfe0ba4a5 100644 --- a/src/memdir/memdir.py +++ b/src/memdir/memdir.py @@ -18,10 +18,11 @@ import logging from dataclasses import dataclass -import os from pathlib import Path from typing import Iterable +from src.utils.clawcodex_dirs import get_sessions_dir + from .memory_types import ( MEMORY_FRONTMATTER_EXAMPLE, TRUSTING_RECALL_SECTION, @@ -211,10 +212,11 @@ def build_searching_past_context_section(auto_mem_dir: str) -> list[str]: tool is hidden (ant-native embedded search / REPL script mode); this port always ships the Grep tool, so the tool-invocation forms are used unconditionally. The transcript target is this port's saved-session - store (``~/.clawcodex/sessions/``, ``*.json``) rather than the reference - project-transcript dir. + store (the ``sessions/`` dir under the clawcodex config root — + ``$CLAWCODEX_CONFIG_DIR`` or ``~/.clawcodex`` — ``*.json``) rather than + the reference project-transcript dir. """ - sessions_dir = os.path.join(os.path.expanduser("~"), ".clawcodex", "sessions") + sessions_dir = str(get_sessions_dir()) mem_search = ( f'Grep with pattern="" path="{auto_mem_dir}" glob="*.md"' ) diff --git a/src/server/agent_server.py b/src/server/agent_server.py index 86f30b772..1ea696b6c 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -4193,7 +4193,10 @@ async def chat_async(self, *args: Any, **kwargs: Any) -> Any: def _sessions_dir() -> Path: - return Path.home() / ".clawcodex" / "sessions" + # Honors $CLAWCODEX_CONFIG_DIR (default ~/.clawcodex/sessions). + from src.utils.clawcodex_dirs import get_sessions_dir + + return get_sessions_dir() def _first_prompt_preview(msgs: list) -> str: diff --git a/src/services/cost_restore.py b/src/services/cost_restore.py index 7e323d89a..ce70980a1 100644 --- a/src/services/cost_restore.py +++ b/src/services/cost_restore.py @@ -25,11 +25,16 @@ SessionId, set_cost_state_for_restore, ) +from src.utils.clawcodex_dirs import get_sessions_dir def _sessions_dir() -> Path: - """Persistence directory — extracted so tests can monkeypatch.""" - return Path.home() / ".clawcodex" / "sessions" + """Persistence directory — extracted so tests can monkeypatch. + + Delegates to ``get_sessions_dir()`` so it honors ``$CLAWCODEX_CONFIG_DIR`` + (default ``~/.clawcodex/sessions``). + """ + return get_sessions_dir() def build_cost_block() -> dict[str, Any]: diff --git a/src/services/session_storage.py b/src/services/session_storage.py index 1ebb2381f..61062a774 100644 --- a/src/services/session_storage.py +++ b/src/services/session_storage.py @@ -20,11 +20,17 @@ from typing import Any from ..types.messages import Message, message_to_dict, message_from_dict +from ..utils.clawcodex_dirs import get_sessions_dir logger = logging.getLogger(__name__) -# Default directories -SESSIONS_DIR = Path.home() / ".clawcodex" / "sessions" +# Default directories. Resolved through get_sessions_dir() so the store honors +# $CLAWCODEX_CONFIG_DIR (default ~/.clawcodex/sessions). Kept as a module +# attribute — the internal ``sessions_dir or SESSIONS_DIR`` fallbacks and tests +# that monkeypatch ``SESSIONS_DIR`` both read it. Resolved at import: the +# override is an env var fixed before process start, so this is correct in +# production; tests override the attribute directly. +SESSIONS_DIR = get_sessions_dir() CONTENT_DIR_NAME = "content" # Thresholds diff --git a/src/utils/clawcodex_dirs.py b/src/utils/clawcodex_dirs.py index 2a2303ead..cd0544765 100644 --- a/src/utils/clawcodex_dirs.py +++ b/src/utils/clawcodex_dirs.py @@ -72,6 +72,32 @@ def get_legacy_user_config_dir() -> Path: return Path.home() / LEGACY_USER_DIR_NAME +def get_sessions_dir() -> Path: + """User session store: ``/sessions``. + + Resumable per-session state (``*.json`` with the conversation, cost, + preview, cwd) lives here. Resolved through ``get_user_config_dir()`` so + it honors ``$CLAWCODEX_CONFIG_DIR`` — consistent with config/memory/ + skills/auth/mcp. (Historically the ~10 session writers each hardcoded + ``~/.clawcodex/sessions`` and ignored the override; this is the single + source of truth that fixed that.) Not created here — writers ``mkdir`` + on demand. + """ + return get_user_config_dir() / "sessions" + + +def get_transcripts_dir() -> Path: + """User transcript store: ``/transcripts``. + + Append-only per-agent transcript logs (``*.jsonl``) and workflow-run + journals (under ``transcripts/workflows/``) live here. Resolved through + ``get_user_config_dir()`` so it honors ``$CLAWCODEX_CONFIG_DIR`` — see + ``get_sessions_dir`` for the rationale. Not created here — writers + ``mkdir`` on demand. + """ + return get_user_config_dir() / "transcripts" + + __all__ = [ "CONFIG_DIR_ENV", "MANAGED_CONFIG_DIR_ENV", @@ -82,4 +108,6 @@ def get_legacy_user_config_dir() -> Path: "get_user_config_dir", "get_managed_config_dir", "get_legacy_user_config_dir", + "get_sessions_dir", + "get_transcripts_dir", ] diff --git a/tests/test_prompt_assembly.py b/tests/test_prompt_assembly.py index 23ac36961..0461954c1 100644 --- a/tests/test_prompt_assembly.py +++ b/tests/test_prompt_assembly.py @@ -640,12 +640,12 @@ def test_includes_clawcodex_data_dir(self): class TestClawcodexDataDirLine(unittest.TestCase): """The env-section pointer to clawcodex's session store.""" - def test_names_clawcodex_home_and_ignores_override(self): - # sessions/ and transcripts/ are hardcoded to ~/.clawcodex and do NOT - # honor $CLAWCODEX_CONFIG_DIR, so the line must name ~/.clawcodex in - # BOTH modes — never the override root, which would authoritatively - # misdirect the model (worse than the guessing this line replaces). - expected = os.path.join(os.path.expanduser("~"), ".clawcodex") + def test_follows_config_dir_override(self): + # sessions/ and transcripts/ now resolve through get_user_config_dir() + # (get_sessions_dir/get_transcripts_dir), so the line must relocate WITH + # $CLAWCODEX_CONFIG_DIR — pointing the model at the real session store + # in both the default and the override configuration. + default_root = os.path.join(os.path.expanduser("~"), ".clawcodex") with patch.dict(os.environ, {}, clear=False): os.environ.pop("CLAWCODEX_CONFIG_DIR", None) @@ -653,13 +653,13 @@ def test_names_clawcodex_home_and_ignores_override(self): with patch.dict(os.environ, {"CLAWCODEX_CONFIG_DIR": "/custom/cc"}): line_override = _clawcodex_data_dir_line() + assert line_default is not None and line_override is not None + self.assertIn(default_root, line_default) + self.assertIn("/custom/cc", line_override) + # Both still name the two subdirectories the model should grep. for line in (line_default, line_override): - assert line is not None - self.assertIn(expected, line) self.assertIn("sessions/", line) self.assertIn("transcripts/", line) - assert line_override is not None - self.assertNotIn("/custom/cc", line_override) def test_named_root_matches_actual_stores(self): # Drift guard: if EITHER the sessions or the transcripts store is ever diff --git a/tests/utils/test_clawcodex_dirs.py b/tests/utils/test_clawcodex_dirs.py new file mode 100644 index 000000000..2e27af95f --- /dev/null +++ b/tests/utils/test_clawcodex_dirs.py @@ -0,0 +1,53 @@ +"""Tests for the canonical session/transcript path resolvers. + +These stores used to be hardcoded to ``~/.clawcodex/sessions`` and +``~/.clawcodex/transcripts`` in ~10 writers, ignoring ``$CLAWCODEX_CONFIG_DIR`` +(which config/memory/skills/auth/mcp already honor). ``get_sessions_dir()`` / +``get_transcripts_dir()`` are the single source of truth that fixed that. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from src.utils.clawcodex_dirs import ( + get_sessions_dir, + get_transcripts_dir, + get_user_config_dir, +) + + +def test_default_is_home_clawcodex(monkeypatch): + """Override unset → ~/.clawcodex/{sessions,transcripts} (unchanged from the + pre-migration hardcoded location, so existing users see no move).""" + monkeypatch.delenv("CLAWCODEX_CONFIG_DIR", raising=False) + home_root = Path.home() / ".clawcodex" + assert get_sessions_dir() == home_root / "sessions" + assert get_transcripts_dir() == home_root / "transcripts" + + +def test_honors_config_dir_override(tmp_path, monkeypatch): + """Override set → stores relocate under it, consistent with config/memory.""" + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", str(tmp_path)) + assert get_sessions_dir() == tmp_path / "sessions" + assert get_transcripts_dir() == tmp_path / "transcripts" + + +def test_anchored_on_the_same_resolver_as_config(tmp_path, monkeypatch): + """Both live directly under the user config root — so the # Environment + prompt line (which names get_user_config_dir()) is always accurate.""" + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", str(tmp_path)) + root = get_user_config_dir() + assert get_sessions_dir().parent == root + assert get_transcripts_dir().parent == root + + +def test_expands_user_in_override(monkeypatch): + """A ``~``-relative override is expanduser'd (mirrors get_user_config_dir).""" + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", "~/some-cc-root") + expected = Path(os.path.expanduser("~/some-cc-root")) + assert get_sessions_dir() == expected / "sessions" + assert get_transcripts_dir() == expected / "transcripts"