Compare commits

..
Author SHA1 Message Date
Ben d1e69c5391 docs(codex): add docs page and sidebar entry for Codex CLI integration 2026-03-27 15:03:40 -04:00
Ben 71125cd9f5 feat(codex): add automated test suite and finalize recall-only mode 2026-03-27 14:57:09 -04:00
Ben 8fcba6ef46 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)
2026-03-27 13:51:01 -04:00
Ben c2a808f080 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).
2026-03-27 13:49:07 -04:00
Ben 3c45f11055 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.
2026-03-27 13:41:19 -04:00
Ben b01d213bcd 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
2026-03-26 11:25:20 -04:00
Nicolò Boschi c9ff37dcbf fix(python-client): async=true silently ignored on retain (#709)
* docs(claude-code): tidy configuration reference and sync README

Add missing settings (retainMode, retainToolCalls, retainTags,
retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and
several recall options) that existed in code but not in docs.
Restructure config tables with prose introductions, clearer
descriptions, and consistent layout across both files.

* refactor(claude-code): remove recallTopK setting

Unused client-side cap — Hindsight server already controls result
count via recallBudget and recallMaxTokens.

* fix(python-client): async=true was silently ignored on retain calls

The hand-written client wrapper passed `async_=retain_async` to
RetainRequest, but the generated Pydantic model uses `var_async` as the
Python field name (with `alias="async"`). The `async_` kwarg didn't
match either the field name or the alias, so Pydantic silently ignored
it — every retain call ran synchronously regardless of the flag.

This has been broken since the client was first introduced (6073ac4f),
not a regression.

Also adds unit tests that verify the async field serializes correctly
in the request JSON, preventing future regressions.
2026-03-26 15:21:43 +01:00
Nicolò Boschi 91397190c0 docs(claude-code): tidy configuration reference and sync README (#706)
* docs(claude-code): tidy configuration reference and sync README

Add missing settings (retainMode, retainToolCalls, retainTags,
retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and
several recall options) that existed in code but not in docs.
Restructure config tables with prose introductions, clearer
descriptions, and consistent layout across both files.

* refactor(claude-code): remove recallTopK setting

Unused client-side cap — Hindsight server already controls result
count via recallBudget and recallMaxTokens.
2026-03-26 14:07:35 +01:00
Nicolò Boschi fd88c0efa5 feat(retain): delta retain — skip LLM for unchanged chunks on upsert (#701)
* feat(retain): delta retain — skip LLM re-extraction for unchanged chunks on upsert

When upserting a document (same document_id), instead of deleting all
facts and re-extracting from scratch, compare chunk content hashes
and only process changed/new chunks. Unchanged chunks keep their
existing facts, entities, and links.

- Add content_hash column to chunks table (migration b3c4d5e6f7a8)
- Add chunk delta comparison functions in chunk_storage.py
- Add delta_mode to fact_storage.handle_document_tracking (skip full delete)
- Add update_memory_units_tags for propagating tag changes to existing facts
- Refactor orchestrator into _try_delta_retain and _full_retain paths
- Automatic fallback to full retain for pre-migration data or all-changed scenarios
- Fix ty type error in metrics.py (resource module import on Windows)
- 16 new tests covering entities, links, tags, metadata, edge cases

* refactor(retain): deduplicate delta and full retain paths

Extract shared _insert_facts_and_links() and _extract_and_embed()
functions used by both the full retain and delta retain paths.
Remove delta_mode flag from handle_document_tracking — delta path
uses dedicated upsert_document_metadata() instead.

* chore: regenerate clients, openapi spec, and lockfile

* chore: regenerate docs skill
2026-03-26 13:50:55 +01:00
Nicolò Boschi ea4df8dbb5 fix: resolve remaining Dependabot security alerts (#705)
- python-multipart: pin >=0.0.22 (arbitrary file write via non-default config)
- requests: pin >=2.33.0 in litellm, langgraph, crewai integrations (insecure temp file reuse)

Remaining unfixable alerts: diskcache (<=5.6.3, no patch) and Pygments (<=2.19.2, no patch).
2026-03-26 13:43:27 +01:00
35 changed files with 4645 additions and 425 deletions
@@ -0,0 +1,32 @@
"""add content_hash to chunks table for delta retain
Revision ID: b3c4d5e6f7a8
Revises: a3b4c5d6e7f8
Create Date: 2026-03-25
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "b3c4d5e6f7a8"
down_revision: str | Sequence[str] | None = "a3b4c5d6e7f8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Add content_hash column to chunks table for delta comparison
op.execute(f"ALTER TABLE {schema}chunks ADD COLUMN IF NOT EXISTS content_hash TEXT")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}chunks DROP COLUMN IF EXISTS content_hash")
@@ -4,7 +4,9 @@ Chunk storage for retain pipeline.
Handles storage of document chunks in the database.
"""
import hashlib
import logging
from dataclasses import dataclass
from ..memory_engine import fq_table
from .types import ChunkMetadata
@@ -12,6 +14,61 @@ from .types import ChunkMetadata
logger = logging.getLogger(__name__)
def compute_chunk_hash(chunk_text: str) -> str:
"""Compute SHA256 hash of chunk text for delta comparison."""
return hashlib.sha256(chunk_text.encode()).hexdigest()
@dataclass
class ExistingChunk:
"""Represents a chunk already stored in the database."""
chunk_id: str
chunk_index: int
content_hash: str | None
async def load_existing_chunks(conn, bank_id: str, document_id: str) -> list[ExistingChunk]:
"""
Load existing chunk metadata for a document.
Returns list of ExistingChunk with chunk_id, chunk_index, and content_hash.
"""
rows = await conn.fetch(
f"""
SELECT chunk_id, chunk_index, content_hash
FROM {fq_table("chunks")}
WHERE document_id = $1 AND bank_id = $2
ORDER BY chunk_index
""",
document_id,
bank_id,
)
return [
ExistingChunk(
chunk_id=row["chunk_id"],
chunk_index=row["chunk_index"],
content_hash=row["content_hash"],
)
for row in rows
]
async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
"""
Delete specific chunks by their IDs.
This cascades to memory_units (via FK with CASCADE delete)
and their links.
"""
if not chunk_ids:
return
await conn.execute(
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
chunk_ids,
)
async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata]) -> dict[int, str]:
"""
Store document chunks in the database.
@@ -32,6 +89,7 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
chunk_ids = []
chunk_texts = []
chunk_indices = []
content_hashes = []
chunk_id_map = {}
for chunk in chunks:
@@ -39,19 +97,21 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
chunk_ids.append(chunk_id)
chunk_texts.append(chunk.chunk_text)
chunk_indices.append(chunk.chunk_index)
content_hashes.append(compute_chunk_hash(chunk.chunk_text))
chunk_id_map[chunk.chunk_index] = chunk_id
# Batch insert all chunks
await conn.execute(
f"""
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[])
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index, content_hash)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[], $6::text[])
""",
chunk_ids,
[document_id] * len(chunk_texts),
[bank_id] * len(chunk_texts),
chunk_texts,
chunk_indices,
content_hashes,
)
return chunk_id_map
@@ -221,7 +221,10 @@ async def handle_document_tracking(
document_tags: list[str] | None = None,
) -> None:
"""
Handle document tracking in the database.
Handle document tracking in the database (full-replace mode).
Deletes the existing document (cascading to all units and links) on the
first batch, then inserts the new document record.
Args:
conn: Database connection
@@ -238,14 +241,51 @@ async def handle_document_tracking(
combined_content = _sanitize_text(combined_content) or ""
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
# Always delete old document first if it exists (cascades to units and links)
# Delete old document first (cascades to units and links)
# Only delete on the first batch to avoid deleting data we just inserted
if is_first_batch:
await conn.fetchval(
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", document_id, bank_id
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id",
document_id,
bank_id,
)
# Insert document (or update if exists from concurrent operations)
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
async def upsert_document_metadata(
conn,
bank_id: str,
document_id: str,
combined_content: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
) -> None:
"""
Update document metadata without deleting existing facts/chunks.
Used by delta retain: the document row is upserted but chunks and
memory_units are managed separately at the chunk level.
"""
import hashlib
combined_content = _sanitize_text(combined_content) or ""
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
async def _upsert_document_row(
conn,
bank_id: str,
document_id: str,
combined_content: str,
content_hash: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
) -> None:
"""Insert or update a document row."""
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params, tags)
@@ -266,3 +306,34 @@ async def handle_document_tracking(
json.dumps(retain_params) if retain_params else None,
document_tags or [],
)
async def update_memory_units_tags(
conn,
bank_id: str,
document_id: str,
tags: list[str],
) -> int:
"""
Update tags on all memory_units belonging to a document.
Used during delta retain to propagate tag changes to unchanged facts.
Returns:
Number of memory units updated.
"""
result = await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET tags = $3, updated_at = NOW()
WHERE bank_id = $1 AND document_id = $2
""",
bank_id,
document_id,
tags or [],
)
# result is a status string like "UPDATE 5"
try:
return int(result.split()[-1])
except (ValueError, IndexError):
return 0
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -11,14 +11,11 @@ This module provides metrics for:
- Database connection pool metrics
"""
import importlib
import logging
import os
import types
try:
import resource
except ImportError:
resource: types.ModuleType | None = None # Windows doesn't have resource module
_resource_mod = importlib.import_module("resource") if importlib.util.find_spec("resource") else None
import threading
import time
from contextlib import contextmanager
@@ -460,13 +457,13 @@ class MetricsCollector(MetricsCollectorBase):
def _setup_process_metrics(self):
"""Set up observable gauges for process metrics."""
if resource is None:
if _resource_mod is None:
return # Skip process metrics on Windows
def get_cpu_times(_options):
"""Get process CPU times."""
try:
rusage = resource.getrusage(resource.RUSAGE_SELF)
rusage = _resource_mod.getrusage(_resource_mod.RUSAGE_SELF)
yield metrics.Observation(rusage.ru_utime, {"type": "user"})
yield metrics.Observation(rusage.ru_stime, {"type": "system"})
except Exception:
@@ -475,7 +472,7 @@ class MetricsCollector(MetricsCollectorBase):
def get_memory_usage(_options):
"""Get process memory usage in bytes."""
try:
rusage = resource.getrusage(resource.RUSAGE_SELF)
rusage = _resource_mod.getrusage(_resource_mod.RUSAGE_SELF)
# ru_maxrss is in kilobytes on Linux, bytes on macOS
max_rss = rusage.ru_maxrss
if os.uname().sysname == "Linux":
@@ -493,7 +490,7 @@ class MetricsCollector(MetricsCollectorBase):
yield metrics.Observation(count)
else:
# Fallback: use resource limits
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
soft, hard = _resource_mod.getrlimit(_resource_mod.RLIMIT_NOFILE)
yield metrics.Observation(soft, {"limit": "soft"})
except Exception:
pass
+1
View File
@@ -57,6 +57,7 @@ dependencies = [
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
"orjson>=3.11.6", # Unbounded recursion DoS fix
"python-multipart>=0.0.22", # Arbitrary file write via non-default configuration fix
"tornado>=6.5.5", # DoS multipart/incomplete cookie validation fix
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"claude-agent-sdk>=0.1.27",
@@ -0,0 +1,842 @@
"""
Tests for delta retain — upsert optimization that only re-processes changed chunks.
"""
import logging
from datetime import datetime, timezone
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
def _ts():
return datetime.now(timezone.utc).timestamp()
# ============================================================
# Core Delta Retain Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_unchanged_content_skips_llm(memory, request_context):
"""
When upserting a document with identical content, no new facts should be
extracted (LLM is not called for unchanged chunks). The existing facts
should be preserved.
"""
bank_id = f"test_delta_unchanged_{_ts()}"
document_id = "conversation-001"
try:
content = "Alice works at Google. Bob works at Microsoft."
# First retain — full processing
v1_units = await memory.retain_async(
bank_id=bank_id,
content=content,
context="team info",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0, "v1 should create facts"
# Get v1 document state
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
v1_unit_count = doc_v1["memory_unit_count"]
# Second retain — same content, should use delta path (no new facts)
v2_units = await memory.retain_async(
bank_id=bank_id,
content=content,
context="team info",
document_id=document_id,
request_context=request_context,
)
# No new units should be returned (nothing changed)
assert v2_units == [], "Delta retain with unchanged content should return empty unit list"
# Existing facts should still be there
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2["memory_unit_count"] == v1_unit_count, "Existing facts should be preserved"
# Verify recall still works
result = await memory.recall_async(
bank_id=bank_id,
query="Where does Alice work?",
budget=Budget.MID,
max_tokens=1000,
request_context=request_context,
)
assert len(result.results) > 0, "Should still recall facts after delta retain"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_appended_content(memory, request_context):
"""
When a conversation grows (new content appended), only new chunks should
be processed. Facts from unchanged chunks should be preserved.
"""
bank_id = f"test_delta_append_{_ts()}"
document_id = "growing-conversation"
try:
# First version — short content (single chunk)
v1_content = "Alice is a software engineer at Google. She works on search infrastructure."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="profile",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# Get v1 facts via recall
v1_recall = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
v1_fact_texts = {r.text for r in v1_recall.results}
# Second version — original content + new content appended
# This should preserve facts from the first chunk and add new ones
v2_content = v1_content + "\n\nBob joined Google as a product manager in 2024. He previously worked at Meta on AR/VR products."
v2_units = await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="profile",
document_id=document_id,
request_context=request_context,
)
# Should have facts about Bob from the new content
v2_recall = await memory.recall_async(
bank_id=bank_id,
query="What does Bob do?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
bob_facts = [r for r in v2_recall.results if "bob" in r.text.lower()]
assert len(bob_facts) > 0, "Should have facts about Bob from appended content"
# Should still have facts about Alice from original content
alice_recall = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
assert len(alice_recall.results) > 0, "Should still have Alice facts from original content"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_modified_chunk(memory, request_context):
"""
When content in the middle changes, that chunk should be re-processed
while other chunks are preserved.
"""
bank_id = f"test_delta_modified_{_ts()}"
document_id = "changing-doc"
try:
# v1: Alice works at Google
v1_content = "Alice works at Google as a senior engineer."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# v2: Alice works at Microsoft (changed)
v2_content = "Alice works at Microsoft as a principal engineer."
v2_units = await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="team",
document_id=document_id,
request_context=request_context,
)
# New facts should reflect the updated content
result = await memory.recall_async(
bank_id=bank_id,
query="Where does Alice work?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
all_texts = " ".join(r.text.lower() for r in result.results)
assert "microsoft" in all_texts, f"Should have updated fact about Microsoft, got: {all_texts}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Entity & Link Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_entities_preserved_for_unchanged_chunks(memory, request_context):
"""
Entities linked to unchanged chunks should be preserved after delta retain.
"""
bank_id = f"test_delta_entities_{_ts()}"
document_id = "entity-doc"
try:
v1_content = "Alice works at Google. She is a senior engineer in the Cloud division."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# Check entities exist
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_entity_names = {e["canonical_name"].lower() for e in v1_entities}
assert len(v1_entity_names) > 0, "Should have entities after v1 retain"
# Upsert with same content — entities should persist
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_entity_names = {e["canonical_name"].lower() for e in v2_entities}
# All v1 entities should still exist
assert v1_entity_names.issubset(v2_entity_names), (
f"v1 entities {v1_entity_names} should be preserved, got {v2_entity_names}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_new_entities_created_for_new_chunks(memory, request_context):
"""
New entities should be created for newly added chunks during delta retain.
"""
bank_id = f"test_delta_new_entities_{_ts()}"
document_id = "entity-growth-doc"
try:
v1_content = "Alice works at Google."
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_entity_names = {e["canonical_name"].lower() for e in v1_entities}
# Append content mentioning new entities
v2_content = v1_content + "\n\nBob joined Facebook. He works with Charlie on the Reality Labs project."
await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="team",
document_id=document_id,
request_context=request_context,
)
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_entity_names = {e["canonical_name"].lower() for e in v2_entities}
# Should have more entities after adding content with new people/orgs
assert len(v2_entity_names) > len(v1_entity_names), (
f"Should have more entities after append: v1={v1_entity_names}, v2={v2_entity_names}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_links_preserved_for_unchanged_chunks(memory, request_context):
"""
Memory links (temporal, semantic, entity) for unchanged chunks should be preserved.
"""
bank_id = f"test_delta_links_{_ts()}"
document_id = "links-doc"
try:
content = "Alice is a senior engineer at Google Cloud. She mentors junior engineers and reviews their code."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=content,
context="team",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# Count links after v1
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_link_count = await conn.fetchval(
"""SELECT COUNT(*) FROM memory_links ml
JOIN memory_units mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1 AND mu.document_id = $2""",
bank_id,
document_id,
)
# Upsert with same content
await memory.retain_async(
bank_id=bank_id,
content=content,
context="team",
document_id=document_id,
request_context=request_context,
)
# Links should be preserved
async with pool.acquire() as conn:
v2_link_count = await conn.fetchval(
"""SELECT COUNT(*) FROM memory_links ml
JOIN memory_units mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1 AND mu.document_id = $2""",
bank_id,
document_id,
)
assert v2_link_count == v1_link_count, (
f"Links should be preserved: v1={v1_link_count}, v2={v2_link_count}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Document Metadata & Tags Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_document_metadata_updated(memory, request_context):
"""
Document metadata (retain_params, tags) should be updated even when
chunk content hasn't changed.
"""
bank_id = f"test_delta_meta_{_ts()}"
document_id = "metadata-doc"
try:
content = "Alice works at Google."
# v1 with initial tags
await memory.retain_async(
bank_id=bank_id,
content=content,
context="initial context",
document_id=document_id,
request_context=request_context,
)
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v1 is not None
# v2 with updated context (same content — triggers delta path)
await memory.retain_async(
bank_id=bank_id,
content=content,
context="updated context",
document_id=document_id,
request_context=request_context,
)
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2 is not None
assert doc_v2["updated_at"] >= doc_v1["updated_at"], "Document should have updated timestamp"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_tags_propagated_to_existing_units(memory, request_context):
"""
When tags change during an upsert with unchanged content, the new tags
should be propagated to all existing memory units.
"""
bank_id = f"test_delta_tags_{_ts()}"
document_id = "tags-doc"
try:
content = "Alice works at Google."
# v1 with tag "team-a"
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": content,
"document_id": document_id,
"tags": ["team-a"],
}],
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_tags = await conn.fetch(
"SELECT tags FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
assert all("team-a" in row["tags"] for row in v1_tags), "v1 units should have team-a tag"
# v2 with same content but different tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": content,
"document_id": document_id,
"tags": ["team-b", "important"],
}],
request_context=request_context,
)
async with pool.acquire() as conn:
v2_tags = await conn.fetch(
"SELECT tags FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
for row in v2_tags:
assert "team-b" in row["tags"], f"v2 units should have team-b tag, got {row['tags']}"
assert "important" in row["tags"], f"v2 units should have important tag, got {row['tags']}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Chunk Management Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_removed_chunks_delete_facts(memory, request_context):
"""
When content is shortened (chunks removed), facts from the removed
chunks should be deleted.
"""
bank_id = f"test_delta_removed_{_ts()}"
document_id = "shrinking-doc"
try:
# v1: longer content with facts about Alice and Bob
v1_content = (
"Alice is a senior engineer at Google Cloud. "
"She leads the infrastructure team and has been there for 5 years.\n\n"
"Bob is a product manager at Facebook Reality Labs. "
"He previously worked at Amazon on Alexa voice products."
)
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="profiles",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
v1_count = doc_v1["memory_unit_count"]
# v2: Completely different content — all chunks change
v2_content = "Charlie works at Netflix as a data scientist."
v2_units = await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="profiles",
document_id=document_id,
request_context=request_context,
)
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2 is not None
# Should have facts about Charlie
result = await memory.recall_async(
bank_id=bank_id,
query="Who works at Netflix?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
all_texts = " ".join(r.text.lower() for r in result.results)
assert "charlie" in all_texts or "netflix" in all_texts, (
f"Should have facts about Charlie/Netflix after replacing content, got: {all_texts}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_chunks_have_content_hash(memory, request_context):
"""
After retain, chunks should have content_hash populated.
"""
bank_id = f"test_delta_hash_{_ts()}"
document_id = "hash-doc"
try:
content = "Alice works at Google as a software engineer."
await memory.retain_async(
bank_id=bank_id,
content=content,
document_id=document_id,
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
chunks = await conn.fetch(
"SELECT chunk_id, content_hash FROM chunks WHERE document_id = $1 AND bank_id = $2",
document_id,
bank_id,
)
assert len(chunks) > 0, "Should have stored chunks"
for chunk in chunks:
assert chunk["content_hash"] is not None, f"Chunk {chunk['chunk_id']} should have content_hash"
assert len(chunk["content_hash"]) == 64, "content_hash should be SHA256 hex (64 chars)"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Backward Compatibility Tests
# ============================================================
@pytest.mark.asyncio
async def test_retain_without_document_id_still_works(memory, request_context):
"""
Retain without document_id should still work normally (no delta path).
"""
bank_id = f"test_no_docid_{_ts()}"
try:
units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="test",
request_context=request_context,
)
assert len(units) > 0, "Should create facts without document_id"
result = await memory.recall_async(
bank_id=bank_id,
query="Where does Alice work?",
budget=Budget.MID,
max_tokens=1000,
request_context=request_context,
)
assert len(result.results) > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_first_retain_full_path(memory, request_context):
"""
First retain of a new document should use the full path (no delta possible).
"""
bank_id = f"test_first_retain_{_ts()}"
document_id = "new-doc"
try:
units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="test",
document_id=document_id,
request_context=request_context,
)
assert len(units) > 0, "First retain should create facts via full path"
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["memory_unit_count"] > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Edge Cases
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_empty_to_content(memory, request_context):
"""
Going from gibberish (zero facts) to real content should work.
"""
bank_id = f"test_delta_empty_{_ts()}"
document_id = "empty-to-content"
try:
# v1: content that probably produces zero facts
await memory.retain_async(
bank_id=bank_id,
content="!!!###$$$%%%",
document_id=document_id,
request_context=request_context,
)
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v1 is not None
# v2: real content
v2_units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a senior engineer.",
document_id=document_id,
request_context=request_context,
)
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2 is not None
assert doc_v2["memory_unit_count"] > 0 or len(v2_units) > 0, "Should have facts after updating with real content"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_multiple_upserts(memory, request_context):
"""
Multiple sequential upserts should work correctly, with delta optimization
kicking in after the first retain.
"""
bank_id = f"test_delta_multi_{_ts()}"
document_id = "multi-upsert"
try:
# v1: initial
v1_content = "Alice works at Google."
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
document_id=document_id,
request_context=request_context,
)
# v2: same content (delta: no changes)
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
document_id=document_id,
request_context=request_context,
)
# v3: append
v3_content = v1_content + "\n\nBob works at Microsoft."
await memory.retain_async(
bank_id=bank_id,
content=v3_content,
document_id=document_id,
request_context=request_context,
)
# v4: same as v3 (delta: no changes again)
await memory.retain_async(
bank_id=bank_id,
content=v3_content,
document_id=document_id,
request_context=request_context,
)
# Final check: should have facts about both Alice and Bob
result = await memory.recall_async(
bank_id=bank_id,
query="Who works where?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
all_texts = " ".join(r.text.lower() for r in result.results)
assert "alice" in all_texts or "google" in all_texts, f"Should have Alice/Google facts, got: {all_texts}"
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["memory_unit_count"] > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_with_user_entities(memory, request_context):
"""
User-provided entities should work correctly with delta retain.
"""
bank_id = f"test_delta_user_entities_{_ts()}"
document_id = "user-entity-doc"
try:
content = "The project is going well."
# v1 with user entities
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": content,
"document_id": document_id,
"entities": [{"text": "Project Alpha", "type": "PROJECT"}],
}],
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_names = {e["canonical_name"].lower() for e in v1_entities}
# v2 with additional entity, same content
# Note: same content = delta path (no re-extraction)
# The user entities for NEW chunks only get processed
v2_content = content + "\n\nThe timeline is on track for Q2 delivery."
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": v2_content,
"document_id": document_id,
"entities": [
{"text": "Project Alpha", "type": "PROJECT"},
{"text": "Q2 Deadline", "type": "MILESTONE"},
],
}],
request_context=request_context,
)
# Should have entities from both v1 and v2
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_names = {e["canonical_name"].lower() for e in v2_entities}
# v1 entities should be preserved
assert v1_names.issubset(v2_names), f"v1 entities should be preserved: {v1_names} not in {v2_names}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_recall_with_chunks(memory, request_context):
"""
After delta retain, recall with include_chunks should return correct chunk data.
"""
bank_id = f"test_delta_recall_chunks_{_ts()}"
document_id = "recall-chunks-doc"
try:
content = "Alice is a senior engineer at Google Cloud. She designs distributed systems."
await memory.retain_async(
bank_id=bank_id,
content=content,
context="profile",
document_id=document_id,
request_context=request_context,
)
# Upsert with same content (delta: no changes)
await memory.retain_async(
bank_id=bank_id,
content=content,
context="profile",
document_id=document_id,
request_context=request_context,
)
# Recall with chunks
result = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
budget=Budget.MID,
max_tokens=2000,
include_chunks=True,
max_chunk_tokens=8192,
request_context=request_context,
)
assert len(result.results) > 0, "Should recall facts"
# Facts with chunk_ids should have corresponding chunks
facts_with_chunks = [r for r in result.results if r.chunk_id]
if facts_with_chunks and result.chunks:
for fact in facts_with_chunks:
assert fact.chunk_id in result.chunks, (
f"Chunk {fact.chunk_id} should be in returned chunks"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,189 @@
---
sidebar_position: 6
---
# OpenAI Codex CLI
Persistent memory for [OpenAI Codex CLI](https://github.com/openai/codex) using [Hindsight](https://vectorize.io/hindsight). Three Python hook scripts automatically recall relevant context before each prompt and retain conversations after each turn — no changes to your Codex workflow required.
## Quick Start
```bash
# 1. Clone the Hindsight repo and install the plugin
git clone https://github.com/vectorize-io/hindsight.git
cd hindsight/hindsight-integrations/codex
./install.sh
# 2. Configure your Hindsight connection
cat > ~/.hindsight/codex.json << 'EOF'
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token_here",
"bankId": "codex"
}
EOF
# 3. Start Codex — memory is live
codex
```
For a local Hindsight instance, set `hindsightApiUrl` to `http://localhost:9077` and omit `hindsightApiToken`.
## Features
- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as `additionalContext` (invisible to the transcript, visible to Codex)
- **Auto-retain** — after each Codex response, stores the conversation transcript to Hindsight for future recall
- **Dynamic bank IDs** — supports per-project memory isolation based on the working directory
- **Session-level upsert** — uses the session ID as the document ID so re-running the same session updates rather than duplicates stored content
- **Zero dependencies** — pure Python stdlib, no pip install required
## Architecture
The plugin uses three Codex hook events:
| Hook | Event | Purpose |
|------|-------|---------|
| `session_start.py` | `SessionStart` | Warm up — verify Hindsight is reachable |
| `recall.py` | `UserPromptSubmit` | **Auto-recall** — query memories, inject as `additionalContext` |
| `retain.py` | `Stop` | **Auto-retain** — extract transcript, POST to Hindsight (async) |
On `UserPromptSubmit`, the hook reads the prompt, queries Hindsight for the most relevant memories, and outputs a `hookSpecificOutput.additionalContext` block. Codex prepends this to the conversation before sending it to the model:
```
<hindsight_memories>
Relevant memories from past conversations...
Current time - 2026-03-27 09:14
- Project uses FastAPI with asyncpg — not SQLAlchemy [world] (2026-03-26)
- Preferred testing framework: pytest with pytest-asyncio [experience] (2026-03-26)
</hindsight_memories>
```
On `Stop`, the hook reads the session transcript, strips previously injected memory tags (to prevent feedback loops), and POSTs the conversation to Hindsight asynchronously.
## Connection Modes
### 1. External API (recommended)
Connect to a running Hindsight server (cloud or self-hosted):
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token"
}
```
### 2. Local Daemon
Run `hindsight-embed` locally. The `session_start.py` hook will detect it on `apiPort` (default `9077`). The daemon is not auto-started by the Codex plugin — start it separately:
```bash
uvx hindsight-embed
```
Then leave `hindsightApiUrl` empty in your config and the plugin will connect to `http://localhost:9077`.
## Configuration
Settings are loaded from `~/.hindsight/codex.json`. Every setting can also be overridden via environment variable.
**Loading order** (later entries win):
1. Built-in defaults
2. Plugin `settings.json` (at `~/.hindsight/codex/settings.json`)
3. User config (`~/.hindsight/codex.json`)
4. Environment variables
---
### Connection
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` | URL of the Hindsight API server. Required. |
| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | API token for authentication. Required for Hindsight Cloud. |
| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port for the local `hindsight-embed` daemon. |
---
### Memory Bank
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `bankId` | `HINDSIGHT_BANK_ID` | `"codex"` | The bank to read from and write to. All sessions share this bank unless `dynamicBankId` is enabled. |
| `bankMission` | `HINDSIGHT_BANK_MISSION` | coding assistant prompt | Describes the agent's purpose. Sent when creating or updating the bank. |
| `retainMission` | — | extraction prompt | Instructions for Hindsight's fact extraction — what to extract from coding conversations. |
| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | When `true`, derives a unique bank ID from `dynamicBankGranularity` fields — useful for per-project isolation. |
| `dynamicBankGranularity` | — | `["agent", "project"]` | Which fields to combine for dynamic bank IDs. `"project"` = working directory, `"agent"` = agent name. |
| `bankIdPrefix` | — | `""` | Prefix prepended to all bank IDs. |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"codex"` | Agent name used in dynamic bank ID derivation. |
---
### Auto-Recall
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. |
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Search depth: `"low"` (fast), `"mid"` (balanced), `"high"` (thorough). |
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Max tokens in the recalled memory block. |
| `recallTypes` | — | `["world", "experience"]` | Memory types to retrieve. |
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | Prior turns to include when building the recall query. `1` = latest prompt only. |
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Max characters in the query sent to Hindsight. |
| `recallRoles` | — | `["user", "assistant"]` | Which roles to include when building a multi-turn query. |
| `recallPromptPreamble` | — | built-in | Text placed above the recalled memories in the injected context block. |
---
### Auto-Retain
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Master switch for auto-retain. |
| `retainMode` | `HINDSIGHT_RETAIN_MODE` | `"full-session"` | `"full-session"` sends the full transcript per session (upserted by session ID). `"chunked"` sends sliding windows every N turns. |
| `retainEveryNTurns` | — | `10` | Retain fires every N turns. `1` = every turn. Higher values reduce API calls. |
| `retainOverlapTurns` | — | `2` | Extra turns included from the previous chunk (chunked mode only). |
| `retainRoles` | — | `["user", "assistant"]` | Which roles to include in the retained transcript. |
| `retainTags` | — | `["{session_id}"]` | Tags attached to the stored document. `{session_id}` is replaced at runtime. |
| `retainMetadata` | — | `{}` | Arbitrary key-value metadata attached to the stored document. |
| `retainContext` | — | `"codex"` | Label identifying the source integration. Useful when multiple integrations write to the same bank. |
---
### Debug
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `debug` | `HINDSIGHT_DEBUG` | `false` | Enable verbose logging to stderr. All log lines are prefixed with `[Hindsight]`. |
## Per-Project Memory
To give each project its own isolated memory bank, enable dynamic bank IDs:
```json
{
"dynamicBankId": true,
"dynamicBankGranularity": ["agent", "project"]
}
```
With this config, running Codex in `~/projects/api` and `~/projects/frontend` stores and recalls memories separately. Bank IDs are derived from the working directory path.
## Troubleshooting
**Hooks not firing**: Check that `~/.codex/config.toml` contains `codex_hooks = true` under `[features]`. Re-run `install.sh` to write this automatically.
**No memories recalled**: Recall returns results only after something has been retained. Either complete one Codex session first, or seed your bank manually using the [cookbook example](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/codex-memory).
**Memory not being stored**: `retainEveryNTurns` defaults to `10` — retain only fires every 10 turns. While testing, add `"retainEveryNTurns": 1` to `~/.hindsight/codex.json`.
**Debug mode**: Add `"debug": true` to `~/.hindsight/codex.json` to see what Hindsight is doing on each turn:
```
[Hindsight] Recalling from bank 'codex', query length: 42
[Hindsight] Injecting 3 memories
[Hindsight] Retaining to bank 'codex', doc 'sess-abc123', 2 messages, 847 chars
```
**High latency on recall**: Use `"recallBudget": "low"` or reduce `recallMaxTokens` to speed up recall queries.
+6
View File
@@ -190,6 +190,12 @@ const sidebars: SidebarsConfig = {
label: 'Claude Code',
customProps: { icon: '/img/icons/claudecode.svg' },
},
{
type: 'doc',
id: 'sdks/integrations/codex',
label: 'OpenAI Codex CLI',
customProps: { icon: '/img/icons/terminal.svg' },
},
{
type: 'doc',
id: 'sdks/integrations/openclaw',
+127
View File
@@ -0,0 +1,127 @@
# Hindsight for OpenAI Codex CLI
Long-term memory for [OpenAI Codex CLI](https://github.com/openai/codex) — remembers your projects, preferences, and past sessions across every conversation.
## How it works
Three Codex hooks keep memory in sync automatically:
| Hook | Action |
|------|--------|
| `SessionStart` | Warms up the Hindsight server in the background |
| `UserPromptSubmit` | Recalls relevant memories and injects them into context |
| `Stop` | Retains the conversation to long-term memory |
## Requirements
- **OpenAI Codex CLI** v0.116.0 or later (hooks support)
- **Python 3.9+** (for hook scripts)
- **Hindsight**: [Hindsight Cloud](https://hindsight.vectorize.io) or local `hindsight-embed`
## Installation
```bash
git clone https://github.com/vectorize-io/hindsight
cd hindsight/hindsight-integrations/codex
./install.sh
```
The installer:
1. Copies scripts to `~/.hindsight/codex/scripts/`
2. Writes `~/.codex/hooks.json` with absolute paths to the scripts
3. Adds `codex_hooks = true` to `~/.codex/config.toml`
### Uninstall
```bash
./install.sh --uninstall
```
## Configuration
The default config is written to `~/.hindsight/codex/settings.json` on first install.
For personal overrides (stable across updates), create `~/.hindsight/codex.json`:
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "your-api-key",
"bankId": "my-codex-memory"
}
```
### Hindsight Cloud
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "your-api-key"
}
```
### Local daemon (hindsight-embed)
Set an LLM API key and Hindsight will start the local server automatically:
```bash
export OPENAI_API_KEY=sk-your-key
# or
export ANTHROPIC_API_KEY=your-key
```
### Configuration options
| Key | Default | Description |
|-----|---------|-------------|
| `hindsightApiUrl` | `""` | External API URL (empty = local daemon) |
| `hindsightApiToken` | `null` | API token for Hindsight Cloud |
| `bankId` | `"codex"` | Memory bank identifier |
| `bankMission` | (set) | Guides what facts Hindsight retains |
| `autoRecall` | `true` | Inject memories before each prompt |
| `autoRetain` | `true` | Store conversations after each turn |
| `retainMode` | `"full-session"` | `"full-session"` or `"chunked"` |
| `retainEveryNTurns` | `10` | Retain every N turns (1 = every turn) |
| `recallBudget` | `"mid"` | Recall depth: `"low"`, `"mid"`, `"high"` |
| `recallMaxTokens` | `1024` | Max tokens for injected memories |
| `dynamicBankId` | `false` | Separate bank per project/session |
| `dynamicBankGranularity` | `["agent", "project"]` | Fields for dynamic bank ID |
| `debug` | `false` | Log debug info to stderr |
### Environment variable overrides
All settings can also be set via environment variables:
```bash
export HINDSIGHT_API_URL=https://api.hindsight.vectorize.io
export HINDSIGHT_API_TOKEN=your-api-key
export HINDSIGHT_BANK_ID=my-project
export HINDSIGHT_DEBUG=true
```
## How memory works
**Recall** — before each prompt, Hindsight searches your memory bank for facts relevant to what you're about to ask. Found memories are injected as context so Codex has continuity across sessions.
**Retain** — after each turn, Codex's conversation is stored to Hindsight. The memory engine extracts facts, relationships, and experiences — so you don't need to re-explain your stack, preferences, or past decisions.
## Dynamic bank IDs
To keep separate memory per project:
```json
{
"dynamicBankId": true,
"dynamicBankGranularity": ["agent", "project"]
}
```
This creates banks like `codex::my-project` automatically, using the working directory name.
## Troubleshooting
**Memory not appearing**: Enable debug mode (`"debug": true`) and check stderr output.
**Server not starting**: Set `hindsightApiUrl` to use an external server, or ensure `uvx` is on PATH for local daemon mode.
**Hooks not firing**: Check that `~/.codex/config.toml` contains `codex_hooks = true` under `[features]`, and that your Codex CLI version supports hooks (v0.116.0+).
@@ -0,0 +1,37 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"__SCRIPTS_DIR__/session_start.py\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"__SCRIPTS_DIR__/recall.py\"",
"timeout": 12
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"__SCRIPTS_DIR__/retain.py\"",
"timeout": 30
}
]
}
]
}
}
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Hindsight memory integration for OpenAI Codex CLI
#
# This script installs the Hindsight hooks into ~/.codex/ and copies
# the hook scripts to ~/.hindsight/codex/scripts/.
#
# Usage:
# ./install.sh # Install (or update)
# ./install.sh --uninstall # Remove Hindsight hooks
set -euo pipefail
INTEGRATION_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="${HOME}/.hindsight/codex"
SCRIPTS_DIR="${INSTALL_DIR}/scripts"
CODEX_DIR="${HOME}/.codex"
HOOKS_FILE="${CODEX_DIR}/hooks.json"
CONFIG_FILE="${CODEX_DIR}/config.toml"
# ──────────────────────────────────────────────────────────────────────────────
# Uninstall
# ──────────────────────────────────────────────────────────────────────────────
if [[ "${1:-}" == "--uninstall" ]]; then
echo "Uninstalling Hindsight Codex integration..."
# Remove scripts directory
if [[ -d "${SCRIPTS_DIR}" ]]; then
rm -rf "${SCRIPTS_DIR}"
echo " Removed ${SCRIPTS_DIR}"
fi
# Remove hooks.json
if [[ -f "${HOOKS_FILE}" ]]; then
rm -f "${HOOKS_FILE}"
echo " Removed ${HOOKS_FILE}"
fi
# Remove codex_hooks feature flag from config.toml (if present)
if [[ -f "${CONFIG_FILE}" ]]; then
# Remove the [features] block line for codex_hooks
sed -i.bak '/^codex_hooks *= *true/d' "${CONFIG_FILE}" && rm -f "${CONFIG_FILE}.bak"
echo " Removed codex_hooks from ${CONFIG_FILE}"
fi
echo "Uninstall complete."
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────────
# Install
# ──────────────────────────────────────────────────────────────────────────────
echo "Installing Hindsight Codex integration..."
# 1. Copy scripts to ~/.hindsight/codex/scripts/
mkdir -p "${SCRIPTS_DIR}"
cp -r "${INTEGRATION_DIR}/scripts/." "${SCRIPTS_DIR}/"
chmod +x "${SCRIPTS_DIR}/session_start.py"
chmod +x "${SCRIPTS_DIR}/recall.py"
chmod +x "${SCRIPTS_DIR}/retain.py"
echo " Scripts installed to ${SCRIPTS_DIR}"
# 2. Copy default settings (don't overwrite user's existing settings)
SETTINGS_DST="${INSTALL_DIR}/settings.json"
if [[ ! -f "${SETTINGS_DST}" ]]; then
cp "${INTEGRATION_DIR}/settings.json" "${SETTINGS_DST}"
echo " Default settings written to ${SETTINGS_DST}"
else
echo " Keeping existing settings at ${SETTINGS_DST}"
fi
# 3. Write ~/.codex/hooks.json with absolute paths
mkdir -p "${CODEX_DIR}"
cat > "${HOOKS_FILE}" <<EOF
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${SCRIPTS_DIR}/session_start.py\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${SCRIPTS_DIR}/recall.py\"",
"timeout": 12
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${SCRIPTS_DIR}/retain.py\"",
"timeout": 30
}
]
}
]
}
}
EOF
echo " Hooks written to ${HOOKS_FILE}"
# 4. Enable codex_hooks in ~/.codex/config.toml
if [[ ! -f "${CONFIG_FILE}" ]]; then
touch "${CONFIG_FILE}"
fi
# Check if [features] section exists
if grep -q '^\[features\]' "${CONFIG_FILE}" 2>/dev/null; then
# Section exists — add codex_hooks under it if not already present
if ! grep -q '^codex_hooks' "${CONFIG_FILE}"; then
# Insert codex_hooks after [features]
sed -i.bak '/^\[features\]/a codex_hooks = true' "${CONFIG_FILE}" && rm -f "${CONFIG_FILE}.bak"
echo " Added codex_hooks = true to [features] in ${CONFIG_FILE}"
else
echo " codex_hooks already enabled in ${CONFIG_FILE}"
fi
else
# No [features] section — append it
printf '\n[features]\ncodex_hooks = true\n' >> "${CONFIG_FILE}"
echo " Added [features] codex_hooks = true to ${CONFIG_FILE}"
fi
echo ""
echo "Hindsight is installed for Codex."
echo ""
echo "Configuration:"
echo " Edit ${SETTINGS_DST} to customize settings."
echo " Or create ~/.hindsight/codex.json for personal overrides."
echo ""
echo "For Hindsight Cloud, set:"
echo " \"hindsightApiUrl\": \"https://api.hindsight.vectorize.io\""
echo " \"hindsightApiToken\": \"your-api-key\""
echo ""
echo "For local daemon mode, set an LLM API key:"
echo " export OPENAI_API_KEY=sk-your-key"
echo ""
echo "Start a new Codex session to activate."
@@ -0,0 +1,91 @@
"""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}")
@@ -0,0 +1,163 @@
"""Hindsight REST API client.
Communicates with a Hindsight server via HTTP. Mirrors the HTTP mode of the
Openclaw HindsightClient (client.js), adapted for Python stdlib.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional
DEFAULT_TIMEOUT = 15 # seconds
HEALTH_CHECK_RETRIES = 3
HEALTH_CHECK_DELAY = 2 # seconds
def _validate_api_url(url: str) -> str:
"""Validate and normalize the API URL. Reject non-HTTP schemes."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Hindsight API URL must use http or https, got: {parsed.scheme!r}")
if not parsed.hostname:
raise ValueError(f"Hindsight API URL has no hostname: {url!r}")
return url.rstrip("/")
class HindsightClient:
"""HTTP client for the Hindsight API."""
def __init__(self, api_url: str, api_token: Optional[str] = None):
self.api_url = _validate_api_url(api_url)
self.api_token = api_token
def _headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
return headers
def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
url = f"{self.api_url}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body_text = ""
try:
body_text = e.read().decode()
except Exception:
pass
raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e
def health_check(self, timeout: int = 5) -> bool:
"""Check if the Hindsight server is reachable.
Mirrors Openclaw's checkExternalApiHealth: retries up to 3 times
with 2s delay between attempts.
"""
import time
for attempt in range(1, HEALTH_CHECK_RETRIES + 1):
try:
url = f"{self.api_url}/health"
req = urllib.request.Request(url, headers=self._headers(), method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status == 200:
return True
except Exception:
pass
if attempt < HEALTH_CHECK_RETRIES:
time.sleep(HEALTH_CHECK_DELAY)
return False
def recall(
self,
bank_id: str,
query: str,
max_tokens: int = 1024,
budget: str = "mid",
types: Optional[list] = None,
timeout: int = 10,
) -> dict:
"""Recall memories from a bank.
Returns the raw API response dict with 'results' list.
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
body = {
"query": query,
"max_tokens": max_tokens,
}
if budget:
body["budget"] = budget
if types:
body["types"] = types
return self._request("POST", path, body, timeout=timeout)
def retain(
self,
bank_id: str,
content: str,
document_id: str = "conversation",
context: Optional[str] = None,
metadata: Optional[dict] = None,
tags: Optional[list] = None,
timeout: int = 15,
) -> dict:
"""Retain content into a bank's memory.
Posts with async=true so the server processes in the background.
The context field helps Hindsight cluster memories by provenance
(e.g. "claude-code" vs manual retains).
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
item = {
"content": content,
"document_id": document_id,
"metadata": metadata or {},
}
if context:
item["context"] = context
if tags:
item["tags"] = tags
body = {
"items": [item],
"async": True,
}
return self._request("POST", path, body, timeout=timeout)
def reflect(
self,
bank_id: str,
query: str,
budget: str = "mid",
max_tokens: int = 1024,
timeout: int = 30,
) -> dict:
"""Reflect on memories and return a synthesized answer.
Runs an agentic loop that retrieves facts, mental models, and experiences,
then uses the LLM to formulate a coherent response. Slower than recall
but produces a synthesized prose answer rather than raw facts.
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/reflect"
body = {"query": query, "budget": budget, "max_tokens": max_tokens}
return self._request("POST", path, body, timeout=timeout)
def set_bank_mission(
self, bank_id: str, mission: str, retain_mission: Optional[str] = None, timeout: int = 15
) -> dict:
"""Set the mission/persona for a bank.
Uses PATCH /banks/{id}/config with reflect_mission and retain_mission.
The old PUT /banks/{id} with 'mission' field is deprecated in v0.4.19.
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/config"
updates = {"reflect_mission": mission}
if retain_mission:
updates["retain_mission"] = retain_mission
return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
@@ -0,0 +1,143 @@
"""Configuration management for Hindsight Codex plugin.
Loads settings from settings.json (plugin defaults) merged with environment
variable overrides. Full config schema matching Openclaw's 30+ options.
"""
import json
import os
import sys
DEFAULTS = {
# Recall
"autoRecall": True,
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallTypes": ["world", "experience"],
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallPromptPreamble": (
"Relevant memories from past conversations (prioritize recent when "
"conflicting). Only use memories that are directly useful to continue "
"this conversation; ignore the rest:"
),
# Retain
"autoRetain": True,
"retainMode": "full-session",
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 10,
"retainOverlapTurns": 2,
"retainToolCalls": True,
"retainContext": "codex",
"retainTags": [],
"retainMetadata": {},
# Connection
"hindsightApiUrl": None,
"hindsightApiToken": None,
"apiPort": 9077,
"daemonIdleTimeout": 0,
"embedVersion": "latest",
"embedPackagePath": None,
# Bank
"bankId": None,
"bankIdPrefix": "",
"dynamicBankId": False,
"dynamicBankGranularity": ["agent", "project"],
"bankMission": "",
"retainMission": None,
"agentName": "codex",
# LLM (for daemon mode)
"llmProvider": None,
"llmModel": None,
"llmApiKeyEnv": None,
# Misc
"debug": False,
}
# Map env var names to config keys and their types
ENV_OVERRIDES = {
"HINDSIGHT_API_URL": ("hindsightApiUrl", str),
"HINDSIGHT_API_TOKEN": ("hindsightApiToken", str),
"HINDSIGHT_BANK_ID": ("bankId", str),
"HINDSIGHT_AGENT_NAME": ("agentName", str),
"HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
"HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
"HINDSIGHT_RETAIN_MODE": ("retainMode", str),
"HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
"HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int),
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
"HINDSIGHT_API_PORT": ("apiPort", int),
"HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
"HINDSIGHT_EMBED_VERSION": ("embedVersion", str),
"HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str),
"HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool),
"HINDSIGHT_BANK_MISSION": ("bankMission", str),
"HINDSIGHT_LLM_PROVIDER": ("llmProvider", str),
"HINDSIGHT_LLM_MODEL": ("llmModel", str),
"HINDSIGHT_DEBUG": ("debug", bool),
}
def _cast_env(value: str, typ):
"""Cast environment variable string to target type. Returns None on failure."""
try:
if typ is bool:
return value.lower() in ("true", "1", "yes")
if typ is int:
return int(value)
return value
except (ValueError, AttributeError):
return None
def _load_settings_file(path: str, config: dict) -> None:
"""Merge a settings.json file into config in-place. Silently skips if missing."""
if not os.path.exists(path):
return
try:
with open(path) as f:
file_config = json.load(f)
config.update({k: v for k, v in file_config.items() if v is not None})
except (json.JSONDecodeError, OSError) as e:
debug_log(config, f"Failed to load {path}: {e}")
def load_config() -> dict:
"""Load plugin configuration from settings.json + env overrides.
Loading order (later entries win):
1. Built-in defaults
2. Plugin install settings.json (~/.hindsight/codex/settings.json)
3. User config (~/.hindsight/codex.json)
4. Environment variable overrides
~/.hindsight/codex.json is the recommended place to configure the
plugin — stable across updates.
"""
config = dict(DEFAULTS)
# 1. Plugin install settings.json (written by install.sh)
install_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_load_settings_file(os.path.join(install_root, "settings.json"), config)
# 2. User config — stable, version-independent
user_config_path = os.path.join(os.path.expanduser("~"), ".hindsight", "codex.json")
_load_settings_file(user_config_path, config)
# Apply environment variable overrides
for env_name, (key, typ) in ENV_OVERRIDES.items():
val = os.environ.get(env_name)
if val is not None:
cast_val = _cast_env(val, typ)
if cast_val is not None:
config[key] = cast_val
return config
def debug_log(config: dict, *args):
"""Log to stderr if debug mode is enabled."""
if config.get("debug"):
print("[Hindsight]", *args, file=sys.stderr)
@@ -0,0 +1,318 @@
"""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)
@@ -0,0 +1,275 @@
"""Hindsight-embed daemon lifecycle management for Codex.
Manages three connection modes:
1. External API — user provides hindsightApiUrl (skip daemon entirely)
2. Existing local server — user already has hindsight running
3. Auto-managed daemon — plugin starts/stops hindsight-embed
Daemon state is tracked via files in ~/.hindsight/codex/state/.
"""
import os
import platform
import subprocess
import time
import urllib.error
import urllib.request
from .llm import detect_llm_config, get_llm_env_vars
from .state import read_state, write_state
DAEMON_STATE_FILE = "daemon.json"
PROFILE_NAME = "codex"
def _get_embed_command(config: dict) -> list:
"""Get the command to run hindsight-embed."""
embed_path = config.get("embedPackagePath")
if embed_path:
return ["uv", "run", "--directory", embed_path, "hindsight-embed"]
version = config.get("embedVersion", "latest")
package = f"hindsight-embed@{version}" if version else "hindsight-embed@latest"
return ["uvx", package]
def _run_embed(config: dict, args: list, env: dict = None, timeout: int = 10) -> subprocess.CompletedProcess:
"""Run a hindsight-embed command and return the result."""
cmd = _get_embed_command(config) + args
run_env = dict(os.environ)
if env:
run_env.update(env)
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=run_env,
)
def _is_embed_available(config: dict) -> bool:
"""Quick check if hindsight-embed is available on PATH."""
import shutil
embed_path = config.get("embedPackagePath")
if embed_path:
return os.path.isdir(embed_path)
return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
def _check_health(base_url: str, timeout: int = 2) -> bool:
"""Quick health check against a Hindsight server."""
try:
url = f"{base_url.rstrip('/')}/health"
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status == 200
except Exception:
return False
def get_api_url(config: dict, debug_fn=None, allow_daemon_start: bool = False) -> str:
"""Determine the API URL, optionally starting daemon if needed.
Connection mode priority:
1. External API (hindsightApiUrl configured)
2. Existing local server (check port health)
3. Auto-managed daemon (only if allow_daemon_start=True)
"""
# Mode 1: External API
external_url = config.get("hindsightApiUrl")
if external_url:
if debug_fn:
debug_fn(f"Using external API: {external_url}")
return external_url
# Mode 2 & 3: Local server
port = config.get("apiPort", 9077)
base_url = f"http://127.0.0.1:{port}"
if _check_health(base_url):
if debug_fn:
debug_fn(f"Existing server healthy on port {port}")
return base_url
# Mode 3: Auto-start daemon (only when allowed)
if not allow_daemon_start:
raise RuntimeError(
f"No Hindsight server on port {port}. Set hindsightApiUrl for external "
f"API, start hindsight-embed manually, or wait for the retain hook to "
f"auto-start the daemon."
)
if debug_fn:
debug_fn(f"No server on port {port}, attempting daemon start")
try:
_ensure_daemon_running(config, port, debug_fn)
except Exception as e:
if debug_fn:
debug_fn(f"Daemon start failed: {e}")
raise RuntimeError(
"No Hindsight server available. Set hindsightApiUrl for external API, "
"or ensure hindsight-embed is installed for local daemon mode."
) from e
return base_url
def _ensure_daemon_running(config: dict, port: int, debug_fn=None):
"""Start the hindsight-embed daemon if not already running."""
if not _is_embed_available(config):
raise RuntimeError(
"hindsight-embed not found (uvx not on PATH). "
"Install with: pip install hindsight-embed, or set hindsightApiUrl."
)
base_url = f"http://127.0.0.1:{port}"
try:
llm_config = detect_llm_config(config)
except RuntimeError as e:
raise RuntimeError(f"Cannot start daemon: {e}") from e
llm_env = get_llm_env_vars(llm_config)
daemon_env = dict(llm_env)
idle_timeout = config.get("daemonIdleTimeout", 300)
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
if platform.system() == "Darwin":
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
# Step 1: Configure profile
if debug_fn:
debug_fn(f'Configuring "{PROFILE_NAME}" profile...')
profile_args = [
"profile",
"create",
PROFILE_NAME,
"--merge",
"--port",
str(port),
]
for env_name, env_val in daemon_env.items():
if env_val:
profile_args.extend(["--env", f"{env_name}={env_val}"])
try:
result = _run_embed(config, profile_args, daemon_env, timeout=10)
if result.returncode != 0:
if debug_fn:
debug_fn(f"Profile create stderr: {result.stderr.strip()}")
raise RuntimeError(f"Profile create failed (exit {result.returncode}): {result.stderr}")
if debug_fn:
debug_fn("Profile configured")
except subprocess.TimeoutExpired:
raise RuntimeError("Profile create timed out")
except FileNotFoundError:
raise RuntimeError(
"hindsight-embed not found. Install with: pip install hindsight-embed "
"or set hindsightApiUrl for external API mode."
)
# Step 2: Start daemon
if debug_fn:
debug_fn("Starting daemon...")
try:
result = _run_embed(
config,
["daemon", "--profile", PROFILE_NAME, "start"],
daemon_env,
timeout=30,
)
if debug_fn:
debug_fn(f"Daemon start exit={result.returncode} stdout={result.stdout.strip()}")
if result.returncode != 0 and "already running" not in result.stderr.lower():
raise RuntimeError(f"Daemon start failed (exit {result.returncode}): {result.stderr}")
except subprocess.TimeoutExpired:
raise RuntimeError("Daemon start timed out")
# Step 3: Wait for ready
if debug_fn:
debug_fn("Waiting for daemon to be ready...")
for attempt in range(30):
if _check_health(base_url):
if debug_fn:
debug_fn(f"Daemon ready after {attempt + 1} attempts")
write_state(
DAEMON_STATE_FILE,
{
"port": port,
"started_by_plugin": True,
"started_at": time.time(),
"pid": os.getpid(),
},
)
return
time.sleep(1)
raise RuntimeError("Daemon failed to become ready within 30 seconds")
def prestart_daemon_background(config: dict, debug_fn=None):
"""Fire off daemon startup in the background — non-blocking.
Called from SessionStart hook to warm up the daemon before the first
recall or retain hook fires.
"""
if config.get("hindsightApiUrl"):
return # External API mode — no local daemon needed
port = config.get("apiPort", 9077)
if _check_health(f"http://127.0.0.1:{port}"):
if debug_fn:
debug_fn(f"Daemon already running on port {port}, skipping pre-start")
return
if not _is_embed_available(config):
if debug_fn:
debug_fn("hindsight-embed not available, skipping pre-start")
return
try:
llm_config = detect_llm_config(config)
except RuntimeError as e:
if debug_fn:
debug_fn(f"No LLM configured, skipping daemon pre-start: {e}")
return
llm_env = get_llm_env_vars(llm_config)
daemon_env = dict(os.environ)
daemon_env.update(llm_env)
idle_timeout = config.get("daemonIdleTimeout", 300)
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
if platform.system() == "Darwin":
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
embed_cmd = _get_embed_command(config)
profile_args = ["profile", "create", PROFILE_NAME, "--merge", "--port", str(port)]
for env_name, env_val in llm_env.items():
if env_val:
profile_args.extend(["--env", f"{env_name}={env_val}"])
import shlex
profile_str = shlex.join(embed_cmd + profile_args)
daemon_str = shlex.join(embed_cmd + ["daemon", "--profile", PROFILE_NAME, "start"])
import subprocess as _sp
_sp.Popen(
f"{profile_str} && {daemon_str}",
shell=True,
env=daemon_env,
stdout=_sp.DEVNULL,
stderr=_sp.DEVNULL,
start_new_session=True,
)
if debug_fn:
debug_fn(f"Daemon pre-start initiated in background (port {port})")
@@ -0,0 +1,146 @@
"""LLM provider detection for Hindsight's fact extraction.
Port of: detectLLMConfig() in index.js
When running hindsight-embed locally (daemon mode), it needs an LLM to
extract facts from retained conversations. This module detects the LLM
config using the same priority chain as Openclaw:
1. HINDSIGHT_API_LLM_* environment variables (highest priority)
2. Plugin config (llmProvider, llmModel, llmApiKeyEnv)
3. Auto-detect from standard provider env vars
4. External API mode (server-side LLM, no local config needed)
"""
import os
# Provider detection table — same order as Openclaw
PROVIDER_DETECTION = [
{"name": "openai", "key_env": "OPENAI_API_KEY"},
{"name": "anthropic", "key_env": "ANTHROPIC_API_KEY"},
{"name": "gemini", "key_env": "GEMINI_API_KEY"},
{"name": "groq", "key_env": "GROQ_API_KEY"},
{"name": "ollama", "key_env": ""},
{"name": "openai-codex", "key_env": ""},
{"name": "claude-code", "key_env": ""},
]
# Providers that don't require an API key
NO_KEY_REQUIRED = {"ollama", "openai-codex", "claude-code"}
def _find_provider(name):
"""Find a provider entry by name."""
for p in PROVIDER_DETECTION:
if p["name"] == name:
return p
return None
def detect_llm_config(config: dict) -> dict:
"""Detect LLM configuration.
Returns dict with: provider, api_key, model, base_url, source.
Returns None values for external API mode (server handles LLM).
Raises RuntimeError if no configuration found and not in external API mode.
"""
override_provider = os.environ.get("HINDSIGHT_API_LLM_PROVIDER")
override_model = os.environ.get("HINDSIGHT_API_LLM_MODEL")
override_key = os.environ.get("HINDSIGHT_API_LLM_API_KEY")
override_base_url = os.environ.get("HINDSIGHT_API_LLM_BASE_URL")
# Priority 1: HINDSIGHT_API_LLM_PROVIDER env var
if override_provider:
if not override_key and override_provider not in NO_KEY_REQUIRED:
raise RuntimeError(
f'HINDSIGHT_API_LLM_PROVIDER is set to "{override_provider}" but HINDSIGHT_API_LLM_API_KEY is not set.'
)
pinfo = _find_provider(override_provider)
return {
"provider": override_provider,
"api_key": override_key or "",
"model": override_model,
"base_url": override_base_url,
"source": "HINDSIGHT_API_LLM_PROVIDER override",
}
# Priority 2: Plugin config llmProvider/llmModel
cfg_provider = config.get("llmProvider")
if cfg_provider:
pinfo = _find_provider(cfg_provider)
api_key = ""
key_env_name = config.get("llmApiKeyEnv")
if key_env_name:
api_key = os.environ.get(key_env_name, "")
elif pinfo and pinfo["key_env"]:
api_key = os.environ.get(pinfo["key_env"], "")
if not api_key and cfg_provider not in NO_KEY_REQUIRED:
key_source = key_env_name or (pinfo["key_env"] if pinfo else "unknown")
raise RuntimeError(
f'Plugin config llmProvider is "{cfg_provider}" but no API key found. Expected env var: {key_source}'
)
return {
"provider": cfg_provider,
"api_key": api_key,
"model": config.get("llmModel") or override_model,
"base_url": override_base_url,
"source": "plugin config",
}
# Priority 3: Auto-detect from standard provider env vars
for pinfo in PROVIDER_DETECTION:
if pinfo["name"] in NO_KEY_REQUIRED:
continue # Must be explicitly requested
if not pinfo["key_env"]:
continue
api_key = os.environ.get(pinfo["key_env"], "")
if api_key:
return {
"provider": pinfo["name"],
"api_key": api_key,
"model": override_model,
"base_url": override_base_url,
"source": f"auto-detected from {pinfo['key_env']}",
}
# Priority 4: External API mode — server handles LLM
if config.get("hindsightApiUrl"):
return {
"provider": None,
"api_key": None,
"model": None,
"base_url": None,
"source": "external-api-mode-no-llm",
}
raise RuntimeError(
"No LLM configuration found for Hindsight.\n\n"
"Option 1: Set a standard provider API key (auto-detect):\n"
" export OPENAI_API_KEY=sk-your-key\n"
" export ANTHROPIC_API_KEY=your-key\n\n"
"Option 2: Override with Hindsight-specific env vars:\n"
" export HINDSIGHT_API_LLM_PROVIDER=openai\n"
" export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n\n"
"Option 3: Use an external Hindsight API (server-side LLM):\n"
" Set hindsightApiUrl in settings.json or HINDSIGHT_API_URL env var\n\n"
"The model will be selected automatically by Hindsight. To override: export HINDSIGHT_API_LLM_MODEL=your-model"
)
def get_llm_env_vars(llm_config: dict) -> dict:
"""Build environment variables for hindsight-embed daemon from LLM config.
These are passed to the daemon subprocess so it knows which LLM to use
for fact extraction.
"""
env = {}
if llm_config.get("provider"):
env["HINDSIGHT_API_LLM_PROVIDER"] = llm_config["provider"]
if llm_config.get("api_key"):
env["HINDSIGHT_API_LLM_API_KEY"] = llm_config["api_key"]
if llm_config.get("model"):
env["HINDSIGHT_API_LLM_MODEL"] = llm_config["model"]
if llm_config.get("base_url"):
env["HINDSIGHT_API_LLM_BASE_URL"] = llm_config["base_url"]
return env
@@ -0,0 +1,113 @@
"""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]
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Auto-recall hook for UserPromptSubmit.
Fires before each user prompt. Retrieves relevant memories from Hindsight
and injects them into the Codex context via hookSpecificOutput.additionalContext.
Flow:
1. Read hook input from stdin (session_id, transcript_path, prompt/user_prompt)
2. Resolve API URL
3. Derive bank ID and ensure mission
4. Compose multi-turn query if recallContextTurns > 1
5. Truncate to recallMaxQueryChars
6. Call Hindsight recall API
7. Format memories and output hookSpecificOutput.additionalContext
Exit codes:
0 — always (graceful degradation on any error)
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.bank import derive_bank_id, ensure_bank_mission
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.content import (
compose_recall_query,
format_current_time,
format_memories,
read_transcript,
truncate_recall_query,
)
from lib.daemon import get_api_url
from lib.state import write_state
LAST_RECALL_STATE = "last_recall.json"
def main():
config = load_config()
if not config.get("autoRecall"):
debug_log(config, "Auto-recall disabled, exiting")
return
# Read hook input from stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print("[Hindsight] Failed to read hook input", file=sys.stderr)
return
debug_log(config, f"Hook input keys: {list(hook_input.keys())}")
# Extract user query — accept both "prompt" and "user_prompt" defensively
prompt = (hook_input.get("prompt") or hook_input.get("user_prompt") or "").strip()
if not prompt or len(prompt) < 5:
debug_log(config, "Prompt too short for recall, skipping")
return
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
except RuntimeError as e:
print(f"[Hindsight] {e}", file=sys.stderr)
return
api_token = config.get("hindsightApiToken")
try:
client = HindsightClient(api_url, api_token)
except ValueError as e:
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
return
bank_id = derive_bank_id(hook_input, config)
ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
# Multi-turn query composition
recall_context_turns = config.get("recallContextTurns", 1)
recall_max_query_chars = config.get("recallMaxQueryChars", 800)
recall_roles = config.get("recallRoles", ["user", "assistant"])
if recall_context_turns > 1:
transcript_path = hook_input.get("transcript_path", "")
messages = read_transcript(transcript_path)
debug_log(config, f"Multi-turn context: {recall_context_turns} turns, {len(messages)} messages")
query = compose_recall_query(prompt, messages, recall_context_turns, recall_roles)
else:
query = prompt
query = truncate_recall_query(query, prompt, recall_max_query_chars)
if len(query) > recall_max_query_chars:
query = query[:recall_max_query_chars]
current_time = format_current_time()
preamble = config.get("recallPromptPreamble", "")
debug_log(config, f"Recalling from bank '{bank_id}', query length: {len(query)}")
try:
response = client.recall(
bank_id=bank_id,
query=query,
max_tokens=config.get("recallMaxTokens", 1024),
budget=config.get("recallBudget", "mid"),
types=config.get("recallTypes"),
timeout=10,
)
except Exception as e:
print(f"[Hindsight] Recall failed: {e}", file=sys.stderr)
return
results = response.get("results", [])
if not results:
debug_log(config, "No memories found")
return
debug_log(config, f"Injecting {len(results)} memories")
memories_formatted = format_memories(results)
context_message = (
f"<hindsight_memories>\n"
f"{preamble}\n"
f"Current time - {current_time}\n\n"
f"{memories_formatted}\n"
f"</hindsight_memories>"
)
write_state(
LAST_RECALL_STATE,
{
"context": context_message,
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"bank_id": bank_id,
"result_count": len(results),
},
)
# Output JSON for Codex hook system
output = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context_message,
}
}
json.dump(output, sys.stdout)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] Unexpected error in recall: {e}", file=sys.stderr)
try:
from lib.config import load_config
sys.exit(2 if load_config().get("debug") else 0)
except Exception:
sys.exit(0)
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Auto-retain hook for Stop event.
Fires after each agent turn. Reads the Codex session transcript and stores
the conversation into Hindsight memory for future recall.
Flow:
1. Read hook input from stdin (session_id, transcript_path, cwd)
2. Read conversation transcript from transcript_path
3. Apply chunked retention logic (retainEveryNTurns + overlap window)
4. Resolve API URL (external, existing local, or auto-start daemon)
5. Derive bank ID and ensure mission
6. Format transcript (strip memory tags, filter roles)
7. POST to Hindsight retain API
Exit codes:
0 — always (graceful degradation on any error)
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.bank import derive_bank_id, ensure_bank_mission
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.content import (
prepare_retention_transcript,
read_transcript,
slice_last_turns_by_user_boundary,
)
from lib.daemon import get_api_url
from lib.state import increment_turn_count
def main():
config = load_config()
if not config.get("autoRetain"):
debug_log(config, "Auto-retain disabled, exiting")
return
# Read hook input from stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print("[Hindsight] Failed to read hook input", file=sys.stderr)
return
debug_log(config, f"Stop hook input keys: {list(hook_input.keys())}")
session_id = hook_input.get("session_id", "unknown")
transcript_path = hook_input.get("transcript_path", "")
# Read full transcript
all_messages = read_transcript(transcript_path)
if not all_messages:
debug_log(config, "No messages in transcript, skipping retain")
return
debug_log(config, f"Read {len(all_messages)} messages from transcript")
# Retention mode: full session (default) or chunked (legacy)
retain_mode = config.get("retainMode", "full-session")
retain_every_n = max(1, config.get("retainEveryNTurns", 1))
retain_full_window = False
messages_to_retain = all_messages
# Respect retainEveryNTurns in both modes
if retain_every_n > 1:
turn_count = increment_turn_count(session_id)
if turn_count % retain_every_n != 0:
next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
return
if retain_mode == "chunked" and retain_every_n > 1:
overlap_turns = config.get("retainOverlapTurns", 0)
window_turns = retain_every_n + overlap_turns
messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
retain_full_window = True
debug_log(
config,
f"Chunked retain firing (window: {window_turns} turns, {len(messages_to_retain)} messages)",
)
else:
retain_full_window = True
debug_log(config, f"Full session retain: {len(all_messages)} messages")
# Format transcript
retain_roles = config.get("retainRoles", ["user", "assistant"])
transcript, message_count = prepare_retention_transcript(
messages_to_retain, retain_roles, retain_full_window
)
if not transcript:
debug_log(config, "Empty transcript after formatting, skipping retain")
return
# Resolve API URL
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=True)
except RuntimeError as e:
print(f"[Hindsight] {e}", file=sys.stderr)
return
api_token = config.get("hindsightApiToken")
try:
client = HindsightClient(api_url, api_token)
except ValueError as e:
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
return
# Derive bank ID and ensure mission
bank_id = derive_bank_id(hook_input, config)
ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
# Document ID: use session_id so the same session always upserts.
# In chunked mode, append timestamp to create distinct documents per chunk.
if retain_mode == "chunked" and retain_every_n > 1:
document_id = f"{session_id}-{int(time.time() * 1000)}"
else:
document_id = session_id
# Resolve template variables in tags and metadata
template_vars = {
"session_id": session_id,
"bank_id": bank_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
def _resolve_template(value: str) -> str:
for k, v in template_vars.items():
value = value.replace(f"{{{k}}}", v)
return value
raw_tags = config.get("retainTags", [])
tags = [_resolve_template(t) for t in raw_tags] if raw_tags else None
metadata = {
"retained_at": template_vars["timestamp"],
"message_count": str(message_count),
"session_id": session_id,
}
for k, v in config.get("retainMetadata", {}).items():
metadata[k] = _resolve_template(str(v))
debug_log(
config, f"Retaining to bank '{bank_id}', doc '{document_id}', {message_count} messages, {len(transcript)} chars"
)
if tags:
debug_log(config, f"Tags: {tags}")
# POST to Hindsight retain API
try:
response = client.retain(
bank_id=bank_id,
content=transcript,
document_id=document_id,
context=config.get("retainContext", "codex"),
metadata=metadata,
tags=tags,
timeout=15,
)
debug_log(config, f"Retain response: {json.dumps(response)[:200]}")
except Exception as e:
print(f"[Hindsight] Retain failed: {e}", file=sys.stderr)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] Unexpected error in retain: {e}", file=sys.stderr)
try:
from lib.config import load_config
sys.exit(2 if load_config().get("debug") else 0)
except Exception:
sys.exit(0)
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""SessionStart hook: health check and daemon pre-start.
Fires once when a Codex session begins. Verifies the Hindsight server is
reachable, and kicks off a background daemon pre-start if not — so it's
ready by the first recall or retain hook.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.daemon import get_api_url, prestart_daemon_background
def main():
config = load_config()
if not config.get("autoRecall") and not config.get("autoRetain"):
debug_log(config, "Both autoRecall and autoRetain disabled, skipping session start")
return
# Consume stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
hook_input = {}
debug_log(config, f"SessionStart hook, session: {hook_input.get('session_id', 'unknown')}")
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
HindsightClient(api_url, config.get("hindsightApiToken"))
debug_log(config, f"Hindsight server reachable at {api_url}")
except (RuntimeError, ValueError) as e:
debug_log(config, f"Hindsight not running, initiating background pre-start: {e}")
prestart_daemon_background(config, debug_fn=_dbg)
return
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] SessionStart error: {e}", file=sys.stderr)
sys.exit(0)
@@ -0,0 +1,35 @@
{
"hindsightApiUrl": "",
"bankId": "codex",
"bankMission": "You are a Codex AI coding assistant. Focus on technical decisions, code changes, debugging sessions, and project context relevant to the user's work.",
"retainMission": "Extract technical decisions, code patterns, debugging solutions, user preferences, project context, and architectural choices. Ignore routine greetings and transient operational details.",
"autoRecall": true,
"autoRetain": true,
"retainMode": "full-session",
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallTypes": ["world", "experience"],
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 10,
"retainOverlapTurns": 2,
"retainTags": ["{session_id}"],
"retainMetadata": {},
"retainContext": "codex",
"hindsightApiToken": null,
"apiPort": 9077,
"daemonIdleTimeout": 0,
"embedVersion": "latest",
"embedPackagePath": null,
"bankIdPrefix": "",
"dynamicBankId": false,
"dynamicBankGranularity": ["agent", "project"],
"agentName": "codex",
"llmProvider": null,
"llmModel": null,
"llmApiKeyEnv": null,
"debug": false
}
@@ -0,0 +1,90 @@
"""Shared fixtures for Hindsight Codex plugin tests."""
import io
import json
import os
import sys
import pytest
# Make scripts/ importable as the root — the hook scripts do:
# sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# so lib.* imports resolve relative to scripts/
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS_DIR not in sys.path:
sys.path.insert(0, os.path.abspath(SCRIPTS_DIR))
def make_hook_input(
prompt="What is the capital of France?",
session_id="sess-abc123",
cwd="/home/user/myproject",
transcript_path="",
):
return {
"prompt": prompt,
"session_id": session_id,
"cwd": cwd,
"transcript_path": transcript_path,
}
def make_transcript_file(tmp_path, messages, codex_format=False):
"""Write messages as a JSONL transcript file.
By default writes flat format {role, content} which read_transcript() accepts.
Set codex_format=True to write actual Codex response_item format.
"""
f = tmp_path / "rollout-test.jsonl"
lines = []
for msg in messages:
if codex_format:
role = msg["role"]
text = msg["content"]
content_type = "input_text" if role == "user" else "output_text"
entry = {
"type": "response_item",
"payload": {
"type": "message",
"role": role,
"content": [{"type": content_type, "text": text}],
},
}
if role == "assistant":
entry["payload"]["phase"] = "final_answer"
lines.append(json.dumps(entry))
else:
lines.append(json.dumps(msg))
f.write_text("\n".join(lines))
return str(f)
def make_memory(text, mem_type="experience", mentioned_at="2024-01-15"):
return {"text": text, "type": mem_type, "mentioned_at": mentioned_at}
def make_user_config(tmp_path, overrides=None):
"""Write a ~/.hindsight/codex.json in tmp_path with test defaults."""
hindsight_dir = tmp_path / ".hindsight"
hindsight_dir.mkdir(exist_ok=True)
config = {"retainEveryNTurns": 1}
if overrides:
config.update(overrides)
(hindsight_dir / "codex.json").write_text(json.dumps(config))
class FakeHTTPResponse:
"""Minimal urllib response mock."""
def __init__(self, data: dict, status: int = 200):
self.status = status
self._data = json.dumps(data).encode()
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *_):
pass
@@ -0,0 +1,364 @@
"""Tests for lib/content.py — pure content-processing functions."""
import json
import os
import sys
import pytest
from lib.content import (
compose_recall_query,
format_memories,
prepare_retention_transcript,
read_transcript,
slice_last_turns_by_user_boundary,
strip_memory_tags,
truncate_recall_query,
)
# ---------------------------------------------------------------------------
# strip_memory_tags
# ---------------------------------------------------------------------------
class TestStripMemoryTags:
def test_strips_hindsight_memories_block(self):
raw = "before\n<hindsight_memories>secret</hindsight_memories>\nafter"
result = strip_memory_tags(raw)
assert "hindsight_memories" not in result
assert "before" in result
assert "after" in result
def test_strips_relevant_memories_block(self):
raw = "text <relevant_memories>old stuff</relevant_memories> text"
result = strip_memory_tags(raw)
assert "relevant_memories" not in result
assert "old stuff" not in result
def test_passthrough_clean_text(self):
raw = "no memory tags here"
assert strip_memory_tags(raw) == raw
def test_strips_multiline_block(self):
raw = "<hindsight_memories>\n- mem1\n- mem2\n</hindsight_memories>"
assert strip_memory_tags(raw).strip() == ""
# ---------------------------------------------------------------------------
# read_transcript — flat format
# ---------------------------------------------------------------------------
def _write_jsonl(tmp_path, entries):
f = tmp_path / "transcript.jsonl"
f.write_text("\n".join(json.dumps(e) for e in entries))
return str(f)
class TestReadTranscriptFlat:
def test_reads_flat_format(self, tmp_path):
path = _write_jsonl(tmp_path, [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
])
msgs = read_transcript(path)
assert len(msgs) == 2
assert msgs[0] == {"role": "user", "content": "hello"}
def test_returns_empty_for_missing_file(self):
assert read_transcript("/nonexistent/path.jsonl") == []
def test_returns_empty_for_empty_string(self):
assert read_transcript("") == []
class TestReadTranscriptCodexFormat:
def test_reads_codex_response_item_format(self, tmp_path):
entries = [
{
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "What is Python?"}],
},
},
{
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "A programming language."}],
"phase": "final_answer",
},
},
]
path = _write_jsonl(tmp_path, entries)
msgs = read_transcript(path)
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[0]["content"] == "What is Python?"
assert msgs[1]["role"] == "assistant"
assert msgs[1]["content"] == "A programming language."
def test_skips_non_final_answer_assistant_messages(self, tmp_path):
entries = [
{
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "thinking..."}],
"phase": "reasoning",
},
},
{
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "The answer is 42."}],
"phase": "final_answer",
},
},
]
path = _write_jsonl(tmp_path, entries)
msgs = read_transcript(path)
assert len(msgs) == 1
assert msgs[0]["content"] == "The answer is 42."
def test_skips_non_message_response_items(self, tmp_path):
entries = [
{"type": "response_item", "payload": {"type": "tool_call", "name": "Bash"}},
{
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
},
},
]
path = _write_jsonl(tmp_path, entries)
msgs = read_transcript(path)
assert len(msgs) == 1
assert msgs[0]["role"] == "user"
def test_skips_invalid_roles(self, tmp_path):
entries = [
{
"type": "response_item",
"payload": {
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "system message"}],
},
},
]
path = _write_jsonl(tmp_path, entries)
msgs = read_transcript(path)
assert len(msgs) == 0
def test_skips_blank_lines_gracefully(self, tmp_path):
f = tmp_path / "transcript.jsonl"
f.write_text('\n{"role": "user", "content": "hi"}\n\n{"role": "assistant", "content": "hey"}\n')
msgs = read_transcript(str(f))
assert len(msgs) == 2
# ---------------------------------------------------------------------------
# slice_last_turns_by_user_boundary
# ---------------------------------------------------------------------------
def _msgs(*pairs):
return [{"role": r, "content": c} for r, c in pairs]
class TestSliceLastTurnsByUserBoundary:
def test_returns_all_when_fewer_turns_than_requested(self):
msgs = _msgs(("user", "hi"), ("assistant", "hello"))
assert slice_last_turns_by_user_boundary(msgs, 5) == msgs
def test_slices_to_last_one_turn(self):
msgs = _msgs(
("user", "first"), ("assistant", "a1"),
("user", "second"), ("assistant", "a2"),
)
result = slice_last_turns_by_user_boundary(msgs, 1)
assert result[0]["content"] == "second"
assert len(result) == 2
def test_slices_to_last_two_turns(self):
msgs = _msgs(
("user", "u1"), ("assistant", "a1"),
("user", "u2"), ("assistant", "a2"),
("user", "u3"), ("assistant", "a3"),
)
result = slice_last_turns_by_user_boundary(msgs, 2)
assert result[0]["content"] == "u2"
assert len(result) == 4
def test_empty_list_returns_empty(self):
assert slice_last_turns_by_user_boundary([], 3) == []
def test_zero_turns_returns_empty(self):
assert slice_last_turns_by_user_boundary(_msgs(("user", "hi")), 0) == []
def test_non_list_returns_empty(self):
assert slice_last_turns_by_user_boundary(None, 1) == []
# ---------------------------------------------------------------------------
# compose_recall_query
# ---------------------------------------------------------------------------
class TestComposeRecallQuery:
def test_single_turn_returns_latest_only(self):
msgs = _msgs(("user", "previous"), ("assistant", "reply"))
result = compose_recall_query("new query", msgs, recall_context_turns=1)
assert result == "new query"
def test_multi_turn_includes_prior_context(self):
msgs = _msgs(("user", "prior question"), ("assistant", "prior answer"))
result = compose_recall_query("current question", msgs, recall_context_turns=2)
assert "Prior context:" in result
assert "prior question" in result
assert "current question" in result
def test_skips_duplicate_of_latest_query(self):
msgs = _msgs(("user", "same question"), ("assistant", "answer"))
result = compose_recall_query("same question", msgs, recall_context_turns=2)
assert result.count("same question") == 1
def test_empty_messages_returns_latest(self):
result = compose_recall_query("query", [], recall_context_turns=3)
assert result == "query"
def test_strips_memory_tags_from_context(self):
msgs = _msgs(("user", "<hindsight_memories>secret</hindsight_memories> actual question"))
result = compose_recall_query("now", msgs, recall_context_turns=2)
assert "hindsight_memories" not in result
assert "secret" not in result
def test_filters_by_recall_roles(self):
msgs = _msgs(("user", "user msg"), ("assistant", "assistant msg"))
result = compose_recall_query("query", msgs, recall_context_turns=2, recall_roles=["user"])
assert "user msg" in result
assert "assistant msg" not in result
# ---------------------------------------------------------------------------
# truncate_recall_query
# ---------------------------------------------------------------------------
class TestTruncateRecallQuery:
def test_short_query_unchanged(self):
q = "short"
assert truncate_recall_query(q, q, max_chars=100) == q
def test_plain_query_truncated_to_max(self):
q = "x" * 50
result = truncate_recall_query(q, q, max_chars=20)
assert len(result) <= 20
def test_preserves_latest_when_context_dropped(self):
latest = "final question"
query = f"Prior context:\n\nuser: old stuff\nassistant: old reply\n\n{latest}"
result = truncate_recall_query(query, latest, max_chars=30)
assert latest in result
def test_drops_oldest_context_lines_first(self):
latest = "latest"
query = f"Prior context:\n\nuser: oldest\nassistant: old\nuser: newer\n\n{latest}"
max_chars = len(f"Prior context:\n\nnewer\n\n{latest}") + 5
result = truncate_recall_query(query, latest, max_chars=max_chars)
if "Prior context:" in result:
assert "oldest" not in result
def test_zero_max_returns_query_unchanged(self):
q = "anything"
assert truncate_recall_query(q, q, max_chars=0) == q
# ---------------------------------------------------------------------------
# format_memories
# ---------------------------------------------------------------------------
class TestFormatMemories:
def test_formats_single_memory(self):
mems = [{"text": "Paris is the capital", "type": "world", "mentioned_at": "2024-01-01"}]
result = format_memories(mems)
assert "Paris is the capital" in result
assert "[world]" in result
assert "(2024-01-01)" in result
def test_formats_multiple_memories(self):
mems = [
{"text": "mem1", "type": "experience", "mentioned_at": "2024-01-01"},
{"text": "mem2", "type": "world", "mentioned_at": "2024-02-01"},
]
result = format_memories(mems)
assert "mem1" in result
assert "mem2" in result
def test_empty_list_returns_empty_string(self):
assert format_memories([]) == ""
def test_missing_optional_fields_graceful(self):
mems = [{"text": "bare memory"}]
result = format_memories(mems)
assert "bare memory" in result
# ---------------------------------------------------------------------------
# prepare_retention_transcript
# ---------------------------------------------------------------------------
class TestPrepareRetentionTranscript:
def test_formats_last_turn_by_default(self):
msgs = _msgs(("user", "old"), ("assistant", "old reply"), ("user", "new"), ("assistant", "new reply"))
transcript, count = prepare_retention_transcript(msgs, retain_full_window=False)
assert "new" in transcript
assert "new reply" in transcript
assert count == 2
def test_full_window_retains_all(self):
msgs = _msgs(("user", "msg1"), ("assistant", "reply1"), ("user", "msg2"), ("assistant", "reply2"))
transcript, count = prepare_retention_transcript(msgs, retain_full_window=True)
assert "msg1" in transcript
assert "msg2" in transcript
assert count == 4
def test_strips_memory_tags(self):
msgs = _msgs(("user", "<hindsight_memories>leaked</hindsight_memories> actual question"))
transcript, _ = prepare_retention_transcript(msgs, retain_full_window=True)
assert "leaked" not in transcript
assert "actual question" in transcript
def test_filters_by_retain_roles(self):
msgs = _msgs(("user", "user msg"), ("assistant", "assistant msg"))
transcript, _ = prepare_retention_transcript(msgs, retain_roles=["user"], retain_full_window=True)
assert "user msg" in transcript
assert "assistant msg" not in transcript
def test_empty_messages_returns_none(self):
result, count = prepare_retention_transcript([])
assert result is None
assert count == 0
def test_role_markers_present(self):
msgs = _msgs(("user", "hello"))
transcript, _ = prepare_retention_transcript(msgs, retain_full_window=True)
assert "[role: user]" in transcript
assert "[user:end]" in transcript
def test_no_user_message_returns_none(self):
msgs = [{"role": "assistant", "content": "only assistant"}]
result, _ = prepare_retention_transcript(msgs, retain_full_window=False)
assert result is None
@@ -0,0 +1,319 @@
"""End-to-end tests for recall.py and retain.py hook scripts.
Mocks the Codex hook runtime:
- stdin → io.StringIO(json.dumps(hook_input))
- stdout → io.StringIO() captured for assertions
- urllib.request.urlopen → fake HTTP responses
- HOME → tmp_path (isolates ~/.hindsight/codex.json and state)
"""
import importlib
import io
import json
import os
import sys
from unittest.mock import patch
import pytest
from conftest import FakeHTTPResponse, make_hook_input, make_memory, make_transcript_file, make_user_config
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_hook(module_name, hook_input, monkeypatch, tmp_path, urlopen_side_effect=None, user_config=None):
"""Import and run a hook script's main() with mocked stdin/stdout/HTTP."""
# Isolate HOME so ~/.hindsight/codex.json and state land in tmp_path
monkeypatch.setenv("HOME", str(tmp_path))
# Strip real HINDSIGHT_* env vars
for k in list(os.environ):
if k.startswith("HINDSIGHT_"):
monkeypatch.delenv(k, raising=False)
# Set required API URL via env var
monkeypatch.setenv("HINDSIGHT_API_URL", "http://fake:9077")
# Write user config (enables retain on every turn + any overrides)
cfg = {"retainEveryNTurns": 1, "autoRecall": True, "autoRetain": True}
if user_config:
cfg.update(user_config)
make_user_config(tmp_path, cfg)
stdin_data = io.StringIO(json.dumps(hook_input))
stdout_capture = io.StringIO()
# Force reimport so the module picks up patched env
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
spec = importlib.util.spec_from_file_location(
module_name + "_fresh", os.path.join(scripts_dir, f"{module_name}.py")
)
mod = importlib.util.module_from_spec(spec)
default_response = FakeHTTPResponse({"results": []})
side_effect = urlopen_side_effect or (lambda *a, **kw: default_response)
with (
patch("sys.stdin", stdin_data),
patch("sys.stdout", stdout_capture),
patch("urllib.request.urlopen", side_effect=side_effect),
):
spec.loader.exec_module(mod)
mod.main()
return stdout_capture.getvalue()
# ---------------------------------------------------------------------------
# recall hook
# ---------------------------------------------------------------------------
class TestRecallHook:
def test_outputs_additional_context_when_memories_found(self, monkeypatch, tmp_path):
memory = make_memory("Paris is the capital of France", "world")
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What is the capital of France?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=lambda *a, **kw: response)
data = json.loads(output)
context = data["hookSpecificOutput"]["additionalContext"]
assert "Paris is the capital of France" in context
assert "<hindsight_memories>" in context
def test_no_output_when_no_memories(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hello there world")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
assert output.strip() == ""
def test_no_output_for_short_prompt(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hi")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
assert output.strip() == ""
def test_graceful_on_api_error(self, monkeypatch, tmp_path):
def raise_error(*a, **kw):
raise OSError("connection refused")
hook_input = make_hook_input(prompt="What is my project about?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
assert output.strip() == ""
def test_output_format_matches_codex_spec(self, monkeypatch, tmp_path):
memory = make_memory("User prefers Python")
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What language should I use?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=lambda *a, **kw: response)
data = json.loads(output)
assert data["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
assert "additionalContext" in data["hookSpecificOutput"]
def test_multi_turn_context_from_transcript(self, monkeypatch, tmp_path):
"""When recallContextTurns > 1, prior transcript is included in query."""
messages = [
{"role": "user", "content": "I use Python for all my scripts"},
{"role": "assistant", "content": "Noted!"},
]
transcript = make_transcript_file(tmp_path, messages)
captured_body = {}
def capture_and_respond(req, timeout=None):
if "/recall" in req.full_url:
captured_body["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"results": []})
hook_input = make_hook_input(prompt="What language should I use?", transcript_path=transcript)
_run_hook("recall", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=capture_and_respond,
user_config={"recallContextTurns": 2})
if "body" in captured_body:
assert "Python" in captured_body["body"].get("query", "")
def test_disabled_auto_recall_produces_no_output(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="What is the capital of France?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path,
user_config={"autoRecall": False})
assert output.strip() == ""
# ---------------------------------------------------------------------------
# retain hook
# ---------------------------------------------------------------------------
class TestRetainHook:
def test_posts_transcript_to_hindsight(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"status": "accepted"})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "body" in captured, "retain API was not called"
assert "hello" in captured["body"]["items"][0]["content"]
def test_no_retain_on_empty_transcript(self, monkeypatch, tmp_path):
hook_input = make_hook_input(transcript_path="/nonexistent/transcript.jsonl")
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url:
captured["called"] = True
return FakeHTTPResponse({})
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "called" not in captured
def test_strips_memory_tags_before_retaining(self, monkeypatch, tmp_path):
messages = [
{"role": "user", "content": "<hindsight_memories>old memories</hindsight_memories> actual question"},
{"role": "assistant", "content": "sure!"},
]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
if "body" in captured:
content = captured["body"]["items"][0]["content"]
assert "old memories" not in content
assert "actual question" in content
def test_retain_posts_async_true(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
if "body" in captured:
assert captured["body"].get("async") is True
def test_retain_includes_codex_context_label(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
if "body" in captured:
assert captured["body"]["items"][0]["context"] == "codex"
def test_retain_skips_below_every_n_turns_threshold(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["called"] = True
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
# retainEveryNTurns=3 — first call should be skipped
_run_hook("retain", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=capture,
user_config={"retainEveryNTurns": 3})
assert "called" not in captured
def test_retain_uses_session_id_as_document_id(self, monkeypatch, tmp_path):
messages = [
{"role": "user", "content": "question"}, {"role": "assistant", "content": "answer"},
]
transcript = make_transcript_file(tmp_path, messages)
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-doc-test")
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "body" in captured
assert captured["body"]["items"][0]["document_id"] == "sess-doc-test"
def test_graceful_on_retain_api_error(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "test"}, {"role": "assistant", "content": "response"}]
transcript = make_transcript_file(tmp_path, messages)
hook_input = make_hook_input(transcript_path=transcript)
def raise_error(req, timeout=None):
if "/memories" in req.full_url:
raise OSError("connection refused")
return FakeHTTPResponse({})
# Should not raise
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
def test_disabled_auto_retain_does_not_call_api(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}]
transcript = make_transcript_file(tmp_path, messages)
hook_input = make_hook_input(transcript_path=transcript)
captured = {}
def capture(req, timeout=None):
captured["called"] = True
return FakeHTTPResponse({})
_run_hook("retain", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=capture,
user_config={"autoRetain": False})
assert "called" not in captured
def test_reads_codex_response_item_format(self, monkeypatch, tmp_path):
"""Retain should correctly parse the actual Codex on-disk transcript format."""
messages = [
{"role": "user", "content": "I like TypeScript"},
{"role": "assistant", "content": "Great choice!"},
]
transcript = make_transcript_file(tmp_path, messages, codex_format=True)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "body" in captured, "retain API was not called"
content = captured["body"]["items"][0]["content"]
assert "TypeScript" in content
@@ -33,6 +33,7 @@ dependencies = [
"cryptography>=46.0.5", # Subgroup attack vulnerability fix
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
"requests>=2.33.0", # Insecure temp file reuse in extract_zipped_paths()
]
[project.optional-dependencies]
+5 -5
View File
@@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -1018,6 +1017,7 @@ dependencies = [
{ name = "hindsight-client" },
{ name = "pillow" },
{ name = "pyjwt" },
{ name = "requests" },
]
[package.optional-dependencies]
@@ -1040,8 +1040,8 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.12.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" },
{ name = "requests", specifier = ">=2.33.0" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -3116,7 +3116,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -3124,9 +3124,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]
@@ -30,6 +30,8 @@ classifiers = [
dependencies = [
"langchain-core>=0.3.0",
"hindsight-client>=0.4.0",
# Transitive dependency security fixes
"requests>=2.33.0", # Insecure temp file reuse in extract_zipped_paths()
]
[project.optional-dependencies]
+8 -8
View File
@@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.10"
[[package]]
@@ -490,11 +489,12 @@ wheels = [
[[package]]
name = "hindsight-langgraph"
version = "0.1.0"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "hindsight-client" },
{ name = "langchain-core" },
{ name = "requests" },
]
[package.optional-dependencies]
@@ -516,10 +516,10 @@ dev = [
requires-dist = [
{ name = "hindsight-client", specifier = ">=0.4.0" },
{ name = "langchain-core", specifier = ">=0.3.0" },
{ name = "langgraph", marker = "extra == 'all'", specifier = ">=0.5.0" },
{ name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=0.5.0" },
{ name = "langgraph", marker = "extra == 'all'", specifier = ">=0.3.0" },
{ name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=0.3.0" },
{ name = "requests", specifier = ">=2.33.0" },
]
provides-extras = ["langgraph", "all"]
[package.metadata.requires-dev]
dev = [
@@ -1349,7 +1349,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1357,9 +1357,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]
@@ -37,6 +37,7 @@ dependencies = [
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"filelock>=3.20.3", # TOCTOU race condition
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass
"requests>=2.33.0", # Insecure temp file reuse in extract_zipped_paths()
]
[project.optional-dependencies]
+7 -7
View File
@@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -691,12 +690,13 @@ wheels = [
[[package]]
name = "hindsight-litellm"
version = "0.4.19"
version = "0.5.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "filelock" },
{ name = "litellm" },
{ name = "requests" },
{ name = "urllib3" },
]
@@ -716,13 +716,13 @@ dev = [
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3" },
{ name = "filelock", specifier = ">=3.20.3" },
{ name = "litellm", specifier = ">=1.40.0" },
{ name = "litellm", specifier = ">=1.40.0,<=1.82.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" },
{ name = "requests", specifier = ">=2.33.0" },
{ name = "urllib3", specifier = ">=2.6.3" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -1723,7 +1723,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1731,9 +1731,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]
@@ -7,3 +7,27 @@ import PageHero from '@site/src/components/PageHero';
<PageHero title="Claude Code Changelog" subtitle="hindsight-memory — Hindsight memory plugin for Claude Code." />
[← Claude Code integration](../../sdks/integrations/claude-code.md)
## [0.3.0](https://github.com/vectorize-io/hindsight/tree/integrations/claude-code/v0.3.0)
**Features**
- Claude Code integration now retains tool calls as structured JSON for more accurate memory and retrieval. ([`8cb8b912`](https://github.com/vectorize-io/hindsight/commit/8cb8b912))
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/claude-code/v0.2.0)
**Features**
- Added a Claude Code integration plugin for capturing and using Hindsight memory in Claude Code. ([`f4390bdc`](https://github.com/vectorize-io/hindsight/commit/f4390bdc))
- Claude Code integration can retain full sessions with document upsert and configurable tagging. ([`2d31b67d`](https://github.com/vectorize-io/hindsight/commit/2d31b67d))
**Improvements**
- Improved Claude Code plugin installation and configuration experience. ([`35b2cbb6`](https://github.com/vectorize-io/hindsight/commit/35b2cbb6))
- Integrations no longer rely on hardcoded default models, allowing model selection to be fully configured. ([`58e68f3e`](https://github.com/vectorize-io/hindsight/commit/58e68f3e))
- Claude Code now starts the Hindsight background daemon automatically at session start for smoother operation. ([`26944e25`](https://github.com/vectorize-io/hindsight/commit/26944e25))
**Bug Fixes**
- Added a supported setup command to register hooks reliably, fixing hook registration issues. ([`22ca6a8d`](https://github.com/vectorize-io/hindsight/commit/22ca6a8d))
- Fixed Claude Code integration compatibility on Windows. ([`a94a90ea`](https://github.com/vectorize-io/hindsight/commit/a94a90ea))
Generated
+5 -3
View File
@@ -1568,6 +1568,7 @@ dependencies = [
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "rich" },
{ name = "sqlalchemy" },
{ name = "tiktoken" },
@@ -1683,6 +1684,7 @@ requires-dist = [
{ name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.0.0" },
{ name = "python-dateutil", specifier = ">=2.8.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "rich", specifier = ">=13.0.0" },
{ name = "safetensors", marker = "extra == 'local-ml'", specifier = ">=0.6.2" },
{ name = "sentence-transformers", marker = "extra == 'local-ml'", specifier = ">=3.3.0" },
@@ -4180,11 +4182,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.20"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 },
]
[[package]]