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.
- package/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8307 -7620
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- 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
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
535
|
-
self.assertAlmostEqual(score,
|
|
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.
|
|
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,
|
|
542
|
-
self.assertAlmostEqual(score,
|
|
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.
|
|
546
|
-
self.assertEqual(recall.
|
|
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.
|
|
550
|
-
self.assertEqual(recall.
|
|
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.
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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
|
|
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",
|