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
@@ -28,6 +28,7 @@ from lib.directives import ( # noqa: E402
28
28
  fetch_active_directives,
29
29
  fetch_active_directives_cached,
30
30
  format_active_directives_block,
31
+ injected_directive_ids,
31
32
  invalidate_directives_cache,
32
33
  parse_active_directives_block,
33
34
  rule_already_captured,
@@ -242,6 +243,58 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
242
243
  # The in-prompt footer is still emitted (agent-facing signal).
243
244
  self.assertIn("(+4 more, omitted)", out)
244
245
 
246
+ def test_overflow_footer_is_loud_and_operator_directed(self):
247
+ """memory-RFC P7 — the in-prompt overflow notice must be LOUD and tell
248
+ the agent to surface the drop to the operator this turn.
249
+
250
+ The pre-P7 block emitted only the quiet `(+N more, omitted)`
251
+ parenthetical, which reaches the agent but neither states that explicit
252
+ instructions were dropped nor directs the agent to relay it. This
253
+ asserts the OUTCOME of the loud in-turn channel: the rendered block, on
254
+ overflow, names the dropped count, the total, the cap, states the loss
255
+ in unmistakable terms, and instructs the agent to tell the operator.
256
+ A test that only checked `(+N more, omitted)` would still pass on the
257
+ pre-P7 code, so it would not guard this change; these assertions fail
258
+ without it.
259
+ """
260
+ total = MAX_DIRECTIVES + 3
261
+ directives = [
262
+ _directive(f"d{i}", f"c{i}", priority=total - i) for i in range(total)
263
+ ]
264
+ with patch("sys.stderr", new=StringIO()):
265
+ out = format_active_directives_block(directives)
266
+ # A loud, explicitly-labelled overflow banner (not a lone parenthetical).
267
+ self.assertIn("DIRECTIVE OVERFLOW", out)
268
+ # Names the loss in unmistakable terms, with count/total/cap.
269
+ self.assertIn("DROPPED", out)
270
+ self.assertIn("3 of", out) # omitted of total
271
+ self.assertIn(str(total), out)
272
+ self.assertIn(f"MAX_DIRECTIVES={MAX_DIRECTIVES}", out)
273
+ # Directs the agent to surface it to the OPERATOR this turn — the
274
+ # in-turn operator-visible channel P7 adds.
275
+ self.assertIn("operator", out.lower())
276
+ self.assertIn("ACTION", out)
277
+ # The literal marker is retained for the recall_log/doctor cross-checks
278
+ # and the dedup parser.
279
+ self.assertIn("(+3 more, omitted)", out)
280
+
281
+ def test_no_overflow_banner_when_under_cap(self):
282
+ """The loud banner must appear ONLY on overflow — an under-cap block is
283
+ unchanged and carries neither the banner nor the action directive."""
284
+ directives = [_directive(f"d{i}", f"c{i}", priority=5 - i) for i in range(3)]
285
+ with patch("sys.stderr", new=StringIO()) as fake_err:
286
+ out = format_active_directives_block(directives)
287
+ self.assertNotIn("DIRECTIVE OVERFLOW", out)
288
+ self.assertNotIn("ACTION", out)
289
+ self.assertNotIn("more, omitted", out)
290
+ self.assertEqual(fake_err.getvalue(), "")
291
+
292
+ def test_max_directives_is_unchanged_by_p7(self):
293
+ """P7 is the loud-channel PR only. Raising MAX_DIRECTIVES is a separate,
294
+ later change (RFC §5 P7: order is non-negotiable). Pin the constant so a
295
+ cap bump cannot ride in on this change unnoticed."""
296
+ self.assertEqual(MAX_DIRECTIVES, 30)
297
+
245
298
  def test_count_omitted_directives_matches_the_rendered_footer(self):
246
299
  """The recall_log's `directives_omitted` number must equal what the
247
300
  block actually dropped — it is the operator-visible record of it."""
@@ -260,6 +313,51 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
260
313
  # Honours a custom cap the same way the formatter does.
261
314
  self.assertEqual(count_omitted_directives(directives, max_directives=5), total - 5)
262
315
 
316
+ def test_injected_directive_ids_matches_the_rendered_block(self):
317
+ """Memory-redesign step 1 (E-45 recommendation (b)): the id list
318
+ `recall.py` puts on the recall_log row must name exactly the
319
+ directives `format_active_directives_block` actually rendered —
320
+ not the full fetched set, and in the same priority order."""
321
+ total = MAX_DIRECTIVES + 4
322
+ directives = [
323
+ _directive(f"d{i}", f"c{i}", priority=total - i) for i in range(total)
324
+ ]
325
+ with patch("sys.stderr", new=StringIO()):
326
+ out = format_active_directives_block(directives)
327
+ ids = injected_directive_ids(directives)
328
+ self.assertEqual(len(ids), MAX_DIRECTIVES)
329
+ # Real, concrete values — the head-slice in priority order, not a
330
+ # placeholder or a count.
331
+ self.assertEqual(ids, [f"id-d{i}" for i in range(MAX_DIRECTIVES)])
332
+ # Every injected id is actually present in the rendered block...
333
+ for i in range(MAX_DIRECTIVES):
334
+ self.assertIn(f"d{i}: c{i}", out)
335
+ # ...and the omitted tail's ids are excluded.
336
+ for i in range(MAX_DIRECTIVES, total):
337
+ self.assertNotIn(f"id-d{i}", ids)
338
+ self.assertNotIn(f"d{i}: c{i}", out)
339
+
340
+ def test_injected_directive_ids_under_cap_returns_all(self):
341
+ directives = [_directive(f"d{i}", f"c{i}", priority=5 - i) for i in range(3)]
342
+ self.assertEqual(injected_directive_ids(directives), ["id-d0", "id-d1", "id-d2"])
343
+
344
+ def test_injected_directive_ids_empty_list(self):
345
+ self.assertEqual(injected_directive_ids([]), [])
346
+
347
+ def test_injected_directive_ids_skips_malformed_entries(self):
348
+ directives = [
349
+ _directive("good", "content", priority=9),
350
+ {"priority": 5}, # no id — must not crash or contribute a None
351
+ "not-a-dict",
352
+ ]
353
+ self.assertEqual(injected_directive_ids(directives), ["id-good"])
354
+
355
+ def test_injected_directive_ids_honours_custom_cap(self):
356
+ directives = [_directive(f"d{i}", f"c{i}", priority=10 - i) for i in range(5)]
357
+ self.assertEqual(
358
+ injected_directive_ids(directives, max_directives=2), ["id-d0", "id-d1"]
359
+ )
360
+
263
361
  def test_no_warning_when_nothing_is_truncated(self):
264
362
  directives = [_directive("only", "single", priority=5)]
265
363
  with patch("sys.stderr", new=StringIO()) as fake_err:
@@ -0,0 +1,293 @@
1
+ """Switchroom memory-RFC P1 — incremental SessionEnd sweep, OUTCOME tests.
2
+
3
+ Before P1 the SessionEnd hook called ``run_retain(force=True)`` which retained
4
+ the WHOLE transcript, duplicating content the per-window retains already landed
5
+ (RFC §1.2). P1 makes the forced chunked sweep slice only the transcript tail
6
+ after the committed watermark, degrading to the whole transcript on any failure
7
+ so the §4.3 hazard (a raise here DELETES a turn) is never triggered.
8
+
9
+ Stdlib-only (`python3 -m unittest discover tests/`). Every test drives the real
10
+ hook code against a FAKE in-process daemon (no network, no LLM) and asserts
11
+ OUTCOMES — the bytes/turns that actually landed in the bank — not that a code
12
+ path ran.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import tempfile
20
+ import unittest
21
+ from unittest import mock
22
+
23
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
24
+ if SCRIPTS_DIR not in sys.path:
25
+ sys.path.insert(0, SCRIPTS_DIR)
26
+
27
+ import retain # noqa: E402
28
+ from lib import watermark # noqa: E402
29
+ from lib.client import HindsightClient # noqa: E402
30
+
31
+
32
+ class FakeDaemon:
33
+ """Records retained documents by document_id with upsert semantics."""
34
+
35
+ def __init__(self):
36
+ self.docs = {} # document_id -> content
37
+ self.posts = [] # [(document_id, async_processing)]
38
+
39
+ def retain(self, bank_id, content, document_id="conversation", context=None,
40
+ metadata=None, tags=None, timeout=15, async_processing=True,
41
+ observation_scopes=None):
42
+ self.posts.append((document_id, async_processing))
43
+ # Upsert: same document_id overwrites (the daemon contract §1).
44
+ self.docs[document_id] = content
45
+ return {"ok": True}
46
+
47
+ def content_blob(self):
48
+ return "\n".join(self.docs.values())
49
+
50
+ def bytes_for(self, session_prefix: str) -> int:
51
+ """Total stored content bytes across every document for a session."""
52
+ return sum(
53
+ len(c.encode("utf-8"))
54
+ for doc_id, c in self.docs.items()
55
+ if doc_id.startswith(session_prefix)
56
+ )
57
+
58
+ def last_post_content(self) -> str:
59
+ return self.docs[self.posts[-1][0]]
60
+
61
+
62
+ def _write_transcript(path, n_turns, prefix):
63
+ """Flat-format JSONL: n_turns human turns, each user+assistant with a uuid."""
64
+ lines = []
65
+ for i in range(n_turns):
66
+ lines.append(json.dumps(
67
+ {"role": "user", "content": f"user turn {i}", "uuid": f"{prefix}-u{i}"}))
68
+ lines.append(json.dumps(
69
+ {"role": "assistant", "content": f"assistant turn {i}", "uuid": f"{prefix}-a{i}"}))
70
+ with open(path, "w", encoding="utf-8") as f:
71
+ f.write("\n".join(lines))
72
+
73
+
74
+ def _stdin(obj):
75
+ import io
76
+ return io.StringIO(json.dumps(obj))
77
+
78
+
79
+ class IncrementalSweepBase(unittest.TestCase):
80
+ def setUp(self):
81
+ self.tmp = tempfile.mkdtemp(prefix="hs-rfc-p1-")
82
+ self.plugin_root = os.path.join(self.tmp, "plugin_root")
83
+ self.home = os.path.join(self.tmp, "home")
84
+ self.data = os.path.join(self.tmp, "data")
85
+ self.transcripts = os.path.join(self.tmp, "transcripts")
86
+ for d in (self.plugin_root, self.home, self.data, self.transcripts):
87
+ os.makedirs(d)
88
+
89
+ self._write_settings(8) # default cadence for these tests; overridable
90
+
91
+ self.env = mock.patch.dict(os.environ, {
92
+ "CLAUDE_PLUGIN_ROOT": self.plugin_root,
93
+ "CLAUDE_PLUGIN_DATA": self.data,
94
+ "HOME": self.home,
95
+ "HINDSIGHT_PENDING_DIR": os.path.join(self.home, ".hindsight", "pending-retains"),
96
+ "HINDSIGHT_RETAINED_DIR": os.path.join(self.home, ".hindsight", "retained"),
97
+ "HINDSIGHT_INFLIGHT_LOCK": os.path.join(self.home, ".hindsight", "retain-inflight.lock"),
98
+ "HINDSIGHT_TRANSCRIPTS_DIR": self.transcripts,
99
+ }, clear=False)
100
+ self.env.start()
101
+ for k in list(os.environ):
102
+ if k.startswith("HINDSIGHT_") and k not in (
103
+ "HINDSIGHT_PENDING_DIR", "HINDSIGHT_RETAINED_DIR",
104
+ "HINDSIGHT_INFLIGHT_LOCK", "HINDSIGHT_TRANSCRIPTS_DIR",
105
+ ):
106
+ os.environ.pop(k, None)
107
+
108
+ self.daemon = FakeDaemon()
109
+ self._patches = [
110
+ mock.patch.object(HindsightClient, "retain", self._fake_retain),
111
+ mock.patch("retain.get_api_url", return_value="http://fake"),
112
+ ]
113
+ for p in self._patches:
114
+ p.start()
115
+
116
+ def _write_settings(self, every_n_turns: int):
117
+ settings = {
118
+ "autoRetain": True,
119
+ "retainMode": "chunked",
120
+ "retainEveryNTurns": every_n_turns,
121
+ "retainOverlapTurns": 0,
122
+ "bankId": "test-bank",
123
+ }
124
+ with open(os.path.join(self.plugin_root, "settings.json"), "w") as f:
125
+ json.dump(settings, f)
126
+
127
+ def _fake_retain(self, *a, **kw):
128
+ return self.daemon.retain(*a, **kw)
129
+
130
+ def tearDown(self):
131
+ for p in self._patches:
132
+ p.stop()
133
+ self.env.stop()
134
+ shutil.rmtree(self.tmp, ignore_errors=True)
135
+
136
+ def _hook(self, session):
137
+ return {
138
+ "session_id": session,
139
+ "transcript_path": os.path.join(self.transcripts, f"{session}.jsonl"),
140
+ "cwd": "/x",
141
+ }
142
+
143
+ def _fire_window(self, session, transcript_turns, turn_count):
144
+ """Drive a live per-window Stop retain (advances the watermark)."""
145
+ _write_transcript(self._hook(session)["transcript_path"], transcript_turns, session)
146
+ with mock.patch("retain.increment_turn_count", return_value=turn_count), \
147
+ mock.patch("sys.stdin", _stdin(self._hook(session))):
148
+ retain.main()
149
+
150
+
151
+ class TestIncrementalSweep(IncrementalSweepBase):
152
+
153
+ def test_two_turn_no_watermark_sweep_retains_both_turns(self):
154
+ # A short session that never fired a per-window retain has no committed
155
+ # watermark, so the forced sweep must flush the WHOLE (2-turn) transcript
156
+ # (RFC §4.2 — short sessions still land on disk).
157
+ session = "sessShort"
158
+ _write_transcript(self._hook(session)["transcript_path"], 2, session)
159
+ self.assertIsNone(watermark.load(session)) # precondition: no watermark
160
+
161
+ result = retain.run_retain(self._hook(session), force=True)
162
+
163
+ self.assertEqual(result.get("status"), "ok")
164
+ blob = self.daemon.content_blob()
165
+ self.assertIn("user turn 0", blob)
166
+ self.assertIn("user turn 1", blob)
167
+
168
+ def test_thirty_turn_sweep_is_incremental_and_smaller_than_full(self):
169
+ # Windows fire at turns 8/16/24 (n=8, overlap 0), then SessionEnd sweeps.
170
+ # The sweep must contain ONLY turns after the last window's tail uuid,
171
+ # and the total bytes stored for the session must be strictly less than
172
+ # the pre-change (full-session sweep) total. This is the test that fails
173
+ # on the bug it guards: a non-incremental sweep re-stores all 30 turns,
174
+ # so the totals become equal and assertLess fires.
175
+ inc = "sessInc"
176
+ for fire_at in (8, 16, 24):
177
+ self._fire_window(inc, fire_at, fire_at)
178
+ # The last window committed its tail uuid as the watermark.
179
+ wm = watermark.load(inc)
180
+ self.assertIsNotNone(wm, "expected the per-window retains to commit a watermark")
181
+ self.assertEqual(wm["last_uuid"], f"{inc}-a23")
182
+
183
+ _write_transcript(self._hook(inc)["transcript_path"], 30, inc)
184
+ result = retain.run_retain(self._hook(inc), force=True)
185
+ self.assertEqual(result.get("status"), "ok")
186
+
187
+ sweep = self.daemon.last_post_content()
188
+ # Only turns AFTER the watermark (24..29); nothing at/ before it.
189
+ for i in range(24, 30):
190
+ self.assertIn(f"user turn {i}", sweep, f"turn {i} missing from incremental sweep")
191
+ for i in (0, 15, 23):
192
+ self.assertNotIn(f"user turn {i}", sweep,
193
+ f"turn {i} leaked into the incremental sweep")
194
+
195
+ # Pre-change baseline: identical scenario, but the SessionEnd sweep runs
196
+ # full-session (watermark unseen), on a distinct session id in the same
197
+ # daemon so window docs are directly comparable.
198
+ full = "sessFull"
199
+ for fire_at in (8, 16, 24):
200
+ self._fire_window(full, fire_at, fire_at)
201
+ _write_transcript(self._hook(full)["transcript_path"], 30, full)
202
+ with mock.patch("retain.watermark.load", return_value=None):
203
+ self.assertEqual(retain.run_retain(self._hook(full), force=True).get("status"), "ok")
204
+
205
+ self.assertLess(
206
+ self.daemon.bytes_for(inc),
207
+ self.daemon.bytes_for(full),
208
+ "incremental sweep did not reduce total retained bytes vs full sweep",
209
+ )
210
+
211
+ def test_corrupt_watermark_json_sweeps_whole_transcript_no_raise(self):
212
+ # A watermark file corrupted to invalid JSON must degrade to a whole
213
+ # -transcript sweep, and no exception may escape run_retain (§4.3).
214
+ session = "sessCorrupt"
215
+ _write_transcript(self._hook(session)["transcript_path"], 4, session)
216
+ retained_dir = os.environ["HINDSIGHT_RETAINED_DIR"]
217
+ os.makedirs(retained_dir, exist_ok=True)
218
+ with open(os.path.join(retained_dir, f"{session}.json"), "w") as f:
219
+ f.write("{ this is not valid json ]]]")
220
+
221
+ try:
222
+ result = retain.run_retain(self._hook(session), force=True)
223
+ except Exception as e: # noqa: BLE001 - the whole point is nothing escapes
224
+ self.fail(f"run_retain raised into the SessionEnd seam: {e!r}")
225
+
226
+ self.assertEqual(result.get("status"), "ok")
227
+ blob = self.daemon.content_blob()
228
+ for i in range(4):
229
+ self.assertIn(f"user turn {i}", blob)
230
+
231
+ def test_watermark_load_raising_degrades_to_full_sweep(self):
232
+ # Belt-and-braces for the §4.3 catch-all: even if the watermark READ
233
+ # itself raises an unexpected error, run_retain must NOT propagate it —
234
+ # it degrades to the whole-transcript sweep.
235
+ session = "sessBoom"
236
+ _write_transcript(self._hook(session)["transcript_path"], 4, session)
237
+
238
+ def _boom(_):
239
+ raise RuntimeError("simulated watermark read explosion")
240
+
241
+ with mock.patch("retain.watermark.load", side_effect=_boom):
242
+ try:
243
+ result = retain.run_retain(self._hook(session), force=True)
244
+ except Exception as e: # noqa: BLE001
245
+ self.fail(f"run_retain propagated a watermark read failure: {e!r}")
246
+
247
+ self.assertEqual(result.get("status"), "ok")
248
+ blob = self.daemon.content_blob()
249
+ for i in range(4):
250
+ self.assertIn(f"user turn {i}", blob)
251
+
252
+ def test_compacted_watermark_uuid_sweeps_whole_transcript(self):
253
+ # The watermark anchor was compacted out of the transcript: tail_after
254
+ # cannot find it and returns the whole transcript (a safe re-upsert).
255
+ session = "sessCompact"
256
+ _write_transcript(self._hook(session)["transcript_path"], 4, session)
257
+ # Commit a watermark whose uuid is NOT present in the transcript.
258
+ watermark.commit(session, "ghost-uuid-not-in-transcript", "doc-ghost",
259
+ ordered_uuids=["ghost-uuid-not-in-transcript"])
260
+ self.assertEqual(watermark.load(session)["last_uuid"], "ghost-uuid-not-in-transcript")
261
+
262
+ result = retain.run_retain(self._hook(session), force=True)
263
+ self.assertEqual(result.get("status"), "ok")
264
+ blob = self.daemon.content_blob()
265
+ for i in range(4):
266
+ self.assertIn(f"user turn {i}", blob)
267
+
268
+ def test_every_n_turns_1_is_byte_identical_full_session(self):
269
+ # At retainEveryNTurns==1 the document id is {session_id} and a tail slice
270
+ # would TRUNCATE it (RFC §4.2). The n==1 path must keep the full-session
271
+ # sweep even when a committed watermark exists — byte-identical to before.
272
+ self._write_settings(1)
273
+ session = "sessN1"
274
+ _write_transcript(self._hook(session)["transcript_path"], 4, session)
275
+ # A watermark exists (a prior every-turn fire) sitting mid-transcript.
276
+ watermark.commit(session, f"{session}-a1", "doc-prior",
277
+ ordered_uuids=[f"{session}-u{i//2}" if i % 2 == 0 else f"{session}-a{i//2}"
278
+ for i in range(8)])
279
+ self.assertEqual(watermark.load(session)["last_uuid"], f"{session}-a1")
280
+
281
+ result = retain.run_retain(self._hook(session), force=True)
282
+ self.assertEqual(result.get("status"), "ok")
283
+
284
+ # Whole transcript, under the plain {session_id} document id — the tail
285
+ # slice must NOT have applied.
286
+ self.assertIn(session, self.daemon.docs, "n==1 sweep must post under the {session_id} id")
287
+ content = self.daemon.docs[session]
288
+ for i in range(4):
289
+ self.assertIn(f"user turn {i}", content, f"turn {i} missing — n==1 sweep was truncated")
290
+
291
+
292
+ if __name__ == "__main__":
293
+ unittest.main()