switchroom 0.19.23 → 0.19.25
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/agent-scheduler/index.js +18 -7
- package/dist/auth-broker/index.js +117 -33
- package/dist/cli/autoaccept-poll.js +0 -1
- package/dist/cli/drive-write-pretool.mjs +5 -0
- package/dist/cli/ms-365-write-pretool.mjs +5 -0
- package/dist/cli/notion-write-pretool.mjs +18 -6
- package/dist/cli/switchroom.js +2916 -1481
- package/dist/host-control/main.js +116 -34
- package/dist/vault/approvals/kernel-server.js +115 -33
- package/dist/vault/broker/server.js +281 -76
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +52 -13
- package/profiles/_shared/dev-protocol.md.hbs +3 -4
- package/skills/dev-protocol/SKILL.md +22 -15
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-release/SKILL.md +2 -1
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
- package/telegram-plugin/gateway/gateway.ts +34 -35
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
- package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
- package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
- package/vendor/hindsight-memory/scripts/recall.py +503 -252
- package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
- package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- package/vendor/hindsight-memory/tests/test_content.py +218 -0
|
@@ -7,13 +7,16 @@ import pytest
|
|
|
7
7
|
from lib.content import (
|
|
8
8
|
_extract_text_content,
|
|
9
9
|
_is_channel_message_tool,
|
|
10
|
+
_selectivity_score,
|
|
10
11
|
compose_recall_query,
|
|
11
12
|
format_current_time,
|
|
12
13
|
format_memories,
|
|
13
14
|
prepare_retention_transcript,
|
|
15
|
+
shape_recall_query,
|
|
14
16
|
slice_last_turns_by_user_boundary,
|
|
15
17
|
strip_channel_envelope,
|
|
16
18
|
strip_memory_tags,
|
|
19
|
+
tokenize_for_bm25,
|
|
17
20
|
truncate_recall_query,
|
|
18
21
|
)
|
|
19
22
|
|
|
@@ -648,3 +651,218 @@ class TestFormatCurrentTime:
|
|
|
648
651
|
monkeypatch.delenv("TZ", raising=False)
|
|
649
652
|
out = format_current_time()
|
|
650
653
|
assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM)", out), out
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# Switchroom #3757 — BM25 query shaping
|
|
658
|
+
#
|
|
659
|
+
# The bug these guard: the recall hook composed an 800-char, ~110-token query
|
|
660
|
+
# and Hindsight OR-joined every token into one tsquery. On the live `overlord`
|
|
661
|
+
# bank that matched 119,510 of 135,565 rows and took 14.0s for the 3-arm BM25
|
|
662
|
+
# UNION — past the per-bank client timeout, so 96.8% of that agent's own-bank
|
|
663
|
+
# recalls returned NOTHING. Two of those tokens were scaffolding this hook
|
|
664
|
+
# added itself: `user` (67,363 rows = 50% of the bank) and `assistant`
|
|
665
|
+
# (29,942 = 22%).
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _production_shaped_query():
|
|
670
|
+
"""A composed query with the shape recall.py actually produces."""
|
|
671
|
+
msgs = _msgs(
|
|
672
|
+
("user", "the deploy went out this morning but the rollout never reached the fleet"),
|
|
673
|
+
(
|
|
674
|
+
"assistant",
|
|
675
|
+
"I checked the manifest for v0.19.17 and the digest does not match what "
|
|
676
|
+
"the agent container is running, so the pull raced the tag",
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
return compose_recall_query(
|
|
680
|
+
"why did recall for the v0.19.17 rollout return v0.18.15 instead",
|
|
681
|
+
msgs,
|
|
682
|
+
recall_context_turns=2,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class TestComposedQueryHasNoRoleLabels:
|
|
687
|
+
def test_compose_does_not_prefix_role_labels(self):
|
|
688
|
+
# The regression this guards: `context_lines.append(f"{role}: {content}")`.
|
|
689
|
+
msgs = _msgs(("user", "prior question"), ("assistant", "prior answer"))
|
|
690
|
+
result = compose_recall_query("current question", msgs, recall_context_turns=2)
|
|
691
|
+
assert "prior question" in result
|
|
692
|
+
assert "prior answer" in result
|
|
693
|
+
assert "user:" not in result
|
|
694
|
+
assert "assistant:" not in result
|
|
695
|
+
|
|
696
|
+
def test_role_labels_are_not_bm25_tokens(self):
|
|
697
|
+
# The property that actually matters — not "the string is absent" but
|
|
698
|
+
# "the term never reaches the tsquery".
|
|
699
|
+
query = _production_shaped_query()
|
|
700
|
+
shaped = shape_recall_query(query, "why did recall for the v0.19.17 rollout "
|
|
701
|
+
"return v0.18.15 instead")
|
|
702
|
+
terms = set(tokenize_for_bm25(shaped))
|
|
703
|
+
assert "user" not in terms
|
|
704
|
+
assert "assistant" not in terms
|
|
705
|
+
assert "prior" not in terms
|
|
706
|
+
assert "context" not in terms
|
|
707
|
+
|
|
708
|
+
def test_labels_embedded_in_transcript_text_are_still_stripped(self):
|
|
709
|
+
# Defence in depth: an old composed string, or a turn that literally
|
|
710
|
+
# contains a `user:` line, must not smuggle the label back in.
|
|
711
|
+
legacy = "Prior context:\n\nuser: the deploy failed\nassistant: I checked it\n\nwhy"
|
|
712
|
+
terms = set(tokenize_for_bm25(shape_recall_query(legacy, "why")))
|
|
713
|
+
assert "user" not in terms
|
|
714
|
+
assert "assistant" not in terms
|
|
715
|
+
assert "deploy" in terms
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class TestShapeRecallQuery:
|
|
719
|
+
def test_caps_distinct_bm25_terms(self):
|
|
720
|
+
query = " ".join(f"distinctword{i}" for i in range(200))
|
|
721
|
+
shaped = shape_recall_query(query, "", max_tokens=24)
|
|
722
|
+
assert len(set(tokenize_for_bm25(shaped))) <= 24
|
|
723
|
+
|
|
724
|
+
def test_cap_counts_compound_token_expansion(self):
|
|
725
|
+
# A compound is emitted by the server ALONGSIDE its fragments
|
|
726
|
+
# (`v0.19.17` → v0.19.17, v0, 19, 17), so a naive "count the words we
|
|
727
|
+
# sent" cap would silently ship 4x the terms it promised.
|
|
728
|
+
query = " ".join(f"v0.19.{i}" for i in range(40))
|
|
729
|
+
shaped = shape_recall_query(query, "", max_tokens=24)
|
|
730
|
+
assert len(set(tokenize_for_bm25(shaped))) <= 24
|
|
731
|
+
|
|
732
|
+
def test_cap_is_configurable(self):
|
|
733
|
+
query = " ".join(f"distinctword{i}" for i in range(200))
|
|
734
|
+
assert len(set(tokenize_for_bm25(shape_recall_query(query, "", max_tokens=8)))) <= 8
|
|
735
|
+
assert len(set(tokenize_for_bm25(shape_recall_query(query, "", max_tokens=40)))) <= 40
|
|
736
|
+
|
|
737
|
+
def test_zero_disables_shaping(self):
|
|
738
|
+
query = "Prior context:\n\nsomething\n\nlatest"
|
|
739
|
+
assert shape_recall_query(query, "latest", max_tokens=0) == query
|
|
740
|
+
|
|
741
|
+
def test_prefers_latest_turn_over_prior_context(self):
|
|
742
|
+
# A chronological truncation would keep the OLDEST context and throw
|
|
743
|
+
# away the question the user actually asked, so the latest turn takes
|
|
744
|
+
# the MAJORITY of the budget. But recency is a weight plus a bounded
|
|
745
|
+
# quota, not an absolute tier (#3760 review, Blocker 2): the subject of
|
|
746
|
+
# a conversation routinely sits in the turn BEFORE the one that refers
|
|
747
|
+
# to it as "it"/"that", and an absolute tier starves it every time.
|
|
748
|
+
prior = " ".join(f"stalecontextword{i}" for i in range(60))
|
|
749
|
+
latest = "why does the reaper skip orphaned worktrees"
|
|
750
|
+
query = f"Prior context:\n\n{prior}\n\n{latest}"
|
|
751
|
+
# Every prior token here is strictly MORE selective than anything in
|
|
752
|
+
# the question (they carry digits; the question is plain English), so
|
|
753
|
+
# pure merit ordering would hand prior context all four slots and the
|
|
754
|
+
# user would search for none of what they asked.
|
|
755
|
+
assert _selectivity_score("stalecontextword0") > _selectivity_score("worktrees")
|
|
756
|
+
# The latest-turn reserve is what stops that. At a punishing cap it is
|
|
757
|
+
# only `max_tokens // 3` slots — the question is represented, not
|
|
758
|
+
# preserved whole, which is the honest tradeoff when prior context is
|
|
759
|
+
# genuinely more discriminating.
|
|
760
|
+
tight = tokenize_for_bm25(shape_recall_query(query, latest, max_tokens=4))
|
|
761
|
+
from_question = set(tight) & {"reaper", "skip", "orphaned", "worktrees"}
|
|
762
|
+
assert len(from_question) >= max(1, 4 // 3)
|
|
763
|
+
assert len(set(tight)) <= 4
|
|
764
|
+
# With slack, the latest turn is still fully present and the leftover
|
|
765
|
+
# budget goes to context (which is the point of composing at all).
|
|
766
|
+
loose = set(tokenize_for_bm25(shape_recall_query(query, latest, max_tokens=12)))
|
|
767
|
+
assert {"reaper", "skip", "orphaned", "worktrees"} <= loose
|
|
768
|
+
assert any(t.startswith("stalecontextword") for t in loose)
|
|
769
|
+
assert len(loose) <= 12
|
|
770
|
+
|
|
771
|
+
def test_drops_english_stopwords(self):
|
|
772
|
+
latest = "what did we decide about the worktree reaper"
|
|
773
|
+
terms = set(tokenize_for_bm25(shape_recall_query(latest, latest, max_tokens=24)))
|
|
774
|
+
assert "worktree" in terms
|
|
775
|
+
assert "reaper" in terms
|
|
776
|
+
assert "decide" in terms
|
|
777
|
+
for stop in ("what", "did", "we", "about", "the"):
|
|
778
|
+
assert stop not in terms
|
|
779
|
+
|
|
780
|
+
def test_operator_stop_terms_are_dropped(self):
|
|
781
|
+
# Bank-specific high-df words the generic stoplist cannot know about.
|
|
782
|
+
latest = "the switchroom agent rollout stalled on the reaper"
|
|
783
|
+
terms = set(
|
|
784
|
+
tokenize_for_bm25(
|
|
785
|
+
shape_recall_query(latest, latest, max_tokens=24,
|
|
786
|
+
stop_terms=["switchroom", "agent"])
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
assert "reaper" in terms
|
|
790
|
+
assert "rollout" in terms
|
|
791
|
+
assert "switchroom" not in terms
|
|
792
|
+
assert "agent" not in terms
|
|
793
|
+
|
|
794
|
+
def test_short_query_survives_intact(self):
|
|
795
|
+
# No regression for the small-bank / short-prompt case: every content
|
|
796
|
+
# word is kept, so recall quality is unchanged.
|
|
797
|
+
latest = "worktree gc reaper timings"
|
|
798
|
+
terms = set(tokenize_for_bm25(shape_recall_query(latest, latest, max_tokens=24)))
|
|
799
|
+
assert terms == {"worktree", "gc", "reaper", "timings"}
|
|
800
|
+
|
|
801
|
+
def test_all_stopword_query_is_not_emptied(self):
|
|
802
|
+
# A conversational prompt must never be shaped down to nothing — an
|
|
803
|
+
# empty query would return zero memories, the exact failure we are
|
|
804
|
+
# fixing.
|
|
805
|
+
latest = "what about that"
|
|
806
|
+
shaped = shape_recall_query(latest, latest, max_tokens=24)
|
|
807
|
+
assert tokenize_for_bm25(shaped)
|
|
808
|
+
|
|
809
|
+
def test_untokenizable_query_returns_original(self):
|
|
810
|
+
assert shape_recall_query("!!! ???", "!!! ???", max_tokens=24) == "!!! ???"
|
|
811
|
+
|
|
812
|
+
def test_preserves_original_word_order(self):
|
|
813
|
+
latest = "reaper skipped orphaned worktrees before rollout"
|
|
814
|
+
shaped = shape_recall_query(latest, latest, max_tokens=24)
|
|
815
|
+
# "before" is a stopword; the survivors keep the source order.
|
|
816
|
+
assert shaped.split() == ["reaper", "skipped", "orphaned", "worktrees", "rollout"]
|
|
817
|
+
|
|
818
|
+
def test_unparseable_cap_disables_shaping_instead_of_raising(self):
|
|
819
|
+
# max_tokens arrives from settings.json / env. A config error must
|
|
820
|
+
# degrade to "send it unshaped", never raise on the recall hot path.
|
|
821
|
+
latest = "the reaper skipped orphaned worktrees before the rollout"
|
|
822
|
+
for bad in (None, "x", "", [], {}):
|
|
823
|
+
assert shape_recall_query(latest, latest, max_tokens=bad) == latest
|
|
824
|
+
|
|
825
|
+
def test_numeric_string_cap_is_honoured(self):
|
|
826
|
+
latest = "the reaper skipped orphaned worktrees before the rollout"
|
|
827
|
+
shaped = shape_recall_query(latest, latest, max_tokens="3")
|
|
828
|
+
assert len(set(tokenize_for_bm25(shaped))) <= 3
|
|
829
|
+
|
|
830
|
+
def test_stop_terms_given_as_a_bare_string_are_split_not_iterated(self):
|
|
831
|
+
# "reaper,worktrees" iterated as characters would stop-list half the
|
|
832
|
+
# alphabet and gut the query.
|
|
833
|
+
latest = "the reaper skipped orphaned worktrees before the rollout"
|
|
834
|
+
shaped = shape_recall_query(latest, latest, 24, stop_terms="reaper, worktrees")
|
|
835
|
+
terms = set(tokenize_for_bm25(shaped))
|
|
836
|
+
assert "reaper" not in terms
|
|
837
|
+
assert "worktrees" not in terms
|
|
838
|
+
assert {"skipped", "orphaned", "rollout"} <= terms
|
|
839
|
+
|
|
840
|
+
def test_preserves_original_case(self):
|
|
841
|
+
# The shaped string feeds BOTH arms. BM25 lowercases server-side, but
|
|
842
|
+
# the embedding arm does not, so shaping must not flatten `Python` to
|
|
843
|
+
# `python` or `PR` to `pr`.
|
|
844
|
+
latest = "should the Python worker open a PR against Coolify"
|
|
845
|
+
shaped = shape_recall_query(latest, latest, max_tokens=24)
|
|
846
|
+
assert shaped.split() == ["Python", "worker", "open", "PR", "Coolify"]
|
|
847
|
+
|
|
848
|
+
def test_production_shaped_query_fits_the_budget(self):
|
|
849
|
+
query = _production_shaped_query()
|
|
850
|
+
latest = "why did recall for the v0.19.17 rollout return v0.18.15 instead"
|
|
851
|
+
shaped = shape_recall_query(query, latest, max_tokens=24)
|
|
852
|
+
terms = set(tokenize_for_bm25(shaped))
|
|
853
|
+
assert len(terms) <= 24
|
|
854
|
+
# The discriminating identifiers from the latest turn survive.
|
|
855
|
+
assert "v0.19.17" in terms
|
|
856
|
+
assert "rollout" in terms
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
class TestTokenizeForBm25:
|
|
860
|
+
def test_matches_server_tokenizer_on_compounds(self):
|
|
861
|
+
# Mirrors hindsight_api/engine/search/retrieval.py::tokenize_query —
|
|
862
|
+
# fragments PLUS the intact compound.
|
|
863
|
+
tokens = tokenize_for_bm25("bumped to v0.19.17")
|
|
864
|
+
assert "v0.19.17" in tokens
|
|
865
|
+
assert {"v0", "19", "17"} <= set(tokens)
|
|
866
|
+
|
|
867
|
+
def test_empty_for_punctuation_only(self):
|
|
868
|
+
assert tokenize_for_bm25("!!! ???") == []
|