switchroom 0.21.13 → 0.21.15

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 (27) hide show
  1. package/dist/agent-scheduler/index.js +6 -2
  2. package/dist/auth-broker/index.js +6 -2
  3. package/dist/cli/notion-write-pretool.mjs +6 -2
  4. package/dist/cli/switchroom.js +1608 -699
  5. package/dist/host-control/main.js +22 -16
  6. package/dist/vault/approvals/kernel-server.js +6 -2
  7. package/dist/vault/broker/server.js +132 -11
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +9 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +32 -86
  11. package/profiles/_shared/vault-protocol.md.hbs +17 -62
  12. package/profiles/default/CLAUDE.md.hbs +76 -74
  13. package/skills/switchroom-runtime/SKILL.md +32 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +10 -6
  15. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  16. package/vendor/hindsight-memory/scripts/lib/config.py +22 -0
  17. package/vendor/hindsight-memory/scripts/lib/directives.py +63 -7
  18. package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
  19. package/vendor/hindsight-memory/scripts/recall.py +349 -5
  20. package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
  21. package/vendor/hindsight-memory/scripts/retain.py +59 -3
  22. package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
  23. package/vendor/hindsight-memory/scripts/tests/test_directives.py +98 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +53 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_recall_query_timestamp.py +376 -0
@@ -0,0 +1,376 @@
1
+ """Switchroom P2 (memory-redesign RFC §5) — pass a `query_timestamp` anchor
2
+ from the recall hook when the inbound prompt is time-relative.
3
+
4
+ `query_timestamp` is an ISO 8601 datetime naming when the query is being asked
5
+ (https://hindsight.vectorize.io/developer/api/recall). The engine uses it to
6
+ resolve relative temporal expressions in the query ("last week", "yesterday",
7
+ "on the 12th") and to anchor recency scoring. A REST probe on the live engine
8
+ (2026-08-17) confirmed the field is ACCEPTED and HONOURED by the recall body:
9
+ a malformed value 400s ("Invalid query_timestamp format. Expected ISO format")
10
+ and a different anchor changes the returned result ordering — so it is neither
11
+ ignored nor rejected.
12
+
13
+ The guarantees under test are OUTCOMES on the wire body and the recall_log
14
+ row, never a branch taken:
15
+
16
+ 1. The date parser maps representative temporal phrases to a deterministic
17
+ anchor (the injected `now`, ISO 8601) and returns None for text with no
18
+ temporal phrase — a pure, IO-free, model-free regex.
19
+ 2. Absent a temporal phrase the field is NEVER added to the recall body, so
20
+ the wire body is byte-identical to a pre-P2 client.
21
+ 3. Present a temporal phrase, the exact anchor reaches the wire.
22
+ 4. The recall_log row carries `query_timestamp` (the ISO value when it fired,
23
+ null otherwise) so its firing rate is measurable from day one.
24
+ 5. The `recallQueryTimestamp: false` gate suppresses the field even on a
25
+ temporal prompt (the rollback lever).
26
+
27
+ Stdlib-only (unittest + mock); runs under ``python3 -m unittest discover
28
+ tests/``. Wire-body harness mirrors ``test_observation_scopes.py``; the
29
+ end-to-end harness mirrors ``test_recall_min_score.py``.
30
+ """
31
+
32
+ import io
33
+ import json
34
+ import os
35
+ import shutil
36
+ import sys
37
+ import tempfile
38
+ import unittest
39
+ from datetime import datetime, timedelta, timezone
40
+ from unittest.mock import patch
41
+
42
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
43
+ if SCRIPTS_DIR not in sys.path:
44
+ sys.path.insert(0, SCRIPTS_DIR)
45
+
46
+ import recall # noqa: E402
47
+ from recall import detect_query_timestamp # noqa: E402
48
+ from lib.client import HindsightClient # noqa: E402
49
+
50
+ # A fixed anchor so every parser assertion is byte-exact and clock-independent.
51
+ FIXED_NOW = datetime(2026, 8, 17, 12, 0, 0, tzinfo=timezone.utc)
52
+ FIXED_ISO = FIXED_NOW.isoformat()
53
+
54
+ OWN = "test-bank"
55
+
56
+
57
+ class DetectQueryTimestampParser(unittest.TestCase):
58
+ """The deterministic temporal-expression parser (pure function)."""
59
+
60
+ # Representative phrases across the families the regex covers. Each MUST
61
+ # map to the injected anchor — the value is always "now" by the field's
62
+ # documented semantics (the engine resolves the phrase against the anchor).
63
+ TEMPORAL = [
64
+ "what did we work on last week",
65
+ "remind me what happened yesterday",
66
+ "did we ship it this month",
67
+ "what's on for next week",
68
+ "what did I say a couple of days ago",
69
+ "we discussed this 3 weeks ago",
70
+ "what did we decide on the 12th",
71
+ "the incident on tuesday",
72
+ "what did we do last tuesday",
73
+ "the plan from last night",
74
+ "back in june we agreed something",
75
+ "the deploy earlier today",
76
+ "the other day you mentioned a bug",
77
+ "the release two months ago",
78
+ ]
79
+
80
+ # Ordinary sentences whose tokens brush temporal words but carry no
81
+ # actual temporal expression — the negative guard must keep the field off.
82
+ NON_TEMPORAL = [
83
+ "how do I restart the hindsight container",
84
+ "explain the recall cache key",
85
+ "may I ask about the auth flow", # bare "may" (modal), not a month
86
+ "the friday deploy script is broken", # bare weekday, no preposition
87
+ "august is a bank name here", # bare month, no preposition
88
+ "summarise the current architecture",
89
+ "what is 2 plus 2",
90
+ ]
91
+
92
+ def test_temporal_phrases_map_to_the_injected_anchor(self):
93
+ for phrase in self.TEMPORAL:
94
+ with self.subTest(phrase=phrase):
95
+ self.assertEqual(
96
+ detect_query_timestamp(phrase, now=FIXED_NOW),
97
+ FIXED_ISO,
98
+ f"expected anchor for temporal phrase: {phrase!r}",
99
+ )
100
+
101
+ def test_non_temporal_text_returns_none(self):
102
+ for phrase in self.NON_TEMPORAL:
103
+ with self.subTest(phrase=phrase):
104
+ self.assertIsNone(
105
+ detect_query_timestamp(phrase, now=FIXED_NOW),
106
+ f"unexpected anchor for non-temporal phrase: {phrase!r}",
107
+ )
108
+
109
+ def test_empty_and_non_string_return_none(self):
110
+ self.assertIsNone(detect_query_timestamp("", now=FIXED_NOW))
111
+ self.assertIsNone(detect_query_timestamp(" ", now=FIXED_NOW))
112
+ self.assertIsNone(detect_query_timestamp(None, now=FIXED_NOW))
113
+ self.assertIsNone(detect_query_timestamp(42, now=FIXED_NOW))
114
+
115
+ def test_default_anchor_is_local_wall_clock_not_utc(self):
116
+ # No injected now → real clock. The anchor must carry the PROCESS-LOCAL
117
+ # offset (datetime.now().astimezone()), never a hardcoded UTC. A
118
+ # UTC-stamped anchor tells the engine the operator is in UTC and
119
+ # resolves "yesterday"/"last week"/"on the 12th" against the wrong
120
+ # calendar day for a Melbourne query — the exact off-by-one P2 serves.
121
+ out = detect_query_timestamp("what did we do yesterday")
122
+ self.assertIsNotNone(out)
123
+ parsed = datetime.fromisoformat(out)
124
+ self.assertIsNotNone(parsed.tzinfo, "anchor must be timezone-aware")
125
+ # The offset is the process-local one, whatever the CI TZ — not an
126
+ # assumed UTC. In the Melbourne container this is +10/+11, never +00:00.
127
+ self.assertEqual(
128
+ parsed.utcoffset(),
129
+ datetime.now().astimezone().utcoffset(),
130
+ "anchor offset must be the process-local offset, not UTC",
131
+ )
132
+
133
+ def test_injected_local_instant_keeps_the_local_day_not_the_utc_day(self):
134
+ # Pin a Melbourne-morning instant (UTC+10) whose UTC calendar day is
135
+ # the PREVIOUS day. The returned anchor must keep the LOCAL day (17th),
136
+ # because that is the operator's real "today"; a UTC conversion would
137
+ # slip it to the 16th and mis-resolve every relative phrase by a day.
138
+ aest = timezone(timedelta(hours=10))
139
+ local_now = datetime(2026, 8, 17, 9, 0, 0, tzinfo=aest)
140
+ # Guard the fixture itself: local and UTC really are different days.
141
+ self.assertEqual(local_now.date().isoformat(), "2026-08-17")
142
+ self.assertEqual(
143
+ local_now.astimezone(timezone.utc).date().isoformat(), "2026-08-16"
144
+ )
145
+ out = detect_query_timestamp("what did we do yesterday", now=local_now)
146
+ parsed = datetime.fromisoformat(out)
147
+ self.assertEqual(parsed.date().isoformat(), "2026-08-17")
148
+ self.assertEqual(parsed.utcoffset(), timedelta(hours=10))
149
+
150
+ def test_parser_is_pure_and_deterministic(self):
151
+ # Same input + same now → identical output, every call.
152
+ a = detect_query_timestamp("what did we work on last week", now=FIXED_NOW)
153
+ b = detect_query_timestamp("what did we work on last week", now=FIXED_NOW)
154
+ self.assertEqual(a, b)
155
+
156
+
157
+ class _RecordingClient(HindsightClient):
158
+ """Captures request bodies instead of putting them on a socket."""
159
+
160
+ def __init__(self, *a, **kw):
161
+ super().__init__(*a, **kw)
162
+ self.bodies = []
163
+
164
+ def _request(self, method, path, body=None, timeout=30):
165
+ self.bodies.append(body)
166
+ return {"results": []}
167
+
168
+
169
+ class WireBody(unittest.TestCase):
170
+ """What actually goes on the recall wire — the additive-field invariant."""
171
+
172
+ def setUp(self):
173
+ self.client = _RecordingClient("http://hindsight.invalid")
174
+
175
+ def test_unset_omits_the_key_entirely(self):
176
+ self.client.recall("bank", "a query")
177
+ # Not present-and-null — ABSENT. A pre-P2 body simply had no such key,
178
+ # so the engine's own current-time anchor stands.
179
+ self.assertNotIn("query_timestamp", self.client.bodies[0])
180
+
181
+ def test_explicit_none_omits_the_key_entirely(self):
182
+ self.client.recall("bank", "a query", query_timestamp=None)
183
+ self.assertNotIn("query_timestamp", self.client.bodies[0])
184
+
185
+ def test_unset_body_is_identical_to_a_pre_field_body(self):
186
+ # The literal body a pre-P2 client would have posted.
187
+ expected = {"query": "a query", "max_tokens": 1024, "budget": "mid"}
188
+ self.client.recall("bank", "a query")
189
+ self.assertEqual(self.client.bodies[0], expected)
190
+
191
+ def test_set_reaches_the_wire_verbatim(self):
192
+ self.client.recall("bank", "a query", query_timestamp=FIXED_ISO)
193
+ self.assertEqual(self.client.bodies[0]["query_timestamp"], FIXED_ISO)
194
+
195
+
196
+ class _E2EClient:
197
+ """Fake client that records the query_timestamp kwarg each bank sees."""
198
+
199
+ def __init__(self):
200
+ self.recall_kwargs = []
201
+
202
+ def list_directives(self, bank_id, active_only=True, timeout=2):
203
+ return {"items": []}
204
+
205
+ def recall(self, bank_id, query, **kwargs):
206
+ self.recall_kwargs.append(kwargs.get("query_timestamp"))
207
+ return {"results": []}
208
+
209
+
210
+ class _StrictSignatureClient:
211
+ """A pre-P2 client: recall() takes the exact old keyword set, with NO
212
+ query_timestamp and NO **kwargs. Handing it the kwarg would TypeError on
213
+ bind (before the body), so `calls` only increments on a clean pre-P2 call.
214
+ """
215
+
216
+ def __init__(self):
217
+ self.calls = 0
218
+
219
+ def list_directives(self, bank_id, active_only=True, timeout=2):
220
+ return {"items": []}
221
+
222
+ def recall(
223
+ self,
224
+ bank_id,
225
+ query,
226
+ max_tokens=1024,
227
+ budget="mid",
228
+ types=None,
229
+ tags=None,
230
+ tags_match=None,
231
+ tag_groups=None,
232
+ prefer_observations=None,
233
+ timeout=10,
234
+ ):
235
+ self.calls += 1
236
+ return {"results": []}
237
+
238
+
239
+ class RecallLogRow(unittest.TestCase):
240
+ """Drives recall.main() end to end with an isolated recall log."""
241
+
242
+ def setUp(self):
243
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-qts-test-")
244
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
245
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
246
+
247
+ def tearDown(self):
248
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
249
+ if self._prev is None:
250
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
251
+ else:
252
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
253
+
254
+ def _log_row(self):
255
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
256
+ with open(path, encoding="utf-8") as fh:
257
+ rows = [json.loads(line) for line in fh if line.strip()]
258
+ self.assertTrue(rows, "no recall_log row was written")
259
+ return rows[-1]
260
+
261
+ def _run(self, prompt, client, config_extra=None, cache_hit_context=None):
262
+ hook_input = {
263
+ "prompt": prompt,
264
+ "session_id": "test-session",
265
+ "transcript_path": "",
266
+ "cwd": "/tmp",
267
+ }
268
+ config = {
269
+ "autoRecall": True,
270
+ "bankId": OWN,
271
+ "recallMaxTokens": 1024,
272
+ "recallBudget": "mid",
273
+ "recallContextTurns": 1,
274
+ "recallMaxQueryChars": 800,
275
+ "recallPromptPreamble": "",
276
+ "recallParallelDeadlineSeconds": 5,
277
+ "directivesCacheTtlSeconds": 0,
278
+ }
279
+ if config_extra:
280
+ config.update(config_extra)
281
+ # Cache-hit path: a positive TTL plus a lookup that returns context
282
+ # makes run_recall take the early cache-hit branch (no bank runs).
283
+ cache_ttl = 300 if cache_hit_context is not None else 0
284
+ stdout = io.StringIO()
285
+ stderr = io.StringIO()
286
+ with patch.object(recall, "load_config", return_value=config), patch.object(
287
+ recall, "get_api_url", return_value="http://localhost:18888"
288
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
289
+ recall, "ensure_bank_mission", return_value=None
290
+ ), patch.object(recall, "write_state", return_value=None), patch.object(
291
+ recall, "_cache_ttl_secs", return_value=cache_ttl
292
+ ), patch.object(
293
+ recall, "_cache_lookup", return_value=cache_hit_context
294
+ ), patch.object(recall, "_cache_store", return_value=None), patch(
295
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
296
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
297
+ recall.main()
298
+
299
+ def test_temporal_prompt_logs_and_sends_an_anchor(self):
300
+ client = _E2EClient()
301
+ self._run("what did we work on last week", client)
302
+ row = self._log_row()
303
+ self.assertIn("query_timestamp", row)
304
+ self.assertIsNotNone(row["query_timestamp"], "temporal turn must log an anchor")
305
+ # And the same anchor reached the bank on the wire.
306
+ self.assertEqual(client.recall_kwargs, [row["query_timestamp"]])
307
+ # It is a parseable, tz-aware ISO string (engine 400s otherwise).
308
+ parsed = datetime.fromisoformat(row["query_timestamp"])
309
+ self.assertIsNotNone(parsed.tzinfo)
310
+
311
+ def test_non_temporal_prompt_logs_null_and_sends_nothing(self):
312
+ client = _E2EClient()
313
+ self._run("how do I restart the hindsight container", client)
314
+ row = self._log_row()
315
+ self.assertIn("query_timestamp", row)
316
+ self.assertIsNone(row["query_timestamp"])
317
+ # No anchor on the wire — byte-identical to pre-P2 behaviour.
318
+ self.assertEqual(client.recall_kwargs, [None])
319
+
320
+ def test_gate_off_suppresses_the_field_on_a_temporal_prompt(self):
321
+ client = _E2EClient()
322
+ self._run(
323
+ "what did we work on last week",
324
+ client,
325
+ config_extra={"recallQueryTimestamp": False},
326
+ )
327
+ row = self._log_row()
328
+ self.assertIsNone(row["query_timestamp"], "gate off must not send an anchor")
329
+ self.assertEqual(client.recall_kwargs, [None])
330
+
331
+ def test_narrow_client_signature_is_safe_on_a_non_temporal_turn(self):
332
+ # Nit #3 — the conditional-passing guarantee. A client whose recall()
333
+ # has the pre-P2 signature (no query_timestamp, no **kwargs) must NOT
334
+ # be handed the kwarg on a non-temporal turn. If it were (unconditional
335
+ # `=None`), binding would TypeError BEFORE the body runs, so `.calls`
336
+ # would stay 0 and the turn would degrade. calls==1 proves recall was
337
+ # entered cleanly with a byte-identical pre-P2 call.
338
+ client = _StrictSignatureClient()
339
+ self._run("how do I restart the hindsight container", client)
340
+ row = self._log_row()
341
+ self.assertEqual(client.calls, 1, "narrow-signature recall must run, not TypeError")
342
+ self.assertIsNone(row["query_timestamp"])
343
+
344
+ def test_cache_hit_row_carries_the_field(self):
345
+ # Nit #4 — the cache-hit log site (no bank runs) must still carry
346
+ # query_timestamp for a uniformly queryable schema and firing-rate
347
+ # measurement. A temporal prompt on a cache hit logs the anchor.
348
+ client = _E2EClient()
349
+ self._run(
350
+ "what did we work on last week",
351
+ client,
352
+ cache_hit_context="<hindsight_memories>cached</hindsight_memories>",
353
+ )
354
+ # No bank ran on the hit — the anchor was computed but never sent.
355
+ self.assertEqual(client.recall_kwargs, [])
356
+ row = self._log_row()
357
+ self.assertIn("query_timestamp", row)
358
+ self.assertIsNotNone(row["query_timestamp"], "cache-hit temporal turn must log the anchor")
359
+
360
+ def test_cache_hit_row_logs_null_on_a_non_temporal_turn(self):
361
+ # Nit #4 companion — cache-hit + no temporal phrase → null field, not
362
+ # a missing key (schema uniformity).
363
+ client = _E2EClient()
364
+ self._run(
365
+ "how do I restart the hindsight container",
366
+ client,
367
+ cache_hit_context="<hindsight_memories>cached</hindsight_memories>",
368
+ )
369
+ self.assertEqual(client.recall_kwargs, [])
370
+ row = self._log_row()
371
+ self.assertIn("query_timestamp", row)
372
+ self.assertIsNone(row["query_timestamp"])
373
+
374
+
375
+ if __name__ == "__main__":
376
+ unittest.main()