Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/agent/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/agent/transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand All @@ -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
``<tmp>/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

Expand Down
23 changes: 9 additions & 14 deletions src/context_system/prompt_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
10 changes: 6 additions & 4 deletions src/memdir/memdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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="<search term>" path="{auto_mem_dir}" glob="*.md"'
)
Expand Down
5 changes: 4 additions & 1 deletion src/server/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions src/services/cost_restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
10 changes: 8 additions & 2 deletions src/services/session_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/utils/clawcodex_dirs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ``<user config dir>/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: ``<user config dir>/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",
Expand All @@ -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",
]
20 changes: 10 additions & 10 deletions tests/test_prompt_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,26 +640,26 @@ 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)
line_default = _clawcodex_data_dir_line()
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
Expand Down
53 changes: 53 additions & 0 deletions tests/utils/test_clawcodex_dirs.py
Original file line number Diff line number Diff line change
@@ -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"
Loading