fleet-memory/hindsight-integrations/codex/scripts/lib/content.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

318 lines
10 KiB
Python

"""Content processing utilities for Codex.
Adapts Openclaw/Claude Code content processing for Codex's transcript format.
Codex transcript format (JSONL):
{"session_id": "...", "ts": 1234567890, "msg": {"type": "user_message", "message": "..."}}
EventMsg types (from codex-rs/protocol/src/protocol.rs, serde snake_case):
- user_message → role: user
- agent_message → role: assistant
- task_started, task_complete, exec, etc. → skipped
"""
import json
import os
import re
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Memory tag stripping (anti-feedback-loop)
# ---------------------------------------------------------------------------
def strip_memory_tags(content: str) -> str:
"""Remove <hindsight_memories> and <relevant_memories> blocks.
Prevents retain feedback loop — these were injected during recall and
should not be re-stored.
"""
content = re.sub(r"<hindsight_memories>[\s\S]*?</hindsight_memories>", "", content)
content = re.sub(r"<relevant_memories>[\s\S]*?</relevant_memories>", "", content)
return content
# ---------------------------------------------------------------------------
# Transcript reading
# ---------------------------------------------------------------------------
def read_transcript(transcript_path: str) -> list:
"""Read a Codex JSONL transcript and return list of {role, content} dicts.
Codex disk format (rollout-*.jsonl):
User: {"type":"response_item","payload":{"type":"message","role":"user",
"content":[{"type":"input_text","text":"..."}]}}
Assistant: {"type":"response_item","payload":{"type":"message","role":"assistant",
"content":[{"type":"output_text","text":"..."}],"phase":"final_answer"}}
Flat format for testing:
{"role": "user", "content": "..."}
"""
if not transcript_path or not os.path.isfile(transcript_path):
return []
messages = []
try:
with open(transcript_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
# Codex response_item format
if entry.get("type") == "response_item":
payload = entry.get("payload", {})
if payload.get("type") == "message":
role = payload.get("role", "")
if role not in ("user", "assistant"):
continue
# Only include final_answer for assistant (not reasoning/intermediary)
if role == "assistant" and payload.get("phase") != "final_answer":
continue
content_blocks = payload.get("content", [])
text_parts = []
for block in content_blocks:
if isinstance(block, dict) and block.get("type") in ("input_text", "output_text"):
t = block.get("text", "").strip()
if t:
text_parts.append(t)
text = "\n".join(text_parts).strip()
if text:
messages.append({"role": role, "content": text})
# Flat format (testing / future compatibility)
elif "role" in entry and "content" in entry:
messages.append({"role": entry["role"], "content": entry["content"]})
except json.JSONDecodeError:
continue
except OSError:
pass
return messages
# ---------------------------------------------------------------------------
# Recall: query composition and truncation
# ---------------------------------------------------------------------------
def compose_recall_query(
latest_query: str,
messages: list,
recall_context_turns: int,
recall_roles: list = None,
) -> str:
"""Compose a multi-turn recall query from conversation history.
When recallContextTurns > 1, includes prior context above the latest
user query. Format:
Prior context:
user: ...
assistant: ...
<latest query>
"""
latest = latest_query.strip()
if recall_context_turns <= 1 or not isinstance(messages, list) or not messages:
return latest
allowed_roles = set(recall_roles or ["user", "assistant"])
contextual_messages = slice_last_turns_by_user_boundary(messages, recall_context_turns)
context_lines = []
for msg in contextual_messages:
role = msg.get("role")
if role not in allowed_roles:
continue
content = msg.get("content", "")
if not isinstance(content, str):
content = str(content)
content = strip_memory_tags(content).strip()
if not content:
continue
if role == "user" and content == latest:
continue
context_lines.append(f"{role}: {content}")
if not context_lines:
return latest
return "\n\n".join(
[
"Prior context:",
"\n".join(context_lines),
latest,
]
)
def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
"""Truncate a composed recall query to max_chars.
Preserves the latest user message. Drops oldest context lines first.
"""
if max_chars <= 0:
return query
latest = latest_query.strip()
if len(query) <= max_chars:
return query
latest_only = latest[:max_chars] if len(latest) > max_chars else latest
if "Prior context:" not in query:
return latest_only
context_marker = "Prior context:\n\n"
marker_index = query.find(context_marker)
if marker_index == -1:
return latest_only
suffix_marker = "\n\n" + latest
suffix_index = query.rfind(suffix_marker)
if suffix_index == -1:
return latest_only
suffix = query[suffix_index:]
if len(suffix) >= max_chars:
return latest_only
context_body = query[marker_index + len(context_marker) : suffix_index]
context_lines = [line for line in context_body.split("\n") if line]
kept = []
for i in range(len(context_lines) - 1, -1, -1):
kept.insert(0, context_lines[i])
candidate = f"{context_marker}{chr(10).join(kept)}{suffix}"
if len(candidate) > max_chars:
kept.pop(0)
break
if kept:
return f"{context_marker}{chr(10).join(kept)}{suffix}"
return latest_only
# ---------------------------------------------------------------------------
# Turn slicing
# ---------------------------------------------------------------------------
def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
"""Slice messages to the last N turns, where a turn starts at a user message."""
if not isinstance(messages, list) or not messages or turns <= 0:
return []
user_turns_seen = 0
start_index = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
user_turns_seen += 1
if user_turns_seen >= turns:
start_index = i
break
if start_index == -1:
return list(messages)
return messages[start_index:]
# ---------------------------------------------------------------------------
# Memory formatting (recall results → context string)
# ---------------------------------------------------------------------------
def format_memories(results: list) -> str:
"""Format recall results into human-readable text."""
if not results:
return ""
lines = []
for r in results:
text = r.get("text", "")
mem_type = r.get("type", "")
mentioned_at = r.get("mentioned_at", "")
type_str = f" [{mem_type}]" if mem_type else ""
date_str = f" ({mentioned_at})" if mentioned_at else ""
lines.append(f"- {text}{type_str}{date_str}")
return "\n\n".join(lines)
def format_current_time() -> str:
"""Format current UTC time for recall context."""
now = datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d %H:%M")
# ---------------------------------------------------------------------------
# Retention transcript formatting
# ---------------------------------------------------------------------------
def prepare_retention_transcript(
messages: list,
retain_roles: list = None,
retain_full_window: bool = False,
) -> tuple:
"""Format messages into a retention transcript.
Outputs plain text with [role: ...]...[role:end] markers.
Codex doesn't have tool calls to retain (it's a coding agent with
shell/patch commands, not MCP tools), so we use the text format only.
Args:
messages: List of {role, content} dicts.
retain_roles: Roles to include (default: ['user', 'assistant']).
retain_full_window: If True, retain all messages. If False, retain
only the last turn (last user msg + responses).
Returns:
(transcript_text, message_count) or (None, 0) if nothing to retain.
"""
if not messages:
return None, 0
if retain_full_window:
target_messages = messages
else:
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
last_user_idx = i
break
if last_user_idx == -1:
return None, 0
target_messages = messages[last_user_idx:]
allowed_roles = set(retain_roles or ["user", "assistant"])
parts = []
for msg in target_messages:
role = msg.get("role", "unknown")
if role not in allowed_roles:
continue
content = msg.get("content", "")
if not isinstance(content, str):
content = str(content)
content = strip_memory_tags(content).strip()
if not content:
continue
parts.append(f"[role: {role}]\n{content}\n[{role}:end]")
if not parts:
return None, 0
transcript = "\n\n".join(parts)
if len(transcript.strip()) < 10:
return None, 0
return transcript, len(parts)