switchroom 0.12.26 → 0.12.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +359 -357
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +2 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +368 -209
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +55 -40
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- package/vendor/hindsight-memory/tests/test_state.py +125 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"""End-to-end tests for recall.py and retain.py hook scripts.
|
|
2
|
+
|
|
3
|
+
Mocks the Claude Code hook runtime:
|
|
4
|
+
- stdin → io.StringIO(json.dumps(hook_input))
|
|
5
|
+
- stdout → io.StringIO() captured for assertions
|
|
6
|
+
- urllib.request.urlopen → fake HTTP responses
|
|
7
|
+
- CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA → temp dirs
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from conftest import FakeHTTPResponse, make_hook_input, make_memory, make_transcript_file
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Helpers
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_hook(module_name, hook_input, monkeypatch, tmp_path, urlopen_side_effect=None, extra_env=None, extra_settings=None):
|
|
29
|
+
"""Import and run a hook script's main() with mocked stdin/stdout/HTTP."""
|
|
30
|
+
# Isolated plugin dirs
|
|
31
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
|
|
32
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
|
|
33
|
+
(tmp_path / "plugin_root").mkdir(exist_ok=True)
|
|
34
|
+
(tmp_path / "plugin_data").mkdir(exist_ok=True)
|
|
35
|
+
|
|
36
|
+
# Strip real HINDSIGHT_* env vars and neutralize user config (~/.hindsight/claude-code.json)
|
|
37
|
+
for k in list(os.environ):
|
|
38
|
+
if k.startswith("HINDSIGHT_"):
|
|
39
|
+
monkeypatch.delenv(k, raising=False)
|
|
40
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
41
|
+
|
|
42
|
+
for k, v in (extra_env or {}).items():
|
|
43
|
+
monkeypatch.setenv(k, v)
|
|
44
|
+
|
|
45
|
+
# Write a minimal settings.json enabling fast retains
|
|
46
|
+
settings = {"autoRecall": True, "autoRetain": True, "retainEveryNTurns": 1, "hindsightApiUrl": "http://fake:9077"}
|
|
47
|
+
if extra_settings:
|
|
48
|
+
settings.update(extra_settings)
|
|
49
|
+
(tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
|
|
50
|
+
|
|
51
|
+
stdin_data = io.StringIO(json.dumps(hook_input))
|
|
52
|
+
stdout_capture = io.StringIO()
|
|
53
|
+
|
|
54
|
+
# Force reimport so the module picks up patched env / path
|
|
55
|
+
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
56
|
+
spec = importlib.util.spec_from_file_location(module_name, os.path.join(scripts_dir, f"{module_name}.py"))
|
|
57
|
+
mod = importlib.util.module_from_spec(spec)
|
|
58
|
+
|
|
59
|
+
default_response = FakeHTTPResponse({"results": []})
|
|
60
|
+
side_effect = urlopen_side_effect or (lambda *a, **kw: default_response)
|
|
61
|
+
|
|
62
|
+
with (
|
|
63
|
+
patch("sys.stdin", stdin_data),
|
|
64
|
+
patch("sys.stdout", stdout_capture),
|
|
65
|
+
patch("urllib.request.urlopen", side_effect=side_effect),
|
|
66
|
+
):
|
|
67
|
+
spec.loader.exec_module(mod)
|
|
68
|
+
mod.main()
|
|
69
|
+
|
|
70
|
+
return stdout_capture.getvalue()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# recall hook
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestRecallHook:
|
|
79
|
+
def test_outputs_additional_context_when_memories_found(self, monkeypatch, tmp_path):
|
|
80
|
+
memory = make_memory("Paris is the capital of France", "world")
|
|
81
|
+
response = FakeHTTPResponse({"results": [memory]})
|
|
82
|
+
|
|
83
|
+
hook_input = make_hook_input(prompt="What is the capital of France?")
|
|
84
|
+
output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=lambda *a, **kw: response)
|
|
85
|
+
|
|
86
|
+
data = json.loads(output)
|
|
87
|
+
context = data["hookSpecificOutput"]["additionalContext"]
|
|
88
|
+
assert "Paris is the capital of France" in context
|
|
89
|
+
assert "<hindsight_memories>" in context
|
|
90
|
+
|
|
91
|
+
def test_no_output_when_no_memories(self, monkeypatch, tmp_path):
|
|
92
|
+
hook_input = make_hook_input(prompt="hello there world")
|
|
93
|
+
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
|
|
94
|
+
# Empty stdout = no memories injected
|
|
95
|
+
assert output.strip() == ""
|
|
96
|
+
|
|
97
|
+
def test_no_output_for_short_prompt(self, monkeypatch, tmp_path):
|
|
98
|
+
hook_input = make_hook_input(prompt="hi")
|
|
99
|
+
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
|
|
100
|
+
assert output.strip() == ""
|
|
101
|
+
|
|
102
|
+
def test_graceful_on_api_error(self, monkeypatch, tmp_path):
|
|
103
|
+
def raise_error(*a, **kw):
|
|
104
|
+
raise OSError("connection refused")
|
|
105
|
+
|
|
106
|
+
hook_input = make_hook_input(prompt="What is my project about?")
|
|
107
|
+
# Should not raise — graceful degradation
|
|
108
|
+
output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
|
|
109
|
+
assert output.strip() == ""
|
|
110
|
+
|
|
111
|
+
def test_output_format_matches_claude_code_spec(self, monkeypatch, tmp_path):
|
|
112
|
+
memory = make_memory("User prefers Python")
|
|
113
|
+
response = FakeHTTPResponse({"results": [memory]})
|
|
114
|
+
|
|
115
|
+
hook_input = make_hook_input(prompt="What language should I use?")
|
|
116
|
+
output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=lambda *a, **kw: response)
|
|
117
|
+
|
|
118
|
+
data = json.loads(output)
|
|
119
|
+
assert data["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
|
|
120
|
+
assert "additionalContext" in data["hookSpecificOutput"]
|
|
121
|
+
|
|
122
|
+
def test_multi_turn_context_from_transcript(self, monkeypatch, tmp_path):
|
|
123
|
+
"""When recallContextTurns > 1, prior transcript is included in query."""
|
|
124
|
+
messages = [
|
|
125
|
+
{"role": "user", "content": "I use Python for all my scripts"},
|
|
126
|
+
{"role": "assistant", "content": "Noted!"},
|
|
127
|
+
]
|
|
128
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
129
|
+
|
|
130
|
+
# Override to use multi-turn recall
|
|
131
|
+
settings = {
|
|
132
|
+
"autoRecall": True,
|
|
133
|
+
"hindsightApiUrl": "http://fake:9077",
|
|
134
|
+
"recallContextTurns": 2,
|
|
135
|
+
"retainEveryNTurns": 1,
|
|
136
|
+
"autoRetain": True,
|
|
137
|
+
}
|
|
138
|
+
(tmp_path / "plugin_root").mkdir(exist_ok=True)
|
|
139
|
+
(tmp_path / "plugin_data").mkdir(exist_ok=True)
|
|
140
|
+
|
|
141
|
+
captured_body = {}
|
|
142
|
+
|
|
143
|
+
def capture_and_respond(req, timeout=None):
|
|
144
|
+
if "/recall" in req.full_url:
|
|
145
|
+
captured_body["body"] = json.loads(req.data.decode())
|
|
146
|
+
return FakeHTTPResponse({"results": []})
|
|
147
|
+
|
|
148
|
+
for k in list(os.environ):
|
|
149
|
+
if k.startswith("HINDSIGHT_"):
|
|
150
|
+
monkeypatch.delenv(k, raising=False)
|
|
151
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
|
|
152
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
|
|
153
|
+
(tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
|
|
154
|
+
|
|
155
|
+
hook_input = make_hook_input(prompt="What language should I use?", transcript_path=transcript)
|
|
156
|
+
stdin_data = io.StringIO(json.dumps(hook_input))
|
|
157
|
+
|
|
158
|
+
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
159
|
+
spec = importlib.util.spec_from_file_location("recall", os.path.join(scripts_dir, "recall.py"))
|
|
160
|
+
mod = importlib.util.module_from_spec(spec)
|
|
161
|
+
|
|
162
|
+
with (
|
|
163
|
+
patch("sys.stdin", stdin_data),
|
|
164
|
+
patch("sys.stdout", io.StringIO()),
|
|
165
|
+
patch("urllib.request.urlopen", side_effect=capture_and_respond),
|
|
166
|
+
):
|
|
167
|
+
spec.loader.exec_module(mod)
|
|
168
|
+
mod.main()
|
|
169
|
+
|
|
170
|
+
# The query should contain prior context from the transcript
|
|
171
|
+
if "body" in captured_body:
|
|
172
|
+
assert "Python" in captured_body["body"].get("query", "")
|
|
173
|
+
|
|
174
|
+
def test_cache_disabled_by_default_no_state_written(self, monkeypatch, tmp_path):
|
|
175
|
+
"""Without HINDSIGHT_RECALL_CACHE_TTL_SECS the recall_cache.json
|
|
176
|
+
state file should never be created — backwards-compatible default."""
|
|
177
|
+
memory = make_memory("Some fact", "world")
|
|
178
|
+
response = FakeHTTPResponse({"results": [memory]})
|
|
179
|
+
hook_input = make_hook_input(prompt="A meaningful prompt that stays put")
|
|
180
|
+
_run_hook("recall", hook_input, monkeypatch, tmp_path,
|
|
181
|
+
urlopen_side_effect=lambda *a, **kw: response)
|
|
182
|
+
|
|
183
|
+
cache_path = tmp_path / "plugin_data" / "state" / "recall_cache.json"
|
|
184
|
+
assert not cache_path.exists()
|
|
185
|
+
|
|
186
|
+
def test_cache_hit_skips_http_call(self, monkeypatch, tmp_path):
|
|
187
|
+
"""Two identical recall calls with TTL>0 should hit HTTP exactly once."""
|
|
188
|
+
memory = make_memory("Paris is the capital of France", "world")
|
|
189
|
+
response = FakeHTTPResponse({"results": [memory]})
|
|
190
|
+
|
|
191
|
+
call_count = {"n": 0}
|
|
192
|
+
|
|
193
|
+
def counting_urlopen(*a, **kw):
|
|
194
|
+
call_count["n"] += 1
|
|
195
|
+
return FakeHTTPResponse({"results": [memory]})
|
|
196
|
+
|
|
197
|
+
hook_input = make_hook_input(prompt="What is the capital of France?")
|
|
198
|
+
|
|
199
|
+
# First call — must hit HTTP
|
|
200
|
+
out1 = _run_hook(
|
|
201
|
+
"recall", hook_input, monkeypatch, tmp_path,
|
|
202
|
+
urlopen_side_effect=counting_urlopen,
|
|
203
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"},
|
|
204
|
+
)
|
|
205
|
+
first_http = call_count["n"]
|
|
206
|
+
assert first_http >= 1
|
|
207
|
+
assert "Paris is the capital of France" in out1
|
|
208
|
+
|
|
209
|
+
# Second call — same prompt, same session → cache hit, no extra HTTP
|
|
210
|
+
out2 = _run_hook(
|
|
211
|
+
"recall", hook_input, monkeypatch, tmp_path,
|
|
212
|
+
urlopen_side_effect=counting_urlopen,
|
|
213
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"},
|
|
214
|
+
)
|
|
215
|
+
assert call_count["n"] == first_http # unchanged
|
|
216
|
+
# Cached output is byte-equivalent context
|
|
217
|
+
data1 = json.loads(out1)
|
|
218
|
+
data2 = json.loads(out2)
|
|
219
|
+
assert data1["hookSpecificOutput"]["additionalContext"] == \
|
|
220
|
+
data2["hookSpecificOutput"]["additionalContext"]
|
|
221
|
+
|
|
222
|
+
def test_cache_miss_on_different_prompt(self, monkeypatch, tmp_path):
|
|
223
|
+
memory = make_memory("Some fact", "world")
|
|
224
|
+
call_count = {"n": 0}
|
|
225
|
+
|
|
226
|
+
def counting_urlopen(*a, **kw):
|
|
227
|
+
call_count["n"] += 1
|
|
228
|
+
return FakeHTTPResponse({"results": [memory]})
|
|
229
|
+
|
|
230
|
+
hook_a = make_hook_input(prompt="Question one about France?")
|
|
231
|
+
hook_b = make_hook_input(prompt="Question two about Spain?")
|
|
232
|
+
|
|
233
|
+
_run_hook("recall", hook_a, monkeypatch, tmp_path,
|
|
234
|
+
urlopen_side_effect=counting_urlopen,
|
|
235
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"})
|
|
236
|
+
before_b = call_count["n"]
|
|
237
|
+
_run_hook("recall", hook_b, monkeypatch, tmp_path,
|
|
238
|
+
urlopen_side_effect=counting_urlopen,
|
|
239
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"})
|
|
240
|
+
# Different prompts → both must hit HTTP independently
|
|
241
|
+
assert call_count["n"] > before_b
|
|
242
|
+
|
|
243
|
+
def test_cache_miss_on_session_change(self, monkeypatch, tmp_path):
|
|
244
|
+
"""Same prompt but different session_id must MISS the cache —
|
|
245
|
+
sessions are isolated to prevent cross-contamination."""
|
|
246
|
+
memory = make_memory("Some fact", "world")
|
|
247
|
+
call_count = {"n": 0}
|
|
248
|
+
|
|
249
|
+
def counting_urlopen(*a, **kw):
|
|
250
|
+
call_count["n"] += 1
|
|
251
|
+
return FakeHTTPResponse({"results": [memory]})
|
|
252
|
+
|
|
253
|
+
hook_session_a = make_hook_input(prompt="The same prompt", session_id="session-a")
|
|
254
|
+
hook_session_b = make_hook_input(prompt="The same prompt", session_id="session-b")
|
|
255
|
+
|
|
256
|
+
_run_hook("recall", hook_session_a, monkeypatch, tmp_path,
|
|
257
|
+
urlopen_side_effect=counting_urlopen,
|
|
258
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"})
|
|
259
|
+
first = call_count["n"]
|
|
260
|
+
_run_hook("recall", hook_session_b, monkeypatch, tmp_path,
|
|
261
|
+
urlopen_side_effect=counting_urlopen,
|
|
262
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"})
|
|
263
|
+
assert call_count["n"] > first
|
|
264
|
+
|
|
265
|
+
def test_cache_miss_after_ttl_expiry(self, monkeypatch, tmp_path):
|
|
266
|
+
"""An entry older than TTL should NOT serve a cache hit. We
|
|
267
|
+
backdate the cache file's saved_at to simulate TTL elapse."""
|
|
268
|
+
memory = make_memory("Some fact", "world")
|
|
269
|
+
call_count = {"n": 0}
|
|
270
|
+
|
|
271
|
+
def counting_urlopen(*a, **kw):
|
|
272
|
+
call_count["n"] += 1
|
|
273
|
+
return FakeHTTPResponse({"results": [memory]})
|
|
274
|
+
|
|
275
|
+
hook_input = make_hook_input(prompt="Same exact prompt twice")
|
|
276
|
+
|
|
277
|
+
# Populate cache with TTL=60.
|
|
278
|
+
_run_hook("recall", hook_input, monkeypatch, tmp_path,
|
|
279
|
+
urlopen_side_effect=counting_urlopen,
|
|
280
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"})
|
|
281
|
+
first = call_count["n"]
|
|
282
|
+
|
|
283
|
+
# Backdate every entry's saved_at to an hour ago.
|
|
284
|
+
cache_path = tmp_path / "plugin_data" / "state" / "recall_cache.json"
|
|
285
|
+
assert cache_path.exists()
|
|
286
|
+
state = json.loads(cache_path.read_text())
|
|
287
|
+
long_ago = time.time() - 3600
|
|
288
|
+
for k, entry in (state.get("entries") or {}).items():
|
|
289
|
+
entry["saved_at"] = long_ago
|
|
290
|
+
cache_path.write_text(json.dumps(state))
|
|
291
|
+
|
|
292
|
+
# Re-run with the same TTL — should MISS because of expiry.
|
|
293
|
+
_run_hook("recall", hook_input, monkeypatch, tmp_path,
|
|
294
|
+
urlopen_side_effect=counting_urlopen,
|
|
295
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"})
|
|
296
|
+
assert call_count["n"] > first
|
|
297
|
+
|
|
298
|
+
def test_cache_invalid_env_disables_caching(self, monkeypatch, tmp_path):
|
|
299
|
+
"""Garbage TTL env vars are treated as 'disabled' — no cache file."""
|
|
300
|
+
memory = make_memory("Some fact", "world")
|
|
301
|
+
response = FakeHTTPResponse({"results": [memory]})
|
|
302
|
+
hook_input = make_hook_input(prompt="A normal prompt")
|
|
303
|
+
|
|
304
|
+
for bad in ("not-a-number", "-5", "0", " "):
|
|
305
|
+
_run_hook(
|
|
306
|
+
"recall", hook_input, monkeypatch, tmp_path,
|
|
307
|
+
urlopen_side_effect=lambda *a, **kw: response,
|
|
308
|
+
extra_env={"HINDSIGHT_RECALL_CACHE_TTL_SECS": bad},
|
|
309
|
+
)
|
|
310
|
+
cache_path = tmp_path / "plugin_data" / "state" / "recall_cache.json"
|
|
311
|
+
assert not cache_path.exists(), f"Cache should not be written for TTL={bad!r}"
|
|
312
|
+
|
|
313
|
+
def test_disabled_auto_recall_produces_no_output(self, monkeypatch, tmp_path):
|
|
314
|
+
(tmp_path / "plugin_root").mkdir(exist_ok=True)
|
|
315
|
+
(tmp_path / "plugin_data").mkdir(exist_ok=True)
|
|
316
|
+
settings = {"autoRecall": False, "autoRetain": False, "hindsightApiUrl": "http://fake:9077"}
|
|
317
|
+
(tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
|
|
318
|
+
|
|
319
|
+
for k in list(os.environ):
|
|
320
|
+
if k.startswith("HINDSIGHT_"):
|
|
321
|
+
monkeypatch.delenv(k, raising=False)
|
|
322
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
|
|
323
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
|
|
324
|
+
|
|
325
|
+
hook_input = make_hook_input(prompt="What is the capital of France?")
|
|
326
|
+
stdin_data = io.StringIO(json.dumps(hook_input))
|
|
327
|
+
stdout_capture = io.StringIO()
|
|
328
|
+
|
|
329
|
+
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
330
|
+
spec = importlib.util.spec_from_file_location("recall_disabled", os.path.join(scripts_dir, "recall.py"))
|
|
331
|
+
mod = importlib.util.module_from_spec(spec)
|
|
332
|
+
|
|
333
|
+
with patch("sys.stdin", stdin_data), patch("sys.stdout", stdout_capture):
|
|
334
|
+
spec.loader.exec_module(mod)
|
|
335
|
+
mod.main()
|
|
336
|
+
|
|
337
|
+
assert stdout_capture.getvalue().strip() == ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# retain hook
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class TestRetainHook:
|
|
346
|
+
def test_posts_transcript_to_hindsight(self, monkeypatch, tmp_path):
|
|
347
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
348
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
349
|
+
|
|
350
|
+
captured = {}
|
|
351
|
+
|
|
352
|
+
def capture(req, timeout=None):
|
|
353
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
354
|
+
captured["body"] = json.loads(req.data.decode())
|
|
355
|
+
return FakeHTTPResponse({"status": "accepted"})
|
|
356
|
+
|
|
357
|
+
hook_input = make_hook_input(transcript_path=transcript)
|
|
358
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
359
|
+
|
|
360
|
+
assert "body" in captured, "retain API was not called"
|
|
361
|
+
assert "hello" in captured["body"]["items"][0]["content"]
|
|
362
|
+
|
|
363
|
+
def test_no_retain_on_empty_transcript(self, monkeypatch, tmp_path):
|
|
364
|
+
hook_input = make_hook_input(transcript_path="/nonexistent/transcript.jsonl")
|
|
365
|
+
captured = {}
|
|
366
|
+
|
|
367
|
+
def capture(req, timeout=None):
|
|
368
|
+
if "/memories" in req.full_url:
|
|
369
|
+
captured["called"] = True
|
|
370
|
+
return FakeHTTPResponse({})
|
|
371
|
+
|
|
372
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
373
|
+
assert "called" not in captured
|
|
374
|
+
|
|
375
|
+
def test_strips_memory_tags_before_retaining(self, monkeypatch, tmp_path):
|
|
376
|
+
messages = [
|
|
377
|
+
{"role": "user", "content": "<hindsight_memories>old memories</hindsight_memories> actual question"},
|
|
378
|
+
{"role": "assistant", "content": "sure!"},
|
|
379
|
+
]
|
|
380
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
381
|
+
captured = {}
|
|
382
|
+
|
|
383
|
+
def capture(req, timeout=None):
|
|
384
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
385
|
+
captured["body"] = json.loads(req.data.decode())
|
|
386
|
+
return FakeHTTPResponse({})
|
|
387
|
+
|
|
388
|
+
hook_input = make_hook_input(transcript_path=transcript)
|
|
389
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
390
|
+
|
|
391
|
+
if "body" in captured:
|
|
392
|
+
content = captured["body"]["items"][0]["content"]
|
|
393
|
+
assert "old memories" not in content
|
|
394
|
+
assert "actual question" in content
|
|
395
|
+
|
|
396
|
+
def test_retain_tags_with_template_variables(self, monkeypatch, tmp_path):
|
|
397
|
+
"""retainTags config should resolve template variables like {session_id}."""
|
|
398
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
399
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
400
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-tag-test")
|
|
401
|
+
captured = {}
|
|
402
|
+
|
|
403
|
+
def capture(req, timeout=None):
|
|
404
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
405
|
+
captured["body"] = json.loads(req.data.decode())
|
|
406
|
+
return FakeHTTPResponse({})
|
|
407
|
+
|
|
408
|
+
_run_hook(
|
|
409
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
410
|
+
urlopen_side_effect=capture,
|
|
411
|
+
extra_settings={"retainTags": ["{session_id}", "claude-code", "custom-tag"]},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
assert "body" in captured, "retain API was not called"
|
|
415
|
+
item = captured["body"]["items"][0]
|
|
416
|
+
assert item["tags"] == ["sess-tag-test", "claude-code", "custom-tag"]
|
|
417
|
+
|
|
418
|
+
def test_retain_tag_resolves_user_id_when_env_set(self, monkeypatch, tmp_path):
|
|
419
|
+
"""retainTags with {user_id} resolves from HINDSIGHT_USER_ID env var."""
|
|
420
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
421
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
422
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-user-test")
|
|
423
|
+
captured = {}
|
|
424
|
+
|
|
425
|
+
def capture(req, timeout=None):
|
|
426
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
427
|
+
captured["body"] = json.loads(req.data.decode())
|
|
428
|
+
return FakeHTTPResponse({})
|
|
429
|
+
|
|
430
|
+
_run_hook(
|
|
431
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
432
|
+
urlopen_side_effect=capture,
|
|
433
|
+
extra_env={"HINDSIGHT_USER_ID": "alice"},
|
|
434
|
+
extra_settings={"retainTags": ["user:{user_id}", "session:{session_id}"]},
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
assert "body" in captured, "retain API was not called"
|
|
438
|
+
item = captured["body"]["items"][0]
|
|
439
|
+
assert item["tags"] == ["user:alice", "session:sess-user-test"]
|
|
440
|
+
|
|
441
|
+
def test_retain_tag_dropped_when_user_id_env_unset(self, monkeypatch, tmp_path):
|
|
442
|
+
"""user:{user_id} resolves to 'user:' and is dropped when env is unset; other tags survive."""
|
|
443
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
444
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
445
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-drop-test")
|
|
446
|
+
captured = {}
|
|
447
|
+
|
|
448
|
+
def capture(req, timeout=None):
|
|
449
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
450
|
+
captured["body"] = json.loads(req.data.decode())
|
|
451
|
+
return FakeHTTPResponse({})
|
|
452
|
+
|
|
453
|
+
_run_hook(
|
|
454
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
455
|
+
urlopen_side_effect=capture,
|
|
456
|
+
extra_settings={"retainTags": ["user:{user_id}", "session:{session_id}"]},
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
assert "body" in captured, "retain API was not called"
|
|
460
|
+
item = captured["body"]["items"][0]
|
|
461
|
+
assert item["tags"] == ["session:sess-drop-test"]
|
|
462
|
+
assert not any(t.startswith("user:") for t in item["tags"])
|
|
463
|
+
|
|
464
|
+
def test_retain_tag_without_colon_preserved(self, monkeypatch, tmp_path):
|
|
465
|
+
"""Tags without ':' are never dropped, regardless of env state."""
|
|
466
|
+
# _run_hook strips all HINDSIGHT_* env vars, so unset state is the default.
|
|
467
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
468
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
469
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-plain")
|
|
470
|
+
captured = {}
|
|
471
|
+
|
|
472
|
+
def capture(req, timeout=None):
|
|
473
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
474
|
+
captured["body"] = json.loads(req.data.decode())
|
|
475
|
+
return FakeHTTPResponse({})
|
|
476
|
+
|
|
477
|
+
_run_hook(
|
|
478
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
479
|
+
urlopen_side_effect=capture,
|
|
480
|
+
extra_settings={"retainTags": ["plain-tag", "another"]},
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
assert "body" in captured, "retain API was not called"
|
|
484
|
+
item = captured["body"]["items"][0]
|
|
485
|
+
assert item["tags"] == ["plain-tag", "another"]
|
|
486
|
+
|
|
487
|
+
def test_retain_tag_all_dropped_yields_no_tags_field(self, monkeypatch, tmp_path):
|
|
488
|
+
"""If all tags resolve to dangling, the outgoing request omits the tags field."""
|
|
489
|
+
# _run_hook strips all HINDSIGHT_* env vars, so unset state is the default.
|
|
490
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
491
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
492
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-none")
|
|
493
|
+
captured = {}
|
|
494
|
+
|
|
495
|
+
def capture(req, timeout=None):
|
|
496
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
497
|
+
captured["body"] = json.loads(req.data.decode())
|
|
498
|
+
return FakeHTTPResponse({})
|
|
499
|
+
|
|
500
|
+
_run_hook(
|
|
501
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
502
|
+
urlopen_side_effect=capture,
|
|
503
|
+
extra_settings={"retainTags": ["user:{user_id}"]},
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
assert "body" in captured, "retain API was not called"
|
|
507
|
+
item = captured["body"]["items"][0]
|
|
508
|
+
# HindsightClient.retain only sets item["tags"] if tags is truthy (client.py:144).
|
|
509
|
+
# With all tags dropped, retain.py sets tags=None, so "tags" is absent from item.
|
|
510
|
+
assert "tags" not in item
|
|
511
|
+
|
|
512
|
+
def test_retain_custom_metadata(self, monkeypatch, tmp_path):
|
|
513
|
+
"""retainMetadata config should be merged with built-in metadata."""
|
|
514
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
515
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
516
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-meta-test")
|
|
517
|
+
captured = {}
|
|
518
|
+
|
|
519
|
+
def capture(req, timeout=None):
|
|
520
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
521
|
+
captured["body"] = json.loads(req.data.decode())
|
|
522
|
+
return FakeHTTPResponse({})
|
|
523
|
+
|
|
524
|
+
_run_hook(
|
|
525
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
526
|
+
urlopen_side_effect=capture,
|
|
527
|
+
extra_settings={"retainMetadata": {"project": "my-project", "session": "{session_id}"}},
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
assert "body" in captured, "retain API was not called"
|
|
531
|
+
meta = captured["body"]["items"][0]["metadata"]
|
|
532
|
+
# Built-in metadata
|
|
533
|
+
assert meta["session_id"] == "sess-meta-test"
|
|
534
|
+
assert "retained_at" in meta
|
|
535
|
+
# Custom metadata with template resolution
|
|
536
|
+
assert meta["project"] == "my-project"
|
|
537
|
+
assert meta["session"] == "sess-meta-test"
|
|
538
|
+
|
|
539
|
+
def test_full_session_uses_session_id_as_document_id(self, monkeypatch, tmp_path):
|
|
540
|
+
"""In full-session mode, document_id should be the session_id (for upsert)."""
|
|
541
|
+
messages = [
|
|
542
|
+
{"role": "user", "content": "first question"},
|
|
543
|
+
{"role": "assistant", "content": "first answer"},
|
|
544
|
+
{"role": "user", "content": "second question"},
|
|
545
|
+
{"role": "assistant", "content": "second answer"},
|
|
546
|
+
]
|
|
547
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
548
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-full-123")
|
|
549
|
+
captured = {}
|
|
550
|
+
|
|
551
|
+
def capture(req, timeout=None):
|
|
552
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
553
|
+
captured["body"] = json.loads(req.data.decode())
|
|
554
|
+
return FakeHTTPResponse({})
|
|
555
|
+
|
|
556
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
557
|
+
|
|
558
|
+
assert "body" in captured, "retain API was not called"
|
|
559
|
+
item = captured["body"]["items"][0]
|
|
560
|
+
# document_id should be just the session_id, no timestamp suffix
|
|
561
|
+
assert item["document_id"] == "sess-full-123"
|
|
562
|
+
# Should contain ALL messages, not just the last turn
|
|
563
|
+
assert "first question" in item["content"]
|
|
564
|
+
assert "second question" in item["content"]
|
|
565
|
+
|
|
566
|
+
def test_full_session_new_document_after_compaction(self, monkeypatch, tmp_path):
|
|
567
|
+
"""After compaction shrinks the transcript, retain should use a new document_id
|
|
568
|
+
to avoid overwriting the pre-compaction document."""
|
|
569
|
+
# First retain: 4 messages
|
|
570
|
+
messages_full = [
|
|
571
|
+
{"role": "user", "content": "first question"},
|
|
572
|
+
{"role": "assistant", "content": "first answer"},
|
|
573
|
+
{"role": "user", "content": "second question"},
|
|
574
|
+
{"role": "assistant", "content": "second answer"},
|
|
575
|
+
]
|
|
576
|
+
transcript = make_transcript_file(tmp_path, messages_full)
|
|
577
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-compact-test")
|
|
578
|
+
captured_calls = []
|
|
579
|
+
|
|
580
|
+
def capture(req, timeout=None):
|
|
581
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
582
|
+
captured_calls.append(json.loads(req.data.decode()))
|
|
583
|
+
return FakeHTTPResponse({})
|
|
584
|
+
|
|
585
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
586
|
+
|
|
587
|
+
assert len(captured_calls) == 1
|
|
588
|
+
assert captured_calls[0]["items"][0]["document_id"] == "sess-compact-test"
|
|
589
|
+
assert "first question" in captured_calls[0]["items"][0]["content"]
|
|
590
|
+
|
|
591
|
+
# Second retain: compaction happened — transcript now has only 2 messages
|
|
592
|
+
messages_compacted = [
|
|
593
|
+
{"role": "user", "content": "third question"},
|
|
594
|
+
{"role": "assistant", "content": "third answer"},
|
|
595
|
+
]
|
|
596
|
+
transcript = make_transcript_file(tmp_path, messages_compacted)
|
|
597
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-compact-test")
|
|
598
|
+
|
|
599
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
600
|
+
|
|
601
|
+
assert len(captured_calls) == 2
|
|
602
|
+
# Should use a new document_id with chunk suffix
|
|
603
|
+
assert captured_calls[1]["items"][0]["document_id"] == "sess-compact-test-c1"
|
|
604
|
+
assert "third question" in captured_calls[1]["items"][0]["content"]
|
|
605
|
+
|
|
606
|
+
def test_full_session_same_document_when_growing(self, monkeypatch, tmp_path):
|
|
607
|
+
"""When transcript grows (no compaction), retain should keep the same document_id."""
|
|
608
|
+
messages_2 = [
|
|
609
|
+
{"role": "user", "content": "hello"},
|
|
610
|
+
{"role": "assistant", "content": "world"},
|
|
611
|
+
]
|
|
612
|
+
transcript = make_transcript_file(tmp_path, messages_2)
|
|
613
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-grow-test")
|
|
614
|
+
captured_calls = []
|
|
615
|
+
|
|
616
|
+
def capture(req, timeout=None):
|
|
617
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
618
|
+
captured_calls.append(json.loads(req.data.decode()))
|
|
619
|
+
return FakeHTTPResponse({})
|
|
620
|
+
|
|
621
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
622
|
+
|
|
623
|
+
# Second retain: transcript grew to 4 messages
|
|
624
|
+
messages_4 = messages_2 + [
|
|
625
|
+
{"role": "user", "content": "more stuff"},
|
|
626
|
+
{"role": "assistant", "content": "more response"},
|
|
627
|
+
]
|
|
628
|
+
transcript = make_transcript_file(tmp_path, messages_4)
|
|
629
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-grow-test")
|
|
630
|
+
|
|
631
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
632
|
+
|
|
633
|
+
assert len(captured_calls) == 2
|
|
634
|
+
# Both should use the same plain session_id
|
|
635
|
+
assert captured_calls[0]["items"][0]["document_id"] == "sess-grow-test"
|
|
636
|
+
assert captured_calls[1]["items"][0]["document_id"] == "sess-grow-test"
|
|
637
|
+
|
|
638
|
+
def test_full_session_respects_retain_every_n_turns(self, monkeypatch, tmp_path):
|
|
639
|
+
"""In full-session mode, retainEveryNTurns should still gate when retain fires."""
|
|
640
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
641
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
642
|
+
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-throttle")
|
|
643
|
+
captured = {}
|
|
644
|
+
|
|
645
|
+
def capture(req, timeout=None):
|
|
646
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
647
|
+
captured["called"] = True
|
|
648
|
+
captured["body"] = json.loads(req.data.decode())
|
|
649
|
+
return FakeHTTPResponse({})
|
|
650
|
+
|
|
651
|
+
# retainEveryNTurns=3 in full-session mode — first 2 calls should be skipped
|
|
652
|
+
_run_hook(
|
|
653
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
654
|
+
urlopen_side_effect=capture,
|
|
655
|
+
extra_settings={"retainEveryNTurns": 3},
|
|
656
|
+
)
|
|
657
|
+
# Turn 1 of 3 — should NOT retain
|
|
658
|
+
assert "called" not in captured
|
|
659
|
+
|
|
660
|
+
# Turn 2 — still skip
|
|
661
|
+
captured.clear()
|
|
662
|
+
_run_hook(
|
|
663
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
664
|
+
urlopen_side_effect=capture,
|
|
665
|
+
extra_settings={"retainEveryNTurns": 3},
|
|
666
|
+
)
|
|
667
|
+
assert "called" not in captured
|
|
668
|
+
|
|
669
|
+
# Turn 3 — should fire, with full session content and session_id as doc ID
|
|
670
|
+
captured.clear()
|
|
671
|
+
_run_hook(
|
|
672
|
+
"retain", hook_input, monkeypatch, tmp_path,
|
|
673
|
+
urlopen_side_effect=capture,
|
|
674
|
+
extra_settings={"retainEveryNTurns": 3},
|
|
675
|
+
)
|
|
676
|
+
assert "called" in captured, "retain should fire on turn 3"
|
|
677
|
+
item = captured["body"]["items"][0]
|
|
678
|
+
assert item["document_id"] == "sess-throttle" # full-session uses session_id
|
|
679
|
+
assert "hello" in item["content"]
|
|
680
|
+
|
|
681
|
+
def test_chunked_retain_skips_below_threshold(self, monkeypatch, tmp_path):
|
|
682
|
+
"""With retainEveryNTurns=5 and retainMode=chunked, first call should be skipped."""
|
|
683
|
+
(tmp_path / "plugin_root").mkdir(exist_ok=True)
|
|
684
|
+
(tmp_path / "plugin_data").mkdir(exist_ok=True)
|
|
685
|
+
settings = {
|
|
686
|
+
"autoRetain": True,
|
|
687
|
+
"autoRecall": True,
|
|
688
|
+
"retainMode": "chunked",
|
|
689
|
+
"retainEveryNTurns": 5,
|
|
690
|
+
"hindsightApiUrl": "http://fake:9077",
|
|
691
|
+
}
|
|
692
|
+
(tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
|
|
693
|
+
|
|
694
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]
|
|
695
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
696
|
+
hook_input = make_hook_input(transcript_path=transcript)
|
|
697
|
+
|
|
698
|
+
captured = {}
|
|
699
|
+
|
|
700
|
+
def capture(req, timeout=None):
|
|
701
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
702
|
+
captured["called"] = True
|
|
703
|
+
return FakeHTTPResponse({})
|
|
704
|
+
|
|
705
|
+
for k in list(os.environ):
|
|
706
|
+
if k.startswith("HINDSIGHT_"):
|
|
707
|
+
monkeypatch.delenv(k, raising=False)
|
|
708
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
|
|
709
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
|
|
710
|
+
monkeypatch.setenv("HINDSIGHT_RETAIN_MODE", "chunked")
|
|
711
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
712
|
+
|
|
713
|
+
stdin_data = io.StringIO(json.dumps(hook_input))
|
|
714
|
+
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
715
|
+
spec = importlib.util.spec_from_file_location("retain_chunked", os.path.join(scripts_dir, "retain.py"))
|
|
716
|
+
mod = importlib.util.module_from_spec(spec)
|
|
717
|
+
|
|
718
|
+
with (
|
|
719
|
+
patch("sys.stdin", stdin_data),
|
|
720
|
+
patch("sys.stdout", io.StringIO()),
|
|
721
|
+
patch("urllib.request.urlopen", side_effect=capture),
|
|
722
|
+
):
|
|
723
|
+
spec.loader.exec_module(mod)
|
|
724
|
+
mod.main()
|
|
725
|
+
|
|
726
|
+
# Turn 1 of 5 — should NOT retain
|
|
727
|
+
assert "called" not in captured
|
|
728
|
+
|
|
729
|
+
def test_graceful_on_retain_api_error(self, monkeypatch, tmp_path):
|
|
730
|
+
messages = [{"role": "user", "content": "test message"}, {"role": "assistant", "content": "response"}]
|
|
731
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
732
|
+
hook_input = make_hook_input(transcript_path=transcript)
|
|
733
|
+
|
|
734
|
+
def raise_error(req, timeout=None):
|
|
735
|
+
if "/memories" in req.full_url:
|
|
736
|
+
raise OSError("connection refused")
|
|
737
|
+
return FakeHTTPResponse({})
|
|
738
|
+
|
|
739
|
+
# Should not raise
|
|
740
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
|
|
741
|
+
|
|
742
|
+
def test_retain_posts_async_true(self, monkeypatch, tmp_path):
|
|
743
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
744
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
745
|
+
hook_input = make_hook_input(transcript_path=transcript)
|
|
746
|
+
captured = {}
|
|
747
|
+
|
|
748
|
+
def capture(req, timeout=None):
|
|
749
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
750
|
+
captured["body"] = json.loads(req.data.decode())
|
|
751
|
+
return FakeHTTPResponse({})
|
|
752
|
+
|
|
753
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
754
|
+
|
|
755
|
+
if "body" in captured:
|
|
756
|
+
assert captured["body"].get("async") is True
|
|
757
|
+
|
|
758
|
+
def test_retain_includes_context_label(self, monkeypatch, tmp_path):
|
|
759
|
+
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
760
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
761
|
+
hook_input = make_hook_input(transcript_path=transcript)
|
|
762
|
+
captured = {}
|
|
763
|
+
|
|
764
|
+
def capture(req, timeout=None):
|
|
765
|
+
if "/memories" in req.full_url and "/recall" not in req.full_url:
|
|
766
|
+
captured["body"] = json.loads(req.data.decode())
|
|
767
|
+
return FakeHTTPResponse({})
|
|
768
|
+
|
|
769
|
+
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
770
|
+
|
|
771
|
+
if "body" in captured:
|
|
772
|
+
assert captured["body"]["items"][0]["context"] == "claude-code"
|
|
773
|
+
|
|
774
|
+
def test_disabled_auto_retain_does_not_call_api(self, monkeypatch, tmp_path):
|
|
775
|
+
(tmp_path / "plugin_root").mkdir(exist_ok=True)
|
|
776
|
+
(tmp_path / "plugin_data").mkdir(exist_ok=True)
|
|
777
|
+
settings = {"autoRetain": False, "autoRecall": False, "hindsightApiUrl": "http://fake:9077"}
|
|
778
|
+
(tmp_path / "plugin_root" / "settings.json").write_text(json.dumps(settings))
|
|
779
|
+
|
|
780
|
+
messages = [{"role": "user", "content": "hello"}]
|
|
781
|
+
transcript = make_transcript_file(tmp_path, messages)
|
|
782
|
+
hook_input = make_hook_input(transcript_path=transcript)
|
|
783
|
+
captured = {}
|
|
784
|
+
|
|
785
|
+
def capture(req, timeout=None):
|
|
786
|
+
captured["called"] = True
|
|
787
|
+
return FakeHTTPResponse({})
|
|
788
|
+
|
|
789
|
+
for k in list(os.environ):
|
|
790
|
+
if k.startswith("HINDSIGHT_"):
|
|
791
|
+
monkeypatch.delenv(k, raising=False)
|
|
792
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path / "plugin_root"))
|
|
793
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "plugin_data"))
|
|
794
|
+
|
|
795
|
+
stdin_data = io.StringIO(json.dumps(hook_input))
|
|
796
|
+
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
797
|
+
spec = importlib.util.spec_from_file_location("retain_disabled", os.path.join(scripts_dir, "retain.py"))
|
|
798
|
+
mod = importlib.util.module_from_spec(spec)
|
|
799
|
+
|
|
800
|
+
with (
|
|
801
|
+
patch("sys.stdin", stdin_data),
|
|
802
|
+
patch("sys.stdout", io.StringIO()),
|
|
803
|
+
patch("urllib.request.urlopen", side_effect=capture),
|
|
804
|
+
):
|
|
805
|
+
spec.loader.exec_module(mod)
|
|
806
|
+
mod.main()
|
|
807
|
+
|
|
808
|
+
assert "called" not in captured
|