Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55dbc86b3 | ||
|
|
5f4a87fa41 | ||
|
|
80f2d7cbc6 | ||
|
|
8d8a10d70a | ||
|
|
4081445bfe | ||
|
|
6d0bb2b834 | ||
|
|
b40918958a | ||
|
|
2f7fe35bdd |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user