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.
- package/dist/cli/switchroom.js +4 -2
- package/package.json +2 -1
- package/telegram-plugin/dist/gateway/gateway.js +49 -5
- package/telegram-plugin/gateway/gateway.ts +5 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -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,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}
|