switchroom 0.19.22 → 0.19.23

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 (37) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +68 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +552 -320
  5. package/dist/host-control/main.js +69 -2
  6. package/dist/vault/approvals/kernel-server.js +71 -4
  7. package/dist/vault/broker/server.js +71 -4
  8. package/package.json +5 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +13 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +90 -107
  17. package/telegram-plugin/bunfig.toml +10 -0
  18. package/telegram-plugin/dist/gateway/gateway.js +108 -16
  19. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  20. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  21. package/telegram-plugin/gateway/gateway.ts +9 -7
  22. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  23. package/telegram-plugin/gateway/stream-render.ts +6 -0
  24. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  25. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  26. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  27. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  28. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  29. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  30. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  31. package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
  32. package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
  33. package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
  36. package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
  37. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -57,22 +57,24 @@ class TestDerivedLimit(unittest.TestCase):
57
57
  "HINDSIGHT_RETAIN_CHUNK_SIZE",
58
58
  "HINDSIGHT_RETAIN_CHUNK_LATENCY_S",
59
59
  "HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
60
+ "HINDSIGHT_RETAIN_DEADLINE_SAFETY",
60
61
  ):
61
62
  os.environ.pop(key, None)
62
63
 
63
64
  def test_limit_is_chunk_size_times_chunks_that_fit_the_deadline(self):
64
- # chunk_size * floor(deadline / latency) = 3000 * floor(310/18.4) = 48000
65
- self.assertEqual(retain_content_limit(), 48000)
65
+ # chunk_size * floor(deadline * safety / latency)
66
+ # = 3000 * floor(310 * 0.7 / 18.4) = 3000 * 11 = 33000
67
+ self.assertEqual(retain_content_limit(), 33000)
66
68
 
67
69
  def test_limit_is_derived_from_chunk_size_not_a_constant(self):
68
70
  os.environ["HINDSIGHT_RETAIN_CHUNK_SIZE"] = "1000"
69
- # 1000 * floor(310 / 18.4) = 1000 * 16
70
- self.assertEqual(retain_content_limit(), 16000)
71
+ # 1000 * floor(310 * 0.7 / 18.4) = 1000 * 11
72
+ self.assertEqual(retain_content_limit(), 11000)
71
73
 
72
74
  def test_limit_tracks_the_client_deadline(self):
73
75
  os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "92"
74
- # floor(92 / 18.4) = 5 chunks
75
- self.assertEqual(retain_content_limit(), 15000)
76
+ # floor(92 * 0.7 / 18.4) = floor(3.5) = 3 chunks
77
+ self.assertEqual(retain_content_limit(), 9000)
76
78
 
77
79
  def test_limit_never_falls_below_one_chunk(self):
78
80
  os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "1"
@@ -80,7 +82,77 @@ class TestDerivedLimit(unittest.TestCase):
80
82
 
81
83
  def test_garbage_env_falls_back_to_the_derived_default(self):
82
84
  os.environ["HINDSIGHT_RETAIN_CHUNK_LATENCY_S"] = "not-a-number"
85
+ self.assertEqual(retain_content_limit(), 33000)
86
+
87
+
88
+ class TestTheBoundLeavesMarginUnderTheDeadline(unittest.TestCase):
89
+ """The bound must NOT consume the whole deadline (#3693).
90
+
91
+ Sizing a maximally-sized part to ~100% of the client deadline is what
92
+ manufactured `.dead` entries on this fleet: every `.dead` marker measured
93
+ on 2026-07-26 held 16,076-44,568 chars, i.e. every one of them sat UNDER
94
+ the then-current bound and still could not finish inside the deadline the
95
+ bound was derived from. `latency` is a MEAN (n=752), so half the
96
+ extraction calls are slower than it; a part sized to the mean misses its
97
+ deadline about half the time, the drain bumps its attempt count, and
98
+ MAX_ATTEMPTS later the memory is dead.
99
+
100
+ These assert the OUTCOME — real headroom — rather than the arithmetic, so
101
+ they fail on the zero-margin derivation regardless of how it is spelled.
102
+ """
103
+
104
+ def tearDown(self):
105
+ for key in (
106
+ "HINDSIGHT_RETAIN_MAX_CONTENT_CHARS",
107
+ "HINDSIGHT_RETAIN_CHUNK_SIZE",
108
+ "HINDSIGHT_RETAIN_CHUNK_LATENCY_S",
109
+ "HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
110
+ "HINDSIGHT_RETAIN_DEADLINE_SAFETY",
111
+ ):
112
+ os.environ.pop(key, None)
113
+
114
+ @staticmethod
115
+ def _worst_case_seconds() -> float:
116
+ """Sequential extraction time of a maximally-sized part."""
117
+ chunks = retain_content_limit() / retain_split.DEFAULT_RETAIN_CHUNK_SIZE
118
+ return chunks * retain_split.DEFAULT_RETAIN_CHUNK_LATENCY_S
119
+
120
+ def test_a_maximally_sized_part_finishes_well_inside_the_deadline(self):
121
+ deadline = retain_split.retain_client_deadline()
122
+ worst = self._worst_case_seconds()
123
+ # The pre-#3693 derivation gave 294.4s against 310s -> 0.95 here.
124
+ self.assertLessEqual(
125
+ worst / deadline,
126
+ 0.8,
127
+ f"a maximally-sized part is {worst:.1f}s of extraction against a "
128
+ f"{deadline:.0f}s deadline: no room for the POST, server queueing, "
129
+ f"or a chunk slower than the mean",
130
+ )
131
+
132
+ def test_the_headroom_absorbs_a_chunk_distribution_worse_than_its_mean(self):
133
+ deadline = retain_split.retain_client_deadline()
134
+ # Every chunk running 25% slower than the measured mean must still fit.
135
+ self.assertLessEqual(self._worst_case_seconds() * 1.25, deadline)
136
+
137
+ def test_the_margin_holds_at_every_deadline_not_just_the_default(self):
138
+ for seconds in ("120", "200", "310", "600", "900"):
139
+ os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = seconds
140
+ worst = self._worst_case_seconds()
141
+ deadline = float(seconds)
142
+ with self.subTest(deadline=seconds):
143
+ self.assertLessEqual(worst / deadline, 0.8)
144
+
145
+ def test_the_safety_fraction_is_operator_tunable(self):
146
+ os.environ["HINDSIGHT_RETAIN_DEADLINE_SAFETY"] = "0.5"
147
+ # floor(310 * 0.5 / 18.4) = floor(8.42) = 8 chunks
148
+ self.assertEqual(retain_content_limit(), 24000)
149
+
150
+ def test_a_safety_fraction_above_one_cannot_reintroduce_the_overrun(self):
151
+ os.environ["HINDSIGHT_RETAIN_DEADLINE_SAFETY"] = "2.0"
152
+ # Clamped to 1.0, so the WORST an operator can do is the old
153
+ # zero-margin bound -- never a part sized to overrun outright.
83
154
  self.assertEqual(retain_content_limit(), 48000)
155
+ self.assertLessEqual(self._worst_case_seconds(), retain_split.retain_client_deadline())
84
156
 
85
157
 
86
158
  class TestSplitBounds(unittest.TestCase):
@@ -15,6 +15,21 @@ if SCRIPTS_DIR not in sys.path:
15
15
  sys.path.insert(0, SCRIPTS_DIR)
16
16
 
17
17
 
18
+ def _dead_marker(path: str) -> str:
19
+ """Where `mark_dead` puts the marker for the queue entry at `path`.
20
+
21
+ NOT `<path>.dead` any more: a marker is the only remaining copy of its
22
+ memory, so it leaves the live queue directory for the `pending-dead/`
23
+ sibling rather than sitting in the directory external janitors glob
24
+ (switchroom #3697). Asserting the old spelling made this suite red on a
25
+ deliberate change -- invisibly, because CI discovers only
26
+ `scripts/tests/`.
27
+ """
28
+ from lib.pending import dead_dir
29
+
30
+ return os.path.join(dead_dir(), os.path.basename(path) + ".dead")
31
+
32
+
18
33
  class FakeOk:
19
34
  """Minimal urlopen() context-manager stand-in for a 200 OK."""
20
35
 
@@ -229,7 +244,7 @@ class DrainPendingTest(unittest.TestCase):
229
244
  summary = drain_pending.drain({})
230
245
  self.assertEqual(summary["dead"], 1)
231
246
  self.assertFalse(os.path.exists(path))
232
- self.assertTrue(os.path.exists(path + ".dead"))
247
+ self.assertTrue(os.path.exists(_dead_marker(path)))
233
248
 
234
249
  def test_drain_max_attempts_keeps_the_memory_on_a_transient_failure(self):
235
250
  """A dead upstream must never retire a memory, however many attempts.
@@ -251,7 +266,7 @@ class DrainPendingTest(unittest.TestCase):
251
266
  self.assertEqual(summary["dead"], 0)
252
267
  self.assertEqual(summary["retried"], 1)
253
268
  self.assertTrue(os.path.exists(path))
254
- self.assertFalse(os.path.exists(path + ".dead"))
269
+ self.assertFalse(os.path.exists(_dead_marker(path)))
255
270
 
256
271
  def test_drain_stall_guard_stops_after_threshold(self):
257
272
  # Seed 10 entries; with a same-error-class stream, the stall
@@ -38,7 +38,10 @@ class PendingQueueTest(unittest.TestCase):
38
38
  "api_url": "http://fake:9077",
39
39
  "api_token": None,
40
40
  "bank_id": "test-bank",
41
- "content": "user: hello\nassistant: hi",
41
+ # Distinct per document: the queue's dedupe key is
42
+ # (bank_id, part_position, sha256(content)) since switchroom
43
+ # #3688, so a shared body would collapse these into one entry.
44
+ "content": f"user: hello\nassistant: hi ({document_id})",
42
45
  "document_id": document_id,
43
46
  "context": "claude-code",
44
47
  "metadata": {"session_id": "sess-1"},
@@ -59,7 +62,7 @@ class PendingQueueTest(unittest.TestCase):
59
62
  with open(path) as f:
60
63
  entry = json.load(f)
61
64
  self.assertEqual(entry["bank_id"], "test-bank")
62
- self.assertEqual(entry["content"], "user: hello\nassistant: hi")
65
+ self.assertEqual(entry["content"], self._sample_payload()["content"])
63
66
  self.assertEqual(entry["document_id"], "doc-1")
64
67
  self.assertEqual(entry["error_class"], "ValueError")
65
68
  self.assertEqual(entry["error_message"], "nope")
@@ -200,8 +203,13 @@ class PendingQueueTest(unittest.TestCase):
200
203
  self.assertNotIn(
201
204
  "dead_at", e, "a live queue entry must never carry dead_at"
202
205
  )
203
- # The .dead marker exists and carries dead_at.
204
- dead = path + ".dead"
206
+ # The .dead marker exists and carries dead_at. It lives in the
207
+ # `pending-dead/` SIBLING, not in the live queue directory: a marker
208
+ # is the only remaining copy of its memory, and the queue dir is the
209
+ # one external janitors glob (switchroom #3697).
210
+ dead = os.path.join(
211
+ pending_mod.dead_dir(), os.path.basename(path) + ".dead"
212
+ )
205
213
  self.assertTrue(os.path.isfile(dead))
206
214
  with open(dead) as f:
207
215
  self.assertIn("dead_at", json.load(f))