Compare commits

...
Author SHA1 Message Date
Nicolò Boschi f55dbc86b3 ci: add test-claude-code-integration job to run plugin unit tests 2026-03-23 15:11:03 +01:00
Nicolò Boschi 5f4a87fa41 fix(claude-code): set author to Hindsight Team in plugin.json 2026-03-23 15:08:25 +01:00
Nicolò Boschi 80f2d7cbc6 docs(claude-code): add ToS hint for claude-code LLM provider option 2026-03-23 12:30:46 +01:00
Nicolò Boschi 8d8a10d70a fix(claude-code): use ~/.hindsight/claude-code.json for user config
Matches the ~/.openclaw/openclaw.json convention. Removes the confusing
CLAUDE_PLUGIN_DATA path whose name depends on marketplace+plugin identifiers.
2026-03-23 12:29:14 +01:00
Nicolò Boschi 4081445bfe feat(claude-code): user settings.json at CLAUDE_PLUGIN_DATA for stable config
Plugin now checks CLAUDE_PLUGIN_DATA/settings.json after the versioned
plugin default, giving users a path that persists across updates:
  ~/.claude/plugins/data/hindsight-memory-hindsight/settings.json

Loading order: defaults → plugin settings.json → user settings.json → env vars
2026-03-23 12:25:26 +01:00
Nicolò Boschi 6d0bb2b834 test(claude-code): add 116 unit tests for plugin hooks and lib modules 2026-03-23 12:16:57 +01:00
Nicolò Boschi b40918958a remove install.sh — users install via claude plugin commands directly 2026-03-23 12:10:43 +01:00
Nicolò Boschi 2f7fe35bdd fix(claude-code): fix plugin installation and release workflow
- Fix plugin.json author field (string → object) to pass claude plugin validate
- Add hindsight-integrations/.claude-plugin/marketplace.json so users can install
  via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations
- Update README and install.sh with correct two-command install flow
- Fix release-integration.yml: add explicit package.json check for typescript type
  and add plugin type for integrations with neither pyproject.toml nor package.json
  (prevents claude-code from incorrectly falling into the typescript build path)
- Add CHANGELOG.md for the claude-code integration
2026-03-23 12:09:46 +01:00
14 changed files with 1397 additions and 76 deletions
+13 -1
View File
@@ -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
+18
View File
@@ -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
@@ -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"
}
]
}
@@ -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"]
}
@@ -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
+14 -5
View File
@@ -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)
@@ -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)."
@@ -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():
@@ -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
@@ -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
@@ -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"
@@ -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"
@@ -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 = '<channel source="plugin:telegram:telegram" chat_id="123">Hello world</channel>'
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 = "<channel source='s'>\nline1\nline2\n</channel>"
assert strip_channel_envelope(raw) == "line1\nline2"
def test_passthrough_when_no_channel_tag(self):
raw = "<other>stuff</other>"
assert strip_channel_envelope(raw) == raw
# ---------------------------------------------------------------------------
# strip_memory_tags
# ---------------------------------------------------------------------------
class TestStripMemoryTags:
def test_strips_hindsight_memories_block(self):
raw = "before\n<hindsight_memories>secret</hindsight_memories>\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 <relevant_memories>old stuff</relevant_memories> text"
result = strip_memory_tags(raw)
assert "relevant_memories" not in result
assert "old stuff" not in result
def test_passthrough_clean_text(self):
raw = "no memory tags here"
assert strip_memory_tags(raw) == raw
def test_strips_multiline_block(self):
raw = "<hindsight_memories>\n- mem1\n- mem2\n</hindsight_memories>"
assert strip_memory_tags(raw).strip() == ""
# ---------------------------------------------------------------------------
# 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", "<hindsight_memories>secret</hindsight_memories> actual question"),
)
result = compose_recall_query("now", msgs, recall_context_turns=2)
assert "hindsight_memories" not in result
assert "secret" not in result
def test_filters_by_recall_roles(self):
msgs = _msgs(("user", "user msg"), ("assistant", "assistant msg"))
result = compose_recall_query("query", msgs, recall_context_turns=2, recall_roles=["user"])
assert "user msg" in result
assert "assistant msg" not in result
# ---------------------------------------------------------------------------
# truncate_recall_query
# ---------------------------------------------------------------------------
class TestTruncateRecallQuery:
def test_short_query_unchanged(self):
q = "short"
assert truncate_recall_query(q, q, max_chars=100) == q
def test_plain_query_truncated_to_max(self):
q = "x" * 50
result = truncate_recall_query(q, q, max_chars=20)
assert len(result) <= 20
def test_preserves_latest_when_context_dropped(self):
latest = "final question"
query = f"Prior context:\n\nuser: old stuff\nassistant: old reply\n\n{latest}"
result = truncate_recall_query(query, latest, max_chars=30)
assert latest in result
def test_drops_oldest_context_lines_first(self):
latest = "latest"
query = f"Prior context:\n\nuser: oldest\nassistant: old\nuser: newer\n\n{latest}"
# 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", "<hindsight_memories>leaked</hindsight_memories> actual question"))
transcript, _ = prepare_retention_transcript(msgs, retain_full_window=True)
assert "leaked" not in transcript
assert "actual question" in transcript
def test_filters_by_retain_roles(self):
msgs = _msgs(("user", "user msg"), ("assistant", "assistant msg"))
transcript, _ = prepare_retention_transcript(msgs, retain_roles=["user"], retain_full_window=True)
assert "user msg" in transcript
assert "assistant msg" not in transcript
def test_empty_messages_returns_none(self):
result, count = prepare_retention_transcript([])
assert result is None
assert count == 0
def test_role_markers_present(self):
msgs = _msgs(("user", "hello"))
transcript, _ = prepare_retention_transcript(msgs, retain_full_window=True)
assert "[role: user]" in transcript
assert "[user:end]" in transcript
def test_no_user_message_returns_none(self):
msgs = [{"role": "assistant", "content": "only assistant"}]
result, _ = prepare_retention_transcript(msgs, retain_full_window=False)
assert result is None
@@ -0,0 +1,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 "<hindsight_memories>" in context
def test_no_output_when_no_memories(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hello there world")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
# 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": "<hindsight_memories>old memories</hindsight_memories> actual question"},
{"role": "assistant", "content": "sure!"},
]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
if "body" in captured:
content = captured["body"]["items"][0]["content"]
assert "old memories" not in content
assert "actual question" in content
def test_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