switchroom 0.12.27 → 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.
Files changed (46) hide show
  1. package/dist/cli/switchroom.js +4 -2
  2. package/package.json +2 -1
  3. package/telegram-plugin/dist/gateway/gateway.js +49 -5
  4. package/telegram-plugin/gateway/gateway.ts +5 -0
  5. package/telegram-plugin/stderr-timestamps.ts +106 -0
  6. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  7. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  8. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  9. package/vendor/hindsight-memory/LICENSE +21 -0
  10. package/vendor/hindsight-memory/README.md +329 -0
  11. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  12. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  13. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  14. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  15. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  16. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  17. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  18. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  19. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  20. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  21. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  22. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  23. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  24. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  25. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  26. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  27. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  28. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  29. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  33. package/vendor/hindsight-memory/settings.json +37 -0
  34. package/vendor/hindsight-memory/skills/setup.md +24 -0
  35. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  36. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  37. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  38. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  39. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  40. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  41. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  42. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  43. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  44. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  45. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  46. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -0,0 +1,205 @@
1
+ """End-to-end tests for the session_end pending-queue path (#1071).
2
+
3
+ Covers:
4
+ - retain success → queue stays empty, exit 0
5
+ - retain failure → exactly one entry written, payload faithful, exit non-zero
6
+ - daemon stop still runs even when retain fails
7
+ """
8
+
9
+ import importlib
10
+ import io
11
+ import json
12
+ import os
13
+ import sys
14
+ import tempfile
15
+ import unittest
16
+ import urllib.error
17
+ from unittest.mock import patch
18
+
19
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
20
+ if SCRIPTS_DIR not in sys.path:
21
+ sys.path.insert(0, SCRIPTS_DIR)
22
+
23
+
24
+ class FakeHTTPResponse:
25
+ """Minimal urlopen() stand-in (mirror of conftest.FakeHTTPResponse —
26
+ inlined so this module is importable under plain unittest)."""
27
+
28
+ def __init__(self, data: dict, status: int = 200):
29
+ self.status = status
30
+ self._data = json.dumps(data).encode()
31
+
32
+ def read(self):
33
+ return self._data
34
+
35
+ def __enter__(self):
36
+ return self
37
+
38
+ def __exit__(self, *_):
39
+ return False
40
+
41
+
42
+ def _make_transcript(path: str) -> None:
43
+ """Minimal Claude-Code-format transcript that triggers a real retain."""
44
+ rows = [
45
+ {"type": "user", "message": {"role": "user", "content": "tell me a story"}},
46
+ {"type": "assistant", "message": {"role": "assistant", "content": "Once upon a time..."}},
47
+ ]
48
+ with open(path, "w") as f:
49
+ for r in rows:
50
+ f.write(json.dumps(r) + "\n")
51
+
52
+
53
+ class SessionEndPendingTest(unittest.TestCase):
54
+ def setUp(self):
55
+ self._tmp = tempfile.mkdtemp(prefix="hindsight-session-end-test-")
56
+ self._plugin_root = os.path.join(self._tmp, "plugin_root")
57
+ self._plugin_data = os.path.join(self._tmp, "plugin_data")
58
+ self._pending = os.path.join(self._tmp, "pending-retains")
59
+ self._home = os.path.join(self._tmp, "home")
60
+ os.makedirs(self._plugin_root)
61
+ os.makedirs(self._plugin_data)
62
+ os.makedirs(self._home)
63
+
64
+ # Settings — point at a fake URL the test can intercept.
65
+ settings = {
66
+ "autoRecall": False,
67
+ "autoRetain": True,
68
+ "retainEveryNTurns": 1,
69
+ "hindsightApiUrl": "http://fake-host:9077",
70
+ "bankId": "test-bank",
71
+ }
72
+ with open(os.path.join(self._plugin_root, "settings.json"), "w") as f:
73
+ json.dump(settings, f)
74
+
75
+ # Transcript that triggers retain.
76
+ self._transcript = os.path.join(self._tmp, "transcript.jsonl")
77
+ _make_transcript(self._transcript)
78
+
79
+ # Env setup.
80
+ self._env_patcher = patch.dict(
81
+ os.environ,
82
+ {
83
+ "CLAUDE_PLUGIN_ROOT": self._plugin_root,
84
+ "CLAUDE_PLUGIN_DATA": self._plugin_data,
85
+ "HINDSIGHT_PENDING_DIR": self._pending,
86
+ "HOME": self._home,
87
+ },
88
+ clear=False,
89
+ )
90
+ self._env_patcher.start()
91
+ # Strip any host HINDSIGHT_* env that would override settings.json.
92
+ for k in list(os.environ):
93
+ if k.startswith("HINDSIGHT_") and k not in (
94
+ "HINDSIGHT_PENDING_DIR",
95
+ ):
96
+ os.environ.pop(k, None)
97
+
98
+ def tearDown(self):
99
+ self._env_patcher.stop()
100
+ import shutil
101
+
102
+ shutil.rmtree(self._tmp, ignore_errors=True)
103
+
104
+ def _run_session_end(self, urlopen_side_effect):
105
+ """Invoke session_end.main() with the given urlopen side-effect.
106
+
107
+ Returns ``(exit_code, captured_stderr)``.
108
+ """
109
+ hook_input = {
110
+ "session_id": "sess-1",
111
+ "transcript_path": self._transcript,
112
+ "cwd": self._home,
113
+ "reason": "end",
114
+ }
115
+ stdin_data = io.StringIO(json.dumps(hook_input))
116
+ stderr_capture = io.StringIO()
117
+
118
+ # Force a fresh import — module-level state in session_end /
119
+ # retain / lib.* shouldn't leak between tests.
120
+ for mod_name in ("session_end", "retain", "lib.pending", "lib.daemon"):
121
+ sys.modules.pop(mod_name, None)
122
+
123
+ import session_end as session_end_mod # noqa: WPS433
124
+
125
+ with (
126
+ patch("sys.stdin", stdin_data),
127
+ patch("sys.stderr", stderr_capture),
128
+ patch("urllib.request.urlopen", side_effect=urlopen_side_effect),
129
+ # daemon.stop_daemon is a no-op when we never started one,
130
+ # but stub it for hermetic-ness.
131
+ patch("session_end.stop_daemon", return_value=None),
132
+ ):
133
+ exit_code = session_end_mod.main()
134
+ return exit_code, stderr_capture.getvalue()
135
+
136
+ def test_successful_retain_leaves_queue_empty_and_exits_ok(self):
137
+ ok_response = FakeHTTPResponse({"items": [{"id": "abc"}]})
138
+ exit_code, _ = self._run_session_end(lambda *a, **kw: ok_response)
139
+ self.assertEqual(exit_code, 0)
140
+ # Queue should be empty (dir may not even exist)
141
+ if os.path.isdir(self._pending):
142
+ self.assertEqual([], os.listdir(self._pending))
143
+
144
+ def test_retain_http_failure_enqueues_payload_and_exits_nonzero(self):
145
+ # Raise an HTTPError on POST so retain.run_retain catches it
146
+ # and returns status=failed with payload.
147
+ def boom(*a, **kw):
148
+ raise urllib.error.URLError("Connection refused")
149
+
150
+ exit_code, stderr = self._run_session_end(boom)
151
+ # exit 1 == EXIT_QUEUED in session_end.py
152
+ self.assertEqual(exit_code, 1)
153
+ self.assertIn("queued to pending-retains", stderr)
154
+
155
+ # One entry in the queue
156
+ names = [n for n in os.listdir(self._pending) if n.endswith(".json")]
157
+ self.assertEqual(len(names), 1)
158
+
159
+ with open(os.path.join(self._pending, names[0])) as f:
160
+ entry = json.load(f)
161
+ self.assertEqual(entry["bank_id"], "test-bank")
162
+ self.assertEqual(entry["api_url"], "http://fake-host:9077")
163
+ self.assertIn("Once upon a time", entry["content"])
164
+ self.assertEqual(entry["attempt_count"], 1)
165
+ # error_class derives from the original urllib.error.URLError
166
+ self.assertIn("URLError", entry["error_class"])
167
+
168
+ def test_retain_failure_still_runs_daemon_stop(self):
169
+ def boom(*a, **kw):
170
+ raise urllib.error.URLError("nope")
171
+
172
+ # If session_end short-circuits before stop_daemon, this test
173
+ # catches the regression. We assert via a spy.
174
+ hook_input = {
175
+ "session_id": "sess-1",
176
+ "transcript_path": self._transcript,
177
+ "cwd": self._home,
178
+ "reason": "end",
179
+ }
180
+ stdin_data = io.StringIO(json.dumps(hook_input))
181
+ stderr_capture = io.StringIO()
182
+
183
+ for mod_name in ("session_end", "retain", "lib.pending", "lib.daemon"):
184
+ sys.modules.pop(mod_name, None)
185
+
186
+ import session_end as session_end_mod # noqa: WPS433
187
+
188
+ call_log = {"stop_called": 0}
189
+
190
+ def fake_stop(config, debug_fn=None):
191
+ call_log["stop_called"] += 1
192
+
193
+ with (
194
+ patch("sys.stdin", stdin_data),
195
+ patch("sys.stderr", stderr_capture),
196
+ patch("urllib.request.urlopen", side_effect=boom),
197
+ patch("session_end.stop_daemon", side_effect=fake_stop),
198
+ ):
199
+ session_end_mod.main()
200
+
201
+ self.assertEqual(call_log["stop_called"], 1)
202
+
203
+
204
+ if __name__ == "__main__":
205
+ unittest.main()
@@ -0,0 +1,125 @@
1
+ """Unit tests for lib/state.py — retention tracking and compaction detection."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from lib.state import read_state, track_retention, write_state
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def _isolated_state(monkeypatch, tmp_path):
12
+ """Point all state operations at a temp directory."""
13
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # track_retention — core compaction detection
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ class TestTrackRetention:
22
+ def test_first_call_returns_chunk_zero(self):
23
+ chunk, compacted = track_retention("sess-1", 10)
24
+ assert chunk == 0
25
+ assert compacted is False
26
+
27
+ def test_growing_transcript_keeps_same_chunk(self):
28
+ track_retention("sess-1", 4)
29
+ chunk, compacted = track_retention("sess-1", 8)
30
+ assert chunk == 0
31
+ assert compacted is False
32
+
33
+ def test_equal_count_keeps_same_chunk(self):
34
+ track_retention("sess-1", 5)
35
+ chunk, compacted = track_retention("sess-1", 5)
36
+ assert chunk == 0
37
+ assert compacted is False
38
+
39
+ def test_shrinking_transcript_triggers_compaction(self):
40
+ track_retention("sess-1", 10)
41
+ chunk, compacted = track_retention("sess-1", 3)
42
+ assert chunk == 1
43
+ assert compacted is True
44
+
45
+ def test_multiple_compactions_increment_chunk(self):
46
+ track_retention("sess-1", 10)
47
+
48
+ chunk, compacted = track_retention("sess-1", 3)
49
+ assert chunk == 1
50
+ assert compacted is True
51
+
52
+ # Grow again after compaction
53
+ track_retention("sess-1", 8)
54
+
55
+ # Second compaction
56
+ chunk, compacted = track_retention("sess-1", 2)
57
+ assert chunk == 2
58
+ assert compacted is True
59
+
60
+ def test_growth_after_compaction_stays_on_same_chunk(self):
61
+ track_retention("sess-1", 10)
62
+ track_retention("sess-1", 3) # compaction → chunk 1
63
+
64
+ chunk, compacted = track_retention("sess-1", 6)
65
+ assert chunk == 1
66
+ assert compacted is False
67
+
68
+ def test_sessions_are_independent(self):
69
+ track_retention("sess-a", 10)
70
+ track_retention("sess-b", 20)
71
+
72
+ # Compaction on sess-a only
73
+ chunk_a, compacted_a = track_retention("sess-a", 3)
74
+ chunk_b, compacted_b = track_retention("sess-b", 25)
75
+
76
+ assert chunk_a == 1
77
+ assert compacted_a is True
78
+ assert chunk_b == 0
79
+ assert compacted_b is False
80
+
81
+ def test_persists_across_calls(self, tmp_path):
82
+ """State file is written to disk and survives between calls."""
83
+ track_retention("sess-1", 10)
84
+
85
+ # Verify the state file exists
86
+ state_file = tmp_path / "state" / "retention_tracking.json"
87
+ assert state_file.exists()
88
+
89
+ data = json.loads(state_file.read_text())
90
+ assert data["sess-1"]["message_count"] == 10
91
+ assert data["sess-1"]["chunk"] == 0
92
+
93
+ def test_compaction_from_one_message(self):
94
+ """Edge case: transcript shrinks to a single message."""
95
+ track_retention("sess-1", 50)
96
+ chunk, compacted = track_retention("sess-1", 1)
97
+ assert chunk == 1
98
+ assert compacted is True
99
+
100
+ def test_shrink_by_one_triggers_compaction(self):
101
+ """Even shrinking by a single message counts as compaction."""
102
+ track_retention("sess-1", 10)
103
+ chunk, compacted = track_retention("sess-1", 9)
104
+ assert chunk == 1
105
+ assert compacted is True
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # read_state / write_state basics
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ class TestReadWriteState:
114
+ def test_read_nonexistent_returns_default(self):
115
+ assert read_state("does_not_exist.json") is None
116
+ assert read_state("does_not_exist.json", {"key": "val"}) == {"key": "val"}
117
+
118
+ def test_write_then_read_roundtrips(self):
119
+ write_state("test_roundtrip.json", {"foo": 42})
120
+ assert read_state("test_roundtrip.json") == {"foo": 42}
121
+
122
+ def test_write_overwrites_previous(self):
123
+ write_state("test_overwrite.json", {"v": 1})
124
+ write_state("test_overwrite.json", {"v": 2})
125
+ assert read_state("test_overwrite.json") == {"v": 2}