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

91 lines
3 KiB
Python

"""Bank ID derivation and mission management for Codex.
Codex context dimensions:
- agent → configured name or "codex" (HINDSIGHT_AGENT_NAME)
- project → derived from cwd (working directory basename)
- session → session_id from hook input
- user → from env var HINDSIGHT_USER_ID
The channel dimension is omitted — Codex is a CLI tool without multi-channel
routing like Telegram/Discord agents.
"""
import os
import sys
import urllib.parse
from .state import read_state, write_state
DEFAULT_BANK_NAME = "codex"
# Valid granularity fields for Codex
VALID_FIELDS = {"agent", "project", "session", "user"}
def derive_bank_id(hook_input: dict, config: dict) -> str:
"""Derive a bank ID from hook context and config.
When dynamicBankId is false, returns the static bank.
When true, composes from granularity fields joined by '::'.
"""
prefix = config.get("bankIdPrefix", "")
if not config.get("dynamicBankId", False):
base = config.get("bankId") or DEFAULT_BANK_NAME
return f"{prefix}-{base}" if prefix else base
# Dynamic mode — compose from granularity fields
fields = config.get("dynamicBankGranularity")
if not fields or not isinstance(fields, list):
fields = ["agent", "project"]
for f in fields:
if f not in VALID_FIELDS:
print(
f'[Hindsight] Unknown dynamicBankGranularity field "{f}"'
f"valid for Codex: {', '.join(sorted(VALID_FIELDS))}",
file=sys.stderr,
)
cwd = hook_input.get("cwd", "")
session_id = hook_input.get("session_id", "")
agent_name = config.get("agentName", "codex")
user_id = os.environ.get("HINDSIGHT_USER_ID", "")
field_map = {
"agent": agent_name,
"project": os.path.basename(cwd) if cwd else "unknown",
"session": session_id or "unknown",
"user": user_id or "anonymous",
}
segments = [urllib.parse.quote(field_map.get(f, "unknown"), safe="") for f in fields]
base_bank_id = "::".join(segments)
return f"{prefix}-{base_bank_id}" if prefix else base_bank_id
def ensure_bank_mission(client, bank_id: str, config: dict, debug_fn=None):
"""Set bank mission on first use, skip if already set."""
mission = config.get("bankMission", "")
if not mission or not mission.strip():
return
missions_set = read_state("bank_missions.json", {})
if bank_id in missions_set:
return
try:
retain_mission = config.get("retainMission")
client.set_bank_mission(bank_id, mission, retain_mission=retain_mission, timeout=10)
missions_set[bank_id] = True
if len(missions_set) > 10000:
keys = sorted(missions_set.keys())
for k in keys[: len(keys) // 2]:
del missions_set[k]
write_state("bank_missions.json", missions_set)
if debug_fn:
debug_fn(f"Set mission for bank: {bank_id}")
except Exception as e:
if debug_fn:
debug_fn(f"Could not set bank mission for {bank_id}: {e}")