fleet-memory/hindsight-integrations/codex/scripts/lib/state.py
Ben 0b17a67c70
feat: add Hindsight memory integration for OpenAI Codex CLI (#730)
* feat(codex): add Hindsight memory integration for OpenAI Codex CLI

Hooks-based integration that gives Codex CLI long-term memory via Hindsight.
Three hooks keep memory in sync: SessionStart (daemon pre-warm), UserPromptSubmit
(recall + context injection), Stop (retain conversation to memory).

Key differences from the Claude Code integration:
- Codex transcript format: JSONL with {msg: {type, message}} (user_message/agent_message)
- No CODEX_PLUGIN_ROOT env var — install.sh writes hooks.json with absolute paths
- State stored in ~/.hindsight/codex/state/ (not CLAUDE_PLUGIN_DATA)
- No async: true in hooks (not supported by Codex)
- No SessionEnd event
- hooks.json written to ~/.codex/hooks.json with codex_hooks = true in config.toml

* fix(codex): fix transcript parser for actual Codex disk format

Codex stores sessions as rollout-*.jsonl with response_item entries:
  User:      {type:response_item, payload:{type:message, role:user, content:[{type:input_text, text:...}]}}
  Assistant: {type:response_item, payload:{type:message, role:assistant, phase:final_answer, content:[{type:output_text, text:...}]}}

Previous parser expected an undocumented {msg:{type:user_message}} format from the Rust protocol spec
that does not match the actual on-disk storage format.

* feat(codex): add reflect mode to UserPromptSubmit hook

Add recallMode config option (default: 'recall') that switches the
UserPromptSubmit hook between:
- 'recall': existing behavior, fast raw facts list
- 'reflect': agentic synthesis loop, returns coherent prose answer

Also adds reflect() method to HindsightClient and HINDSIGHT_RECALL_MODE
env var override. Reflect uses a 25s timeout (vs 10s for recall).

* feat(codex): auto mode for recall/reflect selection

Add recallMode: 'auto' (new default) that picks the operation per-query:
- Synthesis patterns (what do you know, what's my, summarize, etc.) → reflect
- All other prompts → recall (fast, raw facts, better for code tasks)

* feat(codex): add automated test suite and finalize recall-only mode

* docs(codex): add docs page and sidebar entry for Codex CLI integration
2026-03-30 10:51:53 +02:00

113 lines
3.5 KiB
Python

"""File-based state persistence.
Codex hooks are ephemeral processes — state must be persisted to files.
Uses ~/.hindsight/codex/state/ as the storage directory.
"""
import json
import os
import re
import sys
# fcntl is Unix-only; import conditionally so the module loads on Windows
if sys.platform != "win32":
import fcntl
else:
fcntl = None
def _state_dir() -> str:
"""Get the state directory, creating it if needed."""
state_dir = os.path.join(os.path.expanduser("~"), ".hindsight", "codex", "state")
os.makedirs(state_dir, exist_ok=True)
return state_dir
def _safe_filename(name: str) -> str:
"""Sanitize a filename to prevent path traversal."""
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name)
name = name.replace("..", "_")
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:
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 on Unix to prevent race conditions. On Windows, proceeds
without a lock — minor races here are harmless.
"""
lock_path = _state_file("turns.lock")
if fcntl is not None:
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
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:
pass
# Fallback: proceed without lock
turns = read_state("turns.json", {})
turns[session_id] = turns.get(session_id, 0) + 1
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]