switchroom 0.16.47 → 0.17.1

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 (117) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1359 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +842 -832
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/format.ts +119 -17
  23. package/telegram-plugin/gateway/approvals-commands.ts +6 -2
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +535 -627
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/ms365-write-approval.test.ts +13 -0
  33. package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  35. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  36. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  37. package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
  38. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  39. package/telegram-plugin/history.ts +5 -0
  40. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  41. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  42. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  43. package/telegram-plugin/registry/turns-schema.ts +65 -1
  44. package/telegram-plugin/session-tail.ts +26 -4
  45. package/telegram-plugin/slot-banner-driver.ts +42 -2
  46. package/telegram-plugin/status-query-telemetry.ts +100 -0
  47. package/telegram-plugin/stream-reply-handler.ts +15 -16
  48. package/telegram-plugin/subagent-watcher.ts +182 -30
  49. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  50. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  51. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  52. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  53. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  54. package/telegram-plugin/tests/format-consistency.test.ts +79 -0
  55. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  56. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  57. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  58. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  59. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  60. package/telegram-plugin/tests/model-command.test.ts +54 -1
  61. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  62. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  63. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  64. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  65. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  66. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  68. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  69. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  70. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  71. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  72. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  73. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  74. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  75. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  76. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  77. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  78. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  79. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  80. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  82. package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
  83. package/telegram-plugin/tests/welcome-text.test.ts +64 -0
  84. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  85. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  86. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  87. package/telegram-plugin/tool-activity-summary.ts +19 -0
  88. package/telegram-plugin/turn-flush-safety.ts +16 -1
  89. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  90. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  91. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  92. package/telegram-plugin/welcome-text.ts +13 -9
  93. package/telegram-plugin/worker-activity-feed.ts +75 -15
  94. package/vendor/hindsight-memory/CHANGELOG.md +66 -0
  95. package/vendor/hindsight-memory/README.md +5 -0
  96. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  97. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  98. package/vendor/hindsight-memory/scripts/lib/content.py +43 -4
  99. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  100. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  101. package/vendor/hindsight-memory/scripts/retain.py +79 -11
  102. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  103. package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
  104. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  105. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  106. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
  107. package/vendor/hindsight-memory/settings.json +4 -0
  108. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  109. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  110. package/vendor/hindsight-memory/tests/test_content.py +123 -0
  111. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  112. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  113. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  114. package/telegram-plugin/silent-reply.ts +0 -58
  115. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  116. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  117. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -0,0 +1,261 @@
1
+ """Switchroom Phase 6b — unit tests for retain.py's window selection.
2
+
3
+ `select_retain_window()` decides WHAT to retain once a Stop-hook fire
4
+ happens (the throttle, which decides WHETHER to fire, is separate and
5
+ untouched here). Phase 6b decouples the chunked sliding-window from the
6
+ `retainEveryNTurns > 1` gate so chunked mode works at
7
+ `retainEveryNTurns=1` — switchroom's every-turn crash-durability setting.
8
+
9
+ The load-bearing property is DURABILITY: with every-turn firing, the
10
+ window must always include the turn that just completed, so a fact told
11
+ in a ≤2-turn session survives a restart (the jtbd-memory-survives-restart
12
+ UAT). Because the window always extends to the END of the transcript, the
13
+ just-completed turn is always inside it.
14
+
15
+ Stdlib-only; runs under `python3 -m unittest discover tests/`.
16
+ """
17
+
18
+ import os
19
+ import sys
20
+ import unittest
21
+
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 retain import select_retain_window # noqa: E402
27
+
28
+
29
+ def _transcript(num_turns: int) -> list:
30
+ """Build a transcript of `num_turns` user/assistant turns.
31
+
32
+ Each turn = one user message followed by one assistant message. The
33
+ text encodes the turn index so slices can be identified precisely.
34
+ """
35
+ messages = []
36
+ for i in range(num_turns):
37
+ messages.append({"role": "user", "content": f"user turn {i}"})
38
+ messages.append({"role": "assistant", "content": f"assistant turn {i}"})
39
+ return messages
40
+
41
+
42
+ def _user_turn_indices(messages: list) -> list:
43
+ """Return the turn indices present in a sliced message list."""
44
+ return [
45
+ int(m["content"].split()[-1])
46
+ for m in messages
47
+ if m.get("role") == "user"
48
+ ]
49
+
50
+
51
+ class SelectRetainWindowChunkedEveryTurn(unittest.TestCase):
52
+ """The Phase 6b behaviour: chunked slicing works at retain_every_n=1."""
53
+
54
+ def test_chunked_n1_slices_recent_window_not_full_session(self):
55
+ # n=1, overlap=2 -> window = max(1,1)+2 = 3 recent turns.
56
+ messages = _transcript(5) # turns 0..4
57
+ result, full_window = select_retain_window(
58
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
59
+ )
60
+ # Only the last 3 turns, NOT all 5 — this is the cost fix.
61
+ self.assertEqual(_user_turn_indices(result), [2, 3, 4])
62
+ self.assertLess(len(result), len(messages))
63
+ self.assertTrue(full_window)
64
+
65
+ def test_chunked_n1_window_always_includes_the_just_completed_turn(self):
66
+ # DURABILITY: the newest turn (highest index) must be in the window.
67
+ for total in (1, 2, 3, 4, 10, 50):
68
+ messages = _transcript(total)
69
+ result, _ = select_retain_window(
70
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
71
+ )
72
+ newest = total - 1
73
+ self.assertIn(
74
+ newest,
75
+ _user_turn_indices(result),
76
+ f"newest turn {newest} missing from window (total={total})",
77
+ )
78
+ # Window always extends to the very end of the transcript.
79
+ self.assertEqual(result[-1], messages[-1])
80
+
81
+ def test_single_turn_session_retains_that_turn(self):
82
+ # The restart-survival case: a 1-turn session. Window (3) exceeds
83
+ # available turns, so the whole (1-turn) transcript is retained.
84
+ messages = _transcript(1)
85
+ result, full_window = select_retain_window(
86
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
87
+ )
88
+ self.assertEqual(_user_turn_indices(result), [0])
89
+ self.assertEqual(result, messages)
90
+ self.assertTrue(full_window)
91
+
92
+ def test_two_turn_session_retains_both_turns(self):
93
+ # The exact jtbd shape: fact told in turn 0, one more turn, restart.
94
+ messages = _transcript(2)
95
+ result, _ = select_retain_window(
96
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
97
+ )
98
+ self.assertEqual(_user_turn_indices(result), [0, 1])
99
+
100
+
101
+ class SelectRetainWindowNoRegression(unittest.TestCase):
102
+ """The n>1 chunked path and the full-session path must be unchanged."""
103
+
104
+ def test_chunked_n_gt_1_window_is_n_plus_overlap(self):
105
+ # n=10, overlap=2 -> window = 12 turns, identical to the pre-Phase-6b
106
+ # `retain_every_n + overlap_turns` formula (max(10,1)==10).
107
+ messages = _transcript(20) # turns 0..19
108
+ result, full_window = select_retain_window(
109
+ "chunked", retain_every_n=10, overlap_turns=2, all_messages=messages
110
+ )
111
+ self.assertEqual(_user_turn_indices(result), list(range(8, 20))) # last 12
112
+ self.assertTrue(full_window)
113
+
114
+ def test_chunked_zero_overlap(self):
115
+ # n=1, overlap=0 -> window = 1 (just the current turn).
116
+ messages = _transcript(5)
117
+ result, _ = select_retain_window(
118
+ "chunked", retain_every_n=1, overlap_turns=0, all_messages=messages
119
+ )
120
+ self.assertEqual(_user_turn_indices(result), [4])
121
+
122
+ def test_full_session_retains_all_regardless_of_n(self):
123
+ messages = _transcript(7)
124
+ for n in (1, 5, 10):
125
+ result, full_window = select_retain_window(
126
+ "full-session", retain_every_n=n, overlap_turns=2, all_messages=messages
127
+ )
128
+ self.assertEqual(_user_turn_indices(result), list(range(7)))
129
+ self.assertEqual(len(result), len(messages))
130
+ self.assertTrue(full_window)
131
+
132
+ def test_unknown_mode_falls_back_to_full_session(self):
133
+ messages = _transcript(4)
134
+ result, full_window = select_retain_window(
135
+ "something-else", retain_every_n=1, overlap_turns=2, all_messages=messages
136
+ )
137
+ self.assertEqual(len(result), len(messages))
138
+ self.assertTrue(full_window)
139
+
140
+ def test_returns_a_copy_not_the_same_list_for_full_session(self):
141
+ # select_retain_window returns list(all_messages) for full-session,
142
+ # so mutating the result can't corrupt the caller's transcript.
143
+ messages = _transcript(2)
144
+ result, _ = select_retain_window(
145
+ "full-session", retain_every_n=1, overlap_turns=2, all_messages=messages
146
+ )
147
+ self.assertIsNot(result, messages)
148
+
149
+
150
+ def _human_msg(text: str) -> dict:
151
+ return {"role": "user", "content": text}
152
+
153
+
154
+ def _tool_result_msg(tool_use_id: str, text: str) -> dict:
155
+ # Claude Code emits tool results as role="user" with a content list of
156
+ # tool_result blocks — exactly the shape read_transcript() produces.
157
+ return {
158
+ "role": "user",
159
+ "content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": text}],
160
+ }
161
+
162
+
163
+ def _assistant_msg(text: str) -> dict:
164
+ return {"role": "assistant", "content": text}
165
+
166
+
167
+ def _contains_text(messages: list, needle: str) -> bool:
168
+ for m in messages:
169
+ c = m.get("content")
170
+ if isinstance(c, str) and needle in c:
171
+ return True
172
+ return False
173
+
174
+
175
+ class SelectRetainWindowToolHeavyTurn(unittest.TestCase):
176
+ """Finding 1 regression: tool_result messages are role="user" but are NOT
177
+ human turns. A tool-heavy turn must not push the human's fact out of the
178
+ window. These FAIL before the content.py boundary fix and pass after.
179
+ """
180
+
181
+ def test_tool_heavy_single_turn_keeps_human_fact_in_window(self):
182
+ # One logical human turn: the fact, then 3 sequential tool rounds.
183
+ # OLD boundary semantics count the 3 tool_result "user" messages as
184
+ # 3 turns and slice them off — dropping the human fact. NEW semantics
185
+ # count only the 1 human turn, so the whole (1 human-turn) transcript
186
+ # is retained and the fact survives.
187
+ fact = "my deploy token is DURABILITY_TOKEN_XYZ"
188
+ messages = [
189
+ _human_msg(fact),
190
+ _assistant_msg("let me look that up"),
191
+ _tool_result_msg("t1", "file a"),
192
+ _assistant_msg("checking more"),
193
+ _tool_result_msg("t2", "file b"),
194
+ _assistant_msg("one more"),
195
+ _tool_result_msg("t3", "file c"),
196
+ _assistant_msg("here is your answer"),
197
+ ]
198
+ result, _ = select_retain_window(
199
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
200
+ )
201
+ self.assertTrue(
202
+ _contains_text(result, "DURABILITY_TOKEN_XYZ"),
203
+ "human fact fell outside the retain window on a tool-heavy turn "
204
+ "(silent memory loss). Window was: "
205
+ + repr([m.get("content") for m in result]),
206
+ )
207
+
208
+ def test_tool_heavy_current_turn_among_prior_human_turns(self):
209
+ # Two prior human turns, then a tool-heavy current turn whose human
210
+ # message carries the fact. window=3 human turns must include the
211
+ # current turn's human message regardless of tool volume.
212
+ fact = "the current fact is CURRENT_FACT_42"
213
+ messages = [
214
+ _human_msg("older turn 0"),
215
+ _assistant_msg("a0"),
216
+ _human_msg("older turn 1"),
217
+ _assistant_msg("a1"),
218
+ _human_msg(fact),
219
+ _assistant_msg("looking"),
220
+ _tool_result_msg("t1", "r1"),
221
+ _assistant_msg("more"),
222
+ _tool_result_msg("t2", "r2"),
223
+ _assistant_msg("more"),
224
+ _tool_result_msg("t3", "r3"),
225
+ _assistant_msg("done"),
226
+ ]
227
+ result, _ = select_retain_window(
228
+ "chunked", retain_every_n=1, overlap_turns=2, all_messages=messages
229
+ )
230
+ self.assertTrue(_contains_text(result, "CURRENT_FACT_42"))
231
+ # And the window is anchored to 3 HUMAN turns — so the oldest human
232
+ # message ("older turn 0") is the window start.
233
+ self.assertTrue(_contains_text(result, "older turn 0"))
234
+
235
+
236
+ class SelectRetainWindowForce(unittest.TestCase):
237
+ """Finding 2: SessionEnd force=True widens chunked mode to full-session."""
238
+
239
+ def test_force_retains_full_session_in_chunked_mode(self):
240
+ messages = _transcript(10) # turns 0..9
241
+ result, full_window = select_retain_window(
242
+ "chunked", retain_every_n=1, overlap_turns=2,
243
+ all_messages=messages, force=True,
244
+ )
245
+ # Forced sweep at SessionEnd retains everything, not just the window.
246
+ self.assertEqual(_user_turn_indices(result), list(range(10)))
247
+ self.assertEqual(len(result), len(messages))
248
+ self.assertTrue(full_window)
249
+
250
+ def test_no_force_still_windows_in_chunked_mode(self):
251
+ # Guard: the force widening must not leak into normal per-turn fires.
252
+ messages = _transcript(10)
253
+ result, _ = select_retain_window(
254
+ "chunked", retain_every_n=1, overlap_turns=2,
255
+ all_messages=messages, force=False,
256
+ )
257
+ self.assertEqual(_user_turn_indices(result), [7, 8, 9]) # last 3 turns
258
+
259
+
260
+ if __name__ == "__main__":
261
+ unittest.main()
@@ -13,6 +13,10 @@
13
13
  "recallContextTurns": 1,
14
14
  "recallMaxQueryChars": 800,
15
15
  "recallRoles": ["user", "assistant"],
16
+ "recallTags": [],
17
+ "recallTagsMatch": "any",
18
+ "recallTagGroups": null,
19
+ "recallAdditionalBankFilters": {},
16
20
  "recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
17
21
  "retainRoles": ["user", "assistant"],
18
22
  "retainEveryNTurns": 10,
@@ -230,3 +230,133 @@ class TestHindsightClientSetBankMission:
230
230
  assert "my-bank" in captured["url"]
231
231
  assert captured["body"]["updates"]["reflect_mission"] == "I am Claude"
232
232
  assert captured["body"]["updates"]["retain_mission"] == "Extract facts"
233
+
234
+
235
+ class TestHindsightClientRecallTagFilters:
236
+ """Upstream 962140eef — tag filters are forwarded in the recall body."""
237
+
238
+ def test_sends_tag_filters(self):
239
+ c = HindsightClient("http://localhost:9077")
240
+ captured = {}
241
+
242
+ def fake_open(req, timeout=None):
243
+ captured["body"] = json.loads(req.data.decode())
244
+ return FakeResp({"results": []})
245
+
246
+ with patch("urllib.request.urlopen", side_effect=fake_open):
247
+ c.recall(
248
+ "bank",
249
+ "query",
250
+ tags=["memory_type:rule"],
251
+ tags_match="any_strict",
252
+ tag_groups=[{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}],
253
+ )
254
+
255
+ assert captured["body"]["tags"] == ["memory_type:rule"]
256
+ assert captured["body"]["tags_match"] == "any_strict"
257
+ assert captured["body"]["tag_groups"] == [
258
+ {"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}
259
+ ]
260
+
261
+ def test_omits_tag_filters_when_unset(self):
262
+ c = HindsightClient("http://localhost:9077")
263
+ captured = {}
264
+
265
+ def fake_open(req, timeout=None):
266
+ captured["body"] = json.loads(req.data.decode())
267
+ return FakeResp({"results": []})
268
+
269
+ with patch("urllib.request.urlopen", side_effect=fake_open):
270
+ c.recall("bank", "query")
271
+
272
+ assert "tags" not in captured["body"]
273
+ assert "tags_match" not in captured["body"]
274
+ assert "tag_groups" not in captured["body"]
275
+
276
+
277
+ class TestRequestTimeoutOverride:
278
+ """Upstream 55ef70679 — the constructor override replaces the per-call
279
+ timeout that recall/retain/_request would otherwise use. When unset,
280
+ the original per-call default is preserved."""
281
+
282
+ def test_override_replaces_recall_default(self):
283
+ c = HindsightClient("http://localhost:9077", request_timeout_override=60)
284
+ captured = {}
285
+
286
+ def fake_open(req, timeout=None):
287
+ captured["timeout"] = timeout
288
+ return FakeResp({"results": []})
289
+
290
+ with patch("urllib.request.urlopen", side_effect=fake_open):
291
+ c.recall("bank", "query")
292
+
293
+ assert captured["timeout"] == 60
294
+
295
+ def test_override_replaces_retain_default(self):
296
+ c = HindsightClient("http://localhost:9077", request_timeout_override=60)
297
+ captured = {}
298
+
299
+ def fake_open(req, timeout=None):
300
+ captured["timeout"] = timeout
301
+ return FakeResp({})
302
+
303
+ with patch("urllib.request.urlopen", side_effect=fake_open):
304
+ c.retain("bank", "content")
305
+
306
+ assert captured["timeout"] == 60
307
+
308
+ def test_override_replaces_explicit_recall_timeout(self):
309
+ # Even an explicit per-call timeout (like recall.py's 8s hook
310
+ # budget) is replaced when the override is set — which is exactly
311
+ # why recall.py does NOT construct its client with the override.
312
+ c = HindsightClient("http://localhost:9077", request_timeout_override=60)
313
+ captured = {}
314
+
315
+ def fake_open(req, timeout=None):
316
+ captured["timeout"] = timeout
317
+ return FakeResp({"results": []})
318
+
319
+ with patch("urllib.request.urlopen", side_effect=fake_open):
320
+ c.recall("bank", "query", timeout=8)
321
+
322
+ assert captured["timeout"] == 60
323
+
324
+ def test_no_override_preserves_recall_default(self):
325
+ c = HindsightClient("http://localhost:9077")
326
+ captured = {}
327
+
328
+ def fake_open(req, timeout=None):
329
+ captured["timeout"] = timeout
330
+ return FakeResp({"results": []})
331
+
332
+ with patch("urllib.request.urlopen", side_effect=fake_open):
333
+ c.recall("bank", "query")
334
+
335
+ assert captured["timeout"] == 10
336
+
337
+ def test_no_override_preserves_retain_default(self):
338
+ c = HindsightClient("http://localhost:9077")
339
+ captured = {}
340
+
341
+ def fake_open(req, timeout=None):
342
+ captured["timeout"] = timeout
343
+ return FakeResp({})
344
+
345
+ with patch("urllib.request.urlopen", side_effect=fake_open):
346
+ c.retain("bank", "content")
347
+
348
+ assert captured["timeout"] == 15
349
+
350
+ def test_override_does_not_affect_health_check(self):
351
+ c = HindsightClient("http://localhost:9077", request_timeout_override=60)
352
+ captured = {}
353
+
354
+ def fake_open(req, timeout=None):
355
+ captured["timeout"] = timeout
356
+ return FakeResp({}, status=200)
357
+
358
+ with patch("urllib.request.urlopen", side_effect=fake_open):
359
+ with patch("time.sleep"):
360
+ c.health_check()
361
+
362
+ assert captured["timeout"] == 5
@@ -126,3 +126,50 @@ class TestLoadConfig:
126
126
  monkeypatch.setenv("HINDSIGHT_RECALL_BUDGET", "high")
127
127
  cfg = load_config()
128
128
  assert cfg["recallBudget"] == "high"
129
+
130
+ # Upstream 962140eef — recall tag filter env overrides.
131
+
132
+ def test_recall_tags_env_override_accepts_comma_list(self, tmp_path, monkeypatch):
133
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
134
+ monkeypatch.setenv("HINDSIGHT_RECALL_TAGS", "memory_type:rule, tech_stack:supabase")
135
+ cfg = load_config()
136
+ assert cfg["recallTags"] == ["memory_type:rule", "tech_stack:supabase"]
137
+
138
+ def test_recall_tags_env_override_accepts_json(self, tmp_path, monkeypatch):
139
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
140
+ monkeypatch.setenv("HINDSIGHT_RECALL_TAGS", '["memory_type:rule"]')
141
+ cfg = load_config()
142
+ assert cfg["recallTags"] == ["memory_type:rule"]
143
+
144
+ def test_recall_tag_groups_env_override_accepts_json(self, tmp_path, monkeypatch):
145
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
146
+ monkeypatch.setenv(
147
+ "HINDSIGHT_RECALL_TAG_GROUPS",
148
+ '[{"op":"all","tags":["memory_type:rule","tech_stack:supabase"]}]',
149
+ )
150
+ cfg = load_config()
151
+ assert cfg["recallTagGroups"] == [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}]
152
+
153
+ def test_recall_additional_bank_filters_env_override_accepts_json(self, tmp_path, monkeypatch):
154
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
155
+ monkeypatch.setenv(
156
+ "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS",
157
+ '{"normative":{"recallTags":["memory_type:rule"],"recallTagsMatch":"all"}}',
158
+ )
159
+ cfg = load_config()
160
+ assert cfg["recallAdditionalBankFilters"] == {
161
+ "normative": {"recallTags": ["memory_type:rule"], "recallTagsMatch": "all"}
162
+ }
163
+
164
+ # Upstream 55ef70679 — request timeout override.
165
+
166
+ def test_request_timeout_default_none(self, tmp_path, monkeypatch):
167
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
168
+ cfg = load_config()
169
+ assert cfg["requestTimeoutSeconds"] is None
170
+
171
+ def test_request_timeout_env_override(self, tmp_path, monkeypatch):
172
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
173
+ monkeypatch.setenv("HINDSIGHT_REQUEST_TIMEOUT_SECONDS", "60")
174
+ cfg = load_config()
175
+ assert cfg["requestTimeoutSeconds"] == 60
@@ -1,11 +1,14 @@
1
1
  """Tests for lib/content.py — pure content-processing functions."""
2
2
 
3
+ import re
4
+
3
5
  import pytest
4
6
 
5
7
  from lib.content import (
6
8
  _extract_text_content,
7
9
  _is_channel_message_tool,
8
10
  compose_recall_query,
11
+ format_current_time,
9
12
  format_memories,
10
13
  prepare_retention_transcript,
11
14
  slice_last_turns_by_user_boundary,
@@ -113,6 +116,64 @@ class TestSliceLastTurnsByUserBoundary:
113
116
  def test_non_list_returns_empty(self):
114
117
  assert slice_last_turns_by_user_boundary(None, 1) == []
115
118
 
119
+ # --- switchroom divergence: tool_result user-messages are NOT turns ---
120
+
121
+ @staticmethod
122
+ def _tool_result(tuid: str, text: str) -> dict:
123
+ # Claude Code emits tool results as role="user" with a content list of
124
+ # tool_result blocks (the shape read_transcript produces).
125
+ return {
126
+ "role": "user",
127
+ "content": [{"type": "tool_result", "tool_use_id": tuid, "content": text}],
128
+ }
129
+
130
+ def test_tool_result_messages_are_not_turn_boundaries(self):
131
+ # One human turn + 3 tool rounds. Requesting 1 turn must anchor to the
132
+ # human message, NOT the newest tool_result — otherwise a tool-heavy
133
+ # turn drops the human's text from the window (silent memory loss).
134
+ msgs = [
135
+ {"role": "user", "content": "human fact"},
136
+ {"role": "assistant", "content": "a"},
137
+ self._tool_result("t1", "r1"),
138
+ {"role": "assistant", "content": "a"},
139
+ self._tool_result("t2", "r2"),
140
+ {"role": "assistant", "content": "a"},
141
+ self._tool_result("t3", "r3"),
142
+ {"role": "assistant", "content": "a"},
143
+ ]
144
+ result = slice_last_turns_by_user_boundary(msgs, 1)
145
+ assert result[0]["content"] == "human fact"
146
+ assert result == msgs # only 1 human turn, so the whole thing is kept
147
+
148
+ def test_counts_human_turns_only_across_tool_heavy_turns(self):
149
+ # 2 human turns, each followed by a tool round. Requesting 1 human turn
150
+ # slices to the SECOND human message, not into the first turn's tools.
151
+ msgs = [
152
+ {"role": "user", "content": "human one"},
153
+ {"role": "assistant", "content": "a"},
154
+ self._tool_result("t1", "r1"),
155
+ {"role": "user", "content": "human two"},
156
+ {"role": "assistant", "content": "a"},
157
+ self._tool_result("t2", "r2"),
158
+ ]
159
+ result = slice_last_turns_by_user_boundary(msgs, 1)
160
+ assert result[0]["content"] == "human two"
161
+
162
+ def test_mixed_text_and_tool_result_block_is_a_boundary(self):
163
+ # A user message with BOTH text and a tool_result block still counts as
164
+ # a human turn (conservative — only pure tool_result messages are skipped).
165
+ msgs = [
166
+ {"role": "user", "content": "older"},
167
+ {"role": "assistant", "content": "a"},
168
+ {"role": "user", "content": [
169
+ {"type": "text", "text": "human with attached result"},
170
+ {"type": "tool_result", "tool_use_id": "t1", "content": "r1"},
171
+ ]},
172
+ {"role": "assistant", "content": "a"},
173
+ ]
174
+ result = slice_last_turns_by_user_boundary(msgs, 1)
175
+ assert result[0]["content"][0]["text"] == "human with attached result"
176
+
116
177
 
117
178
  # ---------------------------------------------------------------------------
118
179
  # compose_recall_query
@@ -156,6 +217,53 @@ class TestComposeRecallQuery:
156
217
  assert "user msg" in result
157
218
  assert "assistant msg" not in result
158
219
 
220
+ # --- switchroom divergence: the recall context slice counts HUMAN turns,
221
+ # not tool_result pseudo-turns (follow-up to #2830, closing the reviewer nit
222
+ # that the retain path had a test but the recall path — the OTHER caller of
223
+ # slice_last_turns_by_user_boundary — did not).
224
+
225
+ def test_tool_heavy_prior_turn_keeps_human_text_in_recall_context(self):
226
+ # A prior human turn stating a fact, then 3 sequential tool rounds
227
+ # (Claude Code emits tool results as role="user"). recall_context_turns=2
228
+ # asks for the latest turn + one prior HUMAN turn. If the tool_result
229
+ # messages were counted as boundaries the human fact would be sliced
230
+ # out; the guard skips them so the fact lands in "Prior context:".
231
+ messages = [
232
+ {"role": "user", "content": "my prod database is called ORCHID_PRIMARY"},
233
+ {"role": "assistant", "content": "let me look that up"},
234
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "TOOLPAYLOAD_1"}]},
235
+ {"role": "assistant", "content": "checking more"},
236
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "TOOLPAYLOAD_2"}]},
237
+ {"role": "assistant", "content": "one more"},
238
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t3", "content": "TOOLPAYLOAD_3"}]},
239
+ {"role": "assistant", "content": "here is your schema"},
240
+ ]
241
+ result = compose_recall_query("what port does it listen on", messages, recall_context_turns=2)
242
+ assert "Prior context:" in result
243
+ assert "ORCHID_PRIMARY" in result # human turn survived the tool-heavy turn
244
+ assert "TOOLPAYLOAD" not in result # tool_result payload is not human context
245
+
246
+ def test_recall_context_anchors_to_human_turns_across_tool_volume(self):
247
+ # Two prior human turns, each followed by tool rounds. Asking for 3
248
+ # context turns (latest + 2 prior HUMAN) must reach past all the
249
+ # tool_result messages to the oldest human turn — tool volume must not
250
+ # consume the turn budget.
251
+ messages = [
252
+ {"role": "user", "content": "the deploy key is FALCON_9_KEY"},
253
+ {"role": "assistant", "content": "looking"},
254
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out a"}]},
255
+ {"role": "assistant", "content": "more"},
256
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "out b"}]},
257
+ {"role": "user", "content": "and remind me of the region too"},
258
+ {"role": "assistant", "content": "checking region"},
259
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t3", "content": "out c"}]},
260
+ {"role": "assistant", "content": "region is ap-southeast-2"},
261
+ ]
262
+ result = compose_recall_query("put those together for me", messages, recall_context_turns=3)
263
+ assert "Prior context:" in result
264
+ assert "FALCON_9_KEY" in result
265
+ assert "and remind me of the region too" in result
266
+
159
267
 
160
268
  # ---------------------------------------------------------------------------
161
269
  # truncate_recall_query
@@ -469,3 +577,18 @@ class TestPrepareRetentionTranscript:
469
577
  transcript, _ = prepare_retention_transcript(msgs, retain_full_window=True, include_tool_calls=False)
470
578
  assert "[role: user]" in transcript
471
579
  assert "[user:end]" in transcript
580
+
581
+
582
+ # ---------------------------------------------------------------------------
583
+ # format_current_time
584
+ # ---------------------------------------------------------------------------
585
+
586
+
587
+ class TestFormatCurrentTime:
588
+ def test_includes_utc_suffix(self):
589
+ # The "UTC" suffix prevents client LLMs from misreading the
590
+ # timestamp as local time.
591
+ assert format_current_time().endswith(" UTC")
592
+
593
+ def test_format_shape(self):
594
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC", format_current_time())
@@ -310,6 +310,68 @@ class TestRecallHook:
310
310
  cache_path = tmp_path / "plugin_data" / "state" / "recall_cache.json"
311
311
  assert not cache_path.exists(), f"Cache should not be written for TTL={bad!r}"
312
312
 
313
+ def test_passes_tag_filters_to_recall_api(self, monkeypatch, tmp_path):
314
+ # Upstream 962140eef.
315
+ captured = {}
316
+
317
+ def capture_and_respond(req, timeout=None):
318
+ if "/recall" in req.full_url:
319
+ captured["body"] = json.loads(req.data.decode())
320
+ return FakeHTTPResponse({"results": []})
321
+
322
+ hook_input = make_hook_input(prompt="What project rules apply here?")
323
+ _run_hook(
324
+ "recall",
325
+ hook_input,
326
+ monkeypatch,
327
+ tmp_path,
328
+ urlopen_side_effect=capture_and_respond,
329
+ extra_settings={
330
+ "recallTags": ["memory_type:rule"],
331
+ "recallTagsMatch": "any_strict",
332
+ "recallTagGroups": [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}],
333
+ },
334
+ )
335
+
336
+ assert captured["body"]["tags"] == ["memory_type:rule"]
337
+ assert captured["body"]["tags_match"] == "any_strict"
338
+ assert captured["body"]["tag_groups"] == [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}]
339
+
340
+ def test_additional_bank_filters_override_global_tags(self, monkeypatch, tmp_path):
341
+ # Upstream 962140eef — per-bank overrides beat the global filters.
342
+ captured = []
343
+
344
+ def capture_and_respond(req, timeout=None):
345
+ if "/recall" in req.full_url:
346
+ captured.append(json.loads(req.data.decode()))
347
+ return FakeHTTPResponse({"results": []})
348
+
349
+ hook_input = make_hook_input(prompt="What project rules apply here?")
350
+ _run_hook(
351
+ "recall",
352
+ hook_input,
353
+ monkeypatch,
354
+ tmp_path,
355
+ urlopen_side_effect=capture_and_respond,
356
+ extra_settings={
357
+ "bankId": "project-bank",
358
+ "recallAdditionalBanks": ["normative-bank"],
359
+ "recallTags": ["tech_stack:supabase"],
360
+ "recallTagsMatch": "any",
361
+ "recallAdditionalBankFilters": {
362
+ "normative-bank": {
363
+ "recallTags": ["memory_type:rule"],
364
+ "recallTagsMatch": "all_strict",
365
+ }
366
+ },
367
+ },
368
+ )
369
+
370
+ assert captured[0]["tags"] == ["tech_stack:supabase"]
371
+ assert captured[0]["tags_match"] == "any"
372
+ assert captured[1]["tags"] == ["memory_type:rule"]
373
+ assert captured[1]["tags_match"] == "all_strict"
374
+
313
375
  def test_disabled_auto_recall_produces_no_output(self, monkeypatch, tmp_path):
314
376
  (tmp_path / "plugin_root").mkdir(exist_ok=True)
315
377
  (tmp_path / "plugin_data").mkdir(exist_ok=True)