diff --git a/.github/workflows/release-integration.yml b/.github/workflows/release-integration.yml
index b5d18b9b..6f339b39 100644
--- a/.github/workflows/release-integration.yml
+++ b/.github/workflows/release-integration.yml
@@ -31,8 +31,10 @@ jobs:
run: |
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
echo "type=python" >> $GITHUB_OUTPUT
- else
+ elif [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/package.json" ]; then
echo "type=typescript" >> $GITHUB_OUTPUT
+ else
+ echo "type=plugin" >> $GITHUB_OUTPUT
fi
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
@@ -63,6 +65,16 @@ jobs:
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
+ # ── Plugin integrations (claude-code) — no package to publish ───────────
+
+ - name: Plugin release
+ if: steps.type.outputs.type == 'plugin'
+ run: |
+ echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
+ echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
+
+ # ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
+
- name: Set up Node.js
if: steps.type.outputs.type == 'typescript'
uses: actions/setup-node@v6
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 40d834ad..6881554d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -75,6 +75,24 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: npm run build
+ test-claude-code-integration:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+
+ - name: Install pytest
+ run: pip install pytest
+
+ - name: Run tests
+ working-directory: ./hindsight-integrations/claude-code
+ run: python -m pytest tests/ -v
+
build-ai-sdk-integration:
runs-on: ubuntu-latest
diff --git a/hindsight-integrations/.claude-plugin/marketplace.json b/hindsight-integrations/.claude-plugin/marketplace.json
new file mode 100644
index 00000000..bf28189c
--- /dev/null
+++ b/hindsight-integrations/.claude-plugin/marketplace.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
+ "name": "hindsight",
+ "description": "Official Hindsight integrations for Claude Code",
+ "owner": {
+ "name": "vectorize-io"
+ },
+ "plugins": [
+ {
+ "name": "hindsight-memory",
+ "description": "Automatic long-term memory for Claude Code via Hindsight",
+ "source": "./claude-code"
+ }
+ ]
+}
diff --git a/hindsight-integrations/claude-code/.claude-plugin/plugin.json b/hindsight-integrations/claude-code/.claude-plugin/plugin.json
index b6bcaa69..8046c62a 100644
--- a/hindsight-integrations/claude-code/.claude-plugin/plugin.json
+++ b/hindsight-integrations/claude-code/.claude-plugin/plugin.json
@@ -2,7 +2,7 @@
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight. Recalls relevant memories before each prompt and retains conversation transcripts after each response.",
"version": "0.1.0",
- "author": "Fabio Scarsi",
+ "author": {"name": "Hindsight Team", "url": "https://vectorize.io/hindsight"},
"license": "MIT",
"keywords": ["memory", "hindsight", "recall", "retain"]
}
diff --git a/hindsight-integrations/claude-code/CHANGELOG.md b/hindsight-integrations/claude-code/CHANGELOG.md
new file mode 100644
index 00000000..4f3f06ec
--- /dev/null
+++ b/hindsight-integrations/claude-code/CHANGELOG.md
@@ -0,0 +1,17 @@
+# Changelog
+
+## [0.1.0] - 2025-03-23
+
+### Added
+- Initial release: Claude Code plugin for Hindsight long-term memory
+- Auto-recall on every user prompt via `UserPromptSubmit` hook — injects relevant memories as `additionalContext`
+- Auto-retain after every response via async `Stop` hook — extracts and stores conversation transcript
+- Session lifecycle hooks (`SessionStart` health check, `SessionEnd` daemon cleanup)
+- Three connection modes: external API, auto-managed local daemon (`uvx hindsight-embed`), existing local server
+- Dynamic bank IDs with configurable granularity (`agent`, `project`, `session`, `channel`, `user`)
+- Channel-agnostic: works with Claude Code Channels (Telegram, Discord, Slack) and interactive sessions
+- Zero pip dependencies — pure Python stdlib (`urllib`, `fcntl`, `subprocess`)
+- 34 configuration options via `settings.json` with env var overrides
+- LLM auto-detection from `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`
+- Chunked retention with sliding window (`retainEveryNTurns` + `retainOverlapTurns`)
+- Memory tag stripping to prevent retain feedback loops
diff --git a/hindsight-integrations/claude-code/README.md b/hindsight-integrations/claude-code/README.md
index 4d6b635e..86f2c2ed 100644
--- a/hindsight-integrations/claude-code/README.md
+++ b/hindsight-integrations/claude-code/README.md
@@ -5,18 +5,24 @@ Biomimetic long-term memory for [Claude Code](https://docs.anthropic.com/en/docs
## Quick Start
```bash
-# 1. Configure your LLM provider for memory extraction
+# 1. Add the Hindsight marketplace and install the plugin
+claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations
+claude plugin install hindsight-memory
+
+# 2. Configure your LLM provider for memory extraction
# Option A: OpenAI (auto-detected)
export OPENAI_API_KEY="sk-your-key"
# Option B: Anthropic (auto-detected)
export ANTHROPIC_API_KEY="your-key"
-# Option C: Connect to an external Hindsight server (no local LLM needed)
-# Edit settings.json: set "hindsightApiUrl": "https://your-hindsight-server.com"
+# Option C: No API key needed (uses Claude Code's own model — personal/local use only)
+# See: https://vectorize.io/hindsight/developer/models#claude-code-setup-claude-promax
+export HINDSIGHT_LLM_PROVIDER=claude-code
-# 2. Install the plugin
-claude /plugin install /path/to/hindsight-integrations/claude-code
+# Option D: Connect to an external Hindsight server instead of running locally
+mkdir -p ~/.hindsight
+echo '{"hindsightApiUrl": "https://your-hindsight-server.com"}' > ~/.hindsight/claude-code.json
# 3. Start Claude Code — the plugin activates automatically
claude
@@ -24,6 +30,9 @@ claude
That's it! The plugin will automatically start capturing and recalling memories.
+> **Tip:** Once available in the official Claude Code plugin directory, installation will be a single command:
+> `claude plugin install hindsight-memory`
+
## Features
- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as context (invisible to the chat transcript, visible to Claude)
diff --git a/hindsight-integrations/claude-code/install.sh b/hindsight-integrations/claude-code/install.sh
deleted file mode 100755
index 45f02a9e..00000000
--- a/hindsight-integrations/claude-code/install.sh
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/bin/bash
-set -e
-
-echo "Installing Hindsight Memory Plugin for Claude Code..."
-
-# Get the directory where this script is located
-SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-
-# Check Python version
-if ! command -v python3 &> /dev/null; then
- echo "Error: Python 3 not found. Please install Python 3.8+"
- exit 1
-fi
-
-PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
-echo "Found Python $PYTHON_VERSION"
-
-# Check Claude Code is available
-if ! command -v claude &> /dev/null; then
- echo "Warning: 'claude' command not found. Make sure Claude Code is installed."
- echo " See: https://docs.anthropic.com/en/docs/claude-code"
-fi
-
-# Install via Claude Code plugin system
-echo ""
-echo "To install the plugin, run the following in Claude Code:"
-echo ""
-echo " /plugin install $SCRIPT_DIR"
-echo ""
-echo "Or copy it manually:"
-echo ""
-
-PLUGIN_DIR="$HOME/.claude/plugins/hindsight-memory"
-echo " mkdir -p $PLUGIN_DIR"
-echo " cp -r $SCRIPT_DIR/.claude-plugin $PLUGIN_DIR/"
-echo " cp -r $SCRIPT_DIR/hooks $PLUGIN_DIR/"
-echo " cp -r $SCRIPT_DIR/scripts $PLUGIN_DIR/"
-echo " cp $SCRIPT_DIR/settings.json $PLUGIN_DIR/"
-
-echo ""
-echo "Next steps:"
-echo ""
-echo "1. Configure your LLM provider for memory extraction:"
-echo " # Option A: OpenAI (auto-detected)"
-echo " export OPENAI_API_KEY=\"sk-your-key\""
-echo ""
-echo " # Option B: Anthropic (auto-detected)"
-echo " export ANTHROPIC_API_KEY=\"your-key\""
-echo ""
-echo " # Option C: Explicit provider"
-echo " export HINDSIGHT_API_LLM_PROVIDER=openai"
-echo " export HINDSIGHT_API_LLM_API_KEY=\"sk-your-key\""
-echo ""
-echo "2. Or connect to an external Hindsight server:"
-echo " Edit settings.json and set hindsightApiUrl"
-echo ""
-echo "3. Start Claude Code — the plugin will activate automatically."
-echo ""
-echo "On first use with daemon mode, uvx will download hindsight-embed (no manual install needed)."
diff --git a/hindsight-integrations/claude-code/scripts/lib/config.py b/hindsight-integrations/claude-code/scripts/lib/config.py
index 1ee7c059..96aae693 100644
--- a/hindsight-integrations/claude-code/scripts/lib/config.py
+++ b/hindsight-integrations/claude-code/scripts/lib/config.py
@@ -88,23 +88,44 @@ def _cast_env(value: str, typ):
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}")
+
+
+USER_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".hindsight", "claude-code.json")
+
+
def load_config() -> dict:
- """Load plugin configuration from settings.json + env overrides."""
+ """Load plugin configuration from settings.json + env overrides.
+
+ Loading order (later entries win):
+ 1. Built-in defaults
+ 2. Plugin default settings.json (CLAUDE_PLUGIN_ROOT/settings.json)
+ 3. User config (~/.hindsight/claude-code.json)
+ 4. Environment variable overrides
+
+ ~/.hindsight/claude-code.json is the recommended place to configure the
+ plugin — same convention as ~/.openclaw/openclaw.json. It is stable across
+ plugin updates and marketplace changes.
+ """
config = dict(DEFAULTS)
- # Find settings.json relative to plugin root
+ # 1. Plugin default settings.json (ships with the plugin, version-specific path)
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
if not plugin_root:
plugin_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ _load_settings_file(os.path.join(plugin_root, "settings.json"), config)
- settings_path = os.path.join(plugin_root, "settings.json")
- if os.path.exists(settings_path):
- try:
- with open(settings_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 settings.json: {e}")
+ # 2. User config — stable, version-independent, matches openclaw convention
+ _load_settings_file(USER_CONFIG_PATH, config)
# Apply environment variable overrides
for env_name, (key, typ) in ENV_OVERRIDES.items():
diff --git a/hindsight-integrations/claude-code/tests/conftest.py b/hindsight-integrations/claude-code/tests/conftest.py
new file mode 100644
index 00000000..b8aafcfe
--- /dev/null
+++ b/hindsight-integrations/claude-code/tests/conftest.py
@@ -0,0 +1,94 @@
+"""Shared fixtures for Hindsight Claude Code plugin tests."""
+
+import io
+import json
+import os
+import sys
+import tempfile
+
+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))
+
+
+@pytest.fixture()
+def state_dir(tmp_path, monkeypatch):
+ """Isolated state directory — prevents tests from touching real state files."""
+ d = tmp_path / "state"
+ d.mkdir()
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
+ return d
+
+
+@pytest.fixture()
+def plugin_root(tmp_path):
+ """Temp plugin root with a minimal settings.json."""
+ settings = tmp_path / "settings.json"
+ settings.write_text(json.dumps({}))
+ return tmp_path
+
+
+@pytest.fixture()
+def default_config(plugin_root, monkeypatch):
+ """Load config with no overrides, isolated from real settings.json."""
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
+ # Strip any real HINDSIGHT_* env vars that might bleed in
+ for key in list(os.environ):
+ if key.startswith("HINDSIGHT_"):
+ monkeypatch.delenv(key, raising=False)
+ from lib.config import load_config
+
+ return load_config()
+
+
+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):
+ """Write messages as a JSONL transcript file (flat test format)."""
+ f = tmp_path / "transcript.jsonl"
+ lines = [json.dumps(m) for m in messages]
+ f.write_text("\n".join(lines))
+ return str(f)
+
+
+def make_recall_response(memories):
+ """Build a fake /recall API response."""
+ return {"results": memories}
+
+
+def make_memory(text, mem_type="experience", mentioned_at="2024-01-15"):
+ return {"text": text, "type": mem_type, "mentioned_at": mentioned_at}
+
+
+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
diff --git a/hindsight-integrations/claude-code/tests/test_bank.py b/hindsight-integrations/claude-code/tests/test_bank.py
new file mode 100644
index 00000000..567fe540
--- /dev/null
+++ b/hindsight-integrations/claude-code/tests/test_bank.py
@@ -0,0 +1,136 @@
+"""Tests for lib/bank.py — bank ID derivation and mission management."""
+
+import json
+import urllib.parse
+from unittest.mock import MagicMock
+
+import pytest
+
+from lib.bank import derive_bank_id, ensure_bank_mission
+
+
+def _cfg(**overrides):
+ base = {
+ "dynamicBankId": False,
+ "bankId": "claude-code",
+ "bankIdPrefix": "",
+ "agentName": "claude-code",
+ "dynamicBankGranularity": ["agent", "project"],
+ "bankMission": "",
+ "retainMission": None,
+ }
+ base.update(overrides)
+ return base
+
+
+def _hook(session_id="sess-1", cwd="/home/user/myproject"):
+ return {"session_id": session_id, "cwd": cwd}
+
+
+class TestDeriveBankIdStatic:
+ def test_static_default_bank(self):
+ assert derive_bank_id(_hook(), _cfg()) == "claude-code"
+
+ def test_static_custom_bank_id(self):
+ cfg = _cfg(bankId="my-agent")
+ assert derive_bank_id(_hook(), cfg) == "my-agent"
+
+ def test_static_with_prefix(self):
+ cfg = _cfg(bankId="bot", bankIdPrefix="prod")
+ assert derive_bank_id(_hook(), cfg) == "prod-bot"
+
+ def test_static_prefix_without_bankid_uses_default(self):
+ cfg = _cfg(bankId=None, bankIdPrefix="dev")
+ assert derive_bank_id(_hook(), cfg) == "dev-claude-code"
+
+
+class TestDeriveBankIdDynamic:
+ def test_dynamic_agent_project(self):
+ cfg = _cfg(dynamicBankId=True, agentName="mybot", dynamicBankGranularity=["agent", "project"])
+ result = derive_bank_id(_hook(cwd="/home/user/hindsight"), cfg)
+ assert result == "mybot::hindsight"
+
+ def test_dynamic_url_encodes_special_chars(self):
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
+ result = derive_bank_id(_hook(cwd="/home/user/my project"), cfg)
+ assert "my%20project" in result
+
+ def test_dynamic_session_field(self):
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["session"])
+ result = derive_bank_id(_hook(session_id="abc-123"), cfg)
+ assert "abc-123" in result
+
+ def test_dynamic_with_prefix(self):
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["agent"], bankIdPrefix="v2")
+ result = derive_bank_id(_hook(), cfg)
+ assert result.startswith("v2-")
+
+ def test_dynamic_channel_from_env(self, monkeypatch):
+ monkeypatch.setenv("HINDSIGHT_CHANNEL_ID", "telegram-123")
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["channel"])
+ result = derive_bank_id(_hook(), cfg)
+ assert "telegram-123" in result
+
+ def test_dynamic_user_from_env(self, monkeypatch):
+ monkeypatch.setenv("HINDSIGHT_USER_ID", "user-456")
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["user"])
+ result = derive_bank_id(_hook(), cfg)
+ assert "user-456" in result
+
+ def test_dynamic_missing_env_uses_defaults(self, monkeypatch):
+ monkeypatch.delenv("HINDSIGHT_CHANNEL_ID", raising=False)
+ monkeypatch.delenv("HINDSIGHT_USER_ID", raising=False)
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["channel", "user"])
+ result = derive_bank_id(_hook(), cfg)
+ assert "default" in result
+ assert "anonymous" in result
+
+ def test_dynamic_empty_cwd_uses_unknown(self):
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
+ result = derive_bank_id({"session_id": "s", "cwd": ""}, cfg)
+ assert "unknown" in result
+
+
+class TestEnsureBankMission:
+ def test_sets_mission_on_first_call(self, state_dir):
+ client = MagicMock()
+ cfg = _cfg(bankMission="You are a helpful assistant.", bankId="test-bank")
+ ensure_bank_mission(client, "test-bank", cfg)
+ client.set_bank_mission.assert_called_once_with(
+ "test-bank", "You are a helpful assistant.", retain_mission=None, timeout=10
+ )
+
+ def test_skips_if_already_set(self, state_dir):
+ client = MagicMock()
+ cfg = _cfg(bankMission="mission text")
+ ensure_bank_mission(client, "bank-a", cfg)
+ ensure_bank_mission(client, "bank-a", cfg) # second call
+ assert client.set_bank_mission.call_count == 1
+
+ def test_skips_if_mission_empty(self, state_dir):
+ client = MagicMock()
+ cfg = _cfg(bankMission="")
+ ensure_bank_mission(client, "bank-b", cfg)
+ client.set_bank_mission.assert_not_called()
+
+ def test_includes_retain_mission_if_set(self, state_dir):
+ client = MagicMock()
+ cfg = _cfg(bankMission="reflect mission", retainMission="retain mission")
+ ensure_bank_mission(client, "bank-c", cfg)
+ client.set_bank_mission.assert_called_once_with(
+ "bank-c", "reflect mission", retain_mission="retain mission", timeout=10
+ )
+
+ def test_graceful_on_api_error(self, state_dir):
+ client = MagicMock()
+ client.set_bank_mission.side_effect = RuntimeError("server down")
+ cfg = _cfg(bankMission="mission")
+ # Should not raise
+ ensure_bank_mission(client, "bank-d", cfg)
+
+ def test_different_banks_each_set_once(self, state_dir):
+ client = MagicMock()
+ cfg = _cfg(bankMission="mission")
+ ensure_bank_mission(client, "bank-x", cfg)
+ ensure_bank_mission(client, "bank-y", cfg)
+ assert client.set_bank_mission.call_count == 2
diff --git a/hindsight-integrations/claude-code/tests/test_client.py b/hindsight-integrations/claude-code/tests/test_client.py
new file mode 100644
index 00000000..f5b80dbf
--- /dev/null
+++ b/hindsight-integrations/claude-code/tests/test_client.py
@@ -0,0 +1,216 @@
+"""Tests for lib/client.py — Hindsight REST API client."""
+
+import json
+import urllib.error
+from io import BytesIO
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from lib.client import HindsightClient, _validate_api_url
+
+
+class TestValidateApiUrl:
+ def test_valid_http(self):
+ assert _validate_api_url("http://localhost:9077") == "http://localhost:9077"
+
+ def test_valid_https(self):
+ assert _validate_api_url("https://api.example.com/") == "https://api.example.com"
+
+ def test_trailing_slash_stripped(self):
+ assert _validate_api_url("http://host:8080/") == "http://host:8080"
+
+ def test_invalid_scheme_raises(self):
+ with pytest.raises(ValueError, match="http or https"):
+ _validate_api_url("ftp://host")
+
+ def test_no_hostname_raises(self):
+ with pytest.raises(ValueError):
+ _validate_api_url("http://")
+
+
+class FakeResp:
+ def __init__(self, data, status=200):
+ self.status = status
+ self._body = json.dumps(data).encode()
+
+ def read(self):
+ return self._body
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ pass
+
+
+class TestHindsightClientInit:
+ def test_rejects_non_http_url(self):
+ with pytest.raises(ValueError):
+ HindsightClient("ftp://bad")
+
+ def test_stores_token(self):
+ c = HindsightClient("http://localhost:9077", api_token="tok123")
+ assert c.api_token == "tok123"
+
+ def test_no_token(self):
+ c = HindsightClient("http://localhost:9077")
+ assert c.api_token is None
+
+
+class TestHindsightClientRecall:
+ def test_posts_to_correct_path(self):
+ c = HindsightClient("http://localhost:9077")
+ response_data = {"results": [{"text": "Paris", "type": "world"}]}
+ with patch("urllib.request.urlopen", return_value=FakeResp(response_data)):
+ resp = c.recall("my-bank", "capital of France")
+ assert resp["results"][0]["text"] == "Paris"
+
+ def test_bank_id_url_encoded(self):
+ c = HindsightClient("http://localhost:9077")
+ captured = {}
+
+ def fake_open(req, timeout=None):
+ captured["url"] = req.full_url
+ return FakeResp({"results": []})
+
+ with patch("urllib.request.urlopen", side_effect=fake_open):
+ c.recall("bank with spaces", "query")
+
+ assert "bank%20with%20spaces" in captured["url"]
+
+ def test_includes_auth_header_when_token_set(self):
+ c = HindsightClient("http://localhost:9077", api_token="mytoken")
+ captured = {}
+
+ def fake_open(req, timeout=None):
+ captured["headers"] = dict(req.headers)
+ return FakeResp({"results": []})
+
+ with patch("urllib.request.urlopen", side_effect=fake_open):
+ c.recall("bank", "query")
+
+ assert "Authorization" in captured["headers"]
+ assert "mytoken" in captured["headers"]["Authorization"]
+
+ def test_no_auth_header_without_token(self):
+ c = HindsightClient("http://localhost:9077")
+ captured = {}
+
+ def fake_open(req, timeout=None):
+ captured["headers"] = dict(req.headers)
+ return FakeResp({"results": []})
+
+ with patch("urllib.request.urlopen", side_effect=fake_open):
+ c.recall("bank", "query")
+
+ assert "Authorization" not in captured["headers"]
+
+ def test_http_error_raises_runtime_error(self):
+ c = HindsightClient("http://localhost:9077")
+ err = urllib.error.HTTPError(
+ url="http://localhost:9077/v1/default/banks/b/memories/recall",
+ code=500,
+ msg="Internal Server Error",
+ hdrs={},
+ fp=BytesIO(b"server exploded"),
+ )
+ with patch("urllib.request.urlopen", side_effect=err):
+ with pytest.raises(RuntimeError, match="HTTP 500"):
+ c.recall("b", "query")
+
+ def test_sends_budget_and_types(self):
+ c = HindsightClient("http://localhost:9077")
+ captured = {}
+
+ def fake_open(req, timeout=None):
+ captured["body"] = json.loads(req.data.decode())
+ return FakeResp({"results": []})
+
+ with patch("urllib.request.urlopen", side_effect=fake_open):
+ c.recall("bank", "query", budget="high", types=["world", "experience"])
+
+ assert captured["body"]["budget"] == "high"
+ assert captured["body"]["types"] == ["world", "experience"]
+
+
+class TestHindsightClientRetain:
+ def test_posts_with_async_true(self):
+ c = HindsightClient("http://localhost:9077")
+ captured = {}
+
+ def fake_open(req, timeout=None):
+ captured["body"] = json.loads(req.data.decode())
+ return FakeResp({"status": "accepted"})
+
+ with patch("urllib.request.urlopen", side_effect=fake_open):
+ c.retain("bank", "transcript content", document_id="doc-1", context="claude-code")
+
+ assert captured["body"]["async"] is True
+ assert captured["body"]["items"][0]["content"] == "transcript content"
+ assert captured["body"]["items"][0]["context"] == "claude-code"
+
+ def test_bank_id_encoded_in_retain_path(self):
+ c = HindsightClient("http://localhost:9077")
+ captured = {}
+
+ def fake_open(req, timeout=None):
+ captured["url"] = req.full_url
+ return FakeResp({})
+
+ with patch("urllib.request.urlopen", side_effect=fake_open):
+ c.retain("my::bank", "content")
+
+ assert "my%3A%3Abank" in captured["url"]
+
+
+class TestHindsightClientHealthCheck:
+ def test_returns_true_on_200(self):
+ c = HindsightClient("http://localhost:9077")
+ with patch("urllib.request.urlopen", return_value=FakeResp({}, status=200)):
+ with patch("time.sleep"): # don't actually sleep
+ assert c.health_check() is True
+
+ def test_returns_false_after_retries(self):
+ c = HindsightClient("http://localhost:9077")
+ with patch("urllib.request.urlopen", side_effect=OSError("refused")):
+ with patch("time.sleep"):
+ assert c.health_check() is False
+
+ def test_retries_on_failure(self):
+ c = HindsightClient("http://localhost:9077")
+ call_count = 0
+
+ def flaky(*_a, **_kw):
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise OSError("not yet")
+ return FakeResp({}, status=200)
+
+ with patch("urllib.request.urlopen", side_effect=flaky):
+ with patch("time.sleep"):
+ result = c.health_check()
+
+ assert result is True
+ assert call_count == 3
+
+
+class TestHindsightClientSetBankMission:
+ def test_patches_config_endpoint(self):
+ c = HindsightClient("http://localhost:9077")
+ captured = {}
+
+ def fake_open(req, timeout=None):
+ captured["url"] = req.full_url
+ captured["method"] = req.method
+ captured["body"] = json.loads(req.data.decode())
+ return FakeResp({})
+
+ with patch("urllib.request.urlopen", side_effect=fake_open):
+ c.set_bank_mission("my-bank", "I am Claude", retain_mission="Extract facts")
+
+ assert captured["method"] == "PATCH"
+ assert "my-bank" in captured["url"]
+ assert captured["body"]["updates"]["reflect_mission"] == "I am Claude"
+ assert captured["body"]["updates"]["retain_mission"] == "Extract facts"
diff --git a/hindsight-integrations/claude-code/tests/test_config.py b/hindsight-integrations/claude-code/tests/test_config.py
new file mode 100644
index 00000000..fa3fd048
--- /dev/null
+++ b/hindsight-integrations/claude-code/tests/test_config.py
@@ -0,0 +1,121 @@
+"""Tests for lib/config.py — configuration loading and env overrides."""
+
+import json
+import os
+
+import pytest
+
+from lib.config import _cast_env, load_config
+
+
+class TestCastEnv:
+ def test_bool_true_values(self):
+ for v in ("true", "True", "TRUE", "1", "yes", "YES"):
+ assert _cast_env(v, bool) is True
+
+ def test_bool_false_values(self):
+ for v in ("false", "False", "0", "no"):
+ assert _cast_env(v, bool) is False
+
+ def test_int_cast(self):
+ assert _cast_env("42", int) == 42
+
+ def test_int_invalid_returns_none(self):
+ assert _cast_env("notanint", int) is None
+
+ def test_str_passthrough(self):
+ assert _cast_env("hello", str) == "hello"
+
+
+class TestLoadConfig:
+ def test_defaults_applied_when_no_settings_file(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ # No settings.json in tmp_path
+ cfg = load_config()
+ assert cfg["autoRecall"] is True
+ assert cfg["autoRetain"] is True
+ assert cfg["recallBudget"] == "mid"
+ assert cfg["retainEveryNTurns"] == 10
+
+ def test_settings_json_overrides_defaults(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ (tmp_path / "settings.json").write_text(json.dumps({"recallBudget": "high", "bankId": "my-bank"}))
+ cfg = load_config()
+ assert cfg["recallBudget"] == "high"
+ assert cfg["bankId"] == "my-bank"
+
+ def test_env_var_overrides_settings_json(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ (tmp_path / "settings.json").write_text(json.dumps({"recallBudget": "low"}))
+ monkeypatch.setenv("HINDSIGHT_RECALL_BUDGET", "high")
+ cfg = load_config()
+ assert cfg["recallBudget"] == "high"
+
+ def test_bool_env_var_override(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ monkeypatch.setenv("HINDSIGHT_AUTO_RECALL", "false")
+ cfg = load_config()
+ assert cfg["autoRecall"] is False
+
+ def test_int_env_var_override(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ monkeypatch.setenv("HINDSIGHT_API_PORT", "9999")
+ cfg = load_config()
+ assert cfg["apiPort"] == 9999
+
+ def test_invalid_settings_json_falls_back_to_defaults(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ (tmp_path / "settings.json").write_text("not valid json{{")
+ cfg = load_config()
+ assert cfg["recallBudget"] == "mid" # default still applies
+
+ def test_null_values_in_settings_json_not_applied(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ (tmp_path / "settings.json").write_text(json.dumps({"bankId": None, "recallBudget": "high"}))
+ cfg = load_config()
+ # None values in file should not override defaults
+ assert cfg["bankId"] is None # default is None, so ok
+ assert cfg["recallBudget"] == "high"
+
+ def test_api_url_env_override(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ monkeypatch.setenv("HINDSIGHT_API_URL", "http://myserver:8080")
+ cfg = load_config()
+ assert cfg["hindsightApiUrl"] == "http://myserver:8080"
+
+ def test_user_config_overrides_plugin_settings(self, tmp_path, monkeypatch):
+ plugin_root = tmp_path / "plugin"
+ plugin_root.mkdir()
+
+ # Plugin default ships with "low"
+ (plugin_root / "settings.json").write_text(json.dumps({"recallBudget": "low"}))
+ # User overrides to "high" via ~/.hindsight/claude-code.json
+ user_cfg = tmp_path / ".hindsight" / "claude-code.json"
+ user_cfg.parent.mkdir()
+ user_cfg.write_text(json.dumps({"recallBudget": "high"}))
+
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
+ import lib.config as cfg_mod
+ monkeypatch.setattr(cfg_mod, "USER_CONFIG_PATH", str(user_cfg))
+ cfg = load_config()
+ assert cfg["recallBudget"] == "high"
+
+ def test_user_config_missing_falls_back_gracefully(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
+ import lib.config as cfg_mod
+ monkeypatch.setattr(cfg_mod, "USER_CONFIG_PATH", str(tmp_path / "nonexistent.json"))
+ cfg = load_config()
+ assert cfg["recallBudget"] == "mid" # default
+
+ def test_env_var_wins_over_user_config(self, tmp_path, monkeypatch):
+ plugin_root = tmp_path / "plugin"
+ plugin_root.mkdir()
+ user_cfg = tmp_path / "claude-code.json"
+ user_cfg.write_text(json.dumps({"recallBudget": "low"}))
+
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
+ monkeypatch.setenv("HINDSIGHT_RECALL_BUDGET", "high")
+ import lib.config as cfg_mod
+ monkeypatch.setattr(cfg_mod, "USER_CONFIG_PATH", str(user_cfg))
+ cfg = load_config()
+ assert cfg["recallBudget"] == "high"
diff --git a/hindsight-integrations/claude-code/tests/test_content.py b/hindsight-integrations/claude-code/tests/test_content.py
new file mode 100644
index 00000000..d9620bfe
--- /dev/null
+++ b/hindsight-integrations/claude-code/tests/test_content.py
@@ -0,0 +1,343 @@
+"""Tests for lib/content.py — pure content-processing functions."""
+
+import pytest
+
+from lib.content import (
+ _extract_text_content,
+ _is_channel_message_tool,
+ compose_recall_query,
+ format_memories,
+ prepare_retention_transcript,
+ slice_last_turns_by_user_boundary,
+ strip_channel_envelope,
+ strip_memory_tags,
+ truncate_recall_query,
+)
+
+
+# ---------------------------------------------------------------------------
+# strip_channel_envelope
+# ---------------------------------------------------------------------------
+
+
+class TestStripChannelEnvelope:
+ def test_strips_channel_xml(self):
+ raw = 'Hello world'
+ assert strip_channel_envelope(raw) == "Hello world"
+
+ def test_passthrough_plain_text(self):
+ assert strip_channel_envelope("just plain text") == "just plain text"
+
+ def test_strips_multiline_channel(self):
+ raw = "\nline1\nline2\n"
+ assert strip_channel_envelope(raw) == "line1\nline2"
+
+ def test_passthrough_when_no_channel_tag(self):
+ raw = "stuff"
+ assert strip_channel_envelope(raw) == raw
+
+
+# ---------------------------------------------------------------------------
+# strip_memory_tags
+# ---------------------------------------------------------------------------
+
+
+class TestStripMemoryTags:
+ def test_strips_hindsight_memories_block(self):
+ raw = "before\nsecret\nafter"
+ assert "hindsight_memories" not in strip_memory_tags(raw)
+ assert "before" in strip_memory_tags(raw)
+ assert "after" in strip_memory_tags(raw)
+
+ def test_strips_relevant_memories_block(self):
+ raw = "text old stuff 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 = "\n- mem1\n- mem2\n"
+ assert strip_memory_tags(raw).strip() == ""
+
+
+# ---------------------------------------------------------------------------
+# slice_last_turns_by_user_boundary
+# ---------------------------------------------------------------------------
+
+
+def _msgs(*pairs):
+ """Build a message list from (role, content) 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):
+ msgs = _msgs(("user", "hi"))
+ assert slice_last_turns_by_user_boundary(msgs, 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)
+ # duplicate user msg should be dropped from context
+ 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", "secret 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}"
+ # Allow only the newest context line + latest
+ result = truncate_recall_query(query, latest, max_chars=len(f"Prior context:\n\nnewer\n\n{latest}") + 5)
+ 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_with_separator(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
+
+
+# ---------------------------------------------------------------------------
+# _is_channel_message_tool
+# ---------------------------------------------------------------------------
+
+
+class TestIsChannelMessageTool:
+ def test_telegram_send_message(self):
+ block = {"type": "tool_use", "name": "mcp__telegram__sendMessage", "input": {"text": "hello"}}
+ assert _is_channel_message_tool(block) is True
+
+ def test_slack_reply_tool(self):
+ block = {"type": "tool_use", "name": "mcp__slack__reply", "input": {"body": "hi there"}}
+ assert _is_channel_message_tool(block) is True
+
+ def test_operational_recall_tool_excluded(self):
+ block = {"type": "tool_use", "name": "mcp__hindsight__recall", "input": {"query": "test"}}
+ assert _is_channel_message_tool(block) is False
+
+ def test_builtin_bash_tool_excluded(self):
+ block = {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}}
+ assert _is_channel_message_tool(block) is False
+
+ def test_mcp_tool_without_text_field_excluded(self):
+ block = {"type": "tool_use", "name": "mcp__something__action", "input": {"id": 123}}
+ assert _is_channel_message_tool(block) is False
+
+ def test_mcp_tool_with_empty_text_excluded(self):
+ block = {"type": "tool_use", "name": "mcp__telegram__send", "input": {"text": " "}}
+ assert _is_channel_message_tool(block) is False
+
+ def test_mcp_create_action_excluded(self):
+ block = {"type": "tool_use", "name": "mcp__notion__create_page", "input": {"content": "hello"}}
+ assert _is_channel_message_tool(block) is False
+
+
+# ---------------------------------------------------------------------------
+# _extract_text_content
+# ---------------------------------------------------------------------------
+
+
+class TestExtractTextContent:
+ def test_plain_string_returned_as_is(self):
+ assert _extract_text_content("hello", role="user") == "hello"
+
+ def test_text_block_extracted(self):
+ content = [{"type": "text", "text": "response text"}]
+ assert _extract_text_content(content, role="assistant") == "response text"
+
+ def test_thinking_block_excluded(self):
+ content = [{"type": "thinking", "thinking": "private"}, {"type": "text", "text": "public"}]
+ result = _extract_text_content(content, role="assistant")
+ assert "private" not in result
+ assert "public" in result
+
+ def test_channel_tool_use_extracted_for_assistant(self):
+ content = [{"type": "tool_use", "name": "mcp__telegram__send", "input": {"text": "hello user"}}]
+ result = _extract_text_content(content, role="assistant")
+ assert "hello user" in result
+
+ def test_tool_use_not_extracted_for_user(self):
+ content = [{"type": "tool_use", "name": "mcp__telegram__send", "input": {"text": "hello user"}}]
+ result = _extract_text_content(content, role="user")
+ assert "hello user" not in result
+
+ def test_empty_list_returns_empty_string(self):
+ assert _extract_text_content([], role="assistant") == ""
+
+ def test_non_string_non_list_returns_empty(self):
+ assert _extract_text_content(None, role="user") == ""
+ assert _extract_text_content(42, role="user") == ""
+
+
+# ---------------------------------------------------------------------------
+# 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", "leaked 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
diff --git a/hindsight-integrations/claude-code/tests/test_hooks.py b/hindsight-integrations/claude-code/tests/test_hooks.py
new file mode 100644
index 00000000..74d8f93c
--- /dev/null
+++ b/hindsight-integrations/claude-code/tests/test_hooks.py
@@ -0,0 +1,378 @@
+"""End-to-end tests for recall.py and retain.py hook scripts.
+
+Mocks the Claude Code hook runtime:
+ - stdin → io.StringIO(json.dumps(hook_input))
+ - stdout → io.StringIO() captured for assertions
+ - urllib.request.urlopen → fake HTTP responses
+ - CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA → temp dirs
+"""
+
+import importlib
+import io
+import json
+import os
+import sys
+import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from conftest import FakeHTTPResponse, make_hook_input, make_memory, make_transcript_file
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _run_hook(module_name, hook_input, monkeypatch, tmp_path, urlopen_side_effect=None, extra_env=None):
+ """Import and run a hook script's main() with mocked stdin/stdout/HTTP."""
+ # Isolated plugin dirs
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
+ (tmp_path / "plugin_root").mkdir(exist_ok=True)
+ (tmp_path / "plugin_data").mkdir(exist_ok=True)
+
+ # Strip real HINDSIGHT_* env vars
+ for k in list(os.environ):
+ if k.startswith("HINDSIGHT_"):
+ monkeypatch.delenv(k, raising=False)
+
+ for k, v in (extra_env or {}).items():
+ monkeypatch.setenv(k, v)
+
+ # Write a minimal settings.json enabling fast retains
+ settings = {"autoRecall": True, "autoRetain": True, "retainEveryNTurns": 1, "hindsightApiUrl": "http://fake:9077"}
+ (tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
+
+ stdin_data = io.StringIO(json.dumps(hook_input))
+ stdout_capture = io.StringIO()
+
+ # Force reimport so the module picks up patched env / path
+ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
+ spec = importlib.util.spec_from_file_location(module_name, 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 "" 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)
+ # Empty stdout = no memories injected
+ 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?")
+ # Should not raise — graceful degradation
+ output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
+ assert output.strip() == ""
+
+ def test_output_format_matches_claude_code_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)
+
+ # Override to use multi-turn recall
+ settings = {
+ "autoRecall": True,
+ "hindsightApiUrl": "http://fake:9077",
+ "recallContextTurns": 2,
+ "retainEveryNTurns": 1,
+ "autoRetain": True,
+ }
+ (tmp_path / "plugin_root").mkdir(exist_ok=True)
+ (tmp_path / "plugin_data").mkdir(exist_ok=True)
+
+ 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": []})
+
+ for k in list(os.environ):
+ if k.startswith("HINDSIGHT_"):
+ monkeypatch.delenv(k, raising=False)
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
+ (tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
+
+ hook_input = make_hook_input(prompt="What language should I use?", transcript_path=transcript)
+ stdin_data = io.StringIO(json.dumps(hook_input))
+
+ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
+ spec = importlib.util.spec_from_file_location("recall", os.path.join(scripts_dir, "recall.py"))
+ mod = importlib.util.module_from_spec(spec)
+
+ with (
+ patch("sys.stdin", stdin_data),
+ patch("sys.stdout", io.StringIO()),
+ patch("urllib.request.urlopen", side_effect=capture_and_respond),
+ ):
+ spec.loader.exec_module(mod)
+ mod.main()
+
+ # The query should contain prior context from the transcript
+ 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):
+ (tmp_path / "plugin_root").mkdir(exist_ok=True)
+ (tmp_path / "plugin_data").mkdir(exist_ok=True)
+ settings = {"autoRecall": False, "autoRetain": False, "hindsightApiUrl": "http://fake:9077"}
+ (tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
+
+ for k in list(os.environ):
+ if k.startswith("HINDSIGHT_"):
+ monkeypatch.delenv(k, raising=False)
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
+
+ hook_input = make_hook_input(prompt="What is the capital of France?")
+ stdin_data = io.StringIO(json.dumps(hook_input))
+ stdout_capture = io.StringIO()
+
+ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
+ spec = importlib.util.spec_from_file_location("recall_disabled", os.path.join(scripts_dir, "recall.py"))
+ mod = importlib.util.module_from_spec(spec)
+
+ with patch("sys.stdin", stdin_data), patch("sys.stdout", stdout_capture):
+ spec.loader.exec_module(mod)
+ mod.main()
+
+ assert stdout_capture.getvalue().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": "old 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_chunked_retain_skips_below_threshold(self, monkeypatch, tmp_path):
+ """With retainEveryNTurns=5, first call should be skipped."""
+ (tmp_path / "plugin_root").mkdir(exist_ok=True)
+ (tmp_path / "plugin_data").mkdir(exist_ok=True)
+ settings = {
+ "autoRetain": True,
+ "autoRecall": True,
+ "retainEveryNTurns": 5,
+ "hindsightApiUrl": "http://fake:9077",
+ }
+ (tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
+
+ messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]
+ transcript = make_transcript_file(tmp_path, messages)
+ hook_input = make_hook_input(transcript_path=transcript)
+
+ captured = {}
+
+ def capture(req, timeout=None):
+ if "/memories" in req.full_url and "/recall" not in req.full_url:
+ captured["called"] = True
+ return FakeHTTPResponse({})
+
+ for k in list(os.environ):
+ if k.startswith("HINDSIGHT_"):
+ monkeypatch.delenv(k, raising=False)
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
+
+ stdin_data = io.StringIO(json.dumps(hook_input))
+ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
+ spec = importlib.util.spec_from_file_location("retain_chunked", os.path.join(scripts_dir, "retain.py"))
+ mod = importlib.util.module_from_spec(spec)
+
+ with (
+ patch("sys.stdin", stdin_data),
+ patch("sys.stdout", io.StringIO()),
+ patch("urllib.request.urlopen", side_effect=capture),
+ ):
+ spec.loader.exec_module(mod)
+ mod.main()
+
+ # Turn 1 of 5 — should NOT retain
+ assert "called" not in captured
+
+ def test_graceful_on_retain_api_error(self, monkeypatch, tmp_path):
+ messages = [{"role": "user", "content": "test message"}, {"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_retain_posts_async_true(self, monkeypatch, tmp_path):
+ messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
+ transcript = make_transcript_file(tmp_path, messages)
+ hook_input = make_hook_input(transcript_path=transcript)
+ 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)
+
+ if "body" in captured:
+ assert captured["body"].get("async") is True
+
+ def test_retain_includes_context_label(self, monkeypatch, tmp_path):
+ messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
+ transcript = make_transcript_file(tmp_path, messages)
+ hook_input = make_hook_input(transcript_path=transcript)
+ 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)
+
+ if "body" in captured:
+ assert captured["body"]["items"][0]["context"] == "claude-code"
+
+ def test_disabled_auto_retain_does_not_call_api(self, monkeypatch, tmp_path):
+ (tmp_path / "plugin_root").mkdir(exist_ok=True)
+ (tmp_path / "plugin_data").mkdir(exist_ok=True)
+ settings = {"autoRetain": False, "autoRecall": False, "hindsightApiUrl": "http://fake:9077"}
+ (tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
+
+ 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({})
+
+ for k in list(os.environ):
+ if k.startswith("HINDSIGHT_"):
+ monkeypatch.delenv(k, raising=False)
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
+
+ stdin_data = io.StringIO(json.dumps(hook_input))
+ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
+ spec = importlib.util.spec_from_file_location("retain_disabled", os.path.join(scripts_dir, "retain.py"))
+ mod = importlib.util.module_from_spec(spec)
+
+ with (
+ patch("sys.stdin", stdin_data),
+ patch("sys.stdout", io.StringIO()),
+ patch("urllib.request.urlopen", side_effect=capture),
+ ):
+ spec.loader.exec_module(mod)
+ mod.main()
+
+ assert "called" not in captured