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.
Files changed (52) hide show
  1. package/dist/agent-scheduler/index.js +18 -7
  2. package/dist/auth-broker/index.js +117 -33
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +18 -6
  7. package/dist/cli/switchroom.js +2916 -1481
  8. package/dist/host-control/main.js +116 -34
  9. package/dist/vault/approvals/kernel-server.js +115 -33
  10. package/dist/vault/broker/server.js +281 -76
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/start.sh.hbs +52 -13
  14. package/profiles/_shared/dev-protocol.md.hbs +3 -4
  15. package/skills/dev-protocol/SKILL.md +22 -15
  16. package/skills/switchroom-health/SKILL.md +19 -0
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/skills/switchroom-status/SKILL.md +1 -1
  19. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  20. package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
  21. package/telegram-plugin/gateway/gateway.ts +34 -35
  22. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  23. package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
  24. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  25. package/telegram-plugin/gateway/turn-end.ts +1 -1
  26. package/telegram-plugin/quota-bar-format.ts +4 -1
  27. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  28. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  29. package/telegram-plugin/send-gate.ts +185 -24
  30. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  31. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  32. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  34. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  36. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  37. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  38. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  39. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  41. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  42. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  43. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  44. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  45. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  52. 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("!!! ???") == []