fleet-memory/hindsight-integrations/claude-code/scripts/lib/state.py
Fabio Scarsi f4390bdc2e
feat: Add Claude Code integration plugin (#651)
* feat: Add Claude Code integration plugin

Complete port of hindsight-openclaw (v0.4.19) adapted to Claude Code's
hook-based plugin architecture. Pure Python stdlib, no external dependencies.

- Auto-recall via UserPromptSubmit hook (additionalContext injection)
- Auto-retain via async Stop hook (chunked retention with sliding window)
- Daemon management (auto-start/stop hindsight-embed via uvx)
- Dynamic bank IDs with per-agent/project/channel/user granularity
- All 34 configuration options with env var overrides
- File-based state persistence with fcntl locking
- Graceful degradation on all error paths

Works with Claude Code Channels (Telegram, Discord, Slack) and
interactive sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Set correct chunked retention defaults (10/2, not 1/0)

retainEveryNTurns=10 and retainOverlapTurns=2 are the production-tested
values — every 10 turns, retain a 12-turn sliding window. The previous
defaults (1/0) would retain every single turn with no overlap, defeating
the chunked retention design that prevents API bombardment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Align recallBudget and daemonIdleTimeout with Openclaw defaults

recallBudget: "low" → "mid" (Openclaw default)
daemonIdleTimeout: 300 → 0 (Openclaw default, never auto-stop)

As an official Hindsight integration, defaults should match Openclaw.
Users can optimize locally via settings.json or env vars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:06:54 +01:00

113 lines
3.6 KiB
Python

"""File-based state persistence.
Claude Code hooks are ephemeral processes — state must be persisted to files.
Uses $CLAUDE_PLUGIN_DATA/state/ as the storage directory.
"""
import fcntl
import json
import os
import re
def _state_dir() -> str:
"""Get the state directory, creating it if needed."""
plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "")
if not plugin_data:
# Fallback to a temp location for testing
plugin_data = os.path.join(os.path.expanduser("~"), ".claude", "plugins", "data", "hindsight-memory")
state_dir = os.path.join(plugin_data, "state")
os.makedirs(state_dir, exist_ok=True)
return state_dir
def _safe_filename(name: str) -> str:
"""Sanitize a filename to prevent path traversal.
Strips path separators, .., and control characters. Mirrors Openclaw's
sanitizeFilename().
"""
# Replace path separators and dangerous patterns
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name)
# Collapse .. to prevent traversal
name = name.replace("..", "_")
# Limit length
name = name[:200]
return name or "state"
def _state_file(name: str) -> str:
"""Get path for a state file. Name is sanitized to prevent traversal."""
safe = _safe_filename(name)
path = os.path.join(_state_dir(), safe)
# Final guard: resolved path must be inside state_dir
resolved = os.path.realpath(path)
expected_dir = os.path.realpath(_state_dir())
if not resolved.startswith(expected_dir + os.sep) and resolved != expected_dir:
raise ValueError(f"State file path escapes state directory: {name!r}")
return path
def read_state(name: str, default=None):
"""Read a JSON state file. Returns default if not found."""
path = _state_file(name)
if not os.path.exists(path):
return default
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return default
def write_state(name: str, data):
"""Write data to a JSON state file atomically."""
path = _state_file(name)
tmp_path = path + ".tmp"
try:
with open(tmp_path, "w") as f:
json.dump(data, f)
os.replace(tmp_path, path)
except OSError:
# Best-effort cleanup
try:
os.unlink(tmp_path)
except OSError:
pass
def get_turn_count(session_id: str) -> int:
"""Get the current turn count for a session."""
turns = read_state("turns.json", {})
return turns.get(session_id, 0)
def increment_turn_count(session_id: str) -> int:
"""Increment and return the turn count for a session.
Uses flock to prevent race conditions between concurrent hook processes
(e.g. async Stop + new UserPromptSubmit).
"""
lock_path = _state_file("turns.lock")
try:
lock_fd = open(lock_path, "w")
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
turns = read_state("turns.json", {})
turns[session_id] = turns.get(session_id, 0) + 1
# Cap tracked sessions to prevent unbounded growth
if len(turns) > 10000:
sorted_keys = sorted(turns.keys())
for k in sorted_keys[: len(sorted_keys) // 2]:
del turns[k]
write_state("turns.json", turns)
return turns[session_id]
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
except OSError:
# Fallback: proceed without lock (better than failing)
turns = read_state("turns.json", {})
turns[session_id] = turns.get(session_id, 0) + 1
write_state("turns.json", turns)
return turns[session_id]