switchroom 0.20.21 → 0.21.0

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 (36) hide show
  1. package/dist/auth-broker/index.js +1 -1
  2. package/dist/cli/switchroom.js +1953 -1539
  3. package/dist/host-control/main.js +286 -122
  4. package/dist/vault/approvals/kernel-server.js +1 -1
  5. package/dist/vault/broker/server.js +1 -1
  6. package/package.json +1 -1
  7. package/skills/switchroom-release/SKILL.md +12 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +1405 -851
  9. package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
  10. package/telegram-plugin/gateway/boot-beacon.ts +9 -2
  11. package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
  12. package/telegram-plugin/gateway/gateway.ts +42 -15
  13. package/telegram-plugin/gateway/inbound-router.ts +31 -6
  14. package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
  15. package/telegram-plugin/gateway/pending-card-store.ts +2 -2
  16. package/telegram-plugin/gateway/privacy-state.ts +2 -2
  17. package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
  18. package/telegram-plugin/gateway/system-message-observer.ts +242 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
  20. package/telegram-plugin/history.ts +178 -11
  21. package/telegram-plugin/registry/turns-schema.ts +8 -2
  22. package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
  23. package/telegram-plugin/tests/card-history-lane.test.ts +394 -0
  24. package/telegram-plugin/tests/system-message-observer.test.ts +216 -0
  25. package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
  26. package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
  27. package/vendor/hindsight-memory/scripts/recall.py +11 -2
  28. package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
  29. package/vendor/hindsight-memory/scripts/retain.py +6 -1
  30. package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
  33. package/vendor/hindsight-memory/settings.json +2 -2
  34. package/vendor/hindsight-memory/tests/test_config.py +8 -3
  35. package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
  36. package/vendor/hindsight-memory/tests/test_retain_context.py +69 -0
@@ -565,6 +565,119 @@ class TestObservationScopes(DurabilityTestBase):
565
565
  self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
566
566
 
567
567
 
568
+ class TestDrainAdvancesWatermark(DurabilityTestBase):
569
+ """switchroom #4571 — the drain must advance the transcript watermark for a
570
+ reconcile-sourced slice it confirms durable, so reconcile stops re-deriving
571
+ and re-enqueueing the identical tail on every boot (the recurring
572
+ ``pending-retains`` spike). And it must NOT advance while an earlier sibling
573
+ of the same session is still queued (the no-loss guard).
574
+ """
575
+
576
+ # -- the fix: an enqueued tail, once drained, advances the watermark --------
577
+ def test_enqueued_tail_drained_advances_watermark_and_stops_reenqueue(self):
578
+ # This test FAILS on HEAD: the drain never commits the watermark for an
579
+ # enqueued reconcile slice, so `watermark.load` stays None after the
580
+ # drain and the second reconcile re-enqueues the same tail.
581
+ session = "drainA"
582
+ tpath = os.path.join(self.transcripts, f"{session}.jsonl")
583
+ _write_transcript(tpath, 3, session_prefix=session)
584
+ hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
585
+
586
+ # Force the out-of-lookback ENQUEUE path (no inline POST): lookback=0
587
+ # makes every transcript "too old", so reconcile enqueues the full tail
588
+ # and defers it to the drain — exactly the path that never committed.
589
+ with mock.patch.dict(os.environ, {"HINDSIGHT_RECONCILE_LOOKBACK_H": "0"}):
590
+ s1 = reconcile_tail.reconcile(self._config(), hook_input=hook)
591
+ self.assertEqual(s1["enqueued"], 1)
592
+ self.assertEqual(len(self._pending_entries()), 1)
593
+ # Nothing posted inline, so the watermark is still unset.
594
+ self.assertIsNone(watermark.load(session))
595
+
596
+ # Drain against a healthy daemon: the tail lands durably AND (the fix)
597
+ # the watermark advances to the transcript tail.
598
+ from drain_pending import drain
599
+ drain(self._config())
600
+ self.assertEqual(len(self._pending_entries()), 0)
601
+ wm = watermark.load(session)
602
+ self.assertIsNotNone(
603
+ wm,
604
+ "drain must advance the watermark for a confirmed-durable reconcile "
605
+ "tail (#4571) — without it, reconcile re-enqueues every boot",
606
+ )
607
+ self.assertEqual(wm["last_uuid"], f"{session}-a2")
608
+
609
+ # Second reconcile pass (still out of lookback) is now skipped_clean:
610
+ # the tail is watermarked, so it is NOT re-derived or re-enqueued.
611
+ with mock.patch.dict(os.environ, {"HINDSIGHT_RECONCILE_LOOKBACK_H": "0"}):
612
+ s2 = reconcile_tail.reconcile(self._config(), hook_input=hook)
613
+ self.assertEqual(s2["skipped_clean"], 1)
614
+ self.assertEqual(s2["enqueued"], 0)
615
+ self.assertEqual(len(self._pending_entries()), 0)
616
+
617
+ # -- the no-loss guard: an earlier sibling blocks the tail's advance --------
618
+ def test_tail_drain_does_not_advance_while_earlier_sibling_queued(self):
619
+ session = "drainB"
620
+ ordered = [
621
+ f"{session}-u0", f"{session}-a0", f"{session}-u1",
622
+ f"{session}-a1", f"{session}-u2", f"{session}-a2",
623
+ ]
624
+ older_doc = f"{session}-r{session}-u0-{session}-a1"
625
+ tail_doc = f"{session}-r{session}-u0-{session}-a2"
626
+
627
+ from lib import pending
628
+
629
+ def _payload(doc, content, last_uuid, is_tail):
630
+ return {
631
+ "api_url": "http://fake", "api_token": None, "bank_id": "test-bank",
632
+ "content": content, "document_id": doc, "context": "claude-code",
633
+ "metadata": {}, "tags": [], "observation_scopes": None,
634
+ "reconcile_session_id": session,
635
+ "reconcile_last_uuid": last_uuid,
636
+ "reconcile_ordered_uuids": ordered,
637
+ "reconcile_is_tail": is_tail,
638
+ }
639
+
640
+ # The earlier remainder (mid-transcript last_uuid) is enqueued FIRST so
641
+ # it sorts ahead of the tail in the oldest-first drain order.
642
+ pending.enqueue(
643
+ _payload(older_doc, "older remainder turns", f"{session}-a1", False),
644
+ RuntimeError("reconcile deferred"),
645
+ )
646
+ import time as _t
647
+ _t.sleep(0.002)
648
+ pending.enqueue(
649
+ _payload(tail_doc, "tail turns", f"{session}-a2", True),
650
+ RuntimeError("reconcile deferred"),
651
+ )
652
+ self.assertEqual(len(self._pending_entries()), 2)
653
+
654
+ # Upstream: the tail commits, but the earlier remainder still fails, so
655
+ # it stays queued through the whole drain run.
656
+ posted = []
657
+
658
+ def fake_retain(self_c, bank_id=None, content=None,
659
+ document_id="conversation", **kw):
660
+ posted.append(document_id)
661
+ if document_id == older_doc:
662
+ raise RuntimeError("earlier remainder still failing upstream")
663
+ return {"ok": True}
664
+
665
+ from drain_pending import drain
666
+ with mock.patch.object(HindsightClient, "retain", fake_retain):
667
+ drain(self._config())
668
+
669
+ # The tail POST succeeded and its entry was retired ...
670
+ self.assertIn(tail_doc, posted)
671
+ remaining = [e["document_id"] for _p, e in self._pending_entries()]
672
+ self.assertNotIn(tail_doc, remaining)
673
+ # ... but the watermark did NOT advance: an earlier, still-unconfirmed
674
+ # sibling of the same session is queued, and advancing past it would
675
+ # risk silent loss of the remainder (#4571 no-loss guard).
676
+ self.assertIsNone(watermark.load(session))
677
+ # The earlier remainder is still queued (its POST failed).
678
+ self.assertIn(older_doc, remaining)
679
+
680
+
568
681
  def _stdin(obj):
569
682
  import io
570
683
  return io.StringIO(json.dumps(obj))
@@ -6,7 +6,7 @@
6
6
  "autoRecall": true,
7
7
  "autoRetain": true,
8
8
  "retainMode": "full-session",
9
- "recallBudget": "low",
9
+ "recallBudget": "mid",
10
10
  "recallMaxTokens": 1024,
11
11
  "recallMaxMemories": 12,
12
12
  "recallTypes": ["world", "experience"],
@@ -26,7 +26,7 @@
26
26
  "retainToolCalls": true,
27
27
  "retainTags": ["{session_id}"],
28
28
  "retainMetadata": {},
29
- "retainContext": "claude-code",
29
+ "retainContext": "Transcript of Claude Code agent '{agent}' ({bank_id}). 'assistant'/tool lines are the agent's own first-person actions (experience); 'user' lines are the human operator speaking (their statements are world facts).",
30
30
  "hindsightApiToken": null,
31
31
  "apiPort": 9077,
32
32
  "daemonIdleTimeout": 0,
@@ -42,8 +42,13 @@ class TestLoadConfig:
42
42
  cfg = load_config()
43
43
  assert cfg["autoRecall"] is True
44
44
  assert cfg["autoRetain"] is True
45
- assert cfg["recallBudget"] == "low"
45
+ assert cfg["recallBudget"] == "mid"
46
46
  assert cfg["retainEveryNTurns"] == 10
47
+ # Switchroom — speaker-aware retain context template (resolved
48
+ # per-retain by build_retain_payload). Must carry the {agent} and
49
+ # {bank_id} slots so the consolidation LLM can attribute speakers.
50
+ assert "{agent}" in cfg["retainContext"]
51
+ assert "{bank_id}" in cfg["retainContext"]
47
52
 
48
53
  def test_settings_json_overrides_defaults(self, tmp_path, monkeypatch):
49
54
  monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
@@ -75,7 +80,7 @@ class TestLoadConfig:
75
80
  monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
76
81
  (tmp_path / "settings.json").write_text("not valid json{{")
77
82
  cfg = load_config()
78
- assert cfg["recallBudget"] == "low" # default still applies
83
+ assert cfg["recallBudget"] == "mid" # default still applies
79
84
 
80
85
  def test_null_values_in_settings_json_not_applied(self, tmp_path, monkeypatch):
81
86
  monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
@@ -112,7 +117,7 @@ class TestLoadConfig:
112
117
  # HOME points to tmp_path where no .hindsight/claude-code.json exists
113
118
  monkeypatch.setenv("HOME", str(tmp_path))
114
119
  cfg = load_config()
115
- assert cfg["recallBudget"] == "low" # default
120
+ assert cfg["recallBudget"] == "mid" # default
116
121
 
117
122
  def test_env_var_wins_over_user_config(self, tmp_path, monkeypatch):
118
123
  plugin_root = tmp_path / "plugin"
@@ -833,6 +833,11 @@ class TestRetainHook:
833
833
  assert captured["body"].get("async") is False
834
834
 
835
835
  def test_retain_includes_context_label(self, monkeypatch, tmp_path):
836
+ # Switchroom — the default retainContext is now a speaker-aware
837
+ # template resolved per-retain: {agent} from SWITCHROOM_AGENT_NAME
838
+ # and {bank_id} from the target bank. Pin a known agent name so the
839
+ # resolved label is deterministic regardless of the ambient env.
840
+ monkeypatch.setenv("SWITCHROOM_AGENT_NAME", "testbot")
836
841
  messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
837
842
  transcript = make_transcript_file(tmp_path, messages)
838
843
  hook_input = make_hook_input(transcript_path=transcript)
@@ -846,7 +851,11 @@ class TestRetainHook:
846
851
  _run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
847
852
 
848
853
  if "body" in captured:
849
- assert captured["body"]["items"][0]["context"] == "claude-code"
854
+ context = captured["body"]["items"][0]["context"]
855
+ # Template placeholders must be resolved (no literal braces left)
856
+ # and the agent name must be filled in from the env.
857
+ assert "{" not in context and "}" not in context
858
+ assert "agent 'testbot'" in context
850
859
 
851
860
  def test_disabled_auto_retain_does_not_call_api(self, monkeypatch, tmp_path):
852
861
  (tmp_path / "plugin_root").mkdir(exist_ok=True)
@@ -0,0 +1,69 @@
1
+ """Tests for build_retain_payload's speaker-aware retainContext template.
2
+
3
+ Switchroom — retainContext is no longer the opaque constant "claude-code".
4
+ It is a template resolved per-retain by build_retain_payload's
5
+ _resolve_template, filling {agent} from SWITCHROOM_AGENT_NAME and {bank_id}
6
+ from the target bank so the consolidation LLM knows whose first-person
7
+ experience each transcript line is.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from retain import build_retain_payload
13
+
14
+ MESSAGES = [
15
+ {"role": "user", "content": "human fact"},
16
+ {"role": "assistant", "content": "agent action"},
17
+ ]
18
+
19
+
20
+ def _build(config, monkeypatch, agent="klanker", bank_id="klanker-main"):
21
+ if agent is None:
22
+ monkeypatch.delenv("SWITCHROOM_AGENT_NAME", raising=False)
23
+ else:
24
+ monkeypatch.setenv("SWITCHROOM_AGENT_NAME", agent)
25
+ result = build_retain_payload(
26
+ config,
27
+ session_id="sess-1",
28
+ messages_to_retain=MESSAGES,
29
+ all_messages=MESSAGES,
30
+ bank_id=bank_id,
31
+ api_url="http://localhost:9077",
32
+ api_token=None,
33
+ )
34
+ assert result is not None
35
+ return result["payload"]["context"]
36
+
37
+
38
+ def test_context_template_resolves_agent_and_bank(monkeypatch):
39
+ context = _build(
40
+ {"retainContext": "agent '{agent}' ({bank_id})"},
41
+ monkeypatch,
42
+ agent="klanker",
43
+ bank_id="klanker-main",
44
+ )
45
+ assert context == "agent 'klanker' (klanker-main)"
46
+
47
+
48
+ def test_context_template_agent_empty_outside_switchroom(monkeypatch):
49
+ context = _build(
50
+ {"retainContext": "agent '{agent}'"},
51
+ monkeypatch,
52
+ agent=None,
53
+ )
54
+ assert context == "agent ''"
55
+
56
+
57
+ def test_context_default_is_speaker_aware_and_resolved(monkeypatch):
58
+ # No retainContext in config → build_retain_payload falls back to the
59
+ # "claude-code" literal, which carries no template vars and is returned
60
+ # verbatim. The speaker-aware default lives in settings.json / config.py
61
+ # DEFAULTS and is exercised by test_config; here we assert the fallback
62
+ # path stays byte-stable.
63
+ context = _build({}, monkeypatch)
64
+ assert context == "claude-code"
65
+
66
+
67
+ def test_context_plain_string_passthrough(monkeypatch):
68
+ context = _build({"retainContext": "just plain text"}, monkeypatch)
69
+ assert context == "just plain text"