switchroom 0.19.27 → 0.19.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 (44) hide show
  1. package/dist/agent-scheduler/index.js +16 -2
  2. package/dist/auth-broker/index.js +141 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +18 -2
  5. package/dist/cli/switchroom.js +864 -43
  6. package/dist/host-control/main.js +144 -9
  7. package/dist/vault/approvals/kernel-server.js +146 -9
  8. package/dist/vault/broker/server.js +146 -9
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +79 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +585 -50
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/CHANGELOG.md +50 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
  29. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  30. package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
  31. package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
  32. package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
  33. package/vendor/hindsight-memory/scripts/retain.py +41 -1
  34. package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
  36. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  39. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  41. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
  42. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
  43. package/vendor/hindsight-memory/settings.json +1 -1
  44. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,325 @@
1
+ """Switchroom — per-row ``observation_scopes`` plumbing on the retain path.
2
+
3
+ Hindsight stores an ``observation_scopes`` field per retained row.
4
+ ``"shared"`` makes consolidation write that item's observations into ONE
5
+ global untagged scope instead of a scope per tag. The plugin could not send
6
+ the field at all before this change, so a bank could never be pooled.
7
+
8
+ The load-bearing properties, each asserted as an OUTCOME on the wire body or
9
+ on the kwargs a callsite hands ``client.retain()``:
10
+
11
+ 1. **Unset is byte-identical to before.** With no config the key is ABSENT
12
+ from the POST body — not present-and-null — so the engine default stands.
13
+ 2. **Set reaches the wire**, on every part of a split retain.
14
+ 3. **It survives the pending queue.** The scope is carried on the payload,
15
+ so a retain that fails now and drains hours later lands in the same scope
16
+ it would have landed in inline.
17
+ 4. **Old queue entries still drain.** Entries written by a pre-feature build
18
+ are on disk right now and carry no such key; the drain must read them
19
+ with ``.get`` and post ``None``, never raise.
20
+
21
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
22
+ """
23
+
24
+ import contextlib
25
+ import io
26
+ import json
27
+ import os
28
+ import sys
29
+ import unittest
30
+ from unittest import mock
31
+
32
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
33
+ if SCRIPTS_DIR not in sys.path:
34
+ sys.path.insert(0, SCRIPTS_DIR)
35
+
36
+ import drain_pending # noqa: E402
37
+ import retain # noqa: E402
38
+ from lib.client import HindsightClient # noqa: E402
39
+ from lib.config import ( # noqa: E402
40
+ OBSERVATION_SCOPES_VALUES,
41
+ load_config,
42
+ resolve_observation_scopes,
43
+ )
44
+ from lib.retain_split import retain_content_limit # noqa: E402
45
+
46
+
47
+ class _RecordingClient(HindsightClient):
48
+ """Captures the request bodies instead of putting them on a socket."""
49
+
50
+ def __init__(self, *a, **kw):
51
+ super().__init__(*a, **kw)
52
+ self.bodies = []
53
+
54
+ def _request(self, method, path, body=None, timeout=30):
55
+ self.bodies.append(body)
56
+ return {"ok": True}
57
+
58
+
59
+ def _transcript(n_turns: int) -> list:
60
+ out = []
61
+ for i in range(n_turns):
62
+ out.append({"role": "user", "content": f"user turn {i}", "uuid": f"u{i}"})
63
+ out.append({"role": "assistant", "content": f"assistant turn {i}", "uuid": f"a{i}"})
64
+ return out
65
+
66
+
67
+ class WireBody(unittest.TestCase):
68
+ """What actually goes on the wire."""
69
+
70
+ def setUp(self):
71
+ self.client = _RecordingClient("http://hindsight.invalid")
72
+
73
+ def test_unset_omits_the_key_entirely(self):
74
+ self.client.retain("bank", "some transcript", document_id="doc")
75
+ item = self.client.bodies[0]["items"][0]
76
+ # Not `is None` — ABSENT. A null would be a value the engine has to
77
+ # interpret; the pre-feature body simply had no such key.
78
+ self.assertNotIn("observation_scopes", item)
79
+
80
+ def test_explicit_none_omits_the_key_entirely(self):
81
+ # The default config value is None and every callsite forwards it, so
82
+ # the None path is the one the whole fleet takes.
83
+ self.client.retain(
84
+ "bank", "some transcript", document_id="doc", observation_scopes=None
85
+ )
86
+ self.assertNotIn("observation_scopes", self.client.bodies[0]["items"][0])
87
+
88
+ def test_unset_body_is_identical_to_a_pre_feature_body(self):
89
+ self.client.retain(
90
+ "bank", "t", document_id="doc", context="claude-code",
91
+ metadata={"m": "1"}, tags=["x"],
92
+ )
93
+ self.assertEqual(
94
+ self.client.bodies[0],
95
+ {
96
+ "items": [{
97
+ "content": "t",
98
+ "document_id": "doc",
99
+ "metadata": {"m": "1"},
100
+ "context": "claude-code",
101
+ "tags": ["x"],
102
+ }],
103
+ "async": True,
104
+ },
105
+ )
106
+
107
+ def test_set_value_reaches_the_item(self):
108
+ self.client.retain(
109
+ "bank", "some transcript", document_id="doc", observation_scopes="shared"
110
+ )
111
+ self.assertEqual(
112
+ self.client.bodies[0]["items"][0]["observation_scopes"], "shared"
113
+ )
114
+
115
+ def test_every_part_of_a_split_retain_carries_it(self):
116
+ # A split that lands parts in DIFFERENT scopes would silently shard one
117
+ # memory across scopes — the exact drift this pins.
118
+ big = "z" * (retain_content_limit() * 3)
119
+ self.client.retain("bank", big, document_id="doc", observation_scopes="shared")
120
+ self.assertGreater(len(self.client.bodies), 1)
121
+ for body in self.client.bodies:
122
+ self.assertEqual(body["items"][0]["observation_scopes"], "shared")
123
+
124
+
125
+ class ConfigResolution(unittest.TestCase):
126
+ """``observationScopes`` default + the HINDSIGHT_OBSERVATION_SCOPES env."""
127
+
128
+ def test_default_is_none(self):
129
+ with mock.patch.dict(os.environ, {}, clear=True):
130
+ self.assertIsNone(load_config().get("observationScopes"))
131
+
132
+ def test_env_override_sets_it(self):
133
+ with mock.patch.dict(os.environ, {"HINDSIGHT_OBSERVATION_SCOPES": "shared"},
134
+ clear=True):
135
+ self.assertEqual(load_config().get("observationScopes"), "shared")
136
+
137
+
138
+ class PayloadBuild(unittest.TestCase):
139
+ """``build_retain_payload`` is the single producer for every retain path."""
140
+
141
+ _BASE = {"retainRoles": ["user", "assistant"], "retainContext": "claude-code"}
142
+
143
+ def _build(self, config_extra):
144
+ config = dict(self._BASE, **config_extra)
145
+ return retain.build_retain_payload(
146
+ config, "sess", _transcript(2), _transcript(2),
147
+ bank_id="bank", api_url="http://fake", api_token=None,
148
+ )["payload"]
149
+
150
+ def test_payload_carries_none_when_unconfigured(self):
151
+ self.assertIsNone(self._build({})["observation_scopes"])
152
+
153
+ def test_payload_carries_the_configured_scope(self):
154
+ self.assertEqual(
155
+ self._build({"observationScopes": "shared"})["observation_scopes"],
156
+ "shared",
157
+ )
158
+
159
+
160
+ class DrainOfQueuedEntries(unittest.TestCase):
161
+ """``drain_pending._retry_one`` — the durability path, over MIXED entries."""
162
+
163
+ def setUp(self):
164
+ self.calls = []
165
+ outer = self
166
+
167
+ class _Client:
168
+ def __init__(self, *a, **kw):
169
+ pass
170
+
171
+ def retain(self, **kwargs):
172
+ outer.calls.append(kwargs)
173
+ return {"ok": True}
174
+
175
+ self.patch = mock.patch.object(drain_pending, "HindsightClient", _Client)
176
+ self.patch.start()
177
+ self.addCleanup(self.patch.stop)
178
+
179
+ _LEGACY = {
180
+ "api_url": "http://fake",
181
+ "api_token": None,
182
+ "bank_id": "bank",
183
+ "document_id": "doc",
184
+ "content": "old transcript",
185
+ "context": "claude-code",
186
+ "metadata": {},
187
+ "tags": None,
188
+ }
189
+
190
+ def test_entry_written_before_the_feature_drains_with_none(self):
191
+ # These entries are on disk RIGHT NOW. A KeyError here would strand the
192
+ # last on-disk copy of a turn, which is the #3244 silent-loss shape.
193
+ drain_pending._retry_one(dict(self._LEGACY), timeout=15)
194
+ self.assertEqual(self.calls[0]["content"], "old transcript")
195
+ self.assertIsNone(self.calls[0]["observation_scopes"])
196
+
197
+ def test_entry_carrying_a_scope_drains_into_that_scope(self):
198
+ entry = dict(self._LEGACY, observation_scopes="shared")
199
+ drain_pending._retry_one(entry, timeout=15)
200
+ self.assertEqual(self.calls[0]["observation_scopes"], "shared")
201
+
202
+ def test_scope_survives_a_json_round_trip_through_the_queue_file(self):
203
+ # The queue is JSON on disk; the scope must come back out of it.
204
+ entry = json.loads(json.dumps(dict(self._LEGACY, observation_scopes="shared")))
205
+ drain_pending._retry_one(entry, timeout=15)
206
+ self.assertEqual(self.calls[0]["observation_scopes"], "shared")
207
+
208
+
209
+ class ValueValidation(unittest.TestCase):
210
+ """An off-list scope must not reach the wire — and must not cost a memory.
211
+
212
+ The value is invisible after the write: a typo would keep retaining
213
+ happily, the engine would apply its own default scope, and the damage
214
+ (a bank whose observations never merged) surfaces only much later. The
215
+ `memory.observation_scopes` zod enum is the primary gate, but it cannot
216
+ see a hand-edited settings.json or a raw HINDSIGHT_OBSERVATION_SCOPES
217
+ export — which is why this second gate exists here.
218
+
219
+ TWO different obligations, and they are not the same severity:
220
+
221
+ * `resolve_observation_scopes` is the strict VALIDATOR and RAISES. Callers
222
+ that can safely stop (a config check, a hand-run script) use it.
223
+ * the retain path uses the non-raising classifier, drops the bad field and
224
+ shouts. A misconfigured scope is recoverable; a deleted turn is not, and
225
+ raising at the build seam deleted turns — see
226
+ `test_a_typo_never_reaches_the_payload_BUT_the_memory_survives`.
227
+ """
228
+
229
+ _BASE = {"retainRoles": ["user", "assistant"], "retainContext": "claude-code"}
230
+
231
+ def _build(self, config_extra):
232
+ config = dict(self._BASE, **config_extra)
233
+ return retain.build_retain_payload(
234
+ config, "sess", _transcript(2), _transcript(2),
235
+ bank_id="bank", api_url="http://fake", api_token=None,
236
+ )["payload"]
237
+
238
+ def test_every_accepted_value_resolves_to_itself(self):
239
+ for value in OBSERVATION_SCOPES_VALUES:
240
+ with self.subTest(value=value):
241
+ self.assertEqual(
242
+ resolve_observation_scopes({"observationScopes": value}), value
243
+ )
244
+
245
+ def test_unset_and_empty_resolve_to_none(self):
246
+ # Empty is UNSET, not a typo — matches the plugin's "an empty export
247
+ # hands authority back to the config file" idiom.
248
+ self.assertIsNone(resolve_observation_scopes({}))
249
+ self.assertIsNone(resolve_observation_scopes({"observationScopes": None}))
250
+ self.assertIsNone(resolve_observation_scopes({"observationScopes": ""}))
251
+ self.assertIsNone(resolve_observation_scopes({"observationScopes": " "}))
252
+
253
+ def test_typo_raises_and_names_the_accepted_set(self):
254
+ with self.assertRaises(ValueError) as ctx:
255
+ resolve_observation_scopes({"observationScopes": "shred"})
256
+ msg = str(ctx.exception)
257
+ self.assertIn("shred", msg)
258
+ for value in OBSERVATION_SCOPES_VALUES:
259
+ self.assertIn(value, msg)
260
+
261
+ def test_wrong_case_raises(self):
262
+ with self.assertRaises(ValueError):
263
+ resolve_observation_scopes({"observationScopes": "Shared"})
264
+
265
+ def test_non_string_raises(self):
266
+ with self.assertRaises(ValueError):
267
+ resolve_observation_scopes({"observationScopes": ["shared"]})
268
+
269
+ def test_a_typo_never_reaches_the_payload_BUT_the_memory_survives(self):
270
+ # Two outcomes, and the second is the load-bearing one.
271
+ #
272
+ # (a) The bad value does not ride to the wire — `observation_scopes` is
273
+ # None, so `HindsightClient._retain_one` omits the key and the
274
+ # engine's own default scope stands. That is the pre-feature
275
+ # behaviour, and it is a *recoverable* misconfiguration.
276
+ #
277
+ # (b) The PAYLOAD IS STILL BUILT. `build_retain_payload` raising here
278
+ # was a far worse bug than the one it fixed: the raise unwound past
279
+ # `retain.main`'s `pending_enqueue`, so the turn was never POSTed,
280
+ # never queued and never re-derivable — permanent silent memory
281
+ # loss for as long as the typo sat in the config. A config typo
282
+ # must never be able to delete a memory. End-to-end coverage of
283
+ # the same guarantee lives in
284
+ # tests/test_reconcile_durability.py::TestObservationScopes.
285
+ payload = self._build({"observationScopes": "shred"})
286
+ self.assertIsNone(payload["observation_scopes"])
287
+ self.assertIn("user turn 0", payload["content"])
288
+ self.assertTrue(payload["document_id"])
289
+
290
+ def test_a_typo_never_reaches_the_wire(self):
291
+ client = _RecordingClient("http://fake")
292
+ payload = self._build({"observationScopes": "per-tag"}) # hyphen, not underscore
293
+ client.retain(
294
+ payload["bank_id"],
295
+ payload["content"],
296
+ document_id=payload["document_id"],
297
+ observation_scopes=payload["observation_scopes"],
298
+ )
299
+ self.assertEqual(len(client.bodies), 1)
300
+ # ABSENT, not null: the request body is the pre-feature one.
301
+ self.assertNotIn("observation_scopes", client.bodies[0]["items"][0])
302
+
303
+ def test_a_typo_is_shouted_about_rather_than_swallowed(self):
304
+ # A silent downgrade to the engine default is the ORIGINAL defect this
305
+ # feature exists to prevent. Degrading quietly would just reintroduce
306
+ # it, so the build seam must say so on stderr every time it fires.
307
+ err = io.StringIO()
308
+ with contextlib.redirect_stderr(err):
309
+ self._build({"observationScopes": "shred"})
310
+ msg = err.getvalue()
311
+ self.assertIn("shred", msg)
312
+ self.assertIn("observation_scopes", msg)
313
+ for value in OBSERVATION_SCOPES_VALUES:
314
+ self.assertIn(value, msg)
315
+
316
+ def test_env_var_typo_is_caught_too(self):
317
+ # The env path bypasses zod entirely, so this is the only gate on it.
318
+ with mock.patch.dict(os.environ, {"HINDSIGHT_OBSERVATION_SCOPES": "shred"},
319
+ clear=True):
320
+ with self.assertRaises(ValueError):
321
+ resolve_observation_scopes(load_config())
322
+
323
+
324
+ if __name__ == "__main__": # pragma: no cover
325
+ unittest.main()