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,211 @@
1
+ """Tests for lib/directives.py (active-directives fetch + format).
2
+
3
+ Stdlib-only (unittest) — matches the rest of the hindsight-memory scripts,
4
+ which deliberately avoid third-party dependencies so the hooks run on a
5
+ bare Python install.
6
+
7
+ Run from the repo root:
8
+ python -m unittest discover -s vendor/hindsight-memory/scripts/tests -v
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ import unittest
14
+ from io import StringIO
15
+ from unittest.mock import patch
16
+
17
+ # Ensure the scripts dir is importable so `lib.*` resolves.
18
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
19
+ if SCRIPTS_DIR not in sys.path:
20
+ sys.path.insert(0, SCRIPTS_DIR)
21
+
22
+ from lib.directives import ( # noqa: E402
23
+ MAX_DIRECTIVES,
24
+ fetch_active_directives,
25
+ format_active_directives_block,
26
+ )
27
+
28
+
29
+ class _StubClient:
30
+ """Minimal HindsightClient stand-in for tests.
31
+
32
+ Captures the args list_directives was called with and returns either
33
+ a canned response or raises a configured exception.
34
+ """
35
+
36
+ def __init__(self, response=None, exc=None):
37
+ self._response = response
38
+ self._exc = exc
39
+ self.calls = []
40
+
41
+ def list_directives(self, bank_id, active_only=True, timeout=2):
42
+ self.calls.append({"bank_id": bank_id, "active_only": active_only, "timeout": timeout})
43
+ if self._exc is not None:
44
+ raise self._exc
45
+ return self._response
46
+
47
+
48
+ def _directive(name, content, priority=5, tags=None):
49
+ """Build a synthetic directive dict matching the API shape."""
50
+ return {
51
+ "id": f"id-{name}",
52
+ "bank_id": "test-bank",
53
+ "name": name,
54
+ "content": content,
55
+ "priority": priority,
56
+ "is_active": True,
57
+ "tags": tags or [],
58
+ "created_at": "2026-01-01T00:00:00+00:00",
59
+ "updated_at": "2026-01-01T00:00:00+00:00",
60
+ }
61
+
62
+
63
+ class FetchActiveDirectivesTests(unittest.TestCase):
64
+ def test_returns_priority_sorted_descending(self):
65
+ client = _StubClient(
66
+ response={
67
+ "items": [
68
+ _directive("low", "low content", priority=1),
69
+ _directive("high", "high content", priority=10),
70
+ _directive("mid", "mid content", priority=5),
71
+ ]
72
+ }
73
+ )
74
+ result = fetch_active_directives(client, "test-bank")
75
+ self.assertEqual([d["name"] for d in result], ["high", "mid", "low"])
76
+
77
+ def test_passes_active_only_true(self):
78
+ client = _StubClient(response={"items": []})
79
+ fetch_active_directives(client, "test-bank")
80
+ self.assertEqual(len(client.calls), 1)
81
+ self.assertTrue(client.calls[0]["active_only"])
82
+ self.assertEqual(client.calls[0]["bank_id"], "test-bank")
83
+
84
+ def test_empty_items_returns_empty_list(self):
85
+ client = _StubClient(response={"items": []})
86
+ self.assertEqual(fetch_active_directives(client, "test-bank"), [])
87
+
88
+ def test_http_failure_returns_empty_and_warns(self):
89
+ client = _StubClient(exc=RuntimeError("HTTP 503 from /directives"))
90
+ with patch("sys.stderr", new=StringIO()) as fake_err:
91
+ result = fetch_active_directives(client, "test-bank")
92
+ self.assertEqual(result, [])
93
+ err_output = fake_err.getvalue()
94
+ self.assertIn("list_directives failed", err_output)
95
+ self.assertIn("test-bank", err_output)
96
+
97
+ def test_timeout_exception_returns_empty_no_raise(self):
98
+ client = _StubClient(exc=TimeoutError("timed out"))
99
+ with patch("sys.stderr", new=StringIO()):
100
+ # Must not raise.
101
+ result = fetch_active_directives(client, "test-bank")
102
+ self.assertEqual(result, [])
103
+
104
+ def test_non_dict_response_returns_empty_and_warns(self):
105
+ client = _StubClient(response=["not", "a", "dict"])
106
+ with patch("sys.stderr", new=StringIO()) as fake_err:
107
+ result = fetch_active_directives(client, "test-bank")
108
+ self.assertEqual(result, [])
109
+ self.assertIn("non-dict", fake_err.getvalue())
110
+
111
+ def test_missing_items_key_returns_empty_quietly(self):
112
+ # Banks-with-no-directives is a normal state, not a warn-worthy event.
113
+ client = _StubClient(response={"unrelated": "shape"})
114
+ with patch("sys.stderr", new=StringIO()) as fake_err:
115
+ result = fetch_active_directives(client, "test-bank")
116
+ self.assertEqual(result, [])
117
+ self.assertEqual(fake_err.getvalue(), "")
118
+
119
+ def test_malformed_directive_entries_filtered(self):
120
+ client = _StubClient(
121
+ response={
122
+ "items": [
123
+ _directive("ok", "real content", priority=5),
124
+ "not-a-dict",
125
+ None,
126
+ ]
127
+ }
128
+ )
129
+ result = fetch_active_directives(client, "test-bank")
130
+ self.assertEqual(len(result), 1)
131
+ self.assertEqual(result[0]["name"], "ok")
132
+
133
+ def test_missing_priority_treated_as_zero(self):
134
+ client = _StubClient(
135
+ response={
136
+ "items": [
137
+ _directive("has-priority", "content", priority=3),
138
+ {"name": "no-priority", "content": "content"},
139
+ ]
140
+ }
141
+ )
142
+ result = fetch_active_directives(client, "test-bank")
143
+ self.assertEqual([d["name"] for d in result], ["has-priority", "no-priority"])
144
+
145
+ def test_uses_short_timeout(self):
146
+ # The recall hook is on the UserPromptSubmit critical path —
147
+ # directive fetch must not block it.
148
+ client = _StubClient(response={"items": []})
149
+ fetch_active_directives(client, "test-bank")
150
+ self.assertLessEqual(client.calls[0]["timeout"], 5)
151
+
152
+
153
+ class FormatActiveDirectivesBlockTests(unittest.TestCase):
154
+ def test_returns_none_for_empty_list(self):
155
+ self.assertIsNone(format_active_directives_block([]))
156
+
157
+ def test_formats_multiple_directives(self):
158
+ directives = [
159
+ _directive("trailer", "End every response with: [VERIFIED]", priority=10),
160
+ _directive("greeting", "Open with the user's first name.", priority=8),
161
+ ]
162
+ out = format_active_directives_block(directives)
163
+ self.assertIsNotNone(out)
164
+ self.assertTrue(out.startswith("<active_directives>"))
165
+ self.assertTrue(out.endswith("</active_directives>"))
166
+ self.assertIn("HARD RULES", out)
167
+ self.assertIn("1. [P10] trailer: End every response with: [VERIFIED]", out)
168
+ self.assertIn("2. [P8] greeting: Open with the user's first name.", out)
169
+
170
+ def test_content_is_verbatim(self):
171
+ directives = [_directive("verbatim", "Line one.\nLine two.\nLine three.", priority=5)]
172
+ out = format_active_directives_block(directives)
173
+ self.assertIn("Line one.\nLine two.\nLine three.", out)
174
+
175
+ def test_truncates_at_cap_with_footer(self):
176
+ # 20 synthetic directives — should truncate to MAX_DIRECTIVES with
177
+ # a "(+N more, omitted)" footer.
178
+ directives = [
179
+ _directive(f"d{i}", f"content {i}", priority=20 - i) for i in range(20)
180
+ ]
181
+ out = format_active_directives_block(directives)
182
+ # Cap should be 15 by default.
183
+ self.assertEqual(MAX_DIRECTIVES, 15)
184
+ self.assertIn("1. [P20] d0", out)
185
+ self.assertIn(f"{MAX_DIRECTIVES}. [P", out)
186
+ # 16th item should NOT appear.
187
+ self.assertNotIn(f"{MAX_DIRECTIVES + 1}. [P", out)
188
+ # Footer with the right omitted count.
189
+ self.assertIn(f"(+{20 - MAX_DIRECTIVES} more, omitted)", out)
190
+
191
+ def test_no_footer_when_under_cap(self):
192
+ directives = [_directive("only", "single", priority=5)]
193
+ out = format_active_directives_block(directives)
194
+ self.assertNotIn("more, omitted", out)
195
+
196
+ def test_handles_missing_name_and_content(self):
197
+ directives = [{"priority": 7}]
198
+ out = format_active_directives_block(directives)
199
+ self.assertIn("[P7] (unnamed):", out)
200
+
201
+ def test_custom_cap_respected(self):
202
+ directives = [_directive(f"d{i}", f"c{i}", priority=10) for i in range(5)]
203
+ out = format_active_directives_block(directives, max_directives=2)
204
+ self.assertIn("1. [P10] d0", out)
205
+ self.assertIn("2. [P10] d1", out)
206
+ self.assertNotIn("3. [P10] d2", out)
207
+ self.assertIn("(+3 more, omitted)", out)
208
+
209
+
210
+ if __name__ == "__main__":
211
+ unittest.main()
@@ -0,0 +1,205 @@
1
+ """Unit tests for lib/gateway_ipc.py.
2
+
3
+ Covers:
4
+ - chat_id extraction from <channel ...> wrapper (various attribute orders,
5
+ quote styles, no-channel prompts).
6
+ - socket-path resolution (env override + CLAUDE_PLUGIN_DATA fallback).
7
+ - update_placeholder happy path (one JSON line written, valid shape).
8
+ - update_placeholder failure paths (no socket, refused, timeout) all
9
+ silent — return False but never raise.
10
+
11
+ Stdlib-only.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import socket
17
+ import tempfile
18
+ import threading
19
+ import unittest
20
+
21
+ import sys
22
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
23
+ if SCRIPTS_DIR not in sys.path:
24
+ sys.path.insert(0, SCRIPTS_DIR)
25
+
26
+ from lib.gateway_ipc import ( # noqa: E402
27
+ extract_chat_id_from_prompt,
28
+ gateway_socket_path,
29
+ update_placeholder,
30
+ )
31
+
32
+
33
+ class ExtractChatIdTests(unittest.TestCase):
34
+ def test_double_quoted_attribute(self):
35
+ prompt = '<channel source="switchroom-telegram" chat_id="12345" thread_id="-">\nhi\n</channel>'
36
+ self.assertEqual(extract_chat_id_from_prompt(prompt), "12345")
37
+
38
+ def test_single_quoted_attribute(self):
39
+ prompt = "<channel source='switchroom-telegram' chat_id='12345' user_id='99'>\nhi\n</channel>"
40
+ self.assertEqual(extract_chat_id_from_prompt(prompt), "12345")
41
+
42
+ def test_negative_group_chat_id(self):
43
+ prompt = '<channel source="switchroom-telegram" chat_id="-1001234567890">\nhi\n</channel>'
44
+ self.assertEqual(extract_chat_id_from_prompt(prompt), "-1001234567890")
45
+
46
+ def test_attribute_order_doesnt_matter(self):
47
+ prompt = '<channel chat_id="999" source="switchroom-telegram" user="x">\nhi\n</channel>'
48
+ self.assertEqual(extract_chat_id_from_prompt(prompt), "999")
49
+
50
+ def test_no_channel_wrapper_returns_none(self):
51
+ self.assertIsNone(extract_chat_id_from_prompt("plain user prompt"))
52
+
53
+ def test_channel_without_chat_id_returns_none(self):
54
+ prompt = '<channel source="x" user_id="1">hi</channel>'
55
+ self.assertIsNone(extract_chat_id_from_prompt(prompt))
56
+
57
+ def test_empty_chat_id_returns_none(self):
58
+ prompt = '<channel chat_id="">hi</channel>'
59
+ self.assertIsNone(extract_chat_id_from_prompt(prompt))
60
+
61
+ def test_non_string_input(self):
62
+ self.assertIsNone(extract_chat_id_from_prompt(None)) # type: ignore[arg-type]
63
+ self.assertIsNone(extract_chat_id_from_prompt(""))
64
+ self.assertIsNone(extract_chat_id_from_prompt(12345)) # type: ignore[arg-type]
65
+
66
+ def test_only_inspects_first_kb(self):
67
+ # Pad with content BEFORE the channel wrapper; the regex shouldn't
68
+ # find it because we only inspect the first 1 KB.
69
+ prompt = ("x" * 2000) + '<channel chat_id="111">hi</channel>'
70
+ self.assertIsNone(extract_chat_id_from_prompt(prompt))
71
+
72
+
73
+ class GatewaySocketPathTests(unittest.TestCase):
74
+ def setUp(self):
75
+ self._saved = {
76
+ "SWITCHROOM_GATEWAY_SOCKET": os.environ.get("SWITCHROOM_GATEWAY_SOCKET"),
77
+ "CLAUDE_PLUGIN_DATA": os.environ.get("CLAUDE_PLUGIN_DATA"),
78
+ }
79
+ # Always start clean.
80
+ os.environ.pop("SWITCHROOM_GATEWAY_SOCKET", None)
81
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
82
+
83
+ def tearDown(self):
84
+ for k, v in self._saved.items():
85
+ if v is None:
86
+ os.environ.pop(k, None)
87
+ else:
88
+ os.environ[k] = v
89
+
90
+ def test_explicit_env_override_wins(self):
91
+ os.environ["SWITCHROOM_GATEWAY_SOCKET"] = "/tmp/explicit.sock"
92
+ self.assertEqual(gateway_socket_path(), "/tmp/explicit.sock")
93
+
94
+ def test_env_override_with_only_whitespace_falls_through(self):
95
+ os.environ["SWITCHROOM_GATEWAY_SOCKET"] = " "
96
+ # No CLAUDE_PLUGIN_DATA set → returns None.
97
+ self.assertIsNone(gateway_socket_path())
98
+
99
+ def test_resolves_from_plugin_data_when_socket_exists(self):
100
+ with tempfile.TemporaryDirectory() as tmp:
101
+ agent_dir = os.path.join(tmp, "myagent")
102
+ plugin_data = os.path.join(
103
+ agent_dir, ".claude", "plugins", "data", "hindsight-memory-inline"
104
+ )
105
+ os.makedirs(plugin_data, exist_ok=True)
106
+ tg_dir = os.path.join(agent_dir, "telegram")
107
+ os.makedirs(tg_dir, exist_ok=True)
108
+ sock_path = os.path.join(tg_dir, "gateway.sock")
109
+ # Create a sentinel file so existence check passes.
110
+ open(sock_path, "w").close()
111
+
112
+ os.environ["CLAUDE_PLUGIN_DATA"] = plugin_data
113
+ self.assertEqual(gateway_socket_path(), sock_path)
114
+
115
+ def test_returns_none_when_socket_does_not_exist(self):
116
+ with tempfile.TemporaryDirectory() as tmp:
117
+ plugin_data = os.path.join(
118
+ tmp, "agent", ".claude", "plugins", "data", "hindsight-memory-inline"
119
+ )
120
+ os.makedirs(plugin_data, exist_ok=True)
121
+ os.environ["CLAUDE_PLUGIN_DATA"] = plugin_data
122
+ self.assertIsNone(gateway_socket_path())
123
+
124
+ def test_no_env_no_path(self):
125
+ self.assertIsNone(gateway_socket_path())
126
+
127
+
128
+ class UpdatePlaceholderHappyPathTests(unittest.TestCase):
129
+ """Spin up a real unix socket server, send a placeholder update,
130
+ assert the message we sent matches the wire protocol contract."""
131
+
132
+ def test_writes_one_json_line_with_correct_shape(self):
133
+ with tempfile.TemporaryDirectory() as tmp:
134
+ sock_path = os.path.join(tmp, "test.sock")
135
+ received: list[bytes] = []
136
+ ready = threading.Event()
137
+
138
+ def server():
139
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
140
+ srv.bind(sock_path)
141
+ srv.listen(1)
142
+ ready.set()
143
+ conn, _ = srv.accept()
144
+ conn.settimeout(1.0)
145
+ try:
146
+ chunk = conn.recv(4096)
147
+ if chunk:
148
+ received.append(chunk)
149
+ finally:
150
+ conn.close()
151
+ srv.close()
152
+
153
+ t = threading.Thread(target=server, daemon=True)
154
+ t.start()
155
+ ready.wait(timeout=1.0)
156
+
157
+ ok = update_placeholder(
158
+ "12345",
159
+ "📚 recalling memories…",
160
+ socket_path=sock_path,
161
+ )
162
+ t.join(timeout=1.0)
163
+
164
+ self.assertTrue(ok)
165
+ self.assertEqual(len(received), 1)
166
+ line = received[0].decode("utf-8")
167
+ self.assertTrue(line.endswith("\n"))
168
+ payload = json.loads(line)
169
+ self.assertEqual(payload["type"], "update_placeholder")
170
+ self.assertEqual(payload["chatId"], "12345")
171
+ self.assertEqual(payload["text"], "📚 recalling memories…")
172
+
173
+
174
+ class UpdatePlaceholderFailureTests(unittest.TestCase):
175
+ """Every failure path returns False — never raises."""
176
+
177
+ def test_no_socket_path_returns_false(self):
178
+ # No socket_path arg, no env override, no plugin data → resolves None.
179
+ prev = os.environ.pop("SWITCHROOM_GATEWAY_SOCKET", None)
180
+ prev_data = os.environ.pop("CLAUDE_PLUGIN_DATA", None)
181
+ try:
182
+ self.assertFalse(update_placeholder("123", "x"))
183
+ finally:
184
+ if prev is not None:
185
+ os.environ["SWITCHROOM_GATEWAY_SOCKET"] = prev
186
+ if prev_data is not None:
187
+ os.environ["CLAUDE_PLUGIN_DATA"] = prev_data
188
+
189
+ def test_socket_does_not_exist_returns_false(self):
190
+ # Path is provided but the socket file isn't there.
191
+ self.assertFalse(update_placeholder("123", "x", socket_path="/nonexistent/sock"))
192
+
193
+ def test_empty_chat_id_returns_false(self):
194
+ self.assertFalse(update_placeholder("", "x", socket_path="/tmp/whatever"))
195
+
196
+ def test_empty_text_returns_false(self):
197
+ self.assertFalse(update_placeholder("123", "", socket_path="/tmp/whatever"))
198
+
199
+ def test_non_string_inputs_return_false(self):
200
+ self.assertFalse(update_placeholder(None, "x", socket_path="/tmp/x")) # type: ignore[arg-type]
201
+ self.assertFalse(update_placeholder("123", None, socket_path="/tmp/x")) # type: ignore[arg-type]
202
+
203
+
204
+ if __name__ == "__main__":
205
+ unittest.main()