switchroom 0.19.16 → 0.19.18

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 (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8307 -7620
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -433,6 +433,43 @@ class RecallTelemetryLogTests(unittest.TestCase):
433
433
  self.assertEqual(entries[0]["demoted_count"], 1)
434
434
  self.assertEqual(entries[0]["memory_ids"], ["k1"])
435
435
 
436
+ def test_logs_injected_score_aggregates_post_slice(self):
437
+ """#3541 recall-QUALITY telemetry: the row must carry min/median/max
438
+ of `scores.final` for the memories actually INJECTED — i.e. after the
439
+ head-slice, not the pre-gate candidate pool. Without this, a rollout
440
+ where the gate stops dropping and the cap fills every turn is
441
+ indistinguishable from one feeding 8 mediocre memories per turn.
442
+ """
443
+ def _scored(text, mem_id, final):
444
+ m = _memory(text, mem_id=mem_id)
445
+ m["scores"] = {"final": final}
446
+ return m
447
+
448
+ memories = [
449
+ _scored("low", "id-low", 0.10),
450
+ _scored("high", "id-high", 0.90),
451
+ _scored("mid", "id-mid", 0.80),
452
+ ]
453
+ client = _FakeClient(directives=[], memories=memories)
454
+ _run_main_with(client, config_extra={"recallMaxMemories": 2})
455
+ e = self._read_log()[0]
456
+ # Sorted desc then sliced → the 0.10 memory is NOT injected, and must
457
+ # not appear in the aggregates.
458
+ self.assertEqual(e["memory_ids"], ["id-high", "id-mid"])
459
+ self.assertAlmostEqual(e["injected_score_max"], 0.90, places=6)
460
+ self.assertAlmostEqual(e["injected_score_min"], 0.80, places=6)
461
+ self.assertAlmostEqual(e["injected_score_median"], 0.85, places=6)
462
+
463
+ def test_injected_score_aggregates_are_null_without_scores(self):
464
+ """Score-less results (or an empty set) must log nulls, never a
465
+ sentinel like -inf that would corrupt a dashboard average."""
466
+ client = _FakeClient(directives=[], memories=[_memory("x", mem_id="x1")])
467
+ _run_main_with(client)
468
+ e = self._read_log()[0]
469
+ self.assertIsNone(e["injected_score_min"])
470
+ self.assertIsNone(e["injected_score_median"])
471
+ self.assertIsNone(e["injected_score_max"])
472
+
436
473
  def test_no_log_when_plugin_data_unset(self):
437
474
  # If CLAUDE_PLUGIN_DATA isn't set, the writer no-ops silently —
438
475
  # we don't want a stray log file in the working directory.
@@ -509,59 +546,106 @@ class AckShortCircuitTests(unittest.TestCase):
509
546
  self.assertIsNotNone(ctx)
510
547
 
511
548
 
512
- class JaccardOverlapUnitTests(unittest.TestCase):
513
- """Switchroom #475: pure-function tests for the relevance helpers."""
549
+ class ContainmentOverlapUnitTests(unittest.TestCase):
550
+ """Switchroom #475 / #3541: pure-function tests for the relevance helpers."""
514
551
 
515
552
  def test_identical_text_is_full_overlap(self):
516
553
  # Modulo stop-word stripping (`is`, `the`, `a`, `to` removed).
517
554
  self.assertEqual(
518
- recall.jaccard_overlap("deploy the staging server", "deploy the staging server"),
555
+ recall.containment_overlap("deploy the staging server", "deploy the staging server"),
519
556
  1.0,
520
557
  )
521
558
 
522
559
  def test_disjoint_text_is_zero(self):
523
560
  self.assertEqual(
524
- recall.jaccard_overlap("deploy staging server", "vegan dinner recipes"),
561
+ recall.containment_overlap("deploy staging server", "vegan dinner recipes"),
525
562
  0.0,
526
563
  )
527
564
 
528
565
  def test_partial_overlap_is_between(self):
529
- score = recall.jaccard_overlap(
566
+ score = recall.containment_overlap(
530
567
  "deploy staging server",
531
568
  "deploy production server",
532
569
  )
533
570
  # {deploy, staging, server} vs {deploy, production, server}
534
- # → intersection 2, union 4 → 0.5
535
- self.assertAlmostEqual(score, 0.5, places=2)
571
+ # → intersection 2, |M| = 3 → 0.666…
572
+ self.assertAlmostEqual(score, 2 / 3, places=2)
536
573
 
537
574
  def test_stopwords_dont_inflate_overlap(self):
538
575
  # "the" / "is" / "a" present in both shouldn't count.
539
- score = recall.jaccard_overlap("the cat is a pet", "the dog is a pet")
576
+ score = recall.containment_overlap("the cat is a pet", "the dog is a pet")
540
577
  # Real tokens after stopword strip: {cat, pet} vs {dog, pet}
541
- # → intersection 1, union 3 → 0.333…
542
- self.assertAlmostEqual(score, 1 / 3, places=2)
578
+ # → intersection 1, |M| = 2 → 0.5
579
+ self.assertAlmostEqual(score, 0.5, places=2)
543
580
 
544
581
  def test_empty_text_yields_zero(self):
545
- self.assertEqual(recall.jaccard_overlap("", "anything at all"), 0.0)
546
- self.assertEqual(recall.jaccard_overlap("query", ""), 0.0)
582
+ self.assertEqual(recall.containment_overlap("", "anything at all"), 0.0)
583
+ self.assertEqual(recall.containment_overlap("query", ""), 0.0)
547
584
 
548
585
  def test_non_string_inputs_yield_zero(self):
549
- self.assertEqual(recall.jaccard_overlap(None, "x"), 0.0)
550
- self.assertEqual(recall.jaccard_overlap("x", None), 0.0)
586
+ self.assertEqual(recall.containment_overlap(None, "x"), 0.0)
587
+ self.assertEqual(recall.containment_overlap("x", None), 0.0)
551
588
 
552
589
  def test_case_insensitive(self):
553
590
  self.assertEqual(
554
- recall.jaccard_overlap("DEPLOY Server", "deploy server"),
591
+ recall.containment_overlap("DEPLOY Server", "deploy server"),
555
592
  1.0,
556
593
  )
557
594
 
558
595
  def test_punctuation_stripped(self):
559
596
  self.assertEqual(
560
- recall.jaccard_overlap("deploy, server!", "deploy server"),
597
+ recall.containment_overlap("deploy, server!", "deploy server"),
561
598
  1.0,
562
599
  )
563
600
 
564
601
 
602
+ class InjectedScoreStatsUnitTests(unittest.TestCase):
603
+ """#3541 — aggregates for the injected set. Must be total and non-fatal:
604
+ telemetry can never take recall down."""
605
+
606
+ @staticmethod
607
+ def _m(final):
608
+ return {"text": "t", "scores": {"final": final}}
609
+
610
+ def test_odd_count_uses_middle_value(self):
611
+ s = recall._injected_score_stats([self._m(0.1), self._m(0.9), self._m(0.5)])
612
+ self.assertEqual(s["injected_score_min"], 0.1)
613
+ self.assertEqual(s["injected_score_median"], 0.5)
614
+ self.assertEqual(s["injected_score_max"], 0.9)
615
+
616
+ def test_even_count_averages_the_two_middles(self):
617
+ s = recall._injected_score_stats(
618
+ [self._m(0.2), self._m(0.4), self._m(0.6), self._m(0.8)]
619
+ )
620
+ self.assertEqual(s["injected_score_median"], 0.5)
621
+
622
+ def test_empty_set_is_all_none(self):
623
+ self.assertEqual(
624
+ recall._injected_score_stats([]),
625
+ {
626
+ "injected_score_min": None,
627
+ "injected_score_median": None,
628
+ "injected_score_max": None,
629
+ },
630
+ )
631
+
632
+ def test_scoreless_entries_are_excluded_not_treated_as_negative_infinity(self):
633
+ s = recall._injected_score_stats([self._m(0.6), {"text": "no scores"}])
634
+ self.assertEqual(s["injected_score_min"], 0.6)
635
+ self.assertEqual(s["injected_score_max"], 0.6)
636
+
637
+ def test_malformed_input_returns_nulls_instead_of_raising(self):
638
+ # A telemetry error must never propagate into the recall path.
639
+ self.assertEqual(
640
+ recall._injected_score_stats(object()),
641
+ {
642
+ "injected_score_min": None,
643
+ "injected_score_median": None,
644
+ "injected_score_max": None,
645
+ },
646
+ )
647
+
648
+
565
649
  class OverlapFilterUnitTests(unittest.TestCase):
566
650
  """Switchroom #475: _filter_by_overlap behaviour."""
567
651
 
@@ -584,17 +668,277 @@ class OverlapFilterUnitTests(unittest.TestCase):
584
668
  def test_threshold_keeps_partial_match_at_or_above(self):
585
669
  results = [_memory("deploy production server")]
586
670
  kept, dropped = recall._filter_by_overlap(results, "deploy staging server", 0.5)
587
- # 2/4 = 0.5 ≥ 0.5 → kept
671
+ # 2/|M| = 2/3 = 0.666… ≥ 0.5 → kept
588
672
  self.assertEqual(len(kept), 1)
589
673
  self.assertEqual(dropped, 0)
590
674
 
591
675
  def test_threshold_drops_partial_match_below(self):
592
676
  results = [_memory("deploy production server")]
593
- kept, dropped = recall._filter_by_overlap(results, "deploy staging server", 0.51)
677
+ # 2/|M| = 2/3 = 0.666… < 0.7 → dropped
678
+ kept, dropped = recall._filter_by_overlap(results, "deploy staging server", 0.7)
594
679
  self.assertEqual(len(kept), 0)
595
680
  self.assertEqual(dropped, 1)
596
681
 
597
682
 
683
+ class OverlapGateQueryLengthInvarianceTests(unittest.TestCase):
684
+ """Switchroom #3541 regression: the gate must not be decided by prompt length.
685
+
686
+ Production telemetry (1548 recall_log.jsonl rows, ts >= 2026-07-18) showed
687
+ the survival rate through this gate collapsing monotonically as the prompt
688
+ grew — 7.3% at <200 query chars down to 0.9% at 600-750 chars — with a
689
+ 93% zero-result rate at the long end. That is the Jaccard union term, not a
690
+ relevance judgement. These tests pin the containment metric that fixes it.
691
+ """
692
+
693
+ # A realistic recall query: the memory-bearing sentence plus the long
694
+ # unrelated prior-context preamble the UserPromptSubmit hook prepends.
695
+ CORE_QUERY = "where did we land on the postgres connection pool sizing"
696
+ PREAMBLE = (
697
+ "Prior context: the operator asked about telegram card rendering, then "
698
+ "about vault broker grants, then about the nightly digest schedule and "
699
+ "the reaction dispatch wiring, and separately about docker image "
700
+ "promotion, canary holdback, worktree hygiene, changelog discipline, "
701
+ "eval harness thresholds, skill authoring quotas and mental model "
702
+ "refresh cadence across the whole fleet of agents on this host. "
703
+ )
704
+ MEMORY = "Postgres connection pool sizing was settled at min 5 max 100."
705
+
706
+ @staticmethod
707
+ def _filler(n_words):
708
+ """Generate `n_words` DISTINCT unrelated content words.
709
+
710
+ Distinctness matters: the metrics operate on token *sets*, so
711
+ repeating the same paragraph would not grow ``|Q|`` and a
712
+ length-sensitivity test built on it would silently pass even under
713
+ the buggy Jaccard metric.
714
+
715
+ The words must also be purely alphabetic — ``_overlap_tokens``
716
+ splits on every non-alpha character, so a digit-suffixed word like
717
+ ``filler7word`` collapses to the two tokens ``filler``/``word`` no
718
+ matter how many are emitted.
719
+ """
720
+ letters = "abcdefghijklmnopqrstuvwxyz"
721
+ return " ".join(
722
+ "zz" + letters[i // 26 % 26] + letters[i % 26] for i in range(n_words)
723
+ )
724
+
725
+ def test_extra_preamble_does_not_change_the_score(self):
726
+ """In the production regime (query longer than the memory — always
727
+ true for the UserPromptSubmit preamble), piling on more unrelated
728
+ preamble must NOT move the score. Under Jaccard these two differ,
729
+ because the union term grows with the prompt; under containment the
730
+ denominator is the memory, so they are identical."""
731
+ short = recall.containment_overlap(
732
+ self._filler(20) + " " + self.CORE_QUERY, self.MEMORY
733
+ )
734
+ long_ = recall.containment_overlap(
735
+ self._filler(200) + " " + self.CORE_QUERY, self.MEMORY
736
+ )
737
+ self.assertGreater(short, 0.0, "sanity: the memory does overlap the query")
738
+ self.assertAlmostEqual(
739
+ short,
740
+ long_,
741
+ places=6,
742
+ msg="prompt length changed the gate score — the union-term artifact is back",
743
+ )
744
+
745
+ def test_relevant_memory_survives_a_production_length_prompt(self):
746
+ """At the fleet default threshold (0.10, scaffold.ts), a clearly
747
+ relevant memory must survive a production-p50-length prompt."""
748
+ query = self.PREAMBLE + self.CORE_QUERY
749
+ self.assertGreater(
750
+ len(query), 400, "sanity: this is a production-shaped long prompt"
751
+ )
752
+ kept, dropped = recall._filter_by_overlap([_memory(self.MEMORY)], query, 0.10)
753
+ self.assertEqual(
754
+ len(kept), 1, "relevant memory was dropped from a long prompt"
755
+ )
756
+ self.assertEqual(dropped, 0)
757
+
758
+ def test_gate_still_drops_irrelevant_memories_on_a_long_prompt(self):
759
+ """The floor must still bite: a length-invariant metric must not
760
+ become a passthrough. An unrelated memory is still dropped."""
761
+ query = self.PREAMBLE + self.CORE_QUERY
762
+ kept, dropped = recall._filter_by_overlap(
763
+ [_memory("Ken prefers oat milk in his flat white.")], query, 0.10
764
+ )
765
+ self.assertEqual(len(kept), 0, "gate no longer filters — floor is dead")
766
+ self.assertEqual(dropped, 1)
767
+
768
+ # --- switchroom #3541 review F4: what the floor does and does NOT do ---
769
+ #
770
+ # `test_gate_still_drops_irrelevant_memories_on_a_long_prompt` above uses a
771
+ # memory with ZERO shared terms, so it scores 0.000 and would pass at a
772
+ # threshold of 0.001. It proves the gate is not a total no-op; it does not
773
+ # characterise the floor. These two do.
774
+
775
+ IRRELEVANT_BUT_OVERLAPPING = (
776
+ "The nightly digest schedule and the reaction dispatch wiring "
777
+ "were changed."
778
+ )
779
+
780
+ def test_partially_overlapping_irrelevant_memory_is_KEPT_at_the_default(self):
781
+ """Characterisation, not aspiration: at the 0.10 fleet default the gate
782
+ KEEPS an off-topic memory that merely reuses words from the prompt's
783
+ prior-context preamble.
784
+
785
+ This memory has nothing to do with the actual question (postgres pool
786
+ sizing) but shares most of its content tokens with the preamble, so it
787
+ scores ~0.857 — HIGHER than the genuinely relevant memory (~0.571).
788
+ Containment is therefore NOT monotone in relevance on a preamble-heavy
789
+ prompt, which is precisely why the threshold is not raised to try to
790
+ exclude this: any threshold that drops it drops the relevant memory
791
+ first. Precision here is the engine reranker's job, not the gate's.
792
+
793
+ If this test ever starts failing because the memory is now dropped,
794
+ the gate's selectivity changed — re-measure before accepting it.
795
+ """
796
+ query = self.PREAMBLE + self.CORE_QUERY
797
+ off_topic = recall.containment_overlap(query, self.IRRELEVANT_BUT_OVERLAPPING)
798
+ relevant = recall.containment_overlap(query, self.MEMORY)
799
+ self.assertGreater(
800
+ off_topic,
801
+ relevant,
802
+ "the relevance inversion this test documents is gone — "
803
+ "re-derive the threshold recommendation",
804
+ )
805
+ kept, dropped = recall._filter_by_overlap(
806
+ [_memory(self.IRRELEVANT_BUT_OVERLAPPING)], query, 0.10
807
+ )
808
+ self.assertEqual(len(kept), 1, "gate selectivity changed — re-measure")
809
+ self.assertEqual(dropped, 0)
810
+
811
+ def test_floor_drops_a_long_memory_with_only_incidental_overlap(self):
812
+ """Where the floor DOES bite: because the denominator is the memory's
813
+ own token count, a long memory sharing a single incidental term with
814
+ the prompt scores below 0.10 and is dropped.
815
+
816
+ This is the real content of the floor after #3541 — it removes
817
+ candidates with near-zero lexical relationship to the prompt, and
818
+ (deliberately) little else.
819
+ """
820
+ query = self.PREAMBLE + self.CORE_QUERY
821
+ # Exactly 1 shared content token ("docker") out of 17 -> 0.059 < 0.10
822
+ long_incidental = (
823
+ "docker buildx bake emits oci manifests whose provenance "
824
+ "attestations confuse older registries during garbage collection "
825
+ "sweeps"
826
+ )
827
+ score = recall.containment_overlap(query, long_incidental)
828
+ self.assertLess(score, 0.10, "fixture no longer scores below the floor")
829
+ self.assertGreater(score, 0.0, "sanity: there IS some incidental overlap")
830
+ kept, dropped = recall._filter_by_overlap(
831
+ [_memory(long_incidental)], query, 0.10
832
+ )
833
+ self.assertEqual(len(kept), 0, "floor is dead — nothing is being dropped")
834
+ self.assertEqual(dropped, 1)
835
+
836
+ # --- switchroom #3541 review F5: the |Q| < |M| regime ---
837
+
838
+ def test_short_prompt_uses_the_memory_as_denominator(self):
839
+ """A short prompt must NOT be scored against itself.
840
+
841
+ With the textbook overlap coefficient (`min(|Q|, |M|)`) a prompt
842
+ shorter than the memory flips the denominator to the QUERY, and the
843
+ metric silently becomes "what fraction of the prompt is in the
844
+ memory" — a one-word prompt then scores 1.0 against any memory
845
+ containing that word, re-introducing the query-length dependence
846
+ #3541 exists to remove. Dividing by `|M|` unconditionally has no such
847
+ discontinuity.
848
+ """
849
+ memory = "Postgres connection pool sizing was settled at min 5 max 100."
850
+ n_mem = len(recall._overlap_tokens(memory))
851
+ query = "docker" # 1 content token, far shorter than the memory
852
+ self.assertLess(
853
+ len(recall._overlap_tokens(query)),
854
+ n_mem,
855
+ "sanity: this is the |Q| < |M| regime",
856
+ )
857
+ # A min()-denominator would score this 1.0 (1 shared token / |Q| = 1);
858
+ # dividing by the memory gives 1/2.
859
+ self.assertEqual(recall.containment_overlap(query, "docker image"), 0.5)
860
+
861
+ # And a short prompt overlapping the memory scores by the memory:
862
+ q2 = "postgres pool"
863
+ expected = len(
864
+ recall._overlap_tokens(q2) & recall._overlap_tokens(memory)
865
+ ) / n_mem
866
+ self.assertAlmostEqual(
867
+ recall.containment_overlap(q2, memory), expected, places=6
868
+ )
869
+
870
+ def test_never_stricter_than_the_jaccard_gate_it_replaces(self):
871
+ """Safety property vs what production runs today: because
872
+ `|Q u M| >= |M|`, containment >= Jaccard for every pair. At a fixed
873
+ threshold this gate admits a SUPERSET of what the deployed Jaccard
874
+ gate admits, so no memory that survives today can be dropped by this
875
+ change. Asserted over the fixtures rather than argued in prose.
876
+ """
877
+ query = self.PREAMBLE + self.CORE_QUERY
878
+ cases = [
879
+ self.MEMORY,
880
+ self.IRRELEVANT_BUT_OVERLAPPING,
881
+ "Ken prefers oat milk in his flat white.",
882
+ "docker",
883
+ "postgres pool sizing",
884
+ self.PREAMBLE,
885
+ ]
886
+ for mem in cases:
887
+ a = recall._overlap_tokens(query)
888
+ b = recall._overlap_tokens(mem)
889
+ jac = (len(a & b) / len(a | b)) if (a and b) else 0.0
890
+ score = recall.containment_overlap(query, mem)
891
+ self.assertGreaterEqual(
892
+ score + 1e-12,
893
+ jac,
894
+ f"containment scored BELOW jaccard for {mem[:40]!r} — "
895
+ f"this change could drop a memory production keeps",
896
+ )
897
+ # Upper bound + exact value. Review finding: the lower bound
898
+ # alone is unfalsifiable for ANY implementation dividing by |M|
899
+ # (a constant `return 1.0` satisfies it), so it survived every
900
+ # mutation and proved nothing. These two pin the metric.
901
+ self.assertLessEqual(score, 1.0, f"containment exceeded 1.0 for {mem[:40]!r}")
902
+ expected = (len(a & b) / len(b)) if b else 0.0
903
+ self.assertAlmostEqual(
904
+ score, expected, places=9,
905
+ msg=f"containment is not |Q n M| / |M| for {mem[:40]!r}",
906
+ )
907
+
908
+ def test_strict_subset_memory_scores_strictly_below_one(self):
909
+ """A memory carrying a term the query does NOT have must score < 1.0.
910
+
911
+ The companion upper bound to the superset property above: it is what
912
+ actually falsifies a degenerate `return 1.0` implementation.
913
+ """
914
+ query = self.PREAMBLE + self.CORE_QUERY
915
+ # Every token of this memory except `quokka` appears in the query.
916
+ mem = self.CORE_QUERY + " quokka"
917
+ score = recall.containment_overlap(query, mem)
918
+ self.assertLess(score, 1.0)
919
+ self.assertGreater(score, 0.0)
920
+ # And a fully-contained memory reaches exactly 1.0, so the < 1.0
921
+ # above is a property of the missing term, not a ceiling artefact.
922
+ self.assertAlmostEqual(
923
+ recall.containment_overlap(query, self.CORE_QUERY), 1.0, places=9
924
+ )
925
+
926
+ def test_survival_does_not_degrade_as_the_prompt_grows(self):
927
+ """Sweep prompt length the way production does and assert the relevant
928
+ memory survives at every length — the monotonic collapse is the bug."""
929
+ for n_filler in (0, 20, 50, 100, 200, 400):
930
+ query = self._filler(n_filler) + " " + self.CORE_QUERY
931
+ kept, _ = recall._filter_by_overlap(
932
+ [_memory(self.MEMORY)], query, 0.10
933
+ )
934
+ self.assertEqual(
935
+ len(kept),
936
+ 1,
937
+ f"relevant memory dropped at {n_filler} filler words "
938
+ f"({len(query)} query chars)",
939
+ )
940
+
941
+
598
942
  class OverlapGateIntegrationTests(unittest.TestCase):
599
943
  """Switchroom #475: gate wired through main()."""
600
944
 
@@ -2,7 +2,7 @@
2
2
  "hindsightApiUrl": "",
3
3
  "bankId": "claude_code",
4
4
  "bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
5
- "retainMission": "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
5
+ "retainMission": "Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nNEVER extract:\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- In-flight workflow/process narration (a sub-task started, paused, or is still running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch or session IDs, UUIDs, hashes, or error codes.\n- Hindsight's own errors, retries, backlogs, or internal state — the memory system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Transient state (unread counts, build status, what is running right now) unless the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If nothing durable remains, return an empty facts list.",
6
6
  "autoRecall": true,
7
7
  "autoRetain": true,
8
8
  "retainMode": "full-session",