switchroom 0.19.23 → 0.19.25

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 (52) hide show
  1. package/dist/agent-scheduler/index.js +18 -7
  2. package/dist/auth-broker/index.js +117 -33
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +18 -6
  7. package/dist/cli/switchroom.js +2916 -1481
  8. package/dist/host-control/main.js +116 -34
  9. package/dist/vault/approvals/kernel-server.js +115 -33
  10. package/dist/vault/broker/server.js +281 -76
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/start.sh.hbs +52 -13
  14. package/profiles/_shared/dev-protocol.md.hbs +3 -4
  15. package/skills/dev-protocol/SKILL.md +22 -15
  16. package/skills/switchroom-health/SKILL.md +19 -0
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/skills/switchroom-status/SKILL.md +1 -1
  19. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  20. package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
  21. package/telegram-plugin/gateway/gateway.ts +34 -35
  22. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  23. package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
  24. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  25. package/telegram-plugin/gateway/turn-end.ts +1 -1
  26. package/telegram-plugin/quota-bar-format.ts +4 -1
  27. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  28. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  29. package/telegram-plugin/send-gate.ts +185 -24
  30. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  31. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  32. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  34. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  36. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  37. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  38. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  39. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  41. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  42. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  43. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  44. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  45. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  52. package/vendor/hindsight-memory/tests/test_content.py +218 -0
@@ -0,0 +1,147 @@
1
+ """Switchroom structural fix #7 — the `error` STRING on a recall_log row.
2
+
3
+ Every failure channel on a `recall_log.jsonl` row used to be a boolean:
4
+ `deadline_hit`, `bank_errored`, and per-bank `timed_out` / `errored`. They say
5
+ THAT something broke and never WHICH thing, so a log full of `errored: true`
6
+ cannot distinguish a connection refused from a 500 from a bad bank id from an
7
+ auth failure — four different fixes, one indistinguishable row.
8
+
9
+ That gap is not cosmetic here. `recall.py` deliberately exits 0 on a bank
10
+ failure (blocking the user's prompt would be worse) and Claude Code swallows
11
+ hook stderr on a zero exit, so the `[Hindsight] Recall failed: …` line that
12
+ carries the reason reaches nobody. The JSONL row is the ONLY place the reason
13
+ can survive, and until now it did not carry one.
14
+
15
+ These tests pin the behaviour the watchdog and an operator's `jq` depend on:
16
+
17
+ * `error_text` is type-PREFIXED, because the message alone is frequently
18
+ generic or empty and the exception class is the diagnostic part.
19
+ * It is BOUNDED, because rows are trimmed by line count and one
20
+ traceback-shaped message would evict real history.
21
+ * It collapses whitespace to one line, so the JSONL stays greppable.
22
+ * `recall_error_summary` reports the OWN bank's failure in preference to a
23
+ side bank's — same rule as `degraded_recall_notice`, because a shared
24
+ profile bank timing out does not mean the agent lost its memory.
25
+ * A side-bank failure is NAMED, never reported as if it were the own bank's.
26
+ * A healthy row gets `None`, not `""` — an empty string would count as a
27
+ failure under a truthiness test somewhere downstream.
28
+
29
+ Stdlib-only, no network, no server.
30
+ """
31
+
32
+ import os
33
+ import sys
34
+ import unittest
35
+
36
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
37
+ if SCRIPTS_DIR not in sys.path:
38
+ sys.path.insert(0, SCRIPTS_DIR)
39
+
40
+ from recall import ( # noqa: E402
41
+ ERROR_TEXT_MAX_CHARS,
42
+ error_text,
43
+ recall_error_summary,
44
+ )
45
+
46
+ OWN = "overlord"
47
+ SIDE = "ken-profile"
48
+
49
+
50
+ def timing(bank_id, *, timed_out=False, errored=False, error=None, elapsed_ms=120):
51
+ """A `bank_timings` entry in the shape recall.py now appends."""
52
+ return {
53
+ "bank_id": bank_id,
54
+ "elapsed_ms": elapsed_ms,
55
+ "timed_out": timed_out,
56
+ "errored": errored,
57
+ "error": error,
58
+ }
59
+
60
+
61
+ class ErrorTextTests(unittest.TestCase):
62
+ def test_prefixes_the_exception_type(self):
63
+ # The observed fleet failure renders as its class plus its message;
64
+ # `TimeoutError` alone is what tells an operator this is the 8s bank
65
+ # deadline rather than a 500.
66
+ self.assertEqual(
67
+ error_text(TimeoutError("read timed out")),
68
+ "TimeoutError: read timed out",
69
+ )
70
+
71
+ def test_survives_an_exception_with_no_message(self):
72
+ # The class is the ONLY information here — dropping it would log an
73
+ # empty string and lose the row entirely.
74
+ self.assertEqual(error_text(ConnectionRefusedError()), "ConnectionRefusedError:")
75
+
76
+ def test_passes_a_plain_string_through(self):
77
+ self.assertEqual(error_text("bank not found"), "bank not found")
78
+
79
+ def test_none_for_no_failure(self):
80
+ self.assertIsNone(error_text(None))
81
+
82
+ def test_empty_string_becomes_none_not_empty(self):
83
+ # "" would be falsey but PRESENT, which reads as a failure with no
84
+ # reason to anything that tests for the key.
85
+ self.assertIsNone(error_text(""))
86
+ self.assertIsNone(error_text(" \n "))
87
+
88
+ def test_collapses_newlines_so_the_jsonl_stays_greppable(self):
89
+ self.assertEqual(error_text("line one\n line two\ttab"), "line one line two tab")
90
+
91
+ def test_bounded(self):
92
+ got = error_text("x" * 5000)
93
+ self.assertEqual(len(got), ERROR_TEXT_MAX_CHARS)
94
+
95
+
96
+ class RecallErrorSummaryTests(unittest.TestCase):
97
+ def test_none_when_every_bank_answered(self):
98
+ self.assertIsNone(
99
+ recall_error_summary(OWN, [timing(OWN), timing(SIDE)], directives_timed_out=False)
100
+ )
101
+
102
+ def test_reports_the_own_bank_failure(self):
103
+ got = recall_error_summary(
104
+ OWN,
105
+ [timing(OWN, timed_out=True, error="TimeoutError: read timed out"), timing(SIDE)],
106
+ )
107
+ self.assertEqual(got, "TimeoutError: read timed out")
108
+
109
+ def test_prefers_the_own_bank_over_a_simultaneous_side_failure(self):
110
+ got = recall_error_summary(
111
+ OWN,
112
+ [
113
+ timing(SIDE, errored=True, error="HTTPError: 500"),
114
+ timing(OWN, timed_out=True, error="TimeoutError: own"),
115
+ ],
116
+ )
117
+ self.assertEqual(got, "TimeoutError: own")
118
+
119
+ def test_matches_the_own_bank_by_id_not_by_position(self):
120
+ # Fan-out completion order is not stable; a positional match would
121
+ # attribute the side bank's failure to the agent's own memory.
122
+ got = recall_error_summary(
123
+ OWN, [timing(SIDE, errored=True, error="HTTPError: 500"), timing(OWN)]
124
+ )
125
+ self.assertIn(SIDE, got)
126
+ self.assertIn("additional bank", got)
127
+
128
+ def test_reports_directive_timeout_when_no_bank_failed(self):
129
+ got = recall_error_summary(OWN, [timing(OWN), timing(SIDE)], directives_timed_out=True)
130
+ self.assertEqual(got, "directives fetch timed out")
131
+
132
+ def test_tolerates_an_empty_or_missing_timing_list(self):
133
+ self.assertIsNone(recall_error_summary(OWN, []))
134
+ self.assertIsNone(recall_error_summary(OWN, None))
135
+
136
+ def test_tolerates_legacy_rows_without_the_error_key(self):
137
+ # Rows written before this field shipped are still in the append-only
138
+ # log and must not raise here.
139
+ legacy = {"bank_id": OWN, "elapsed_ms": 8017, "timed_out": True, "errored": False}
140
+ self.assertIsNone(recall_error_summary(OWN, [legacy]))
141
+
142
+ def test_ignores_non_dict_entries(self):
143
+ self.assertIsNone(recall_error_summary(OWN, ["garbage", None]))
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()
@@ -0,0 +1,266 @@
1
+ """Switchroom #3760 review, Major 4 — the recall hook must never spend past its
2
+ UserPromptSubmit ceiling.
3
+
4
+ #3757 inverted the transcript-fallback gate: a per-bank TIMEOUT no longer
5
+ suppresses the bounded transcript grep, because timing out was the common case
6
+ and suppressing on it left the agent with neither memories nor fallback. That
7
+ gate was also, incidentally, the thing protecting the hook ceiling. Without it
8
+ the arithmetic was: 10s shared recall deadline + a flat 1.5s grep = 11.5s
9
+ against a 12s ceiling, leaving ~0.5s for interpreter startup, config load, the
10
+ transcript read, output formatting and the log write — all of which sit outside
11
+ ``recall_start_monotonic``. An overrun is not a degraded recall: Claude Code
12
+ kills the hook and the turn loses memories, the fallback AND the directives,
13
+ which is strictly worse than the bug #3757 fixes.
14
+
15
+ The serial rollback path had the mirror problem — no outer deadline at all, so
16
+ raising the per-bank timeout 8s -> 12s let two banks spend 24s.
17
+
18
+ Acceptance guarantees (outcomes, not code paths):
19
+
20
+ 1. **The ceiling constant cannot drift from the shipped hook config.**
21
+ 2. **The transcript fallback spends at most what is left**, and declines
22
+ entirely rather than gambling the hook when nothing is left.
23
+ 3. **The serial path clamps each bank to the remaining budget**, so N banks
24
+ can no longer sum past the ceiling.
25
+
26
+ Stdlib-only (unittest).
27
+ """
28
+
29
+ import builtins
30
+ import io
31
+ import json
32
+ import os
33
+ import shutil
34
+ import sys
35
+ import tempfile
36
+ import time
37
+ import unittest
38
+ from unittest.mock import patch
39
+
40
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
41
+ if SCRIPTS_DIR not in sys.path:
42
+ sys.path.insert(0, SCRIPTS_DIR)
43
+
44
+ import recall # noqa: E402
45
+
46
+ PLUGIN_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, ".."))
47
+
48
+
49
+ class CeilingMatchesTheShippedHookConfig(unittest.TestCase):
50
+ def test_hook_ceiling_matches_hooks_json(self):
51
+ """`HOOK_CEILING_SECONDS` mirrors a value that lives in another file in
52
+ this same package. Nothing at runtime reconciles them, so this test is
53
+ the reconciliation: if someone retunes the UserPromptSubmit timeout, the
54
+ budget arithmetic must move with it or every clamp here is wrong."""
55
+ with open(os.path.join(PLUGIN_ROOT, "hooks", "hooks.json"), encoding="utf-8") as f:
56
+ hooks = json.load(f)["hooks"]
57
+ timeouts = [
58
+ hook["timeout"]
59
+ for entry in hooks["UserPromptSubmit"]
60
+ for hook in entry["hooks"]
61
+ if "recall.py" in hook.get("command", "")
62
+ ]
63
+ self.assertEqual(len(timeouts), 1, "expected exactly one recall.py UserPromptSubmit hook")
64
+ self.assertEqual(float(timeouts[0]), recall.HOOK_CEILING_SECONDS)
65
+
66
+ def test_reserve_leaves_the_shared_deadline_room(self):
67
+ """The shared recall deadline plus the tail reserve must fit inside the
68
+ ceiling, or a fully-elapsed recall breaches it before the fallback is
69
+ even considered."""
70
+ from lib.config import DEFAULTS
71
+
72
+ self.assertLess(
73
+ float(DEFAULTS["recallParallelDeadlineSeconds"]) + recall.HOOK_TAIL_RESERVE_SECONDS,
74
+ recall.HOOK_CEILING_SECONDS,
75
+ )
76
+
77
+
78
+ def _transcript(tmpdir, turns):
79
+ path = os.path.join(tmpdir, "transcript.jsonl")
80
+ with open(path, "w", encoding="utf-8") as f:
81
+ for i, (role, text) in enumerate(turns):
82
+ f.write(json.dumps({
83
+ "type": role,
84
+ "uuid": f"u-{i}",
85
+ "message": {"role": role, "content": text},
86
+ }) + "\n")
87
+ return path
88
+
89
+
90
+ class FallbackSpendsOnlyWhatIsLeft(unittest.TestCase):
91
+ """The flat 1.5s bound is now ``min(configured, remaining hook budget)``."""
92
+
93
+ def setUp(self):
94
+ recall._begin_hook_budget()
95
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-budget-test-")
96
+ self._path = _transcript(self._tmpdir, [
97
+ ("user", "the nginx TLS cert on the webkite container expired"),
98
+ ("assistant", "I renewed the nginx TLS cert and redeployed webkite"),
99
+ ])
100
+
101
+ def tearDown(self):
102
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
103
+
104
+ def test_declines_entirely_when_no_budget_remains(self):
105
+ for budget in (0, -2500):
106
+ block, telemetry = recall._transcript_grep_fallback(
107
+ self._path, "nginx TLS cert webkite", {}, budget_ms=budget
108
+ )
109
+ self.assertIsNone(block, f"fallback ran with {budget}ms of budget")
110
+ self.assertFalse(telemetry["fired"])
111
+
112
+ def test_no_budget_means_no_transcript_read_at_all(self):
113
+ """The decline has to happen BEFORE the work, not after. The grep reads
114
+ up to `recallTranscriptFallbackMaxBytes` (256 KiB by default) off disk
115
+ and parses it, and only then starts checking the deadline per turn — so
116
+ relying on the in-loop deadline check to notice an exhausted budget
117
+ still spends the read. With nothing left, nothing is opened."""
118
+ opened = []
119
+ real_open = builtins.open
120
+
121
+ def _tracking_open(path, *args, **kwargs):
122
+ opened.append(str(path))
123
+ return real_open(path, *args, **kwargs)
124
+
125
+ with patch.object(builtins, "open", _tracking_open):
126
+ block, telemetry = recall._transcript_grep_fallback(
127
+ self._path, "nginx TLS cert webkite", {}, budget_ms=0
128
+ )
129
+ self.assertIsNone(block)
130
+ self.assertFalse(telemetry["fired"])
131
+ self.assertEqual(
132
+ [p for p in opened if p == self._path],
133
+ [],
134
+ "the transcript was read despite the hook budget being exhausted",
135
+ )
136
+
137
+ def test_a_healthy_budget_still_allows_the_fallback_to_fire(self):
138
+ """The clamp must not become a permanent off switch — with the ceiling
139
+ untouched the fallback behaves exactly as #3757 shipped it."""
140
+ block, telemetry = recall._transcript_grep_fallback(
141
+ self._path,
142
+ "nginx TLS cert webkite",
143
+ {},
144
+ budget_ms=recall._remaining_hook_budget_seconds() * 1000.0,
145
+ )
146
+ self.assertTrue(telemetry["fired"], f"fallback did not fire: {telemetry}")
147
+ self.assertIsNotNone(block)
148
+
149
+ def test_budget_is_measured_from_this_invocation_not_process_lifetime(self):
150
+ """Re-anchoring at main() is what makes the clamp correct in any process
151
+ that runs the hook more than once. Without it the budget decays with
152
+ process age and the fallback silently stops firing."""
153
+ recall._begin_hook_budget()
154
+ first = recall._remaining_hook_budget_seconds()
155
+ time.sleep(0.05)
156
+ stale = recall._remaining_hook_budget_seconds()
157
+ self.assertLess(stale, first)
158
+ recall._begin_hook_budget()
159
+ self.assertGreater(recall._remaining_hook_budget_seconds(), stale)
160
+
161
+ def test_import_cost_is_charged_against_the_ceiling(self):
162
+ """`recall_start_monotonic` is taken well into main(), so budgeting from
163
+ it ignores interpreter startup and import cost — the exact spend that
164
+ made the flat 1.5s bound unsafe."""
165
+ self.assertGreater(recall._IMPORT_ELAPSED_SECONDS, 0.0)
166
+ recall._begin_hook_budget()
167
+ self.assertLessEqual(
168
+ recall._remaining_hook_budget_seconds(),
169
+ recall.HOOK_CEILING_SECONDS
170
+ - recall.HOOK_TAIL_RESERVE_SECONDS
171
+ - recall._IMPORT_ELAPSED_SECONDS,
172
+ )
173
+
174
+
175
+ class SerialPathCannotSumPastTheCeiling(unittest.TestCase):
176
+ """`HINDSIGHT_RECALL_PARALLEL=false` has no shared deadline — bank latencies
177
+ SUM. #3757 raised the per-bank timeout 8s -> 12s, which on that path meant
178
+ two banks could spend 24s against a 12s ceiling. Each bank is now clamped to
179
+ what the hook has left when its turn comes."""
180
+
181
+ class _Client:
182
+ def __init__(self):
183
+ self.timeouts = []
184
+
185
+ def list_directives(self, bank_id, active_only=True, timeout=2):
186
+ return {"items": []}
187
+
188
+ def recall(self, bank_id, query, **kwargs):
189
+ self.timeouts.append(kwargs.get("timeout"))
190
+ return {"results": []}
191
+
192
+ def setUp(self):
193
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-serial-budget-")
194
+ self._env = {}
195
+ self._env["CLAUDE_PLUGIN_DATA"] = os.environ.get("CLAUDE_PLUGIN_DATA")
196
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
197
+
198
+ def tearDown(self):
199
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
200
+ for key, value in self._env.items():
201
+ if value is None:
202
+ os.environ.pop(key, None)
203
+ else:
204
+ os.environ[key] = value
205
+
206
+ def _run(self, client, remaining_seconds):
207
+ config = {
208
+ "autoRecall": True,
209
+ "bankId": "own-bank",
210
+ "additionalBanks": ["second-bank"],
211
+ "recallMaxTokens": 1024,
212
+ "recallBudget": "low",
213
+ "recallContextTurns": 1,
214
+ "recallMaxQueryChars": 800,
215
+ "recallPromptPreamble": "",
216
+ "recallTranscriptFallback": False,
217
+ "recallRequestTimeoutSeconds": 12,
218
+ # The rollback lever this test exists for.
219
+ "recallParallel": False,
220
+ }
221
+ hook_input = {
222
+ "prompt": "did the nginx TLS cert renewal land",
223
+ "session_id": "serial-budget",
224
+ "transcript_path": "",
225
+ "cwd": "/tmp",
226
+ }
227
+ stdout, stderr = io.StringIO(), io.StringIO()
228
+ with patch.object(recall, "load_config", return_value=config), patch.object(
229
+ recall, "get_api_url", return_value="http://localhost:18888"
230
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
231
+ recall, "ensure_bank_mission", return_value=None
232
+ ), patch.object(recall, "write_state", return_value=None), patch.object(
233
+ recall, "_remaining_hook_budget_seconds", return_value=remaining_seconds
234
+ ), patch("sys.stdin", new=io.StringIO(json.dumps(hook_input))), patch(
235
+ "sys.stdout", new=stdout
236
+ ), patch("sys.stderr", new=stderr):
237
+ recall.main()
238
+
239
+ def test_bank_timeout_is_clamped_to_the_remaining_budget(self):
240
+ client = self._Client()
241
+ self._run(client, remaining_seconds=3.0)
242
+ self.assertTrue(client.timeouts, "no bank was queried")
243
+ for timeout in client.timeouts:
244
+ self.assertLessEqual(
245
+ timeout, 3.0, "a serial bank was allowed its full configured timeout"
246
+ )
247
+
248
+ def test_configured_timeout_still_applies_when_budget_is_ample(self):
249
+ # The clamp is a ceiling, not a replacement: with the whole budget
250
+ # available the operator's configured timeout is what binds.
251
+ client = self._Client()
252
+ self._run(client, remaining_seconds=60.0)
253
+ self.assertTrue(client.timeouts)
254
+ for timeout in client.timeouts:
255
+ self.assertEqual(timeout, 12.0)
256
+
257
+ def test_banks_are_skipped_once_the_budget_is_gone(self):
258
+ client = self._Client()
259
+ self._run(client, remaining_seconds=-1.0)
260
+ self.assertEqual(
261
+ client.timeouts, [], "a bank was queried with the hook ceiling already breached"
262
+ )
263
+
264
+
265
+ if __name__ == "__main__": # pragma: no cover
266
+ unittest.main()