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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- 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/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -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 +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -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/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/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/lib/config.py +33 -0
- 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_recall_min_score.py +464 -0
- 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
|
-
#
|
|
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()
|