switchroom 0.19.26 → 0.19.28

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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -556,6 +556,112 @@ def _dir_bytes(d: str, names) -> int:
556
556
  return total
557
557
 
558
558
 
559
+ def _append_ledger(line: str) -> None:
560
+ """Append one line to ``pending-evictions.log``, rotating at its cap.
561
+
562
+ Split out of ``_log_eviction`` (#3688 review R1-M2) so the OTHER way a
563
+ payload leaves this module for good — ``_trim_dir`` shedding an archived
564
+ copy — can be ledgered on exactly the same terms instead of leaving no
565
+ durable trace at all. Best-effort: a ledger that cannot be written *or
566
+ read back* must never take the caller down with it — hence the guard
567
+ catches ``ValueError`` as well as ``OSError`` (see there).
568
+
569
+ ROTATION PRIORITISES ``evicted=`` INSIDE THE SAME BUDGET, and that is a
570
+ direct consequence of sharing the file. Before #3688 every line here
571
+ was an eviction, so "keep the newest ``EVICTIONS_LOG_KEEP_LINES``
572
+ lines" and "keep the newest 2,000 evictions" were the same sentence.
573
+ They are not any more: a single ``collapse_duplicates`` run over the
574
+ measured 1,060-file backlog writes ~192 ``trimmed=`` lines, and a plain
575
+ tail-rotate would let that noise push real ``evicted=`` lines out of
576
+ the 7-day window ``switchroom doctor`` reads — i.e. adding
577
+ observability for the BENIGN channel would have removed it for the one
578
+ channel that means memory is actually gone.
579
+
580
+ So the keep window is filled evictions-first and only then topped up
581
+ with the newest remaining lines. The budget is unchanged at
582
+ ``EVICTIONS_LOG_KEEP_LINES`` lines TOTAL — a ledger of nothing but
583
+ evictions rotates exactly as it did before #3688 — and chronological
584
+ order is preserved because the selection is by index over the original
585
+ lines, which ``switchroom doctor``'s ``$1 >= cutoff`` awk depends on.
586
+ """
587
+ log = evictions_log_path()
588
+ try:
589
+ with open(log, "a", encoding="utf-8") as f:
590
+ print(line, file=f)
591
+ # Bounded, not append-forever. `switchroom doctor` windows this by
592
+ # timestamp so a single legitimate eviction can't turn the row red
593
+ # permanently, but the FILE still needs a ceiling of its own.
594
+ if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
595
+ with open(log, encoding="utf-8") as f:
596
+ lines = f.readlines()
597
+ evictions = [i for i, ln in enumerate(lines) if "evicted=" in ln]
598
+ others = [i for i, ln in enumerate(lines) if "evicted=" not in ln]
599
+ keep_idx = set(evictions[-EVICTIONS_LOG_KEEP_LINES:])
600
+ room = EVICTIONS_LOG_KEEP_LINES - len(keep_idx)
601
+ if room > 0:
602
+ keep_idx.update(others[-room:])
603
+ kept = [ln for i, ln in enumerate(lines) if i in keep_idx]
604
+ tmp = log + ".tmp"
605
+ with open(tmp, "w", encoding="utf-8") as f:
606
+ f.writelines(kept)
607
+ os.chmod(tmp, 0o600)
608
+ os.replace(tmp, log)
609
+ except (OSError, ValueError):
610
+ # ValueError, not just OSError, because BOTH codec errors are
611
+ # ValueError subclasses and neither is an OSError (#3688 re-review
612
+ # B1). The rotate READS the ledger back (``readlines()`` above) —
613
+ # something no revision before #3688 did — so a ledger holding one
614
+ # non-UTF-8 byte raises ``UnicodeDecodeError`` here, and a name
615
+ # carrying a surrogate raises ``UnicodeEncodeError`` at the
616
+ # ``print`` above. Under a bare ``except OSError`` both escape this
617
+ # "best-effort" guard: ``_trim_dir`` → ``_log_archive_trim`` → here
618
+ # runs inside ``archive_reconciled``/``enqueue``, so one corrupt
619
+ # ledger byte would take down every drain — on the sidecar,
620
+ # identically, every 900s forever. A ledger that cannot be written
621
+ # must never take the caller down with it, and that has to include
622
+ # the codec.
623
+ pass
624
+
625
+
626
+ def _log_archive_trim(
627
+ name: str, size: int, reason: str, archive: str, depth: int, nbytes: int
628
+ ) -> None:
629
+ """Ledger ONE archived copy shed by ``_trim_dir``.
630
+
631
+ Until #3688 review R1-M2 a trim was stderr-only: an entry could enter
632
+ ``pending-duplicate/`` (or ``pending-evicted/``, or
633
+ ``pending-reconciled/``) and then be deleted from it with no durable
634
+ record anywhere, while an *eviction* — the other end of the same
635
+ payload's life — has had ``pending-evictions.log`` since #3599. stderr
636
+ is gone with the process; the ledger is what an operator can still read
637
+ a week later. Same file, because it answers one question ("what left
638
+ this agent's queue, and when").
639
+
640
+ THE FIRST TOKEN IS DELIBERATELY ``trimmed=``, NOT ``evicted=``.
641
+ ``switchroom doctor``'s probe counts eviction ledger lines with
642
+ ``awk '$1 >= cutoff && /evicted=/'`` (src/cli/doctor.ts, see
643
+ ``buildPendingRetainsProbeScript``), and that count FAILS the
644
+ pending-retains row. A trim is not an eviction — nothing was shed from
645
+ the live queue and, for ``pending-duplicate/``, a byte-identical copy is
646
+ still queued — so a trim must not be able to turn that row red. No field
647
+ on this line may ever be formatted such that ``evicted=`` appears in it;
648
+ ``ArchiveTrimLedgerTest`` pins that.
649
+ """
650
+ _append_ledger(
651
+ "%s trimmed=%s bytes=%d archive=%s reason=archive-%s "
652
+ "archive_depth=%d archive_bytes=%d"
653
+ % (
654
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
655
+ name,
656
+ size,
657
+ archive,
658
+ reason,
659
+ depth,
660
+ nbytes,
661
+ )
662
+ )
663
+
664
+
559
665
  def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
560
666
  """Keep ``a`` under its count/byte caps, deleting OLDEST first.
561
667
 
@@ -610,6 +716,21 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
610
716
  ``pending-evicted/`` is a different story again — see ``_evict_to_fit``:
611
717
  an entry only reaches it through the ledgered eviction path, and under
612
718
  sustained ENOSPC it may not reach it at all.
719
+
720
+ EVERY DROP IS LEDGERED, AND ONLY A DROP (#3688 review R1-M2, tightened
721
+ by re-review B2). One ``trimmed=… archive=…`` line per shed copy goes
722
+ to ``pending-evictions.log`` via ``_log_archive_trim``, so the trim
723
+ horizon above is observable after the fact and not only in a stderr
724
+ stream nobody kept. That line is deliberately NOT counted as an
725
+ eviction by doctor — see ``_log_archive_trim``.
726
+
727
+ A victim whose ``os.remove`` FAILS (EACCES, read-only mount) is neither
728
+ ledgered nor counted nor named on stderr: it is still on disk, so a
729
+ ``trimmed=`` line for it would be a false claim of deletion in exactly
730
+ the record an operator consults after suspected loss, and the returned
731
+ count is what callers report as "copies shed". ``TrimDirBoundaryTest``
732
+ pins that a failed remove yields ``0``, no ledger line, and every file
733
+ still present.
613
734
  """
614
735
  # `.json` ONLY, and that is load-bearing beyond "skip stray files": a
615
736
  # `.dead` marker is named `<entry>.json.dead`, so it can never be
@@ -622,14 +743,35 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
622
743
  except OSError:
623
744
  return 0
624
745
  dropped = []
625
- while names and (
626
- len(names) > max_entries or _dir_bytes(a, names) > max_bytes
627
- ):
746
+ label = os.path.basename(a.rstrip("/"))
747
+ nbytes = _dir_bytes(a, names)
748
+ while names and (len(names) > max_entries or nbytes > max_bytes):
749
+ reason = "count" if len(names) > max_entries else "bytes"
750
+ victim = names.pop(0)
751
+ vpath = os.path.join(a, victim)
628
752
  try:
629
- os.remove(os.path.join(a, names[0]))
753
+ vsize = os.path.getsize(vpath)
630
754
  except OSError:
631
- pass
632
- dropped.append(names.pop(0))
755
+ vsize = 0
756
+ try:
757
+ os.remove(vpath)
758
+ except OSError:
759
+ # The copy is still on disk, so the running total would drift.
760
+ # Re-measure rather than assume — this preserves the original
761
+ # recompute-every-iteration semantics on the one path where a
762
+ # decrement would be wrong (EACCES / read-only mount).
763
+ nbytes = _dir_bytes(a, names)
764
+ # NOT counted and NOT ledgered (#3688 re-review B2). The ledger
765
+ # is the forensic record an operator reads AFTER suspected data
766
+ # loss; a `trimmed=` line for a copy still sitting on disk sends
767
+ # them hunting a payload that never left, and the returned count
768
+ # (and the stderr line below) would name files that were not
769
+ # dropped. A ledger that claims deletions which never happened
770
+ # is worse than no ledger.
771
+ continue
772
+ nbytes -= vsize
773
+ dropped.append(victim)
774
+ _log_archive_trim(victim, vsize, reason, label, len(names), nbytes)
633
775
  if dropped:
634
776
  shown = ", ".join(dropped[:10])
635
777
  if len(dropped) > 10:
@@ -736,6 +878,47 @@ def archive_duplicate(path: str) -> Optional[str]:
736
878
  return dest
737
879
 
738
880
 
881
+ def _attempt_count(entry: dict) -> int:
882
+ """How many retries this copy has BURNED, for survivor selection.
883
+
884
+ Not ``int(entry.get("attempt_count", 1) or 1)`` (#3688 review R1-L3).
885
+ That expression had two defects, both latent rather than reachable
886
+ today — ``_build_entry`` seeds ``1`` and ``update_attempt`` only ever
887
+ increments — which is exactly why they need pinning rather than
888
+ ignoring: nothing would have caught them turning real.
889
+
890
+ * ``0 or 1`` is ``1``, so a NEVER-ATTEMPTED copy scored the same as one
891
+ that had already burned an attempt, and lost the tie-break to it if
892
+ it happened to be the newer file. That is backwards from the rule
893
+ ``collapse_duplicates`` documents ("the copy with the most retries
894
+ left before ``MAX_ATTEMPTS`` wins") — a 0 must beat a 1.
895
+ * ``int("many")`` RAISES. A single semantically-malformed entry — valid
896
+ JSON, so ``iter_entries`` hands it over rather than quarantining it —
897
+ took the whole collapse pass down with it, and with it (before the
898
+ guard in ``drain_pending._drain_backlog_impl``) the whole drain.
899
+ Unparseable now scores ``MAX_ATTEMPTS``: we cannot tell how many
900
+ attempts it has left, so it is never PREFERRED as the survivor, but
901
+ it is never the reason a collapse fails either. Losing it costs
902
+ nothing anyway — every copy in a group is byte-identical, and the
903
+ loser is archived, not deleted.
904
+
905
+ A missing or ``None`` count reads as ``1``, matching what
906
+ ``_build_entry`` writes, so an entry from an older build that predates
907
+ the field is treated as freshly queued rather than exhausted.
908
+ """
909
+ raw = entry.get("attempt_count", 1)
910
+ if raw is None:
911
+ return 1
912
+ # bool is an int subclass; True would silently score 1. Neither True nor
913
+ # False is an attempt count, so both take the unparseable branch.
914
+ if isinstance(raw, bool):
915
+ return MAX_ATTEMPTS
916
+ try:
917
+ return int(raw)
918
+ except (TypeError, ValueError):
919
+ return MAX_ATTEMPTS
920
+
921
+
739
922
  def collapse_duplicates() -> int:
740
923
  """Collapse entries sharing ``(bank_id, part_position, sha256(content))``.
741
924
 
@@ -754,7 +937,8 @@ def collapse_duplicates() -> int:
754
937
  memory; the one with the most attempts left is the one most likely to
755
938
  get there before ``MAX_ATTEMPTS`` promotes it to ``.dead``. Picking the
756
939
  oldest outright would systematically keep the most-attempted copy —
757
- exactly backwards.
940
+ exactly backwards. ``_attempt_count`` defines "lowest", including what
941
+ a ``0`` and what a malformed count are worth.
758
942
 
759
943
  An entry whose key is ``None`` (no ``content``) is never grouped: its
760
944
  identity cannot be established, so it is always kept.
@@ -773,10 +957,7 @@ def collapse_duplicates() -> int:
773
957
  for key, members in groups.items():
774
958
  if len(members) < 2:
775
959
  continue
776
- survivor = min(
777
- members,
778
- key=lambda m: (int(m[2].get("attempt_count", 1) or 1), m[0]),
779
- )
960
+ survivor = min(members, key=lambda m: (_attempt_count(m[2]), m[0]))
780
961
  for member in members:
781
962
  if member is survivor:
782
963
  continue
@@ -1047,23 +1228,7 @@ def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) ->
1047
1228
  depth,
1048
1229
  nbytes,
1049
1230
  )
1050
- log = evictions_log_path()
1051
- try:
1052
- with open(log, "a", encoding="utf-8") as f:
1053
- print(line, file=f)
1054
- # Bounded, not append-forever. `switchroom doctor` windows this by
1055
- # timestamp so a single legitimate eviction can't turn the row red
1056
- # permanently, but the FILE still needs a ceiling of its own.
1057
- if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
1058
- with open(log, encoding="utf-8") as f:
1059
- kept = f.readlines()[-(EVICTIONS_LOG_KEEP_LINES):]
1060
- tmp = log + ".tmp"
1061
- with open(tmp, "w", encoding="utf-8") as f:
1062
- f.writelines(kept)
1063
- os.chmod(tmp, 0o600)
1064
- os.replace(tmp, log)
1065
- except OSError:
1066
- pass
1231
+ _append_ledger(line)
1067
1232
  print(
1068
1233
  "[Hindsight] pending-retains FULL - evicted OLDEST entry to keep the "
1069
1234
  "newest memory: %s (%d bytes, %s; queue now %d entries / %d bytes). "
@@ -580,6 +580,101 @@ def _injected_score_stats(results) -> dict:
580
580
  return empty
581
581
 
582
582
 
583
+ # Switchroom #3837 — opt-in absolute score floor (`recallMinScore`).
584
+ #
585
+ # READ #3761 FIRST. It removed the lexical `recallMinOverlap` gate and stated
586
+ # "no replacement floor", on a 330-query replay showing that for EVERY floor
587
+ # value tested `top1lost% == zero%`: a floor at 0.001 took zero-result recalls
588
+ # from 5.8% to 28.2%, at 0.05 to 40.6%. That measurement stands and this change
589
+ # does not contradict it — which is why the floor ships DISABLED (0.0) and why
590
+ # its default scope is not "every turn".
591
+ #
592
+ # What #3761 did not separate is the CONDITION of the bank read. Measured
593
+ # fleet-wide from `recall_log.jsonl` on 2026-07-27 and recorded independently
594
+ # in `src/hindsight-watch/thresholds.ts` (RECALL_SCORE_P50_PAGE):
595
+ #
596
+ # own bank HEALTHY (n=74) p50 injected_score_max 0.0850 28.4% below 0.01
597
+ # own bank DEGRADED (n=247) p50 injected_score_max 0.0006 98.4% below 0.01
598
+ #
599
+ # A ~140x separation in the p50, and the below-0.01 fraction separates
600
+ # 28.4% vs 98.4%. Two conclusions follow, and only the second is actionable:
601
+ #
602
+ # * On a HEALTHY turn a low `scores.final` does NOT imply noise. The healthy
603
+ # distribution is bimodal, p10 0.0005 to p90 0.9259 — the score is not
604
+ # calibrated across queries, so 28.4% of perfectly good turns would be
605
+ # emptied by a 0.01 floor. That is #3761's result, reproduced. A floor
606
+ # applied unconditionally re-creates #3541.
607
+ # * On a DEGRADED turn — the agent's own bank timed out or was unreachable,
608
+ # so what survives is side-bank residue — 98.4% of injected sets have a
609
+ # best score below 0.01, and the turn ALREADY carries
610
+ # `degraded_recall_notice`. Withholding that residue costs at most the
611
+ # 1.6% tail and replaces "six memories presented as recall" with the
612
+ # honest "recall was DEGRADED, treat absence as UNKNOWN" the agent can
613
+ # act on. Injecting noise under the banner of recall is strictly worse
614
+ # than injecting nothing WITH a disclosure, because the agent cannot tell
615
+ # the two apart; injecting nothing WITHOUT a disclosure is the failure
616
+ # mode #3619 fixed and is not re-introduced here.
617
+ #
618
+ # Hence `recallMinScoreScope`, default "degraded": when the operator sets a
619
+ # floor, it binds only on turns where the own-bank read was degraded, which is
620
+ # the population the evidence supports. "all" widens it to every turn for an
621
+ # operator who has measured their own bank and wants it — no fleet default
622
+ # recommends that today.
623
+ #
624
+ # Nothing is dropped silently. When the floor empties a non-empty set the turn
625
+ # emits `min_score_withheld_notice` (and the degraded notice too, when that
626
+ # fired), so an empty recall is never mistaken for an empty memory. Per-turn
627
+ # telemetry lands on `recall_log.jsonl` as `min_score_floor`,
628
+ # `min_score_scope`, `min_score_applied` and `dropped_below_min_score`,
629
+ # alongside the existing `injected_score_*` fields — the same instrument the
630
+ # problem was measured with, so the effect is measurable the same way.
631
+ def _filter_by_min_score(results, threshold: float):
632
+ """Drop results whose engine score (`scores.final`) is below `threshold`.
633
+
634
+ Returns ``(kept, dropped)``. ``threshold <= 0`` short-circuits to
635
+ passthrough with no iteration cost, so the disabled default cannot alter
636
+ the injected set.
637
+
638
+ A result carrying NO usable score is KEPT, never dropped. `-inf` is the
639
+ sentinel `_result_final_score` returns for a malformed or score-less
640
+ entry, and "the engine gave us no score" is not evidence of irrelevance —
641
+ dropping on it would turn a response-shape change upstream into silent
642
+ total recall loss. Same reasoning as the sort, which parks score-less
643
+ entries last rather than discarding them.
644
+ """
645
+ if threshold <= 0:
646
+ return results, 0
647
+ kept = []
648
+ dropped = 0
649
+ for m in results:
650
+ score = _result_final_score(m)
651
+ if score == float("-inf") or score >= threshold:
652
+ kept.append(m)
653
+ else:
654
+ dropped += 1
655
+ return kept, dropped
656
+
657
+
658
+ def min_score_withheld_notice(dropped: int, threshold: float) -> str:
659
+ """One line telling the agent that recall HAD candidates and withheld them.
660
+
661
+ Only meaningful when the floor emptied the injected set. The distinction
662
+ it preserves is #3619's: "I could not retrieve anything worth trusting" is
663
+ not "there is nothing to remember", and an agent that cannot tell those
664
+ apart asserts the second. Kept to a single short line — this fires on an
665
+ already-thin turn.
666
+ """
667
+ if dropped <= 0:
668
+ return ""
669
+ return (
670
+ f"[Hindsight] Memory recall returned {dropped} candidate "
671
+ f"{'memory' if dropped == 1 else 'memories'}, all scoring below the "
672
+ f"configured relevance floor ({threshold:g}), so none were injected. "
673
+ f"Treat an absence of relevant memory as UNKNOWN, not as 'nothing was "
674
+ f"remembered'."
675
+ )
676
+
677
+
583
678
  def _sort_by_final_score(results):
584
679
  """Sort merged multi-bank results by `scores.final` descending, in place.
585
680
 
@@ -1676,6 +1771,15 @@ def main():
1676
1771
  # No directives block is built on a cache hit.
1677
1772
  "directives_omitted": None,
1678
1773
  "demoted_count": 0,
1774
+ # #3837 score-floor fields, present for a uniformly queryable
1775
+ # schema. A cache hit replays a formatted context block, not a
1776
+ # result set, so the floor cannot have run: None (not 0.0/False)
1777
+ # so a cache-hit row is never counted as an observed
1778
+ # floor-disabled turn.
1779
+ "min_score_floor": None,
1780
+ "min_score_scope": None,
1781
+ "min_score_applied": None,
1782
+ "dropped_below_min_score": None,
1679
1783
  "capped": False,
1680
1784
  # #3541 quality telemetry — present for a uniformly queryable
1681
1785
  # schema. A cache hit replays a formatted context block, not a
@@ -2146,6 +2250,45 @@ def main():
2146
2250
  # regardless of source bank. Stable sort: ties keep own-bank-first order.
2147
2251
  _sort_by_final_score(results)
2148
2252
 
2253
+ # Switchroom #3619 — DEGRADED-RECALL DISCLOSURE. Computed HERE rather than
2254
+ # at emit time because the #3837 score floor is scoped by it: the same
2255
+ # single source of truth for "the agent's own bank did not answer" decides
2256
+ # both whether the floor binds and what the agent is told. See
2257
+ # `degraded_recall_notice` for why only the agent's OWN bank counts.
2258
+ degraded_block = degraded_recall_notice(bank_id, bank_timings)
2259
+
2260
+ # Switchroom #3837 — opt-in absolute score floor. Runs AFTER the tag
2261
+ # weights and the relevance sort (so it judges the same effective
2262
+ # `scores.final` the ranking used) and BEFORE the head-slice cap, so the
2263
+ # cap sees only survivors. Disabled by default (`recallMinScore` 0.0);
2264
+ # when set it binds on degraded turns only unless the operator widens
2265
+ # `recallMinScoreScope` to "all". The design note above
2266
+ # `_filter_by_min_score` carries the measurement, and why #3761's "no
2267
+ # replacement floor" finding is not contradicted by this.
2268
+ min_score_floor = config.get("recallMinScore", 0.0)
2269
+ if isinstance(min_score_floor, bool) or not isinstance(min_score_floor, (int, float)):
2270
+ min_score_floor = 0.0
2271
+ min_score_floor = float(min_score_floor)
2272
+ min_score_scope = config.get("recallMinScoreScope", "degraded")
2273
+ if min_score_scope not in ("degraded", "all"):
2274
+ min_score_scope = "degraded"
2275
+ min_score_applied = min_score_floor > 0 and (
2276
+ min_score_scope == "all" or bool(degraded_block)
2277
+ )
2278
+ dropped_below_min_score = 0
2279
+ if min_score_applied:
2280
+ pre_min_score_count = len(results)
2281
+ results, dropped_below_min_score = _filter_by_min_score(
2282
+ results, min_score_floor
2283
+ )
2284
+ if dropped_below_min_score > 0:
2285
+ debug_log(
2286
+ config,
2287
+ f"Score floor dropped {dropped_below_min_score}/"
2288
+ f"{pre_min_score_count} memories below scores.final "
2289
+ f"{min_score_floor} (scope={min_score_scope})",
2290
+ )
2291
+
2149
2292
  # Switchroom-local: client-side count cap. Plugin v0.4.0 has no
2150
2293
  # `recallTopK` in the Claude Code integration (Openclaw-only), and a
2151
2294
  # token budget alone doesn't bound count — a single long memory can
@@ -2333,6 +2476,19 @@ def main():
2333
2476
  # doctor's directive-count check will be FAILing too.
2334
2477
  "directives_omitted": count_omitted_directives(directives),
2335
2478
  "demoted_count": demoted_count,
2479
+ # Switchroom #3837 — score-floor telemetry, deliberately alongside the
2480
+ # `injected_score_*` fields below: those are what the floor was derived
2481
+ # from, and these are what says whether it bound and what it cost.
2482
+ # `min_score_floor` is the configured value (0.0 = disabled),
2483
+ # `min_score_scope` the population it may bind on, `min_score_applied`
2484
+ # whether it actually ran this turn, and `dropped_below_min_score` how
2485
+ # many results it removed. dropped > 0 with result_count 0 is the case
2486
+ # the feature exists for: candidates existed, all were noise, none were
2487
+ # injected, and the agent was told so.
2488
+ "min_score_floor": min_score_floor,
2489
+ "min_score_scope": min_score_scope,
2490
+ "min_score_applied": min_score_applied,
2491
+ "dropped_below_min_score": dropped_below_min_score,
2336
2492
  "capped": capped,
2337
2493
  "pre_cap_count": pre_cap_count,
2338
2494
  "memory_ids": [
@@ -2430,10 +2586,17 @@ def main():
2430
2586
  "error": recall_error_summary(bank_id, bank_timings, directives_timed_out),
2431
2587
  })
2432
2588
 
2433
- # Switchroom #3619 — DEGRADED-RECALL DISCLOSURE. See
2434
- # `degraded_recall_notice` for why this exists and why only the agent's
2435
- # OWN bank counts.
2436
- degraded_block = degraded_recall_notice(bank_id, bank_timings)
2589
+ # (`degraded_block` — the #3619 disclosure was computed before the score
2590
+ # floor above, which is scoped by it.)
2591
+ #
2592
+ # Switchroom #3837 — when the floor emptied a non-empty set, say so. Not
2593
+ # emitted when survivors remain: a partial drop still injects real
2594
+ # memories and does not change how the turn should be read.
2595
+ withheld_block = (
2596
+ min_score_withheld_notice(dropped_below_min_score, min_score_floor)
2597
+ if not results
2598
+ else ""
2599
+ )
2437
2600
 
2438
2601
  # If no block has content, there's nothing to inject — exit
2439
2602
  # silently to avoid emitting an empty hookSpecificOutput. #2848: unless
@@ -2441,10 +2604,13 @@ def main():
2441
2604
  # (a correction with no memories/directives still needs the reminder).
2442
2605
  # #3619: a degraded own-bank read is likewise worth emitting alone — that
2443
2606
  # is precisely the turn on which the agent must not assume it remembers.
2607
+ # #3837: so is a set the score floor withheld entirely.
2444
2608
  if not directives_block and not memories_block and not transcript_fallback_block:
2445
- if degraded_block or nudge_block:
2609
+ if degraded_block or withheld_block or nudge_block:
2446
2610
  _emit_cached_context(
2447
- "\n\n".join([b for b in (degraded_block, nudge_block) if b])
2611
+ "\n\n".join(
2612
+ [b for b in (degraded_block, withheld_block, nudge_block) if b]
2613
+ )
2448
2614
  )
2449
2615
  return
2450
2616
 
@@ -2500,7 +2666,10 @@ def main():
2500
2666
  "hookSpecificOutput": {
2501
2667
  "hookEventName": "UserPromptSubmit",
2502
2668
  "additionalContext": _combine_context(
2503
- _combine_context(degraded_block, context_message), nudge_block
2669
+ _combine_context(
2670
+ _combine_context(degraded_block, withheld_block), context_message
2671
+ ),
2672
+ nudge_block,
2504
2673
  ),
2505
2674
  }
2506
2675
  }
@@ -0,0 +1,170 @@
1
+ """Switchroom #3841 — the recall knobs switchroom.yaml exports must actually
2
+ land in the loaded config.
3
+
4
+ Three of them (`recallPreferObservations`, `recallRoles`,
5
+ `recallPromptPreamble`) had a config key and a `recall.py` read but NO entry in
6
+ `ENV_OVERRIDES`, so there was no channel from switchroom.yaml at all: the only
7
+ way to change them was to hand-edit the installed plugin, which
8
+ `switchroom apply` reverts by re-copying this tree.
9
+
10
+ The rest already had entries and only needed the yaml surface — they are
11
+ asserted here too, because the failure they guard against is silent in exactly
12
+ the same way. `start.sh` exports a NAME; if that name is not a key of
13
+ `ENV_OVERRIDES`, `load_config()` ignores it, the plugin default stands, and the
14
+ operator's switchroom.yaml reads as though it were in force. Nothing errors.
15
+
16
+ Stdlib-only.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ import unittest
23
+ from unittest import mock
24
+
25
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
26
+ if SCRIPTS_DIR not in sys.path:
27
+ sys.path.insert(0, SCRIPTS_DIR)
28
+
29
+ from lib.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
30
+
31
+
32
+ # Every env var `profiles/_base/start.sh.hbs` exports for the #3841
33
+ # passthrough, with the config key it must reach.
34
+ PASSTHROUGH_ENV = {
35
+ "HINDSIGHT_RECALL_BUDGET": "recallBudget",
36
+ "HINDSIGHT_RECALL_MAX_TOKENS": "recallMaxTokens",
37
+ "HINDSIGHT_RECALL_PREFER_OBSERVATIONS": "recallPreferObservations",
38
+ "HINDSIGHT_RECALL_CONTEXT_TURNS": "recallContextTurns",
39
+ "HINDSIGHT_RECALL_ROLES": "recallRoles",
40
+ "HINDSIGHT_RECALL_PROMPT_PREAMBLE": "recallPromptPreamble",
41
+ "HINDSIGHT_RECALL_MAX_QUERY_CHARS": "recallMaxQueryChars",
42
+ "HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES": "recallTranscriptTailBytes",
43
+ "HINDSIGHT_RECALL_TAGS": "recallTags",
44
+ "HINDSIGHT_RECALL_TAGS_MATCH": "recallTagsMatch",
45
+ "HINDSIGHT_RECALL_TAG_GROUPS": "recallTagGroups",
46
+ "HINDSIGHT_RECALL_TAG_WEIGHTS": "recallTagWeights",
47
+ "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": "recallAdditionalBankFilters",
48
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK": "recallTranscriptFallback",
49
+ "HINDSIGHT_RECALL_PARALLEL": "recallParallel",
50
+ }
51
+
52
+
53
+ def _load_with(env):
54
+ """load_config() with a hermetic environment (no plugin/user settings)."""
55
+ with mock.patch.dict(os.environ, env, clear=True):
56
+ # Point CLAUDE_PLUGIN_ROOT at a directory with no settings.json so the
57
+ # result is DEFAULTS + env only, and no developer's real
58
+ # ~/.hindsight/claude-code.json can leak in.
59
+ os.environ["CLAUDE_PLUGIN_ROOT"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
60
+ os.environ["HOME"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
61
+ return load_config()
62
+
63
+
64
+ class EveryExportedNameHasAChannel(unittest.TestCase):
65
+ def test_all_passthrough_env_names_are_wired(self):
66
+ missing = [name for name in PASSTHROUGH_ENV if name not in ENV_OVERRIDES]
67
+ self.assertEqual(missing, [], f"exported but never read: {missing}")
68
+
69
+ def test_each_name_maps_to_the_expected_config_key(self):
70
+ for name, key in PASSTHROUGH_ENV.items():
71
+ with self.subTest(name=name):
72
+ self.assertEqual(ENV_OVERRIDES[name][0], key)
73
+
74
+ def test_every_target_key_exists_in_defaults(self):
75
+ for name, key in PASSTHROUGH_ENV.items():
76
+ with self.subTest(name=name):
77
+ self.assertIn(key, DEFAULTS)
78
+
79
+
80
+ class ValuesActuallyLand(unittest.TestCase):
81
+ """The outcome that matters: the loaded config carries the exported value."""
82
+
83
+ def test_prefer_observations_can_be_turned_off(self):
84
+ self.assertTrue(DEFAULTS["recallPreferObservations"])
85
+ cfg = _load_with({"HINDSIGHT_RECALL_PREFER_OBSERVATIONS": "false"})
86
+ self.assertIs(cfg["recallPreferObservations"], False)
87
+
88
+ def test_roles_accepts_the_exported_json_array(self):
89
+ cfg = _load_with({"HINDSIGHT_RECALL_ROLES": '["user"]'})
90
+ self.assertEqual(cfg["recallRoles"], ["user"])
91
+
92
+ def test_prompt_preamble_replaces_the_banner(self):
93
+ cfg = _load_with({"HINDSIGHT_RECALL_PROMPT_PREAMBLE": "Past context:"})
94
+ self.assertEqual(cfg["recallPromptPreamble"], "Past context:")
95
+
96
+ def test_tag_weights_accepts_the_exported_json_object(self):
97
+ cfg = _load_with({"HINDSIGHT_RECALL_TAG_WEIGHTS": '{"sidechain": 0.8}'})
98
+ self.assertEqual(cfg["recallTagWeights"], {"sidechain": 0.8})
99
+
100
+ def test_tag_groups_accepts_both_shapes(self):
101
+ as_list = _load_with({"HINDSIGHT_RECALL_TAG_GROUPS": '[["a","b"],["c"]]'})
102
+ self.assertEqual(as_list["recallTagGroups"], [["a", "b"], ["c"]])
103
+ as_map = _load_with({"HINDSIGHT_RECALL_TAG_GROUPS": '{"g": ["a"]}'})
104
+ self.assertEqual(as_map["recallTagGroups"], {"g": ["a"]})
105
+
106
+ def test_empty_collection_exports_assign_rather_than_being_skipped(self):
107
+ # `[]` / `{}` are what switchroom exports when the operator configured
108
+ # nothing. They must ASSIGN (making env authoritative over a stale
109
+ # ~/.hindsight/claude-code.json), not be dropped like an empty string.
110
+ cfg = _load_with(
111
+ {
112
+ "HINDSIGHT_RECALL_TAGS": "[]",
113
+ "HINDSIGHT_RECALL_TAG_GROUPS": "{}",
114
+ "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": "{}",
115
+ }
116
+ )
117
+ self.assertEqual(cfg["recallTags"], [])
118
+ self.assertEqual(cfg["recallTagGroups"], {})
119
+ self.assertEqual(cfg["recallAdditionalBankFilters"], {})
120
+
121
+ def test_exported_defaults_reproduce_the_shipped_config(self):
122
+ """The no-config path: exporting today's effective values changes nothing.
123
+
124
+ This is the whole contract of the passthrough — an operator who sets no
125
+ `memory.recall.*` key must get the same loaded config as an agent
126
+ running before the exports existed.
127
+ """
128
+ baseline = _load_with({})
129
+ exported = _load_with(
130
+ {
131
+ "HINDSIGHT_RECALL_BUDGET": baseline["recallBudget"],
132
+ "HINDSIGHT_RECALL_MAX_TOKENS": str(baseline["recallMaxTokens"]),
133
+ "HINDSIGHT_RECALL_PREFER_OBSERVATIONS": str(
134
+ baseline["recallPreferObservations"]
135
+ ).lower(),
136
+ "HINDSIGHT_RECALL_CONTEXT_TURNS": str(baseline["recallContextTurns"]),
137
+ "HINDSIGHT_RECALL_ROLES": json.dumps(baseline["recallRoles"]),
138
+ "HINDSIGHT_RECALL_PROMPT_PREAMBLE": baseline["recallPromptPreamble"],
139
+ "HINDSIGHT_RECALL_MAX_QUERY_CHARS": str(baseline["recallMaxQueryChars"]),
140
+ "HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES": str(
141
+ baseline["recallTranscriptTailBytes"]
142
+ ),
143
+ "HINDSIGHT_RECALL_TAGS": json.dumps(baseline["recallTags"]),
144
+ "HINDSIGHT_RECALL_TAGS_MATCH": baseline["recallTagsMatch"],
145
+ # settings.json ships null / DEFAULTS carries None; `{}` is the
146
+ # same thing to recall.py (`config.get(...) or None`) and unlike
147
+ # `null` it actually assigns.
148
+ "HINDSIGHT_RECALL_TAG_GROUPS": "{}",
149
+ "HINDSIGHT_RECALL_TAG_WEIGHTS": json.dumps(baseline["recallTagWeights"]),
150
+ "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": json.dumps(
151
+ baseline["recallAdditionalBankFilters"]
152
+ ),
153
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK": str(
154
+ baseline["recallTranscriptFallback"]
155
+ ).lower(),
156
+ "HINDSIGHT_RECALL_PARALLEL": str(baseline["recallParallel"]).lower(),
157
+ }
158
+ )
159
+ for key in PASSTHROUGH_ENV.values():
160
+ with self.subTest(key=key):
161
+ if key == "recallTagGroups":
162
+ # None vs {} — equivalent at every read site (`or None`).
163
+ self.assertFalse(baseline[key])
164
+ self.assertFalse(exported[key])
165
+ continue
166
+ self.assertEqual(exported[key], baseline[key])
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()