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