switchroom 0.19.26 → 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -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()