feat(hooks): wire Claude Code PreCompact snapshot#225
Conversation
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
📝 WalkthroughSummary
WalkthroughThis PR implements a new PreCompact hook that captures bounded JSON snapshots of brain state before context compaction. It adds a command helper to the base adapter, rewrites the PreCompact hook with snapshot generation logic, integrates the hook into the Claude Code adapter for both PreToolUse and PreCompact lifecycles, and includes comprehensive testing of the snapshot generation and adapter registration. ChangesPreCompact Hook and Adapter Integration
🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 OpenGrep (1.22.0)OpenGrep fatal error (exit code 2): �[32m✔�[39m �[1mOpengrep OSS�[0m �[1m Loading rules from local config...�[0m Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Gradata/src/gradata/hooks/pre_compact.py`:
- Around line 45-50: The function _read_bounded currently calls
path.read_bytes()[:limit] which reads the entire file into memory then
truncates; change it to perform a truly bounded read by opening the file in
binary mode and calling file.read(limit) (e.g., with path.open("rb") as f: data
= f.read(limit)) and then decode with errors="replace"; keep the existing
is_file check and return None for non-files and return the decoded string (or
None) as before.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: ca494a96-e5ad-456e-b72c-960c93a3f7a2
📒 Files selected for processing (5)
Gradata/src/gradata/hooks/adapters/_base.pyGradata/src/gradata/hooks/adapters/claude_code.pyGradata/src/gradata/hooks/pre_compact.pyGradata/tests/test_hook_adapters.pyGradata/tests/test_pre_compact_hook.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
- GitHub Check: pytest macos-latest / py3.12
- GitHub Check: pytest windows-latest / py3.11
- GitHub Check: pytest ubuntu-latest / py3.12
- GitHub Check: pytest macos-latest / py3.11
- GitHub Check: pytest ubuntu-latest / py3.11
- GitHub Check: pytest windows-latest / py3.12
- GitHub Check: pytest (py3.11)
- GitHub Check: pytest (py3.12)
🧰 Additional context used
📓 Path-based instructions (2)
Gradata/tests/**/*.py
📄 CodeRabbit inference engine (Gradata/AGENTS.md)
Gradata/tests/**/*.py: SetBRAIN_DIRenvironment variable viatmp_pathin conftest.py for test isolation — ensure_paths.pymodule cache refreshes when callingBrain.init()directly inside tests
Add unit tests intests/test_*.pyfor every CI push without LLM calls (deterministic); mark integration tests with@pytest.mark.integrationand skip them by default (they hit real LLM APIs)
Files:
Gradata/tests/test_hook_adapters.pyGradata/tests/test_pre_compact_hook.py
Gradata/src/**/*.py
📄 CodeRabbit inference engine (Gradata/AGENTS.md)
Gradata/src/**/*.py: Prefersentence-transformersfor local embeddings,google-genaifor Gemini embeddings,cryptographyfor AES-GCM encrypted system.db,bm25sfor BM25 rule ranking, andmem0aifor external memory adapters — guard all optional dependency imports withtry / except ImportErrorat the call site, never at module level
Maintain strict layering: Layer 0 (Primitives: _types.py, _db.py, _events.py, _paths.py, _file_lock.py; Patterns: contrib/patterns/) must never import from Layer 1 (Enhancements: enhancements/, rules/) or Layer 2 (Public API: brain.py, cli.py, daemon.py, mcp_server.py)
Never use bareexcept: pass— use typed exceptions or at minimumlogger.warning(...)withexc_info=Trueto avoid silent failure in a memory product
Never import from out-of-scope sibling directories../Sprites/or../Hausgem/withingradata/*code — that is a layering bug
Never leak private-sibling paths into public docs/code — no references to../Sprites/,../Hausgem/, email addresses, OneDrive paths, or Sprites-specific examples from insidegradata/*
Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes
Files:
Gradata/src/gradata/hooks/adapters/claude_code.pyGradata/src/gradata/hooks/adapters/_base.pyGradata/src/gradata/hooks/pre_compact.py
🔇 Additional comments (5)
Gradata/src/gradata/hooks/adapters/_base.py (1)
139-143: LGTM!Gradata/src/gradata/hooks/pre_compact.py (1)
1-43: LGTM!Also applies to: 55-123
Gradata/src/gradata/hooks/adapters/claude_code.py (1)
15-15: LGTM!Also applies to: 62-97, 120-137
Gradata/tests/test_hook_adapters.py (1)
3-3: LGTM!Also applies to: 69-89
Gradata/tests/test_pre_compact_hook.py (1)
1-73: LGTM!
| def _read_bounded(path: Path, *, limit: int = _MAX_TEXT_BYTES) -> str | None: | ||
| try: | ||
| brain_dir_str = resolve_brain_dir() | ||
| if not brain_dir_str: | ||
| if not path.is_file(): | ||
| return None | ||
| brain_dir = Path(brain_dir_str) | ||
|
|
||
| compact_type = data.get("type", "unknown") if data else "unknown" | ||
|
|
||
| snapshot = { | ||
| "timestamp": datetime.now(UTC).isoformat(), | ||
| "compact_type": compact_type, | ||
| "brain_dir": str(brain_dir), | ||
| } | ||
|
|
||
| # Include lesson count if available | ||
| lessons_path = brain_dir / "lessons.md" | ||
| if lessons_path.is_file(): | ||
| text = lessons_path.read_text(encoding="utf-8") | ||
| snapshot["lesson_count"] = len( | ||
| [ | ||
| line | ||
| for line in text.splitlines() | ||
| if (stripped := line.strip()) and not stripped.startswith("#") | ||
| ] | ||
| ) | ||
|
|
||
| if hasattr(os, "getuid"): | ||
| uid = os.getuid() | ||
| else: | ||
| try: | ||
| uid = os.getlogin() | ||
| except OSError: | ||
| uid = f"pid{os.getpid()}" | ||
| user_tmp = Path(tempfile.gettempdir()) / f"gradata-{uid}" | ||
| user_tmp.mkdir(parents=True, exist_ok=True) | ||
| dir_hash = hashlib.md5(str(brain_dir).encode()).hexdigest()[:8] | ||
| snapshot_path = user_tmp / f"compact-snapshot-{dir_hash}.json" | ||
| snapshot_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8") | ||
|
|
||
| return {"result": "State saved before compaction"} | ||
| except Exception: | ||
| data = path.read_bytes()[:limit] | ||
| return data.decode("utf-8", errors="replace") |
There was a problem hiding this comment.
Use truly bounded reads in _read_bounded.
Line 49 uses path.read_bytes()[:limit], which loads the full file before truncating. That defeats bounded-read behavior and can spike memory on large files.
Suggested fix
def _read_bounded(path: Path, *, limit: int = _MAX_TEXT_BYTES) -> str | None:
try:
if not path.is_file():
return None
- data = path.read_bytes()[:limit]
+ with path.open("rb") as fh:
+ data = fh.read(limit)
return data.decode("utf-8", errors="replace")
except OSError:
return None🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Gradata/src/gradata/hooks/pre_compact.py` around lines 45 - 50, The function
_read_bounded currently calls path.read_bytes()[:limit] which reads the entire
file into memory then truncates; change it to perform a truly bounded read by
opening the file in binary mode and calling file.read(limit) (e.g., with
path.open("rb") as f: data = f.read(limit)) and then decode with
errors="replace"; keep the existing is_file check and return None for non-files
and return the decoded string (or None) as before.
Implements GRA-1210 / Paperclip issue d87b77e9-d0ff-4876-a3a0-19cf7c711c73.
Changes:
<brain>/.precompact-snapshots/<session-id>.json.gradata install --agent claude-codeto install PreCompact alongside PreToolUse.Verification:
pytest -q tests/test_pre_compact_hook.py tests/test_hook_adapters.py::test_claude_code_install_writes_pre_compact_entry→ 5 passedpython3 -m gradata.hooks.pre_compactsmoke wrote.precompact-snapshots/module-smoke.jsonNote: isolated clean worktree from origin/main; original Paperclip workspace had unrelated dirty files, so this PR branch avoids mixing them.