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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- 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
|
-
|
|
626
|
-
|
|
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.
|
|
753
|
+
vsize = os.path.getsize(vpath)
|
|
630
754
|
except OSError:
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2434
|
-
#
|
|
2435
|
-
#
|
|
2436
|
-
|
|
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(
|
|
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(
|
|
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()
|