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,335 @@
1
+ """RFC phase4 P3 — unit + end-to-end tests for the operator-profile capture nudge.
2
+
3
+ Ken's first stated want is "save memories about him". Auto-retain stores
4
+ transcript facts, but nothing gives a DETERMINISTIC signal that a durable
5
+ *profile fact* about the operator himself just went by — so profile capture is
6
+ left to model discretion (the same per-agent lottery Stage A measured for
7
+ directives). P3 mirrors the shipped directive-capture nudge (recall.py #2848):
8
+ a POSITIVE regex detects a first-person durable self-statement, a NEGATIVE
9
+ regex scrubs the two false-positive shapes (questions, attributions to others)
10
+ BEFORE the positive match, and on a hit the UserPromptSubmit hook appends a
11
+ terse advisory telling the model to persist it with an explicit retain tagged
12
+ `profile:ken` into the agent's OWN bank. Pure regex — no model callsite.
13
+
14
+ These tests pin (all OUTCOME assertions):
15
+ * True positives — the RFC's listed profile shapes fire.
16
+ * True negatives — questions and third-/second-party attributions do NOT
17
+ fire (the negative-lookaround guard scrubs them first).
18
+ * The advisory carries the `profile:ken` tag instruction and targets the
19
+ agent's OWN bank (not a shared / cross-agent person bank).
20
+ * End-to-end through recall.main(): the advisory reaches the emitted
21
+ additionalContext, the recall_log row carries `profile_nudge: true`, and
22
+ the config knob OFF (`profileCaptureNudge: false`) suppresses both.
23
+
24
+ Stdlib-only; runs under `python3 -m unittest discover tests/`. The end-to-end
25
+ harness mirrors test_recall_envelope_strip_telemetry.py.
26
+ """
27
+
28
+ import io
29
+ import json
30
+ import os
31
+ import shutil
32
+ import sys
33
+ import tempfile
34
+ import unittest
35
+ from unittest.mock import patch
36
+
37
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
38
+ if SCRIPTS_DIR not in sys.path:
39
+ sys.path.insert(0, SCRIPTS_DIR)
40
+
41
+ import recall # noqa: E402
42
+ from recall import ( # noqa: E402
43
+ _PROFILE_CAPTURE_NUDGE,
44
+ _combine_context,
45
+ _is_trivial_stateless,
46
+ looks_like_profile_statement,
47
+ )
48
+
49
+
50
+ # First-person durable self-statements that MUST fire the nudge. Drawn from the
51
+ # RFC P3 shapes ("I prefer …", "my … is …", "I always …", "remind me that I …")
52
+ # plus the conservative identity/situation set.
53
+ PROFILE_STATEMENTS = [
54
+ # --- stated preferences ---
55
+ "I prefer dark roast coffee",
56
+ "I'd prefer British spelling in my docs",
57
+ "my preference is tabs over spaces",
58
+ # --- durable self-facts: "my <ATTRIBUTE> is/are …" (tight allow-list) ---
59
+ "my timezone is Australia/Melbourne",
60
+ "my email is ken@example.com",
61
+ "my sister is Lisa",
62
+ "my kids are at school during the day",
63
+ "my name's Ken", # contraction form
64
+ # --- identity / situation ---
65
+ "I live in Melbourne",
66
+ "I work at Anthropic",
67
+ "I'm allergic to peanuts",
68
+ "I'm based in Australia",
69
+ # --- durable identity: diet / abstention / "I'm a <noun>" ---
70
+ "I'm a vegetarian",
71
+ "I don't eat meat",
72
+ "call me Ken",
73
+ # --- tastes (durable like/dislike framing) ---
74
+ "I hate em-dashes",
75
+ # --- durable habits ---
76
+ "I always take my coffee black",
77
+ "I usually work late on Thursdays",
78
+ "I never eat red meat",
79
+ # --- explicit memory framing about the operator himself ---
80
+ "remind me that I have a standing 9am standup",
81
+ "remember that I hate em-dashes",
82
+ ]
83
+
84
+ # Questions and attributions that MUST NOT fire — the negative-lookaround guard
85
+ # scrubs these BEFORE the positive match. A false positive here is nudge noise.
86
+ NON_PROFILE = [
87
+ # --- questions about the operator (not statements of a durable fact) ---
88
+ "do I prefer tea or coffee?",
89
+ "what's my timezone?",
90
+ "where is my email address stored?",
91
+ "how do I fix this bug",
92
+ "should I always run the tests first?",
93
+ "what do I usually do here",
94
+ "remind me what my calendar looks like",
95
+ # --- attributions to a third / second party ---
96
+ "you said I prefer tea",
97
+ "she claims my code is broken",
98
+ "he thinks I always overcomplicate things",
99
+ "they told me my access was revoked",
100
+ # --- neither: no first-person durable self-fact ---
101
+ "the project timezone is UTC",
102
+ "please run the tests",
103
+ "what time is it",
104
+ # --- discourse-marker "my <X> is" — not a durable profile fact. The
105
+ # positive arm uses a TIGHT identity allow-list, so a free noun never
106
+ # reaches the matcher; these confirm that. ---
107
+ "my guess is the cache is stale",
108
+ "my point is that we should ship it",
109
+ "my concern is the timeout",
110
+ # --- transient dev state "my <transient> is/are …". These are the exact
111
+ # over-fires the free-`\w+` arm produced; the allow-list must NOT fire
112
+ # on them (RFC favour-false-negatives constraint on this agent). ---
113
+ "my container is down",
114
+ "my build is failing",
115
+ "my code is broken",
116
+ "my PR is ready",
117
+ "my worktree is dirty",
118
+ "my server is down",
119
+ "my deploy is stuck",
120
+ "my tests are green",
121
+ "my branch is merged",
122
+ # --- pleasantry embedding a bare always/never after "I" ---
123
+ "I always appreciate your help",
124
+ "I never enjoy waiting, but thanks",
125
+ # --- "I'm a <hedge>" is a transient mood, not an "I'm a <noun>" identity ---
126
+ "I'm a bit tired",
127
+ "I'm a little confused about the config",
128
+ "I'm a big fan of shipping fast",
129
+ # --- "call me <phrasing>" as a request, not a name form ---
130
+ "call me back later",
131
+ "call me when the build finishes",
132
+ # --- the <channel …> envelope wrapper on its own must never trigger ---
133
+ '<channel user="ken" chat_id="123">',
134
+ ]
135
+
136
+
137
+ class TestProfileDetection(unittest.TestCase):
138
+ def test_profile_statements_fire_the_nudge(self):
139
+ for p in PROFILE_STATEMENTS:
140
+ with self.subTest(prompt=p):
141
+ self.assertTrue(
142
+ looks_like_profile_statement(p),
143
+ f"expected profile detection for {p!r}",
144
+ )
145
+
146
+ def test_questions_and_attributions_do_not_fire(self):
147
+ for p in NON_PROFILE:
148
+ with self.subTest(prompt=p):
149
+ self.assertFalse(
150
+ looks_like_profile_statement(p),
151
+ f"FALSE POSITIVE: profile nudge would fire for {p!r}",
152
+ )
153
+
154
+ def test_empty_and_non_string_are_false(self):
155
+ for bad in ("", " ", None, 123, [], {}):
156
+ with self.subTest(value=bad):
157
+ self.assertFalse(looks_like_profile_statement(bad))
158
+
159
+ def test_attribution_scrub_does_not_mask_a_real_self_fact(self):
160
+ # A message that opens with an attributed clause AND then states the
161
+ # operator's own durable fact must still fire — the negative guard
162
+ # scrubs only the attributed span (through end-of-sentence).
163
+ self.assertTrue(
164
+ looks_like_profile_statement(
165
+ "she thinks I'm wrong. my timezone is Melbourne"
166
+ )
167
+ )
168
+
169
+ def test_attribution_scrub_stops_at_a_comma(self):
170
+ # The attributed span must stop at a comma, not run to end-of-sentence:
171
+ # a real self-fact trailing the attributed clause in the SAME sentence
172
+ # must still reach the positive matcher (MINOR: greedy [^.?!]* → [^.?!,]*).
173
+ self.assertTrue(
174
+ looks_like_profile_statement(
175
+ "she said the deploy failed, my timezone is Melbourne"
176
+ )
177
+ )
178
+
179
+
180
+ class TestProfileNudgeString(unittest.TestCase):
181
+ def test_nudge_carries_the_profile_ken_tag_instruction(self):
182
+ self.assertIn("profile:ken", _PROFILE_CAPTURE_NUDGE)
183
+ self.assertIn("retain", _PROFILE_CAPTURE_NUDGE)
184
+ self.assertIn("profile_capture_check", _PROFILE_CAPTURE_NUDGE)
185
+
186
+ def test_nudge_targets_the_agents_own_bank_not_a_shared_one(self):
187
+ # Constraint 2 forbids a cross-agent person bank — the advisory must
188
+ # route to the agent's OWN bank and say so explicitly.
189
+ self.assertIn("OWN bank", _PROFILE_CAPTURE_NUDGE)
190
+ self.assertIn("shared", _PROFILE_CAPTURE_NUDGE)
191
+
192
+ def test_combine_appends_nudge_after_recall_block(self):
193
+ base = "<hindsight_memories>\n…\n</hindsight_memories>"
194
+ out = _combine_context(base, _PROFILE_CAPTURE_NUDGE)
195
+ self.assertTrue(out.startswith(base))
196
+ self.assertTrue(out.endswith(_PROFILE_CAPTURE_NUDGE))
197
+
198
+
199
+ class TestTrivialSkipUnaffected(unittest.TestCase):
200
+ """A profile statement carries personal/stateful signal and must never be
201
+ trivial-stateless-skipped; greetings still are."""
202
+
203
+ def test_profile_statements_are_never_trivial_skipped(self):
204
+ for p in PROFILE_STATEMENTS:
205
+ with self.subTest(prompt=p):
206
+ self.assertFalse(
207
+ _is_trivial_stateless("", p),
208
+ f"profile statement {p!r} was trivial-skipped",
209
+ )
210
+
211
+
212
+ # --- End-to-end harness (mirrors test_recall_envelope_strip_telemetry.py) ---
213
+
214
+
215
+ class _RecordingClient:
216
+ def __init__(self, memories=None, directives=None):
217
+ self._memories = memories if memories is not None else []
218
+ self._directives = directives if directives is not None else []
219
+ self.queries = []
220
+
221
+ def list_directives(self, bank_id, active_only=True, timeout=2):
222
+ return {"items": list(self._directives)}
223
+
224
+ def recall(self, bank_id, query, **kwargs):
225
+ self.queries.append(query)
226
+ return {"results": list(self._memories)}
227
+
228
+
229
+ def _run_main_with(client, prompt, config_extra=None):
230
+ hook_input = {
231
+ "prompt": prompt,
232
+ "session_id": "test-session",
233
+ "transcript_path": "",
234
+ "cwd": "/tmp",
235
+ }
236
+ config = {
237
+ "autoRecall": True,
238
+ "bankId": "test-bank",
239
+ "recallMaxTokens": 1024,
240
+ "recallBudget": "mid",
241
+ "recallContextTurns": 1,
242
+ "recallMaxQueryChars": 800,
243
+ "recallPromptPreamble": "",
244
+ }
245
+ if config_extra:
246
+ config.update(config_extra)
247
+
248
+ stdout = io.StringIO()
249
+ stderr = io.StringIO()
250
+ with patch.object(recall, "load_config", return_value=config), patch.object(
251
+ recall, "get_api_url", return_value="http://localhost:18888"
252
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
253
+ recall, "ensure_bank_mission", return_value=None
254
+ ), patch.object(recall, "write_state", return_value=None), patch(
255
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
256
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
257
+ recall.main()
258
+
259
+ raw = stdout.getvalue()
260
+ if not raw.strip():
261
+ return None, raw
262
+ parsed = json.loads(raw)
263
+ return parsed["hookSpecificOutput"]["additionalContext"], raw
264
+
265
+
266
+ # A profile-only prompt: matches the profile regex but NOT the directive regex,
267
+ # so these end-to-end assertions isolate the profile nudge cleanly.
268
+ PROFILE_PROMPT = "my timezone is Australia/Melbourne"
269
+
270
+
271
+ class _LogTestBase(unittest.TestCase):
272
+ def setUp(self):
273
+ self._tmpdir = tempfile.mkdtemp(prefix="profile-nudge-test-")
274
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
275
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
276
+
277
+ def tearDown(self):
278
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
279
+ if self._prev is None:
280
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
281
+ else:
282
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
283
+
284
+ def _read_log(self):
285
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
286
+ if not os.path.isfile(path):
287
+ return []
288
+ with open(path, encoding="utf-8") as f:
289
+ return [json.loads(line) for line in f if line.strip()]
290
+
291
+
292
+ class ProfileNudgeEndToEnd(_LogTestBase):
293
+ def test_advisory_reaches_context_and_row_records_it(self):
294
+ client = _RecordingClient(memories=[{"text": "m", "type": "fact",
295
+ "mentioned_at": "2026-01-01", "id": "m1"}])
296
+ ctx, _raw = _run_main_with(client, prompt=PROFILE_PROMPT)
297
+ # The profile advisory is injected into the turn context, tagged and
298
+ # own-bank-scoped.
299
+ self.assertIsNotNone(ctx)
300
+ self.assertIn("profile:ken", ctx)
301
+ self.assertIn("OWN bank", ctx)
302
+ # The recall_log row carries the firing-rate boolean.
303
+ entries = self._read_log()
304
+ self.assertEqual(len(entries), 1)
305
+ self.assertTrue(entries[0]["profile_nudge"])
306
+
307
+ def test_knob_off_suppresses_nudge_and_row_is_false(self):
308
+ client = _RecordingClient(memories=[{"text": "m", "type": "fact",
309
+ "mentioned_at": "2026-01-01", "id": "m1"}])
310
+ ctx, _raw = _run_main_with(
311
+ client, prompt=PROFILE_PROMPT,
312
+ config_extra={"profileCaptureNudge": False},
313
+ )
314
+ # Nudge suppressed: the advisory is absent from context (memories may
315
+ # still be injected, but never the profile block).
316
+ if ctx is not None:
317
+ self.assertNotIn("profile:ken", ctx)
318
+ self.assertNotIn("profile_capture_check", ctx)
319
+ entries = self._read_log()
320
+ self.assertEqual(len(entries), 1)
321
+ self.assertFalse(entries[0]["profile_nudge"])
322
+
323
+ def test_non_profile_prompt_does_not_fire(self):
324
+ client = _RecordingClient(memories=[{"text": "m", "type": "fact",
325
+ "mentioned_at": "2026-01-01", "id": "m1"}])
326
+ ctx, _raw = _run_main_with(client, prompt="please run the tests")
327
+ if ctx is not None:
328
+ self.assertNotIn("profile:ken", ctx)
329
+ entries = self._read_log()
330
+ self.assertEqual(len(entries), 1)
331
+ self.assertFalse(entries[0]["profile_nudge"])
332
+
333
+
334
+ if __name__ == "__main__":
335
+ unittest.main()
@@ -470,6 +470,59 @@ class RecallTelemetryLogTests(unittest.TestCase):
470
470
  self.assertIsNone(e["injected_score_median"])
471
471
  self.assertIsNone(e["injected_score_max"])
472
472
 
473
+ def test_logs_injected_directive_ids(self):
474
+ """Memory-redesign step 1 (E-45 recommendation (b)): the recall_log
475
+ row must name WHICH directives were injected, not just how many.
476
+ `directive_count` alone can't answer "which directives were never
477
+ once injected" — this makes that queryable.
478
+ """
479
+ directives = [
480
+ _directive("first", "content one", priority=10),
481
+ _directive("second", "content two", priority=5),
482
+ ]
483
+ client = _FakeClient(directives=directives, memories=[])
484
+ _run_main_with(client)
485
+ e = self._read_log()[0]
486
+ self.assertEqual(e["directive_count"], 2)
487
+ self.assertEqual(e["directive_ids"], ["id-first", "id-second"])
488
+
489
+ def test_directive_ids_preserve_priority_order_across_more_than_a_few(self):
490
+ """Real values across several directives, in the rendered priority
491
+ order — not just a two-item happy path."""
492
+ directives = [
493
+ _directive(f"d{i}", f"c{i}", priority=10 - i) for i in range(6)
494
+ ]
495
+ client = _FakeClient(directives=directives, memories=[])
496
+ _run_main_with(client)
497
+ e = self._read_log()[0]
498
+ self.assertEqual(e["directive_count"], 6)
499
+ self.assertEqual(
500
+ e["directive_ids"],
501
+ ["id-d0", "id-d1", "id-d2", "id-d3", "id-d4", "id-d5"],
502
+ )
503
+
504
+ def test_directive_ids_null_on_cache_hit(self):
505
+ """A cache hit replays a formatted context block, not a fetched
506
+ directive list — `directive_ids` must be null (schema-uniform with
507
+ `directive_count`/`directives_omitted`), never a stale prior value.
508
+
509
+ Forces the cache-HIT branch directly (rather than relying on a
510
+ second `main()` call actually persisting a cache entry — the
511
+ integration harness patches `write_state` to a no-op, so
512
+ `_cache_store` never lands between calls)."""
513
+ client = _FakeClient(directives=[_directive("only", "content", priority=5)], memories=[])
514
+ with patch.object(recall, "_cache_lookup", return_value="[Hindsight] cached context"), \
515
+ patch.dict(os.environ, {"HINDSIGHT_RECALL_CACHE_TTL_SECS": "300"}):
516
+ _run_main_with(client)
517
+ entries = self._read_log()
518
+ self.assertEqual(len(entries), 1)
519
+ e = entries[0]
520
+ self.assertTrue(e["cache_hit"])
521
+ self.assertIsNone(e["directive_ids"])
522
+ # Schema-uniform with the fields it mirrors.
523
+ self.assertIsNone(e["directive_count"])
524
+ self.assertIsNone(e["directives_omitted"])
525
+
473
526
  def test_no_log_when_plugin_data_unset(self):
474
527
  # If CLAUDE_PLUGIN_DATA isn't set, the writer no-ops silently —
475
528
  # we don't want a stray log file in the working directory.