Skip to content

Commit 962140e

Browse files
feat(claude-code): Add recall tag filters to memory hook (#2331)
* Add recall tag filters * Support per-bank recall tag filters * Use recall tag config names
1 parent 5e73d5f commit 962140e

8 files changed

Lines changed: 203 additions & 14 deletions

File tree

hindsight-integrations/claude-code/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
227227
| `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. |
228228
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
229229
| `recallRoles` || `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
230+
| `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. |
231+
| `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
232+
| `recallTagGroups` | `HINDSIGHT_RECALL_TAG_GROUPS` | `null` | Optional compound tag filter passed through to the recall API. The env var must be JSON. |
233+
| `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. |
230234
| `recallPromptPreamble` || built-in string | Text placed above the recalled memories in the injected context block. Customize this to change how Claude interprets the memories. |
231235

232236
---

hindsight-integrations/claude-code/scripts/lib/client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ def recall(
111111
max_tokens: int = 1024,
112112
budget: str = "mid",
113113
types: Optional[list] = None,
114+
tags: Optional[list] = None,
115+
tags_match: Optional[str] = None,
116+
tag_groups: Optional[object] = None,
114117
timeout: int = 10,
115118
) -> dict:
116119
"""Recall memories from a bank.
@@ -126,6 +129,12 @@ def recall(
126129
body["budget"] = budget
127130
if types:
128131
body["types"] = types
132+
if tags:
133+
body["tags"] = tags
134+
if tags_match:
135+
body["tags_match"] = tags_match
136+
if tag_groups:
137+
body["tag_groups"] = tag_groups
129138
return self.request("POST", path, body, timeout=timeout)
130139

131140
def retain(

hindsight-integrations/claude-code/scripts/lib/config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
"recallContextTurns": 1,
1818
"recallMaxQueryChars": 800,
1919
"recallRoles": ["user", "assistant"],
20+
"recallTags": [],
21+
"recallTagsMatch": "any",
22+
"recallTagGroups": None,
23+
"recallAdditionalBankFilters": {},
2024
"recallPromptPreamble": (
2125
"Relevant memories from past conversations (prioritize recent when "
2226
"conflicting). Only use memories that are directly useful to continue "
@@ -73,6 +77,10 @@
7377
"HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int),
7478
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
7579
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
80+
"HINDSIGHT_RECALL_TAGS": ("recallTags", list),
81+
"HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str),
82+
"HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict),
83+
"HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict),
7684
"HINDSIGHT_API_PORT": ("apiPort", int),
7785
"HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
7886
"HINDSIGHT_REQUEST_TIMEOUT_SECONDS": ("requestTimeoutSeconds", int),
@@ -93,8 +101,20 @@ def _cast_env(value: str, typ):
93101
return value.lower() in ("true", "1", "yes")
94102
if typ is int:
95103
return int(value)
104+
if typ is list:
105+
parsed = json.loads(value)
106+
if isinstance(parsed, list):
107+
return parsed
108+
return None
109+
if typ is dict:
110+
parsed = json.loads(value)
111+
if isinstance(parsed, (dict, list)):
112+
return parsed
113+
return None
96114
return value
97115
except (ValueError, AttributeError):
116+
if typ is list:
117+
return [part.strip() for part in value.split(",") if part.strip()]
98118
return None
99119

100120

hindsight-integrations/claude-code/scripts/recall.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ def _dbg(*a):
146146

147147
debug_log(config, f"Recalling from bank '{bank_id}', query length: {len(query)}")
148148

149+
recall_tags = config.get("recallTags") or None
150+
tag_groups = config.get("recallTagGroups") or None
151+
tags_match = config.get("recallTagsMatch") if recall_tags or tag_groups else None
152+
additional_bank_filters = config.get("recallAdditionalBankFilters") or {}
153+
149154
# Call Hindsight recall API
150155
try:
151156
response = client.recall(
@@ -154,6 +159,9 @@ def _dbg(*a):
154159
max_tokens=config.get("recallMaxTokens", 1024),
155160
budget=config.get("recallBudget", "mid"),
156161
types=config.get("recallTypes"),
162+
tags=recall_tags,
163+
tags_match=tags_match,
164+
tag_groups=tag_groups,
157165
timeout=10,
158166
)
159167
except Exception as e:
@@ -165,13 +173,23 @@ def _dbg(*a):
165173
# Also recall from any additional banks (e.g. shared user profile bank)
166174
additional_banks = config.get("recallAdditionalBanks", [])
167175
for extra_bank_id in additional_banks:
176+
extra_filter = additional_bank_filters.get(extra_bank_id, {})
177+
extra_tags = extra_filter.get("recallTags", recall_tags) or None
178+
extra_tag_groups = extra_filter.get("recallTagGroups", tag_groups) or None
179+
extra_tags_match = extra_filter.get(
180+
"recallTagsMatch",
181+
tags_match if extra_tags or extra_tag_groups else None,
182+
)
168183
try:
169184
extra_response = client.recall(
170185
bank_id=extra_bank_id,
171186
query=query,
172187
max_tokens=config.get("recallMaxTokens", 1024),
173188
budget=config.get("recallBudget", "mid"),
174189
types=config.get("recallTypes"),
190+
tags=extra_tags,
191+
tags_match=extra_tags_match,
192+
tag_groups=extra_tag_groups,
175193
timeout=10,
176194
)
177195
extra_results = extra_response.get("results", [])

hindsight-integrations/claude-code/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"recallContextTurns": 1,
1313
"recallMaxQueryChars": 800,
1414
"recallRoles": ["user", "assistant"],
15+
"recallTags": [],
16+
"recallTagsMatch": "any",
17+
"recallTagGroups": null,
18+
"recallAdditionalBankFilters": {},
1519
"recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
1620
"retainRoles": ["user", "assistant"],
1721
"retainEveryNTurns": 10,

hindsight-integrations/claude-code/tests/test_client.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from unittest.mock import MagicMock, patch
77

88
import pytest
9-
109
from lib.client import USER_AGENT, HindsightClient, _validate_api_url
1110

1211

@@ -149,6 +148,29 @@ def fake_open(req, timeout=None):
149148
assert captured["body"]["budget"] == "high"
150149
assert captured["body"]["types"] == ["world", "experience"]
151150

151+
def test_sends_tag_filters(self):
152+
c = HindsightClient("http://localhost:9077")
153+
captured = {}
154+
155+
def fake_open(req, timeout=None):
156+
captured["body"] = json.loads(req.data.decode())
157+
return FakeResp({"results": []})
158+
159+
with patch("urllib.request.urlopen", side_effect=fake_open):
160+
c.recall(
161+
"bank",
162+
"query",
163+
tags=["memory_type:rule"],
164+
tags_match="any_strict",
165+
tag_groups=[{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}],
166+
)
167+
168+
assert captured["body"]["tags"] == ["memory_type:rule"]
169+
assert captured["body"]["tags_match"] == "any_strict"
170+
assert captured["body"]["tag_groups"] == [
171+
{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}
172+
]
173+
152174

153175
class TestHindsightClientRetain:
154176
def test_posts_with_async_true(self):

hindsight-integrations/claude-code/tests/test_config.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import os
55

66
import pytest
7-
87
from lib.config import _cast_env, load_config
98

109

@@ -82,6 +81,32 @@ def test_request_timeout_env_override(self, tmp_path, monkeypatch):
8281
cfg = load_config()
8382
assert cfg["requestTimeoutSeconds"] == 60
8483

84+
def test_recall_tags_env_override_accepts_comma_list(self, tmp_path, monkeypatch):
85+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
86+
monkeypatch.setenv("HINDSIGHT_RECALL_TAGS", "memory_type:rule, tech_stack:supabase")
87+
cfg = load_config()
88+
assert cfg["recallTags"] == ["memory_type:rule", "tech_stack:supabase"]
89+
90+
def test_recall_tag_groups_env_override_accepts_json(self, tmp_path, monkeypatch):
91+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
92+
monkeypatch.setenv(
93+
"HINDSIGHT_RECALL_TAG_GROUPS",
94+
'[{"op":"all","tags":["memory_type:rule","tech_stack:supabase"]}]',
95+
)
96+
cfg = load_config()
97+
assert cfg["recallTagGroups"] == [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}]
98+
99+
def test_recall_additional_bank_filters_env_override_accepts_json(self, tmp_path, monkeypatch):
100+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
101+
monkeypatch.setenv(
102+
"HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS",
103+
'{"normative":{"recallTags":["memory_type:rule"],"recallTagsMatch":"all"}}',
104+
)
105+
cfg = load_config()
106+
assert cfg["recallAdditionalBankFilters"] == {
107+
"normative": {"recallTags": ["memory_type:rule"], "recallTagsMatch": "all"}
108+
}
109+
85110
def test_invalid_settings_json_falls_back_to_defaults(self, tmp_path, monkeypatch):
86111
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
87112
(tmp_path / "settings.json").write_text("not valid json{{")

0 commit comments

Comments
 (0)