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.
- package/dist/auth-broker/index.js +1 -1
- package/dist/cli/switchroom.js +1953 -1539
- package/dist/host-control/main.js +286 -122
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +12 -1
- package/telegram-plugin/dist/gateway/gateway.js +1405 -851
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
- package/telegram-plugin/gateway/boot-beacon.ts +9 -2
- package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
- package/telegram-plugin/gateway/gateway.ts +42 -15
- package/telegram-plugin/gateway/inbound-router.ts +31 -6
- package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
- package/telegram-plugin/gateway/pending-card-store.ts +2 -2
- package/telegram-plugin/gateway/privacy-state.ts +2 -2
- package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
- package/telegram-plugin/gateway/system-message-observer.ts +242 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
- package/telegram-plugin/history.ts +178 -11
- package/telegram-plugin/registry/turns-schema.ts +8 -2
- package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
- package/telegram-plugin/tests/card-history-lane.test.ts +394 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +216 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
- package/vendor/hindsight-memory/scripts/recall.py +11 -2
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
- package/vendor/hindsight-memory/scripts/retain.py +6 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
- package/vendor/hindsight-memory/settings.json +2 -2
- package/vendor/hindsight-memory/tests/test_config.py +8 -3
- package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
- 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": "
|
|
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": "
|
|
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"] == "
|
|
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"] == "
|
|
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"] == "
|
|
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
|
-
|
|
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"
|