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
47 changes: 47 additions & 0 deletions src/context_system/prompt_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,52 @@ def _compute_env_info(cwd: str) -> str:
parts.append(f"OS: {platform.system()} {platform.release()}")
# ch17 round-4 — date-only for cache stability (see _build_env_section).
parts.append(f"Date: {_get_session_start_date_iso()}")
data_line = _clawcodex_data_dir_line()
if data_line:
parts.append(data_line)

return "\n".join(parts)


def _clawcodex_data_dir_line() -> str | None:
"""One-line pointer to clawcodex's session store for the env section.

clawcodex is a distinct product from the real Claude Code harness and
keeps its previous-session state under a rebranded root — never
``~/.claude``, which belongs to the other tool. Without this line the
model, when asked to inspect "previous session history", falls back on
its trained prior and greps ``~/.claude`` (or even a mangled
``~/.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
# Environment block without busting the cache prefix.
"""
try:
from pathlib import Path

root = str(Path.home() / ".clawcodex")
except Exception:
return None
return (
f"clawcodex data directory: {root} — this tool's previous session "
f"history and transcripts live in the sessions/ and transcripts/ "
f"subdirectories here, NOT under ~/.claude (which belongs to a "
f"different tool)"
)


# ---------------------------------------------------------------------------
# Full system prompt builder (R2-WS-5)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1092,6 +1134,11 @@ def _build_env_section(cwd: str | None, use_cache: bool) -> SystemPromptSection
parts.append(f"- User: {getpass.getuser()}")
except Exception:
pass
# Tell the model where clawcodex keeps its own state so it stops guessing
# the real Claude Code harness's ~/.claude when asked about session history.
data_line = _clawcodex_data_dir_line()
if data_line:
parts.append(f"- {data_line}")

content = "\n".join(parts)
# Environment changes per request (CWD, date)
Expand Down
10 changes: 10 additions & 0 deletions tests/test_live_base_system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ def test_includes_base_sections(tmp_path: Path):
assert marker in text, f"base section missing: {marker!r}"


def test_names_clawcodex_data_dir(tmp_path: Path):
"""The live prompt tells the model where clawcodex keeps session history,
so it stops falling back on the real Claude Code harness's ~/.claude when
asked to inspect previous sessions."""
ctx = ToolContext(workspace_root=tmp_path)
text = _joined(build_effective_system_prompt(_SENTINEL_STYLE, ctx))
assert "clawcodex data directory:" in text
assert "NOT under ~/.claude" in text


def test_appends_the_resolved_style(tmp_path: Path):
"""The resolved output-style prompt is appended (style still applied)."""
ctx = ToolContext(workspace_root=tmp_path)
Expand Down
72 changes: 72 additions & 0 deletions tests/test_prompt_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from unittest.mock import patch

from src.context_system.prompt_assembly import (
_build_env_section,
_clawcodex_data_dir_line,
_compute_env_info,
_get_local_iso_date,
append_system_context,
Expand Down Expand Up @@ -627,6 +629,76 @@ def test_includes_cwd(self):
self.assertIn("OS:", result)
self.assertIn("Date:", result)

def test_includes_clawcodex_data_dir(self):
# Regression: the model must be told where clawcodex keeps session
# history so it stops guessing the real Claude Code harness's
# ~/.claude directory.
result = _compute_env_info("/test/path")
self.assertIn("clawcodex data directory:", result)


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")

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()

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
# re-homed, this fails loudly rather than letting the system prompt
# silently point at a stale location. Both are pinned to their real
# production sources (not re-derived here) so the guard has teeth.
from pathlib import Path

from src.agent.transcript import get_agent_transcript_path
from src.services.session_storage import SESSIONS_DIR

line = _clawcodex_data_dir_line()
assert line is not None
# SESSIONS_DIR = <root>/sessions ; transcripts = <root>/transcripts.
# The line names <root>, so each store's *parent* must appear in it.
self.assertIn(str(SESSIONS_DIR.parent), line)
transcripts_dir = Path(get_agent_transcript_path("drift-probe")).parent
self.assertIn(str(transcripts_dir.parent), line)

def test_steers_away_from_dot_claude(self):
line = _clawcodex_data_dir_line()
assert line is not None
# Must warn against the OTHER tool's directory, and must not point the
# model at ~/.claude as the place to look.
self.assertIn("NOT under ~/.claude", line)

def test_live_env_block_carries_the_line(self):
section = _build_env_section("/test/path", use_cache=False)
assert section is not None
self.assertIn("clawcodex data directory:", section.content)

def test_env_block_stays_cache_stable(self):
# The line is a constant string, so two builds must be byte-identical
# (the # Environment block is on the REQUEST cache prefix).
s1 = _build_env_section("/test/path", use_cache=False)
s2 = _build_env_section("/test/path", use_cache=False)
assert s1 is not None and s2 is not None
self.assertEqual(s1.content, s2.content)


class TestClearContextCaches(unittest.TestCase):
def test_no_crash(self):
Expand Down
Loading