diff --git a/hindsight-integrations/claude-code/README.md b/hindsight-integrations/claude-code/README.md index f8176634a..916719aca 100644 --- a/hindsight-integrations/claude-code/README.md +++ b/hindsight-integrations/claude-code/README.md @@ -227,6 +227,10 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie | `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. | | `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. | | `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. | +| `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. | +| `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. | +| `recallTagGroups` | `HINDSIGHT_RECALL_TAG_GROUPS` | `null` | Optional compound tag filter passed through to the recall API. The env var must be JSON. | +| `recallAdditionalBankFilters` | `HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS` | `{}` | Optional per-bank tag filter overrides for banks listed in `recallAdditionalBanks`, keyed by bank ID. Each value may set `recallTags`, `recallTagsMatch`, and `recallTagGroups`. The env var must be JSON. | | `recallPromptPreamble` | — | built-in string | Text placed above the recalled memories in the injected context block. Customize this to change how Claude interprets the memories. | --- diff --git a/hindsight-integrations/claude-code/scripts/lib/client.py b/hindsight-integrations/claude-code/scripts/lib/client.py index 1b2961f5f..6d6e1ec57 100644 --- a/hindsight-integrations/claude-code/scripts/lib/client.py +++ b/hindsight-integrations/claude-code/scripts/lib/client.py @@ -111,6 +111,9 @@ def recall( max_tokens: int = 1024, budget: str = "mid", types: Optional[list] = None, + tags: Optional[list] = None, + tags_match: Optional[str] = None, + tag_groups: Optional[object] = None, timeout: int = 10, ) -> dict: """Recall memories from a bank. @@ -126,6 +129,12 @@ def recall( body["budget"] = budget if types: body["types"] = types + if tags: + body["tags"] = tags + if tags_match: + body["tags_match"] = tags_match + if tag_groups: + body["tag_groups"] = tag_groups return self.request("POST", path, body, timeout=timeout) def retain( diff --git a/hindsight-integrations/claude-code/scripts/lib/config.py b/hindsight-integrations/claude-code/scripts/lib/config.py index 98a488c70..668e93533 100644 --- a/hindsight-integrations/claude-code/scripts/lib/config.py +++ b/hindsight-integrations/claude-code/scripts/lib/config.py @@ -17,6 +17,10 @@ "recallContextTurns": 1, "recallMaxQueryChars": 800, "recallRoles": ["user", "assistant"], + "recallTags": [], + "recallTagsMatch": "any", + "recallTagGroups": None, + "recallAdditionalBankFilters": {}, "recallPromptPreamble": ( "Relevant memories from past conversations (prioritize recent when " "conflicting). Only use memories that are directly useful to continue " @@ -73,6 +77,10 @@ "HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int), "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int), "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int), + "HINDSIGHT_RECALL_TAGS": ("recallTags", list), + "HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str), + "HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict), + "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict), "HINDSIGHT_API_PORT": ("apiPort", int), "HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int), "HINDSIGHT_REQUEST_TIMEOUT_SECONDS": ("requestTimeoutSeconds", int), @@ -93,8 +101,20 @@ def _cast_env(value: str, typ): return value.lower() in ("true", "1", "yes") if typ is int: return int(value) + if typ is list: + parsed = json.loads(value) + if isinstance(parsed, list): + return parsed + return None + if typ is dict: + parsed = json.loads(value) + if isinstance(parsed, (dict, list)): + return parsed + return None return value except (ValueError, AttributeError): + if typ is list: + return [part.strip() for part in value.split(",") if part.strip()] return None diff --git a/hindsight-integrations/claude-code/scripts/recall.py b/hindsight-integrations/claude-code/scripts/recall.py index 4e33afa56..7bb57a647 100755 --- a/hindsight-integrations/claude-code/scripts/recall.py +++ b/hindsight-integrations/claude-code/scripts/recall.py @@ -146,6 +146,11 @@ def _dbg(*a): debug_log(config, f"Recalling from bank '{bank_id}', query length: {len(query)}") + recall_tags = config.get("recallTags") or None + tag_groups = config.get("recallTagGroups") or None + tags_match = config.get("recallTagsMatch") if recall_tags or tag_groups else None + additional_bank_filters = config.get("recallAdditionalBankFilters") or {} + # Call Hindsight recall API try: response = client.recall( @@ -154,6 +159,9 @@ def _dbg(*a): max_tokens=config.get("recallMaxTokens", 1024), budget=config.get("recallBudget", "mid"), types=config.get("recallTypes"), + tags=recall_tags, + tags_match=tags_match, + tag_groups=tag_groups, timeout=10, ) except Exception as e: @@ -165,6 +173,13 @@ def _dbg(*a): # Also recall from any additional banks (e.g. shared user profile bank) additional_banks = config.get("recallAdditionalBanks", []) for extra_bank_id in additional_banks: + extra_filter = additional_bank_filters.get(extra_bank_id, {}) + extra_tags = extra_filter.get("recallTags", recall_tags) or None + extra_tag_groups = extra_filter.get("recallTagGroups", tag_groups) or None + extra_tags_match = extra_filter.get( + "recallTagsMatch", + tags_match if extra_tags or extra_tag_groups else None, + ) try: extra_response = client.recall( bank_id=extra_bank_id, @@ -172,6 +187,9 @@ def _dbg(*a): max_tokens=config.get("recallMaxTokens", 1024), budget=config.get("recallBudget", "mid"), types=config.get("recallTypes"), + tags=extra_tags, + tags_match=extra_tags_match, + tag_groups=extra_tag_groups, timeout=10, ) extra_results = extra_response.get("results", []) diff --git a/hindsight-integrations/claude-code/settings.json b/hindsight-integrations/claude-code/settings.json index aa68f7312..7c91f55b1 100644 --- a/hindsight-integrations/claude-code/settings.json +++ b/hindsight-integrations/claude-code/settings.json @@ -12,6 +12,10 @@ "recallContextTurns": 1, "recallMaxQueryChars": 800, "recallRoles": ["user", "assistant"], + "recallTags": [], + "recallTagsMatch": "any", + "recallTagGroups": null, + "recallAdditionalBankFilters": {}, "recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:", "retainRoles": ["user", "assistant"], "retainEveryNTurns": 10, diff --git a/hindsight-integrations/claude-code/tests/test_client.py b/hindsight-integrations/claude-code/tests/test_client.py index e1987ccb4..984c825c3 100644 --- a/hindsight-integrations/claude-code/tests/test_client.py +++ b/hindsight-integrations/claude-code/tests/test_client.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, patch import pytest - from lib.client import USER_AGENT, HindsightClient, _validate_api_url @@ -149,6 +148,29 @@ def fake_open(req, timeout=None): assert captured["body"]["budget"] == "high" assert captured["body"]["types"] == ["world", "experience"] + def test_sends_tag_filters(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", + tags=["memory_type:rule"], + tags_match="any_strict", + tag_groups=[{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}], + ) + + assert captured["body"]["tags"] == ["memory_type:rule"] + assert captured["body"]["tags_match"] == "any_strict" + assert captured["body"]["tag_groups"] == [ + {"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]} + ] + class TestHindsightClientRetain: def test_posts_with_async_true(self): diff --git a/hindsight-integrations/claude-code/tests/test_config.py b/hindsight-integrations/claude-code/tests/test_config.py index f2bc8e8ba..5cb259a53 100644 --- a/hindsight-integrations/claude-code/tests/test_config.py +++ b/hindsight-integrations/claude-code/tests/test_config.py @@ -4,7 +4,6 @@ import os import pytest - from lib.config import _cast_env, load_config @@ -82,6 +81,32 @@ def test_request_timeout_env_override(self, tmp_path, monkeypatch): cfg = load_config() assert cfg["requestTimeoutSeconds"] == 60 + def test_recall_tags_env_override_accepts_comma_list(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path)) + monkeypatch.setenv("HINDSIGHT_RECALL_TAGS", "memory_type:rule, tech_stack:supabase") + cfg = load_config() + assert cfg["recallTags"] == ["memory_type:rule", "tech_stack:supabase"] + + def test_recall_tag_groups_env_override_accepts_json(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path)) + monkeypatch.setenv( + "HINDSIGHT_RECALL_TAG_GROUPS", + '[{"op":"all","tags":["memory_type:rule","tech_stack:supabase"]}]', + ) + cfg = load_config() + assert cfg["recallTagGroups"] == [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}] + + def test_recall_additional_bank_filters_env_override_accepts_json(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path)) + monkeypatch.setenv( + "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS", + '{"normative":{"recallTags":["memory_type:rule"],"recallTagsMatch":"all"}}', + ) + cfg = load_config() + assert cfg["recallAdditionalBankFilters"] == { + "normative": {"recallTags": ["memory_type:rule"], "recallTagsMatch": "all"} + } + 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{{") diff --git a/hindsight-integrations/claude-code/tests/test_hooks.py b/hindsight-integrations/claude-code/tests/test_hooks.py index 22cfc810f..d333171cf 100644 --- a/hindsight-integrations/claude-code/tests/test_hooks.py +++ b/hindsight-integrations/claude-code/tests/test_hooks.py @@ -16,16 +16,16 @@ 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, extra_settings=None): +def _run_hook( + module_name, hook_input, monkeypatch, tmp_path, urlopen_side_effect=None, extra_env=None, extra_settings=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")) @@ -171,6 +171,66 @@ def capture_and_respond(req, timeout=None): if "body" in captured_body: assert "Python" in captured_body["body"].get("query", "") + def test_passes_tag_filters_to_recall_api(self, monkeypatch, tmp_path): + captured = {} + + def capture_and_respond(req, timeout=None): + if "/recall" in req.full_url: + captured["body"] = json.loads(req.data.decode()) + return FakeHTTPResponse({"results": []}) + + hook_input = make_hook_input(prompt="What project rules apply here?") + _run_hook( + "recall", + hook_input, + monkeypatch, + tmp_path, + urlopen_side_effect=capture_and_respond, + extra_settings={ + "recallTags": ["memory_type:rule"], + "recallTagsMatch": "any_strict", + "recallTagGroups": [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}], + }, + ) + + assert captured["body"]["tags"] == ["memory_type:rule"] + assert captured["body"]["tags_match"] == "any_strict" + assert captured["body"]["tag_groups"] == [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}] + + def test_additional_bank_filters_override_global_tags(self, monkeypatch, tmp_path): + captured = [] + + def capture_and_respond(req, timeout=None): + if "/recall" in req.full_url: + captured.append(json.loads(req.data.decode())) + return FakeHTTPResponse({"results": []}) + + hook_input = make_hook_input(prompt="What project rules apply here?") + _run_hook( + "recall", + hook_input, + monkeypatch, + tmp_path, + urlopen_side_effect=capture_and_respond, + extra_settings={ + "bankId": "project-bank", + "recallAdditionalBanks": ["normative-bank"], + "recallTags": ["tech_stack:supabase"], + "recallTagsMatch": "any", + "recallAdditionalBankFilters": { + "normative-bank": { + "recallTags": ["memory_type:rule"], + "recallTagsMatch": "all_strict", + } + }, + }, + ) + + assert captured[0]["tags"] == ["tech_stack:supabase"] + assert captured[0]["tags_match"] == "any" + assert captured[1]["tags"] == ["memory_type:rule"] + assert captured[1]["tags_match"] == "all_strict" + 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) @@ -267,7 +327,10 @@ def capture(req, timeout=None): return FakeHTTPResponse({}) _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainTags": ["{session_id}", "claude-code", "custom-tag"]}, ) @@ -289,7 +352,10 @@ def capture(req, timeout=None): return FakeHTTPResponse({}) _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_env={"HINDSIGHT_USER_ID": "alice"}, extra_settings={"retainTags": ["user:{user_id}", "session:{session_id}"]}, @@ -312,7 +378,10 @@ def capture(req, timeout=None): return FakeHTTPResponse({}) _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainTags": ["user:{user_id}", "session:{session_id}"]}, ) @@ -336,7 +405,10 @@ def capture(req, timeout=None): return FakeHTTPResponse({}) _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainTags": ["plain-tag", "another"]}, ) @@ -359,7 +431,10 @@ def capture(req, timeout=None): return FakeHTTPResponse({}) _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainTags": ["user:{user_id}"]}, ) @@ -383,7 +458,10 @@ def capture(req, timeout=None): return FakeHTTPResponse({}) _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainMetadata": {"project": "my-project", "session": "{session_id}"}}, ) @@ -511,7 +589,10 @@ def capture(req, timeout=None): # retainEveryNTurns=3 in full-session mode — first 2 calls should be skipped _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainEveryNTurns": 3}, ) @@ -521,7 +602,10 @@ def capture(req, timeout=None): # Turn 2 — still skip captured.clear() _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainEveryNTurns": 3}, ) @@ -530,7 +614,10 @@ def capture(req, timeout=None): # Turn 3 — should fire, with full session content and session_id as doc ID captured.clear() _run_hook( - "retain", hook_input, monkeypatch, tmp_path, + "retain", + hook_input, + monkeypatch, + tmp_path, urlopen_side_effect=capture, extra_settings={"retainEveryNTurns": 3}, )