switchroom 0.12.27 → 0.12.29

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.
Files changed (48) hide show
  1. package/dist/cli/switchroom.js +4 -2
  2. package/package.json +2 -1
  3. package/telegram-plugin/dist/gateway/gateway.js +113 -7
  4. package/telegram-plugin/gateway/gateway.ts +52 -9
  5. package/telegram-plugin/gateway/prefix-warmup.ts +123 -0
  6. package/telegram-plugin/stderr-timestamps.ts +106 -0
  7. package/telegram-plugin/tests/prefix-warmup.test.ts +175 -0
  8. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  9. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  10. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  11. package/vendor/hindsight-memory/LICENSE +21 -0
  12. package/vendor/hindsight-memory/README.md +329 -0
  13. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  14. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  15. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  16. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  17. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  18. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  19. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  20. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  21. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  22. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  23. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  24. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  25. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  26. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  27. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  28. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  29. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  30. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  31. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  35. package/vendor/hindsight-memory/settings.json +37 -0
  36. package/vendor/hindsight-memory/skills/setup.md +24 -0
  37. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  38. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  39. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  40. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  41. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  42. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  43. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  44. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  45. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  46. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  47. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  48. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -0,0 +1,94 @@
1
+ """Shared fixtures for Hindsight Claude Code plugin tests."""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+
9
+ import pytest
10
+
11
+ # Make scripts/ importable as the root — the hook scripts do:
12
+ # sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+ # so lib.* imports resolve relative to scripts/
14
+ SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
15
+ if SCRIPTS_DIR not in sys.path:
16
+ sys.path.insert(0, os.path.abspath(SCRIPTS_DIR))
17
+
18
+
19
+ @pytest.fixture()
20
+ def state_dir(tmp_path, monkeypatch):
21
+ """Isolated state directory — prevents tests from touching real state files."""
22
+ d = tmp_path / "state"
23
+ d.mkdir()
24
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
25
+ return d
26
+
27
+
28
+ @pytest.fixture()
29
+ def plugin_root(tmp_path):
30
+ """Temp plugin root with a minimal settings.json."""
31
+ settings = tmp_path / "settings.json"
32
+ settings.write_text(json.dumps({}))
33
+ return tmp_path
34
+
35
+
36
+ @pytest.fixture()
37
+ def default_config(plugin_root, monkeypatch):
38
+ """Load config with no overrides, isolated from real settings.json."""
39
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
40
+ # Strip any real HINDSIGHT_* env vars that might bleed in
41
+ for key in list(os.environ):
42
+ if key.startswith("HINDSIGHT_"):
43
+ monkeypatch.delenv(key, raising=False)
44
+ from lib.config import load_config
45
+
46
+ return load_config()
47
+
48
+
49
+ def make_hook_input(
50
+ prompt="What is the capital of France?",
51
+ session_id="sess-abc123",
52
+ cwd="/home/user/myproject",
53
+ transcript_path="",
54
+ ):
55
+ return {
56
+ "prompt": prompt,
57
+ "session_id": session_id,
58
+ "cwd": cwd,
59
+ "transcript_path": transcript_path,
60
+ }
61
+
62
+
63
+ def make_transcript_file(tmp_path, messages):
64
+ """Write messages as a JSONL transcript file (flat test format)."""
65
+ f = tmp_path / "transcript.jsonl"
66
+ lines = [json.dumps(m) for m in messages]
67
+ f.write_text("\n".join(lines))
68
+ return str(f)
69
+
70
+
71
+ def make_recall_response(memories):
72
+ """Build a fake /recall API response."""
73
+ return {"results": memories}
74
+
75
+
76
+ def make_memory(text, mem_type="experience", mentioned_at="2024-01-15"):
77
+ return {"text": text, "type": mem_type, "mentioned_at": mentioned_at}
78
+
79
+
80
+ class FakeHTTPResponse:
81
+ """Minimal urllib response mock."""
82
+
83
+ def __init__(self, data: dict, status: int = 200):
84
+ self.status = status
85
+ self._data = json.dumps(data).encode()
86
+
87
+ def read(self):
88
+ return self._data
89
+
90
+ def __enter__(self):
91
+ return self
92
+
93
+ def __exit__(self, *_):
94
+ pass
@@ -0,0 +1,142 @@
1
+ """Tests for lib/bank.py — bank ID derivation and mission management."""
2
+
3
+ import json
4
+ from unittest.mock import MagicMock
5
+
6
+ import pytest
7
+
8
+ from lib.bank import derive_bank_id, ensure_bank_mission
9
+
10
+
11
+ def _cfg(**overrides):
12
+ base = {
13
+ "dynamicBankId": False,
14
+ "bankId": "claude-code",
15
+ "bankIdPrefix": "",
16
+ "agentName": "claude-code",
17
+ "dynamicBankGranularity": ["agent", "project"],
18
+ "bankMission": "",
19
+ "retainMission": None,
20
+ }
21
+ base.update(overrides)
22
+ return base
23
+
24
+
25
+ def _hook(session_id="sess-1", cwd="/home/user/myproject"):
26
+ return {"session_id": session_id, "cwd": cwd}
27
+
28
+
29
+ class TestDeriveBankIdStatic:
30
+ def test_static_default_bank(self):
31
+ assert derive_bank_id(_hook(), _cfg()) == "claude-code"
32
+
33
+ def test_static_custom_bank_id(self):
34
+ cfg = _cfg(bankId="my-agent")
35
+ assert derive_bank_id(_hook(), cfg) == "my-agent"
36
+
37
+ def test_static_with_prefix(self):
38
+ cfg = _cfg(bankId="bot", bankIdPrefix="prod")
39
+ assert derive_bank_id(_hook(), cfg) == "prod-bot"
40
+
41
+ def test_static_prefix_without_bankid_uses_default(self):
42
+ cfg = _cfg(bankId=None, bankIdPrefix="dev")
43
+ assert derive_bank_id(_hook(), cfg) == "dev-claude-code"
44
+
45
+
46
+ class TestDeriveBankIdDynamic:
47
+ def test_dynamic_agent_project(self):
48
+ cfg = _cfg(dynamicBankId=True, agentName="mybot", dynamicBankGranularity=["agent", "project"])
49
+ result = derive_bank_id(_hook(cwd="/home/user/hindsight"), cfg)
50
+ assert result == "mybot::hindsight"
51
+
52
+ def test_dynamic_preserves_raw_special_chars(self):
53
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
54
+ result = derive_bank_id(_hook(cwd="/home/user/my project"), cfg)
55
+ assert "my project" in result
56
+ assert "%" not in result
57
+
58
+ def test_dynamic_preserves_raw_utf8(self):
59
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
60
+ result = derive_bank_id(_hook(cwd="/home/user/мой проект"), cfg)
61
+ assert "мой проект" in result
62
+ assert "%" not in result
63
+
64
+ def test_dynamic_session_field(self):
65
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["session"])
66
+ result = derive_bank_id(_hook(session_id="abc-123"), cfg)
67
+ assert "abc-123" in result
68
+
69
+ def test_dynamic_with_prefix(self):
70
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["agent"], bankIdPrefix="v2")
71
+ result = derive_bank_id(_hook(), cfg)
72
+ assert result.startswith("v2-")
73
+
74
+ def test_dynamic_channel_from_env(self, monkeypatch):
75
+ monkeypatch.setenv("HINDSIGHT_CHANNEL_ID", "telegram-123")
76
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["channel"])
77
+ result = derive_bank_id(_hook(), cfg)
78
+ assert "telegram-123" in result
79
+
80
+ def test_dynamic_user_from_env(self, monkeypatch):
81
+ monkeypatch.setenv("HINDSIGHT_USER_ID", "user-456")
82
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["user"])
83
+ result = derive_bank_id(_hook(), cfg)
84
+ assert "user-456" in result
85
+
86
+ def test_dynamic_missing_env_uses_defaults(self, monkeypatch):
87
+ monkeypatch.delenv("HINDSIGHT_CHANNEL_ID", raising=False)
88
+ monkeypatch.delenv("HINDSIGHT_USER_ID", raising=False)
89
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["channel", "user"])
90
+ result = derive_bank_id(_hook(), cfg)
91
+ assert "default" in result
92
+ assert "anonymous" in result
93
+
94
+ def test_dynamic_empty_cwd_uses_unknown(self):
95
+ cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
96
+ result = derive_bank_id({"session_id": "s", "cwd": ""}, cfg)
97
+ assert "unknown" in result
98
+
99
+
100
+ class TestEnsureBankMission:
101
+ def test_sets_mission_on_first_call(self, state_dir):
102
+ client = MagicMock()
103
+ cfg = _cfg(bankMission="You are a helpful assistant.", bankId="test-bank")
104
+ ensure_bank_mission(client, "test-bank", cfg)
105
+ client.set_bank_mission.assert_called_once_with(
106
+ "test-bank", "You are a helpful assistant.", retain_mission=None, timeout=10
107
+ )
108
+
109
+ def test_skips_if_already_set(self, state_dir):
110
+ client = MagicMock()
111
+ cfg = _cfg(bankMission="mission text")
112
+ ensure_bank_mission(client, "bank-a", cfg)
113
+ ensure_bank_mission(client, "bank-a", cfg) # second call
114
+ assert client.set_bank_mission.call_count == 1
115
+
116
+ def test_skips_if_mission_empty(self, state_dir):
117
+ client = MagicMock()
118
+ cfg = _cfg(bankMission="")
119
+ ensure_bank_mission(client, "bank-b", cfg)
120
+ client.set_bank_mission.assert_not_called()
121
+
122
+ def test_includes_retain_mission_if_set(self, state_dir):
123
+ client = MagicMock()
124
+ cfg = _cfg(bankMission="reflect mission", retainMission="retain mission")
125
+ ensure_bank_mission(client, "bank-c", cfg)
126
+ client.set_bank_mission.assert_called_once_with(
127
+ "bank-c", "reflect mission", retain_mission="retain mission", timeout=10
128
+ )
129
+
130
+ def test_graceful_on_api_error(self, state_dir):
131
+ client = MagicMock()
132
+ client.set_bank_mission.side_effect = RuntimeError("server down")
133
+ cfg = _cfg(bankMission="mission")
134
+ # Should not raise
135
+ ensure_bank_mission(client, "bank-d", cfg)
136
+
137
+ def test_different_banks_each_set_once(self, state_dir):
138
+ client = MagicMock()
139
+ cfg = _cfg(bankMission="mission")
140
+ ensure_bank_mission(client, "bank-x", cfg)
141
+ ensure_bank_mission(client, "bank-y", cfg)
142
+ assert client.set_bank_mission.call_count == 2
@@ -0,0 +1,232 @@
1
+ """Tests for lib/client.py — Hindsight REST API client."""
2
+
3
+ import json
4
+ import urllib.error
5
+ from io import BytesIO
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+
10
+ from lib.client import USER_AGENT, HindsightClient, _validate_api_url
11
+
12
+
13
+ class TestValidateApiUrl:
14
+ def test_valid_http(self):
15
+ assert _validate_api_url("http://localhost:9077") == "http://localhost:9077"
16
+
17
+ def test_valid_https(self):
18
+ assert _validate_api_url("https://api.example.com/") == "https://api.example.com"
19
+
20
+ def test_trailing_slash_stripped(self):
21
+ assert _validate_api_url("http://host:8080/") == "http://host:8080"
22
+
23
+ def test_invalid_scheme_raises(self):
24
+ with pytest.raises(ValueError, match="http or https"):
25
+ _validate_api_url("ftp://host")
26
+
27
+ def test_no_hostname_raises(self):
28
+ with pytest.raises(ValueError):
29
+ _validate_api_url("http://")
30
+
31
+
32
+ class FakeResp:
33
+ def __init__(self, data, status=200):
34
+ self.status = status
35
+ self._body = json.dumps(data).encode()
36
+
37
+ def read(self):
38
+ return self._body
39
+
40
+ def __enter__(self):
41
+ return self
42
+
43
+ def __exit__(self, *_):
44
+ pass
45
+
46
+
47
+ class TestHindsightClientInit:
48
+ def test_rejects_non_http_url(self):
49
+ with pytest.raises(ValueError):
50
+ HindsightClient("ftp://bad")
51
+
52
+ def test_stores_token(self):
53
+ c = HindsightClient("http://localhost:9077", api_token="tok123")
54
+ assert c.api_token == "tok123"
55
+
56
+ def test_no_token(self):
57
+ c = HindsightClient("http://localhost:9077")
58
+ assert c.api_token is None
59
+
60
+
61
+ class TestHindsightClientRecall:
62
+ def test_posts_to_correct_path(self):
63
+ c = HindsightClient("http://localhost:9077")
64
+ response_data = {"results": [{"text": "Paris", "type": "world"}]}
65
+ with patch("urllib.request.urlopen", return_value=FakeResp(response_data)):
66
+ resp = c.recall("my-bank", "capital of France")
67
+ assert resp["results"][0]["text"] == "Paris"
68
+
69
+ def test_bank_id_url_encoded(self):
70
+ c = HindsightClient("http://localhost:9077")
71
+ captured = {}
72
+
73
+ def fake_open(req, timeout=None):
74
+ captured["url"] = req.full_url
75
+ return FakeResp({"results": []})
76
+
77
+ with patch("urllib.request.urlopen", side_effect=fake_open):
78
+ c.recall("bank with spaces", "query")
79
+
80
+ assert "bank%20with%20spaces" in captured["url"]
81
+
82
+ def test_includes_auth_header_when_token_set(self):
83
+ c = HindsightClient("http://localhost:9077", api_token="mytoken")
84
+ captured = {}
85
+
86
+ def fake_open(req, timeout=None):
87
+ captured["headers"] = dict(req.headers)
88
+ return FakeResp({"results": []})
89
+
90
+ with patch("urllib.request.urlopen", side_effect=fake_open):
91
+ c.recall("bank", "query")
92
+
93
+ assert "Authorization" in captured["headers"]
94
+ assert "mytoken" in captured["headers"]["Authorization"]
95
+
96
+ def test_no_auth_header_without_token(self):
97
+ c = HindsightClient("http://localhost:9077")
98
+ captured = {}
99
+
100
+ def fake_open(req, timeout=None):
101
+ captured["headers"] = dict(req.headers)
102
+ return FakeResp({"results": []})
103
+
104
+ with patch("urllib.request.urlopen", side_effect=fake_open):
105
+ c.recall("bank", "query")
106
+
107
+ assert "Authorization" not in captured["headers"]
108
+
109
+ def test_sends_user_agent_header(self):
110
+ # Regression test for #1041: the stdlib default "Python-urllib/X.Y" UA
111
+ # is blocked by Cloudflare with error 1010, so we must always send our own.
112
+ c = HindsightClient("http://localhost:9077")
113
+ captured = {}
114
+
115
+ def fake_open(req, timeout=None):
116
+ captured["ua"] = req.get_header("User-agent")
117
+ return FakeResp({"results": []})
118
+
119
+ with patch("urllib.request.urlopen", side_effect=fake_open):
120
+ c.recall("bank", "query")
121
+
122
+ assert captured["ua"] == USER_AGENT
123
+ assert captured["ua"].startswith("hindsight-claude-code/")
124
+
125
+ def test_http_error_raises_runtime_error(self):
126
+ c = HindsightClient("http://localhost:9077")
127
+ err = urllib.error.HTTPError(
128
+ url="http://localhost:9077/v1/default/banks/b/memories/recall",
129
+ code=500,
130
+ msg="Internal Server Error",
131
+ hdrs={},
132
+ fp=BytesIO(b"server exploded"),
133
+ )
134
+ with patch("urllib.request.urlopen", side_effect=err):
135
+ with pytest.raises(RuntimeError, match="HTTP 500"):
136
+ c.recall("b", "query")
137
+
138
+ def test_sends_budget_and_types(self):
139
+ c = HindsightClient("http://localhost:9077")
140
+ captured = {}
141
+
142
+ def fake_open(req, timeout=None):
143
+ captured["body"] = json.loads(req.data.decode())
144
+ return FakeResp({"results": []})
145
+
146
+ with patch("urllib.request.urlopen", side_effect=fake_open):
147
+ c.recall("bank", "query", budget="high", types=["world", "experience"])
148
+
149
+ assert captured["body"]["budget"] == "high"
150
+ assert captured["body"]["types"] == ["world", "experience"]
151
+
152
+
153
+ class TestHindsightClientRetain:
154
+ def test_posts_with_async_true(self):
155
+ c = HindsightClient("http://localhost:9077")
156
+ captured = {}
157
+
158
+ def fake_open(req, timeout=None):
159
+ captured["body"] = json.loads(req.data.decode())
160
+ return FakeResp({"status": "accepted"})
161
+
162
+ with patch("urllib.request.urlopen", side_effect=fake_open):
163
+ c.retain("bank", "transcript content", document_id="doc-1", context="claude-code")
164
+
165
+ assert captured["body"]["async"] is True
166
+ assert captured["body"]["items"][0]["content"] == "transcript content"
167
+ assert captured["body"]["items"][0]["context"] == "claude-code"
168
+
169
+ def test_bank_id_encoded_in_retain_path(self):
170
+ c = HindsightClient("http://localhost:9077")
171
+ captured = {}
172
+
173
+ def fake_open(req, timeout=None):
174
+ captured["url"] = req.full_url
175
+ return FakeResp({})
176
+
177
+ with patch("urllib.request.urlopen", side_effect=fake_open):
178
+ c.retain("my::bank", "content")
179
+
180
+ assert "my%3A%3Abank" in captured["url"]
181
+
182
+
183
+ class TestHindsightClientHealthCheck:
184
+ def test_returns_true_on_200(self):
185
+ c = HindsightClient("http://localhost:9077")
186
+ with patch("urllib.request.urlopen", return_value=FakeResp({}, status=200)):
187
+ with patch("time.sleep"): # don't actually sleep
188
+ assert c.health_check() is True
189
+
190
+ def test_returns_false_after_retries(self):
191
+ c = HindsightClient("http://localhost:9077")
192
+ with patch("urllib.request.urlopen", side_effect=OSError("refused")):
193
+ with patch("time.sleep"):
194
+ assert c.health_check() is False
195
+
196
+ def test_retries_on_failure(self):
197
+ c = HindsightClient("http://localhost:9077")
198
+ call_count = 0
199
+
200
+ def flaky(*_a, **_kw):
201
+ nonlocal call_count
202
+ call_count += 1
203
+ if call_count < 3:
204
+ raise OSError("not yet")
205
+ return FakeResp({}, status=200)
206
+
207
+ with patch("urllib.request.urlopen", side_effect=flaky):
208
+ with patch("time.sleep"):
209
+ result = c.health_check()
210
+
211
+ assert result is True
212
+ assert call_count == 3
213
+
214
+
215
+ class TestHindsightClientSetBankMission:
216
+ def test_patches_config_endpoint(self):
217
+ c = HindsightClient("http://localhost:9077")
218
+ captured = {}
219
+
220
+ def fake_open(req, timeout=None):
221
+ captured["url"] = req.full_url
222
+ captured["method"] = req.method
223
+ captured["body"] = json.loads(req.data.decode())
224
+ return FakeResp({})
225
+
226
+ with patch("urllib.request.urlopen", side_effect=fake_open):
227
+ c.set_bank_mission("my-bank", "I am Claude", retain_mission="Extract facts")
228
+
229
+ assert captured["method"] == "PATCH"
230
+ assert "my-bank" in captured["url"]
231
+ assert captured["body"]["updates"]["reflect_mission"] == "I am Claude"
232
+ assert captured["body"]["updates"]["retain_mission"] == "Extract facts"
@@ -0,0 +1,128 @@
1
+ """Tests for lib/config.py — configuration loading and env overrides."""
2
+
3
+ import json
4
+ import os
5
+
6
+ import pytest
7
+
8
+ from lib.config import _cast_env, load_config
9
+
10
+
11
+ class TestCastEnv:
12
+ def test_bool_true_values(self):
13
+ for v in ("true", "True", "TRUE", "1", "yes", "YES"):
14
+ assert _cast_env(v, bool) is True
15
+
16
+ def test_bool_false_values(self):
17
+ for v in ("false", "False", "0", "no"):
18
+ assert _cast_env(v, bool) is False
19
+
20
+ def test_int_cast(self):
21
+ assert _cast_env("42", int) == 42
22
+
23
+ def test_int_invalid_returns_none(self):
24
+ assert _cast_env("notanint", int) is None
25
+
26
+ def test_str_passthrough(self):
27
+ assert _cast_env("hello", str) == "hello"
28
+
29
+
30
+ class TestLoadConfig:
31
+ @pytest.fixture(autouse=True)
32
+ def _isolate_config(self, tmp_path, monkeypatch):
33
+ """Isolate from real user config and env vars."""
34
+ monkeypatch.setenv("HOME", str(tmp_path))
35
+ for k in list(os.environ):
36
+ if k.startswith("HINDSIGHT_"):
37
+ monkeypatch.delenv(k, raising=False)
38
+
39
+ def test_defaults_applied_when_no_settings_file(self, tmp_path, monkeypatch):
40
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
41
+ # No settings.json in tmp_path
42
+ cfg = load_config()
43
+ assert cfg["autoRecall"] is True
44
+ assert cfg["autoRetain"] is True
45
+ assert cfg["recallBudget"] == "mid"
46
+ assert cfg["retainEveryNTurns"] == 10
47
+
48
+ def test_settings_json_overrides_defaults(self, tmp_path, monkeypatch):
49
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
50
+ (tmp_path / "settings.json").write_text(json.dumps({"recallBudget": "high", "bankId": "my-bank"}))
51
+ cfg = load_config()
52
+ assert cfg["recallBudget"] == "high"
53
+ assert cfg["bankId"] == "my-bank"
54
+
55
+ def test_env_var_overrides_settings_json(self, tmp_path, monkeypatch):
56
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
57
+ (tmp_path / "settings.json").write_text(json.dumps({"recallBudget": "low"}))
58
+ monkeypatch.setenv("HINDSIGHT_RECALL_BUDGET", "high")
59
+ cfg = load_config()
60
+ assert cfg["recallBudget"] == "high"
61
+
62
+ def test_bool_env_var_override(self, tmp_path, monkeypatch):
63
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
64
+ monkeypatch.setenv("HINDSIGHT_AUTO_RECALL", "false")
65
+ cfg = load_config()
66
+ assert cfg["autoRecall"] is False
67
+
68
+ def test_int_env_var_override(self, tmp_path, monkeypatch):
69
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
70
+ monkeypatch.setenv("HINDSIGHT_API_PORT", "9999")
71
+ cfg = load_config()
72
+ assert cfg["apiPort"] == 9999
73
+
74
+ def test_invalid_settings_json_falls_back_to_defaults(self, tmp_path, monkeypatch):
75
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
76
+ (tmp_path / "settings.json").write_text("not valid json{{")
77
+ cfg = load_config()
78
+ assert cfg["recallBudget"] == "mid" # default still applies
79
+
80
+ def test_null_values_in_settings_json_not_applied(self, tmp_path, monkeypatch):
81
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
82
+ (tmp_path / "settings.json").write_text(json.dumps({"bankId": None, "recallBudget": "high"}))
83
+ cfg = load_config()
84
+ # None values in file should not override defaults
85
+ assert cfg["bankId"] is None # default is None, so ok
86
+ assert cfg["recallBudget"] == "high"
87
+
88
+ def test_api_url_env_override(self, tmp_path, monkeypatch):
89
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
90
+ monkeypatch.setenv("HINDSIGHT_API_URL", "http://myserver:8080")
91
+ cfg = load_config()
92
+ assert cfg["hindsightApiUrl"] == "http://myserver:8080"
93
+
94
+ def test_user_config_overrides_plugin_settings(self, tmp_path, monkeypatch):
95
+ plugin_root = tmp_path / "plugin"
96
+ plugin_root.mkdir()
97
+
98
+ # Plugin default ships with "low"
99
+ (plugin_root / "settings.json").write_text(json.dumps({"recallBudget": "low"}))
100
+ # User overrides to "high" via ~/.hindsight/claude-code.json
101
+ user_cfg = tmp_path / ".hindsight" / "claude-code.json"
102
+ user_cfg.parent.mkdir()
103
+ user_cfg.write_text(json.dumps({"recallBudget": "high"}))
104
+
105
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
106
+ monkeypatch.setenv("HOME", str(tmp_path))
107
+ cfg = load_config()
108
+ assert cfg["recallBudget"] == "high"
109
+
110
+ def test_user_config_missing_falls_back_gracefully(self, tmp_path, monkeypatch):
111
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
112
+ # HOME points to tmp_path where no .hindsight/claude-code.json exists
113
+ monkeypatch.setenv("HOME", str(tmp_path))
114
+ cfg = load_config()
115
+ assert cfg["recallBudget"] == "mid" # default
116
+
117
+ def test_env_var_wins_over_user_config(self, tmp_path, monkeypatch):
118
+ plugin_root = tmp_path / "plugin"
119
+ plugin_root.mkdir()
120
+ user_cfg_dir = tmp_path / ".hindsight"
121
+ user_cfg_dir.mkdir()
122
+ (user_cfg_dir / "claude-code.json").write_text(json.dumps({"recallBudget": "low"}))
123
+
124
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
125
+ monkeypatch.setenv("HOME", str(tmp_path))
126
+ monkeypatch.setenv("HINDSIGHT_RECALL_BUDGET", "high")
127
+ cfg = load_config()
128
+ assert cfg["recallBudget"] == "high"