switchroom 0.19.24 → 0.19.26

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 (59) hide show
  1. package/dist/agent-scheduler/index.js +20 -7
  2. package/dist/auth-broker/index.js +93 -28
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +20 -6
  7. package/dist/cli/switchroom.js +3091 -1435
  8. package/dist/host-control/main.js +92 -29
  9. package/dist/vault/approvals/kernel-server.js +92 -28
  10. package/dist/vault/broker/server.js +258 -71
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/cron-session.sh.hbs +6 -0
  14. package/profiles/_base/start.sh.hbs +92 -17
  15. package/skills/switchroom-health/SKILL.md +19 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  18. package/telegram-plugin/dist/gateway/gateway.js +6981 -6747
  19. package/telegram-plugin/gateway/gateway.ts +53 -52
  20. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  21. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  22. package/telegram-plugin/quota-bar-format.ts +4 -1
  23. package/telegram-plugin/status-no-truncate.ts +49 -0
  24. package/telegram-plugin/status-pin-driver.ts +28 -0
  25. package/telegram-plugin/status-pin.ts +33 -4
  26. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  27. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  28. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  29. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  30. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  31. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  32. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  33. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  34. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  35. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  36. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  37. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  38. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  39. package/telegram-plugin/tier-downgrade.ts +3 -2
  40. package/telegram-plugin/tool-activity-summary.ts +61 -18
  41. package/telegram-plugin/uat/assertions.ts +21 -2
  42. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  43. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  44. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  45. package/telegram-plugin/worker-activity-feed.ts +38 -17
  46. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  47. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  48. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  49. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  59. package/vendor/hindsight-memory/tests/test_content.py +218 -0
@@ -39,14 +39,21 @@ Exit codes:
39
39
  stderr and non-zero exit. Existing behaviour.
40
40
  """
41
41
 
42
- import hashlib
43
- import json
44
- import os
45
- import re
46
- import socket
47
- import sys
48
42
  import time
49
- import urllib.error
43
+
44
+ # Taken before anything else is imported, so `_IMPORT_ELAPSED_SECONDS` below
45
+ # captures the real cost of loading this hook's dependencies. That spend is
46
+ # charged against the UserPromptSubmit ceiling (see `HOOK_CEILING_SECONDS`) —
47
+ # `recall_start_monotonic` is taken well into main() and cannot see it.
48
+ _IMPORT_START_MONOTONIC = time.monotonic()
49
+
50
+ import hashlib # noqa: E402
51
+ import json # noqa: E402
52
+ import os # noqa: E402
53
+ import re # noqa: E402
54
+ import socket # noqa: E402
55
+ import sys # noqa: E402
56
+ import urllib.error # noqa: E402
50
57
 
51
58
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
52
59
 
@@ -58,8 +65,10 @@ from lib.content import (
58
65
  compose_recall_query,
59
66
  format_current_time,
60
67
  format_memories,
68
+ shape_recall_query,
61
69
  strip_channel_envelope,
62
70
  strip_memory_tags,
71
+ tokenize_for_bm25,
63
72
  truncate_recall_query,
64
73
  )
65
74
  from lib.daemon import get_api_url
@@ -73,6 +82,11 @@ from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prom
73
82
  from lib.parallel_recall import run_parallel
74
83
  from lib.state import read_state, write_state
75
84
 
85
+ # Cost of everything above, charged against the hook ceiling (see
86
+ # `HOOK_CEILING_SECONDS`). Measured, not estimated: it is dominated by
87
+ # `lib.client` pulling in urllib/ssl and by `lib.content` on cold page cache.
88
+ _IMPORT_ELAPSED_SECONDS = time.monotonic() - _IMPORT_START_MONOTONIC
89
+
76
90
  LAST_RECALL_STATE = "last_recall.json"
77
91
  RECALL_CACHE_STATE = "recall_cache.json"
78
92
 
@@ -396,140 +410,42 @@ def _is_demoted_memory(memory) -> bool:
396
410
  return False
397
411
 
398
412
 
399
- # Switchroom #475 lexical-overlap relevance gate.
400
- #
401
- # Hindsight's HTTP recall API DOES return per-result relevance scores
402
- # (`RecallResult.scores.final`, plus `.semantic`/`.keyword`/`.reranker`);
403
- # the merged multi-bank set is now sorted by `scores.final` before the
404
- # `recallMaxMemories` cap (see the sort just before the cap in
405
- # process_recall) so the most relevant memories survive the head-slice
406
- # regardless of which bank they came from. This gate is a *complementary*,
407
- # opt-in absolute precision floor: `scores.final` is a relative rank that
408
- # still orders weakly-matching memories rather than excluding them, so on a
409
- # low-relevance prompt the top-N could still be low-signal. The lexical
410
- # overlap between the user's query terms and each memory's text terms is a
411
- # rank-independent absolute measure that drops memories below a
412
- # configurable threshold outright — something the relative sort does not do.
413
- #
414
- # --- switchroom #3541: the metric is CONTAINMENT, not Jaccard ---
415
- #
416
- # This gate originally scored with Jaccard similarity,
417
- # `|Q ∩ M| / |Q ∪ M|`. That is the wrong metric here, because the two
418
- # sides have wildly asymmetric lengths: the recall query is the whole
419
- # prior-context preamble (production p50 778 chars) while a memory is a
420
- # single fact (tens of tokens). The union term is dominated by |Q|, so the
421
- # SAME memory with the SAME real overlap scores lower purely because the
422
- # prompt was longer. Query length, not relevance, decided the outcome.
423
- #
424
- # Measured on production recall telemetry (1548 rows, 646 of which put
425
- # candidates through the gate, ts >= 2026-07-18, all fleet agents'
426
- # `recall_log.jsonl`), at the fleet default threshold of 0.10:
427
- #
428
- # query_chars rows candidates survived survival% zero-result%
429
- # 0- 200 30 1364 99 7.3% 23.3%
430
- # 200- 400 110 4199 238 5.7% 17.3%
431
- # 400- 600 143 5305 293 5.5% 72.7%
432
- # 600- 750 213 7288 67 0.9% 93.0%
433
- # 750-1000 150 4968 70 1.4% 90.0%
434
- #
435
- # 22447 of 23124 candidate memories (97.1%) were discarded, and the
436
- # discard rate rose monotonically with prompt length — the signature of
437
- # the union-term artifact, not of a relevance judgement. Where the gate
438
- # actually got to run, it was decisive: of the 526 turns that had at least
439
- # one bank return successfully, 440 (83.7%) still delivered zero memories.
440
- #
441
- # How much of the empty-recall problem is this? Roughly a third — NOT all
442
- # of it. Partitioning the zero-result recalls by why they were empty:
443
- #
444
- # >=1 bank returned OK, and overlap_dropped > 0 448 (~32%) <- this gate
445
- # ALL banks timed out or errored 565 (~41%) <- the deadline
446
- # >=1 bank returned OK, nothing dropped 7
447
- #
448
- # So the 8s outer deadline accounts for MORE empty recalls than the gate
449
- # does, and it is still live after this change: `deadline_hit` is set on
450
- # 991 of 18656 logged recalls and the p50 `total_elapsed_ms` on the
451
- # deadline-hitting rows sits at the 8s ceiling. Fixing the metric does not
452
- # close #3541's latency half — see the reranker analysis in that issue.
453
- # This change fixes the gate; it does not fix the timeout.
454
- #
455
- # The correct metric for asymmetric-length comparison is containment:
456
- # `|Q ∩ M| / |M|` — "what fraction of THIS MEMORY's terms are present in
457
- # the prompt", invariant to how much unrelated preamble the prompt
458
- # carries. (Not `min(|Q|, |M|)`; see `containment_overlap` for why the
459
- # textbook overlap coefficient's short-prompt regime flip is undesirable
460
- # here.)
413
+ # Tokenizer shared by the transcript fallback (`_build_transcript_fallback`).
461
414
  #
462
- # --- what the 0.10 threshold does, and does not, buy ---
415
+ # --- history: the removed lexical-overlap recall gate (#475, #3541, #3761) ---
463
416
  #
464
- # Be honest about the retained default: at 0.10, containment is close to a
465
- # PASSTHROUGH. Because the denominator is the memory's own token count, a
466
- # memory of <= 10 content tokens clears 0.10 on a SINGLE shared word. Only
467
- # longer memories are meaningfully filtered (a 40-token memory still needs
468
- # 4 shared terms). Measured on the production-shaped prompt used in the
469
- # regression tests, a wholly off-topic memory that merely reuses words
470
- # from the prior-context preamble scores 0.857 — HIGHER than the genuinely
471
- # relevant memory at 0.571.
417
+ # These tokens used to feed a `recallMinOverlap` gate that ran between the
418
+ # engine's reranker and the `recallMaxMemories` head-slice, dropping any
419
+ # candidate whose containment overlap with the prompt fell below a threshold.
420
+ # It was removed outright no replacement floor after measurement showed it
421
+ # was pure loss:
472
422
  #
473
- # That inversion is the reason the threshold is NOT being raised to
474
- # compensate. Raising it would drop the relevant memory before the
475
- # off-topic one, because on a preamble-heavy prompt containment is not
476
- # monotone in relevance. Lexical overlap cannot make that distinction at
477
- # any threshold; the engine's reranker can, and does.
423
+ # * On healthy production rows (non-timeout, non-error, ts >= 2026-07-20,
424
+ # n=212 across the fleet) it discarded 6026 of 7549 post-reranker
425
+ # candidates — 79.8% fleet-wide, 94.4% for overlord, 91.0% for klanker.
426
+ # * Replaying 330 real logged queries against the live engine, the gate
427
+ # dropped the engine's OWN top-ranked candidate on 31.2% of queries. It
428
+ # was not acting as a floor; it was acting as a rival, worse ranker.
429
+ # * It filtered no measurable noise. The rate at which the best injected
430
+ # memory scored below 1e-3 was 27.0% with the gate and 28.2% with no gate
431
+ # at all — inside sampling noise. It cost a third of top hits and bought
432
+ # nothing.
478
433
  #
479
- # The threshold was nevertheless re-derived from data rather than inherited
480
- # from the Jaccard era. 31 real production queries (reconstructed from
481
- # transcripts with the hook's own compose+truncate, validated by matching
482
- # 320 of them verbatim against logged recall rows) were replayed against
483
- # the live engine; the 202 returned candidates were rescored offline:
434
+ # The root cause is in the tokenizer below: it keeps only alphabetic tokens of
435
+ # length > 1, so digits, identifiers, version numbers, PR numbers, file paths
436
+ # and short symbols are invisible to it. For a fleet whose prompts are mostly
437
+ # identifiers that is close to worst case.
484
438
  #
485
- # containment survival% zero-result% mean kept/query
486
- # 0.05 99.5% 0.0% 6.5
487
- # 0.10 86.1% 6.5% 5.6 <- shipped
488
- # 0.20 65.8% 41.9% 4.3
489
- # 0.30 58.4% 41.9% 3.8
490
- # 0.40 52.5% 54.8% 3.4
439
+ # No score floor replaced it. `scores.final` is NOT calibrated across queries —
440
+ # the engine's own docs state a clearly-relevant match may score ~0.001 while
441
+ # ranked first, and freed slots are not backfilled. Measured over the same 330
442
+ # replays, every candidate floor value had `top1lost% == zero%` exactly: a
443
+ # floor never trims a bad tail, it only empties the whole result set. A floor
444
+ # at 0.001 would take zero-result recalls from 5.8% to 28.2%; at 0.05, to
445
+ # 40.6%. That re-creates #3541. `min_scores` is deliberately left unset.
491
446
  #
492
- # (Jaccard @0.10 on the same candidates: 70.3% survival, 32.3%
493
- # zero-result i.e. the fix cuts empty recalls from ~1 turn in 3 to
494
- # ~1 in 15 on this sample.)
495
- #
496
- # 0.30 and 0.40 leave 42-55% of turns with NO memories at all — they
497
- # re-create the exact failure #3541 is about, for a precision gain the
498
- # inversion above says is illusory. 0.10 is the shipped value.
499
- #
500
- # Sample caveat, stated plainly: these are single-bank (overlord) replays
501
- # and the engine returned ~6.5 already-top-ranked candidates per query,
502
- # not the ~35-candidate pools seen in the fleet logs. The ABSOLUTE
503
- # survival percentages are therefore optimistic versus production; the
504
- # RANKING of the thresholds, and the zero-result cliff above 0.10, are the
505
- # load-bearing results.
506
- #
507
- # So the honest statement of the design after #3541 is: the effective
508
- # precision control is the engine rerank plus the `recallMaxMemories`
509
- # head-slice, and this gate is a cheap floor that removes only candidates
510
- # with (near-)zero lexical relationship to the prompt. It is no longer
511
- # doing the job the #475 note above describes — "on a low-relevance prompt
512
- # the top-N could still be low-signal" is a real concern that this gate
513
- # does not actually address. Doing so needs a relevance-score floor
514
- # (`scores.final`), not a lexical one; that is deliberately out of scope
515
- # here and wants its own measured change.
516
- #
517
- # This is safe to run permissively: the gate is a FLOOR, not a ranker.
518
- # `_sort_by_final_score` orders the survivors by the engine's reranked
519
- # relevance score immediately afterwards, and only then does
520
- # `recallMaxMemories` head-slice. So admitting more candidates cannot
521
- # lower the quality of what is injected — it can only give the
522
- # score-sort a non-empty set to choose the top-N from. And because
523
- # `|Q ∩ M| / |M| >= |Q ∩ M| / |Q ∪ M|` always, at a fixed threshold this
524
- # metric admits a superset of what the deployed Jaccard gate admits: no
525
- # memory that survives in production today can be dropped by this change.
526
- #
527
- # Threshold default is 0.0 (disabled) so the gate is opt-in initially.
528
- # Operators tune via `memory.recall.min_overlap` in switchroom.yaml or
529
- # `HINDSIGHT_RECALL_MIN_OVERLAP=0.15` env. Telemetry surfaces the dropped
530
- # count via the existing recall_log.jsonl (#432 4.3) under
531
- # `overlap_dropped`, so the gate's effect is observable per turn from
532
- # `switchroom memory recall-log <agent>`.
447
+ # Precision now rests where it belongs: the engine's rerank ordering, the
448
+ # `_sort_by_final_score` merge sort, and the `recallMaxMemories` head-slice.
533
449
  #
534
450
  # A small English stop-word set is removed from both sides before the
535
451
  # overlap is computed — common-word coincidence is not a real signal.
@@ -579,78 +495,6 @@ def _overlap_tokens(text) -> set:
579
495
  return out
580
496
 
581
497
 
582
- def containment_overlap(query: str, memory_text: str) -> float:
583
- """Containment of the MEMORY in the query, after stop-word + punctuation
584
- stripping: ``|Q ∩ M| / |M|``.
585
-
586
- Returns a float in [0.0, 1.0]. Empty/degenerate inputs return 0.0 —
587
- it's safer to drop than retain when we can't compute.
588
-
589
- Unlike Jaccard (`|Q ∩ M| / |Q ∪ M|`, used until switchroom #3541) this
590
- is INVARIANT TO QUERY LENGTH. The recall query is a long prior-context
591
- preamble and a memory is a short fact; dividing by the union made the
592
- score collapse as the prompt grew, so the gate discarded 97.1% of
593
- already-reranked candidates and did so monotonically in prompt length.
594
- Dividing by the memory asks the question the gate actually means: what
595
- fraction of this memory's terms appear in the prompt. See the design
596
- note above `_OVERLAP_STOPWORDS` for the production measurement.
597
-
598
- That invariance is specifically against growth of NON-OVERLAPPING
599
- preamble in the query — the failure mode #3541 hit — and is not
600
- unqualified: a short query still scores low against a long memory
601
- (whenever `|Q| < threshold * |M|` the memory is dropped however
602
- relevant it is), and where `Q ⊆ M` the metric degenerates to exactly
603
- Jaccard. So `|M|` is a trade against `min(|Q|, |M|)`, not a strict
604
- improvement over it; the paragraph below is the argument for which
605
- side of that trade is safer here.
606
-
607
- Why ``|M|`` and not ``min(|Q|, |M|)`` (the textbook overlap coefficient):
608
- the two agree only where the memory is the shorter side, and that is NOT
609
- a safe assumption here. Replaying real production queries against the
610
- live engine, `|Q| < |M|` held for 87 of 202 candidate pairs — 43%.
611
- Memories are routinely LONGER than the prompt that retrieves them.
612
- Wherever that happens `min()` selects the QUERY and the metric silently
613
- becomes the converse measure: what fraction of the *prompt* appears in
614
- the memory. That regime flip re-introduces exactly the query-length
615
- dependence #3541 is about — a one-word prompt scores 1.0 against any
616
- memory containing that word. Dividing by `|M|` unconditionally has no
617
- such discontinuity.
618
-
619
- Safety, relative to what production runs today: for any Q and M,
620
-
621
- |Q ∩ M| / |Q ∪ M| <= |Q ∩ M| / |M| <= |Q ∩ M| / min(|Q|, |M|)
622
-
623
- because `|Q ∪ M| >= |M| >= min(|Q|, |M|)`. So at a fixed threshold this
624
- metric admits a SUPERSET of what the deployed Jaccard gate admits: no
625
- memory that survives the gate today can be dropped by this change.
626
- """
627
- a = _overlap_tokens(query)
628
- b = _overlap_tokens(memory_text)
629
- if not a or not b:
630
- return 0.0
631
- return len(a & b) / len(b)
632
-
633
-
634
- def _filter_by_overlap(results, query: str, threshold: float):
635
- """Drop memories whose containment overlap with the query is below the
636
- threshold. Threshold <= 0 short-circuits to passthrough (no
637
- iteration cost).
638
-
639
- Returns (kept_results, dropped_count).
640
- """
641
- if threshold <= 0:
642
- return results, 0
643
- kept = []
644
- dropped = 0
645
- for m in results:
646
- text = m.get("text", "") if isinstance(m, dict) else ""
647
- if containment_overlap(query, text) >= threshold:
648
- kept.append(m)
649
- else:
650
- dropped += 1
651
- return kept, dropped
652
-
653
-
654
498
  def _result_final_score(m) -> float:
655
499
  """Return a result's engine relevance score (`scores.final`).
656
500
 
@@ -673,12 +517,11 @@ def _injected_score_stats(results) -> dict:
673
517
  """Relevance-score aggregates for the INJECTED set (post-head-slice).
674
518
 
675
519
  Switchroom #3541 review finding — recall-quality telemetry.
676
- `recall_log.jsonl` records volume and plumbing only (`overlap_dropped`,
677
- `capped`, `pre_cap_count`, `memory_ids`, `deadline_hit`). With the
678
- overlap gate deliberately near-passthrough at 0.10, 100% of precision now
679
- rests on the engine's `scores.final` plus the `recallMaxMemories`
680
- head-slice — and no field observes that. Post-rollout, `overlap_dropped`
681
- collapsing to 0 while `result_count` rises to the cap reads as
520
+ `recall_log.jsonl` records volume and plumbing only (`capped`,
521
+ `pre_cap_count`, `memory_ids`, `deadline_hit`). With the lexical overlap
522
+ gate removed (#3761) 100% of precision rests on the engine's
523
+ `scores.final` plus the `recallMaxMemories` head-slice — and no volume
524
+ field observes that. `result_count` rising to the cap reads as
682
525
  unambiguous success on every existing dashboard whether the reranker is
683
526
  good OR whether every agent is being fed 8 mediocre memories per turn.
684
527
  These three fields are what distinguishes those two worlds.
@@ -753,6 +596,174 @@ def _sort_by_final_score(results):
753
596
  return results
754
597
 
755
598
 
599
+ # Key stamped onto every merged result naming the bank it came from. Private to
600
+ # recall.py (leading underscore) and never rendered: `format_memories`
601
+ # (lib/content.py:294) reads only `text` / `type` / `mentioned_at`, so an extra
602
+ # key cannot leak into the injected `<hindsight_memories>` block.
603
+ SOURCE_BANK_KEY = "_source_bank"
604
+
605
+
606
+ def _tag_source_bank(bank_results, bank_id):
607
+ """Stamp `SOURCE_BANK_KEY` onto each result so slot reservation can tell
608
+ own-bank memories from additional-bank (profile / shared / sender) ones
609
+ after the global relevance sort has interleaved them.
610
+
611
+ Returns the same list. Non-dict entries are skipped rather than raising:
612
+ a malformed engine response must not take recall down.
613
+ """
614
+ for m in bank_results:
615
+ if isinstance(m, dict):
616
+ m[SOURCE_BANK_KEY] = bank_id
617
+ return bank_results
618
+
619
+
620
+ def _reservable_slots(cap):
621
+ """How many of `cap` slots the per-bank floors may claim between them.
622
+
623
+ HALF the cap, rounded down. The other half is always awarded on pure global
624
+ relevance, and that headroom is what keeps these FLOORS rather than a fixed
625
+ quota. Without it the mechanism silently inverts at small caps: this fleet
626
+ runs `defaults.memory.recall.max_memories: 6` (switchroom.yaml — it cascades
627
+ to HINDSIGHT_RECALL_MAX_MEMORIES, which wins over the 8 stamped into the
628
+ plugin's settings.json), so floors of 4+2 would consume the entire cap,
629
+ `scores.final` would have no influence on composition on any turn where both
630
+ banks return, and the injected set would be a constant 4/2 split regardless
631
+ of relevance.
632
+
633
+ Scaling with the cap rather than clamping against a hardcoded 6 means the
634
+ invariant holds for any operator cap: at 6 the floors may claim 3, at 12 six,
635
+ at 4 two, at 1 none (reservation is simply off below cap 2).
636
+ """
637
+ if not isinstance(cap, int) or cap <= 0:
638
+ return 0
639
+ return cap // 2
640
+
641
+
642
+ def _reserve_bank_slots(results, cap, own_bank_id, own_floor, additional_floor):
643
+ """Head-slice `results` to `cap`, guaranteeing a minimum number of slots to
644
+ the agent's OWN bank and to the additional (profile / shared / sender) banks.
645
+
646
+ Switchroom — profile-bank crowd-out fix. `_sort_by_final_score` above sorts
647
+ the merged multi-bank set by `scores.final` and the caller then head-slices
648
+ at `recallMaxMemories`. On a turn where BOTH banks return more candidates
649
+ than the cap, that head-slice is winner-take-all across banks: nothing stops
650
+ one bank's score distribution from filling every slot, and the shared
651
+ `ken-profile` bank's facts routinely outscore the agent's own working memory
652
+ because a profile bank is dense with short, highly-rerankable statements.
653
+ The result is an agent handed a dossier about its operator and none of its
654
+ own session memory.
655
+
656
+ Scope, precisely — this fixes score-based crowd-out among results that DID
657
+ return. It does NOT fix the own-bank timeout: a timed-out bank contributes
658
+ zero candidates, so `own_take` is 0 and reservation is a strict no-op there.
659
+ Measured across 1,036 multi-bank non-cache recalls on this host since
660
+ 2026-07-20: own-dead/additional-alive is 67.0% of turns (83.5% for
661
+ overlord), and both-alive — the only state where reservation can act at all
662
+ — is 15.1% fleet-wide, 6.4% for overlord (6.8% once you also require more
663
+ candidates than the cap). The own-bank timeout is a separate, larger defect;
664
+ the `injected_own_bank_count` telemetry added alongside this is what makes
665
+ it legible per-turn.
666
+
667
+ Floors, not quotas, and enforced as such. Each side is guaranteed AT MOST
668
+ `floor` slots, only if it actually has that many results, and only up to
669
+ `_reservable_slots(cap)` (half the cap) between them; every other slot is
670
+ filled from the remaining candidates in pure global-relevance order. So at
671
+ the fleet's deployed cap of 6 with the shipped floors 2/1:
672
+
673
+ * own returns 10, profile returns 10 -> 2 own + 4 profile (profile-favoured
674
+ scores; own is never zeroed)
675
+ * own returns 10, profile returns 10 -> 5 own + 1 profile (own-favoured
676
+ scores; profile is never zeroed)
677
+ * own returns 0, profile returns 10 -> 6 profile (no wasted slots)
678
+ * own returns 10, profile returns 0 -> 6 own (no wasted slots)
679
+ * own returns 1, profile returns 10 -> 1 own + 5 profile
680
+
681
+ Composition still moves with `scores.final` in both directions — that is the
682
+ property the half-cap headroom buys and the property a quota would destroy.
683
+
684
+ A floor of 0 disables reservation for that side. `cap <= 0` disables the cap
685
+ entirely (upstream contract) and is a passthrough here. When the floors sum
686
+ above the reservable half the OWN floor is honoured first — the crowd-out
687
+ being fixed is one-directional, and the agent's own memory is the side that
688
+ loses today.
689
+
690
+ Selection is stable and the returned list is re-sorted by relevance, so
691
+ reservation changes WHICH memories are injected, never the order they are
692
+ presented in. Returns `(selected, reserved_own, reserved_additional)` where
693
+ the two counts are the slots that would NOT have been won on global score
694
+ alone — i.e. the observable effect of this function, logged as telemetry.
695
+ """
696
+ if not isinstance(cap, int) or cap <= 0 or len(results) <= cap:
697
+ return results, 0, 0
698
+
699
+ baseline_ids = {id(m) for m in results[:cap]}
700
+
701
+ own = [m for m in results if _source_bank_of(m) == own_bank_id]
702
+ additional = [m for m in results if _source_bank_of(m) != own_bank_id]
703
+
704
+ # Floors compete for the reservable half only — never for the whole cap.
705
+ reservable = _reservable_slots(cap)
706
+ own_take = max(0, min(int(own_floor or 0), len(own), reservable))
707
+ additional_take = max(
708
+ 0, min(int(additional_floor or 0), len(additional), reservable - own_take)
709
+ )
710
+
711
+ reserved = own[:own_take] + additional[:additional_take]
712
+ reserved_ids = {id(m) for m in reserved}
713
+
714
+ remaining = cap - len(reserved)
715
+ if remaining > 0:
716
+ for m in results:
717
+ if id(m) in reserved_ids:
718
+ continue
719
+ reserved.append(m)
720
+ reserved_ids.add(id(m))
721
+ remaining -= 1
722
+ if remaining == 0:
723
+ break
724
+
725
+ _sort_by_final_score(reserved)
726
+
727
+ promoted_own = sum(
728
+ 1 for m in reserved if id(m) not in baseline_ids and _source_bank_of(m) == own_bank_id
729
+ )
730
+ promoted_additional = sum(
731
+ 1 for m in reserved if id(m) not in baseline_ids and _source_bank_of(m) != own_bank_id
732
+ )
733
+ return reserved, promoted_own, promoted_additional
734
+
735
+
736
+ def _injected_bank_composition(results, own_bank_id) -> dict:
737
+ """Own-bank / additional-bank split of the INJECTED set (post-head-slice).
738
+
739
+ Returns ``{"injected_own_bank_count", "injected_additional_bank_count"}``.
740
+ The two always sum to ``result_count``; results with no stamped source bank
741
+ (only possible from a cached row or a malformed engine response) count as
742
+ additional, so the own-bank number is never optimistic.
743
+ """
744
+ try:
745
+ own = sum(1 for m in results or [] if _source_bank_of(m) == own_bank_id)
746
+ return {
747
+ "injected_own_bank_count": own,
748
+ "injected_additional_bank_count": len(results or []) - own,
749
+ }
750
+ except Exception:
751
+ # Telemetry must never take recall down.
752
+ return {
753
+ "injected_own_bank_count": None,
754
+ "injected_additional_bank_count": None,
755
+ }
756
+
757
+
758
+ def _source_bank_of(m):
759
+ """Read a result's stamped source bank, or None when absent/malformed."""
760
+ if isinstance(m, dict):
761
+ v = m.get(SOURCE_BANK_KEY)
762
+ if isinstance(v, str):
763
+ return v
764
+ return None
765
+
766
+
756
767
  def _is_timeout_error(exc: BaseException) -> bool:
757
768
  """True if `exc` is (or wraps) a network read/connect timeout.
758
769
 
@@ -1003,6 +1014,42 @@ _FALLBACK_TELEMETRY_ZERO = {
1003
1014
  "truncated": False,
1004
1015
  }
1005
1016
 
1017
+ # The UserPromptSubmit timeout Claude Code enforces on THIS script, mirrored
1018
+ # from `hooks/hooks.json` (which ships in this same package, so the two cannot
1019
+ # be configured apart by an operator). `test_hook_ceiling_matches_hooks_json`
1020
+ # fails if they ever drift.
1021
+ #
1022
+ # Overrun is not a degraded recall — Claude Code kills the hook and the turn
1023
+ # loses memories, the fallback AND the directives, which is strictly worse than
1024
+ # the bug this PR fixes. So every optional tail-end spend is budgeted against
1025
+ # what is actually left of the ceiling rather than against a flat constant.
1026
+ HOOK_CEILING_SECONDS = 12.0
1027
+ # Held back for work this arithmetic cannot see: CPython interpreter boot
1028
+ # before our first statement runs, plus rendering the additionalContext
1029
+ # payload, the recall_log write and teardown after the last budgeted step.
1030
+ HOOK_TAIL_RESERVE_SECONDS = 0.75
1031
+ # Anchor for the budget arithmetic. Set at the top of main() rather than at
1032
+ # import, so a process that invokes the hook more than once (the test suite,
1033
+ # and any future in-process driver) budgets each invocation independently
1034
+ # instead of inheriting the whole process lifetime. Import cost is charged
1035
+ # explicitly via `_IMPORT_ELAPSED_SECONDS` so re-anchoring loses nothing.
1036
+ _hook_start_monotonic = None
1037
+
1038
+
1039
+ def _begin_hook_budget():
1040
+ """(Re-)anchor the hook budget clock. Call once at the top of main()."""
1041
+ global _hook_start_monotonic
1042
+ _hook_start_monotonic = time.monotonic() - _IMPORT_ELAPSED_SECONDS
1043
+
1044
+
1045
+ def _remaining_hook_budget_seconds():
1046
+ """Seconds left before this hook risks breaching its UserPromptSubmit ceiling."""
1047
+ if _hook_start_monotonic is None: # pragma: no cover - defensive only
1048
+ return HOOK_CEILING_SECONDS - HOOK_TAIL_RESERVE_SECONDS
1049
+ elapsed = time.monotonic() - _hook_start_monotonic
1050
+ return HOOK_CEILING_SECONDS - HOOK_TAIL_RESERVE_SECONDS - elapsed
1051
+
1052
+
1006
1053
  _FALLBACK_BLOCK_PREAMBLE = (
1007
1054
  "No stored memories matched this query — the fact layer may not have "
1008
1055
  "reconciled this session yet (e.g. an abrupt session death before boot "
@@ -1012,7 +1059,7 @@ _FALLBACK_BLOCK_PREAMBLE = (
1012
1059
  )
1013
1060
 
1014
1061
 
1015
- def _transcript_grep_fallback(transcript_path, query, config):
1062
+ def _transcript_grep_fallback(transcript_path, query, config, budget_ms=None):
1016
1063
  """Bounded transcript-grep fallback for the empty-fact-layer window (#3369).
1017
1064
 
1018
1065
  Reads the CURRENT session transcript's tail (bounded bytes), keeps the most
@@ -1022,9 +1069,22 @@ def _transcript_grep_fallback(transcript_path, query, config):
1022
1069
  (``recallTranscriptFallbackMaxBytes``), matched turns
1023
1070
  (``recallTranscriptFallbackMaxTurns``), emitted characters
1024
1071
  (``recallTranscriptFallbackMaxChars``), and grep wall-time
1025
- (``recallTranscriptFallbackDeadlineMs``). The caller only invokes this when
1026
- all banks returned zero AND no slot hit its deadline, so a timed-out bank can
1027
- never masquerade as a genuinely empty fact layer.
1072
+ (``recallTranscriptFallbackDeadlineMs``, default 1500). The caller invokes
1073
+ this when all banks returned zero and none ERRORED a hard bank error can
1074
+ masquerade as a genuinely empty fact layer, so it still suppresses the
1075
+ fallback. A per-bank TIMEOUT no longer does (#3757): timing out was the
1076
+ common case, and suppressing on it left the agent with neither memories nor
1077
+ fallback.
1078
+
1079
+ That inversion removed the gate that INCIDENTALLY protected the hook
1080
+ ceiling, so the grep wall-time bound is now
1081
+ ``min(recallTranscriptFallbackDeadlineMs, remaining hook budget)`` rather
1082
+ than a flat 1.5s (#3760 review, Major 4). A flat 1.5s after a fully-elapsed
1083
+ 10s recall left ~0.5s for interpreter startup, config load, the transcript
1084
+ read and output formatting — and an overrun costs the turn its directives
1085
+ too. ``budget_ms`` is that remaining-budget clamp, supplied by the caller;
1086
+ ``<= 0`` means "no time left", and the fallback declines rather than
1087
+ gambling the hook.
1028
1088
 
1029
1089
  Returns ``(block_or_None, telemetry)``. Failure-safe: any error path returns
1030
1090
  ``(None, zeroed-telemetry)`` so the fallback can never break recall.
@@ -1046,6 +1106,12 @@ def _transcript_grep_fallback(transcript_path, query, config):
1046
1106
  max_turns = _int_cfg("recallTranscriptFallbackMaxTurns", 6)
1047
1107
  max_chars = _int_cfg("recallTranscriptFallbackMaxChars", 2000)
1048
1108
  deadline_ms = _int_cfg("recallTranscriptFallbackDeadlineMs", 1500)
1109
+ if budget_ms is not None:
1110
+ # Never spend more than the hook has left, even when the configured
1111
+ # bound is larger. A caller with no budget left gets nothing at all.
1112
+ deadline_ms = min(deadline_ms, int(budget_ms))
1113
+ if deadline_ms <= 0:
1114
+ return None, telemetry
1049
1115
 
1050
1116
  if max_turns <= 0 or max_chars <= 0 or max_bytes <= 0:
1051
1117
  return None, telemetry
@@ -1296,6 +1362,63 @@ def _combine_context(base, nudge) -> str:
1296
1362
  return "\n\n".join(parts)
1297
1363
 
1298
1364
 
1365
+ # Switchroom structural-fix #7 — WHAT failed, not merely THAT something did.
1366
+ #
1367
+ # Before this, every failure channel on a recall_log row was a BOOLEAN:
1368
+ # `timed_out`, `errored`, `deadline_hit`. So a log full of `errored: true`
1369
+ # could not answer the first question anyone asks during an incident — is this
1370
+ # a connection refused, a 500, a bad bank id, or an auth failure? Each implies
1371
+ # a different fix, and the log distinguished none of them. Worse, `recall.py`
1372
+ # exits 0 and Claude Code swallows hook stderr, so the exception text printed
1373
+ # on the `[Hindsight]` line reaches nobody: the JSONL row is the ONLY place
1374
+ # this information can survive.
1375
+ ERROR_TEXT_MAX_CHARS = 300
1376
+
1377
+
1378
+ def error_text(err) -> str | None:
1379
+ """One-line, bounded, type-prefixed rendering of a failure. None when clean.
1380
+
1381
+ Type-prefixed because the message alone is often uselessly generic
1382
+ (`''`, `'timed out'`); the exception class is frequently the most
1383
+ diagnostic part. Bounded because rows are size-trimmed by line count and
1384
+ an unbounded traceback-shaped message would evict real history.
1385
+ """
1386
+ if err is None:
1387
+ return None
1388
+ if isinstance(err, BaseException):
1389
+ text = f"{type(err).__name__}: {err}"
1390
+ else:
1391
+ text = str(err)
1392
+ text = " ".join(text.split())
1393
+ if not text:
1394
+ return None
1395
+ return text[:ERROR_TEXT_MAX_CHARS]
1396
+
1397
+
1398
+ def recall_error_summary(bank_id, bank_timings, directives_timed_out=None) -> str | None:
1399
+ """One row-level string answering "what actually failed on this turn?".
1400
+
1401
+ The per-bank `error` strings are the ground truth, but a row-level field is
1402
+ what makes the log GREPPABLE: an operator triaging an incident wants
1403
+ `jq -r .error recall_log.jsonl | sort | uniq -c`, not a nested walk.
1404
+
1405
+ Own bank first, because a side-bank failure does not mean the agent lost
1406
+ its memory (same rationale as `degraded_recall_notice`). Side banks are
1407
+ reported only when the own bank was fine, and are prefixed with their bank
1408
+ id so the summary is never ambiguous about whose failure it names.
1409
+ """
1410
+ entries = [bt for bt in (bank_timings or []) if isinstance(bt, dict)]
1411
+ own = next((bt for bt in entries if bt.get("bank_id") == bank_id), None) if bank_id else None
1412
+ if own and own.get("error"):
1413
+ return error_text(own["error"])
1414
+ for bt in entries:
1415
+ if bt.get("bank_id") != bank_id and bt.get("error"):
1416
+ return error_text(f"additional bank '{bt.get('bank_id')}': {bt['error']}")
1417
+ if directives_timed_out:
1418
+ return "directives fetch timed out"
1419
+ return None
1420
+
1421
+
1299
1422
  def degraded_recall_notice(bank_id, bank_timings) -> str:
1300
1423
  """Switchroom #3619 — return the degraded-recall disclosure for this turn,
1301
1424
  or "" when the agent's own bank answered.
@@ -1347,6 +1470,7 @@ def degraded_recall_notice(bank_id, bank_timings) -> str:
1347
1470
 
1348
1471
 
1349
1472
  def main():
1473
+ _begin_hook_budget()
1350
1474
  config = load_config()
1351
1475
 
1352
1476
  if not config.get("autoRecall"):
@@ -1559,6 +1683,13 @@ def main():
1559
1683
  "injected_score_min": None,
1560
1684
  "injected_score_median": None,
1561
1685
  "injected_score_max": None,
1686
+ # Bank-composition telemetry — present for a uniformly queryable
1687
+ # schema. Same reason as the score fields above: a cache hit
1688
+ # replays a formatted block, not a per-bank result set.
1689
+ "injected_own_bank_count": None,
1690
+ "injected_additional_bank_count": None,
1691
+ "reserved_own_slots": None,
1692
+ "reserved_additional_slots": None,
1562
1693
  "cache_hit": True,
1563
1694
  # A3 stage-1 telemetry keys kept present for a uniformly
1564
1695
  # queryable schema; a cache hit issues no bank HTTP, so there
@@ -1600,6 +1731,11 @@ def main():
1600
1731
  # miscounted as an observed no-error recall — matching the
1601
1732
  # deadline_hit / directives_timed_out convention above.
1602
1733
  "bank_errored": None,
1734
+ # Switchroom structural-fix #7 — WHAT failed, in words. None
1735
+ # here (not "") for the same reason as the fields above: no
1736
+ # banks ran, so this row observed no failure and must not be
1737
+ # counted as one.
1738
+ "error": None,
1603
1739
  })
1604
1740
  return
1605
1741
  debug_log(config, f"Recall cache MISS (key={cache_key[:12]}…)")
@@ -1646,7 +1782,35 @@ def main():
1646
1782
  if len(query) > recall_max_query_chars:
1647
1783
  query = query[:recall_max_query_chars]
1648
1784
 
1649
- debug_log(config, f"Recalling from bank '{bank_id}', query length: {len(query)}")
1785
+ # Switchroom recall-latency fix (#3757) bound the BM25 term count of the
1786
+ # query we put on the wire. `recallMaxQueryChars` bounds CHARACTERS, which
1787
+ # is not the cost driver: Hindsight OR-joins every token into one tsquery
1788
+ # and Postgres native FTS ranks the whole matched set before the top-60
1789
+ # heapsort, so cost tracks the number of DISTINCT TERMS. An 800-char
1790
+ # composed query is ~96 distinct terms and matched 119,510 rows on the
1791
+ # live `overlord` bank (14.0s for the 3-arm UNION, and up to 94s under
1792
+ # load) — past the 8s client timeout, which is why 96.8% of that agent's
1793
+ # own-bank recalls returned nothing. Shaped to 24 terms the same query
1794
+ # matches 48,433 rows in 2.5-2.8s.
1795
+ #
1796
+ # `search_query` is what the SERVER sees. `query` (unshaped) stays the
1797
+ # client-side lexical reference: the `recallMinOverlap` containment gate
1798
+ # and the transcript-grep fallback both measure against the user's real
1799
+ # words, so shaping cannot silently move their thresholds. `query_chars`
1800
+ # telemetry also stays on the unshaped value for continuity with the
1801
+ # existing recall_log history.
1802
+ search_query = shape_recall_query(
1803
+ query,
1804
+ recall_query_text,
1805
+ max_tokens=config.get("recallQueryMaxTokens", 24),
1806
+ stop_terms=config.get("recallQueryStopTerms") or (),
1807
+ )
1808
+
1809
+ debug_log(
1810
+ config,
1811
+ f"Recalling from bank '{bank_id}', query length: {len(query)}, "
1812
+ f"search terms: {len(set(tokenize_for_bm25(search_query)))}",
1813
+ )
1650
1814
 
1651
1815
  # Fetch active directives FIRST (independent of recall — even if recall
1652
1816
  # finds no memories, an agent with active directives still needs them
@@ -1680,11 +1844,18 @@ def main():
1680
1844
  ttl_seconds=config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS),
1681
1845
  )
1682
1846
 
1683
- def _make_bank_task(target_bank_id, b_tags, b_tags_match, b_tag_groups):
1847
+ try:
1848
+ recall_request_timeout = float(config.get("recallRequestTimeoutSeconds", 12))
1849
+ except (TypeError, ValueError):
1850
+ recall_request_timeout = 12.0
1851
+ if recall_request_timeout <= 0:
1852
+ recall_request_timeout = 12.0
1853
+
1854
+ def _make_bank_task(target_bank_id, b_tags, b_tags_match, b_tag_groups, timeout_override=None):
1684
1855
  def _bank_task():
1685
1856
  return client.recall(
1686
1857
  bank_id=target_bank_id,
1687
- query=query,
1858
+ query=search_query,
1688
1859
  max_tokens=config.get("recallMaxTokens", 1024),
1689
1860
  budget=config.get("recallBudget", "mid"),
1690
1861
  types=config.get("recallTypes"),
@@ -1698,12 +1869,31 @@ def main():
1698
1869
  # slots for denser coverage inside the same budget. On by default;
1699
1870
  # operators can pin off via `recallPreferObservations: false`.
1700
1871
  prefer_observations=config.get("recallPreferObservations", True),
1701
- # 8s in-script per-request timeout: even parallelised, each bank
1872
+ # Per-request in-script timeout: even parallelised, each bank
1702
1873
  # carries its own hard deadline so a single hung bank returns
1703
1874
  # cleanly with no memories rather than sitting on the shared
1704
- # deadline. Tightened from 10s in v0.13.22 (2026-05-24 breach
1705
- # audit); the shared deadline below is the outer ceiling guard.
1706
- timeout=8,
1875
+ # deadline. Tightened from 10s to 8s in v0.13.22 (2026-05-24
1876
+ # breach audit); the shared deadline below is the outer ceiling
1877
+ # guard.
1878
+ #
1879
+ # Switchroom #3757: no longer a hardcoded literal. It was the
1880
+ # binding constraint on a slow bank (96.8% of overlord's
1881
+ # own-bank recalls hit exactly 8s and returned NOTHING), and a
1882
+ # hand-patch of the installed copy does not survive
1883
+ # `switchroom apply` — the plugin dir is re-copied from
1884
+ # `vendor/hindsight-memory` on every reconcile. Default raised
1885
+ # to 12s, matching the UserPromptSubmit hook's own 12s budget
1886
+ # (hooks/hooks.json). Note the SHARED multi-bank deadline
1887
+ # (`recallParallelDeadlineSeconds`, default 10s) is the tighter
1888
+ # outer bound in the default configuration, so at the default
1889
+ # this timeout only binds when an operator raises that. SAFETY
1890
+ # NET behind the query-shaping fix above, not the fix.
1891
+ # Operator knob: `memory.recall.request_timeout_seconds`.
1892
+ timeout=(
1893
+ recall_request_timeout
1894
+ if timeout_override is None
1895
+ else timeout_override
1896
+ ),
1707
1897
  )
1708
1898
  return _bank_task
1709
1899
 
@@ -1796,7 +1986,7 @@ def main():
1796
1986
  )
1797
1987
  if bank_results:
1798
1988
  debug_log(config, f"Got {len(bank_results)} memories from bank '{b_id}'")
1799
- results = results + bank_results
1989
+ results = results + _tag_source_bank(bank_results, b_id)
1800
1990
  elif b_outcome.error is not None:
1801
1991
  # Own bank failure surfaces on stderr (journald signal); extra
1802
1992
  # banks are debug-only, matching the pre-A3 serial behaviour.
@@ -1811,6 +2001,12 @@ def main():
1811
2001
  "elapsed_ms": b_outcome.elapsed_ms if b_outcome.elapsed_ms is not None else 0,
1812
2002
  "timed_out": b_timed_out,
1813
2003
  "errored": b_errored,
2004
+ # The failure TEXT, not just the flags. On the parallel path a
2005
+ # slot abandoned at the shared deadline carries no exception at
2006
+ # all, so name that case explicitly rather than logging null
2007
+ # and leaving "why is this row empty" unanswerable.
2008
+ "error": error_text(b_outcome.error)
2009
+ or (None if b_outcome.completed else "abandoned at the shared recall deadline"),
1814
2010
  })
1815
2011
  else:
1816
2012
  # Pre-A3 serial path (rollback lever, HINDSIGHT_RECALL_PARALLEL=false).
@@ -1820,6 +2016,14 @@ def main():
1820
2016
  # own-bank debug line, logs the directives block after the bank loop
1821
2017
  # rather than before, and __main__ still os._exit(0)s on completion.
1822
2018
  recall_mode = "serial"
2019
+ # #3760 review, Major 4. This path has no outer deadline — bank
2020
+ # latencies SUM — so raising the per-bank timeout 8s -> 12s would let
2021
+ # two banks spend 24s against a 12s hook ceiling. Each bank is instead
2022
+ # clamped to whatever the hook has left when its turn comes, so the
2023
+ # serial path can no longer breach the ceiling no matter how many banks
2024
+ # are configured. `deadline_budget_ms` stays None on the log row: this
2025
+ # is a per-bank clamp, not the parallel path's shared deadline, and
2026
+ # conflating them would corrupt the serial-vs-parallel comparison.
1823
2027
  deadline_budget_ms = None
1824
2028
  deadline_effective_ms = None
1825
2029
  _directives_start = time.monotonic()
@@ -1834,16 +2038,29 @@ def main():
1834
2038
  _bank_start = time.monotonic()
1835
2039
  _bank_timed_out = False
1836
2040
  _bank_errored = False
2041
+ _bank_error = None
1837
2042
  try:
1838
- response = _make_bank_task(b_id, b_tags, b_tags_match, b_tag_groups)()
2043
+ _bank_budget = _remaining_hook_budget_seconds()
2044
+ if _bank_budget <= 0:
2045
+ raise TimeoutError(
2046
+ f"hook budget exhausted before bank '{b_id}' was queried"
2047
+ )
2048
+ response = _make_bank_task(
2049
+ b_id,
2050
+ b_tags,
2051
+ b_tags_match,
2052
+ b_tag_groups,
2053
+ timeout_override=min(recall_request_timeout, _bank_budget),
2054
+ )()
1839
2055
  bank_results = response.get("results", []) if isinstance(response, dict) else []
1840
2056
  if bank_results:
1841
2057
  debug_log(config, f"Got {len(bank_results)} memories from bank '{b_id}'")
1842
- results = results + bank_results
2058
+ results = results + _tag_source_bank(bank_results, b_id)
1843
2059
  except Exception as e:
1844
2060
  _bank_timed_out = _is_timeout_error(e)
1845
2061
  # Non-timeout error → hard outage (see parallel-path note above).
1846
2062
  _bank_errored = not _bank_timed_out
2063
+ _bank_error = error_text(e)
1847
2064
  if b_id == bank_id:
1848
2065
  print(f"[Hindsight] Recall failed: {e}", file=sys.stderr)
1849
2066
  else:
@@ -1853,6 +2070,7 @@ def main():
1853
2070
  "elapsed_ms": int((time.monotonic() - _bank_start) * 1000),
1854
2071
  "timed_out": _bank_timed_out,
1855
2072
  "errored": _bank_errored,
2073
+ "error": _bank_error,
1856
2074
  })
1857
2075
 
1858
2076
  # Switchroom hindsight-leverage A3 — FINALIZED `deadline_hit`: True when ANY
@@ -1908,28 +2126,6 @@ def main():
1908
2126
  f"memories (active_thread_id={active_thread_id})",
1909
2127
  )
1910
2128
 
1911
- # Switchroom #475 — lexical-overlap relevance gate. Drops memories
1912
- # whose containment overlap with the query is below
1913
- # `recallMinOverlap` (default 0.0 = disabled). Runs after the
1914
- # demote filter so the threshold sees the operator-curated set.
1915
- # #3541: the metric is containment, NOT Jaccard — see the design
1916
- # note above `_OVERLAP_STOPWORDS`. Jaccard made the gate a function
1917
- # of prompt length and it discarded 97.1% of reranked candidates.
1918
- overlap_threshold = config.get("recallMinOverlap", 0.0)
1919
- if isinstance(overlap_threshold, (int, float)) and overlap_threshold > 0:
1920
- pre_overlap_count = len(results)
1921
- results, overlap_dropped = _filter_by_overlap(
1922
- results, query, float(overlap_threshold)
1923
- )
1924
- if overlap_dropped > 0:
1925
- debug_log(
1926
- config,
1927
- f"Overlap gate dropped {overlap_dropped}/{pre_overlap_count} "
1928
- f"memories below threshold {overlap_threshold}",
1929
- )
1930
- else:
1931
- overlap_dropped = 0
1932
-
1933
2129
  # Switchroom hindsight-leverage PR5 — per-tag score penalty. Applied
1934
2130
  # IMMEDIATELY before the relevance sort so a down-weighted tag (e.g.
1935
2131
  # `sidechain: 0.8`) reorders the merged set without dropping anything. This
@@ -1959,6 +2155,8 @@ def main():
1959
2155
  recall_max_memories = config.get("recallMaxMemories", 0)
1960
2156
  pre_cap_count = len(results)
1961
2157
  capped = False
2158
+ reserved_own = 0
2159
+ reserved_additional = 0
1962
2160
  if (
1963
2161
  isinstance(recall_max_memories, int)
1964
2162
  and recall_max_memories > 0
@@ -1969,7 +2167,29 @@ def main():
1969
2167
  f"Capping {len(results)} memories to {recall_max_memories} "
1970
2168
  f"(set HINDSIGHT_RECALL_MAX_MEMORIES=0 to disable)",
1971
2169
  )
1972
- results = results[:recall_max_memories]
2170
+ # Switchroom — per-bank slot reservation. Head-slicing a globally sorted
2171
+ # merged set is winner-take-all across banks: when both banks return more
2172
+ # candidates than the cap, one bank's score distribution can fill every
2173
+ # slot and the agent's own working memory is crowded out entirely.
2174
+ # Guarantee a floor to each side (bounded to half the cap) before the
2175
+ # remaining slots go to pure global relevance. This addresses score-based
2176
+ # crowd-out only — a timed-out bank contributes no candidates and
2177
+ # reservation is a no-op there; see _reserve_bank_slots for the measured
2178
+ # share of turns it can bind on. Floors of 0/0 restore the head-slice.
2179
+ results, reserved_own, reserved_additional = _reserve_bank_slots(
2180
+ results,
2181
+ recall_max_memories,
2182
+ bank_id,
2183
+ config.get("recallOwnBankMinSlots", 0),
2184
+ config.get("recallAdditionalBankMinSlots", 0),
2185
+ )
2186
+ if reserved_own or reserved_additional:
2187
+ debug_log(
2188
+ config,
2189
+ f"Bank slot reservation promoted {reserved_own} own-bank / "
2190
+ f"{reserved_additional} additional-bank memories over "
2191
+ f"higher-scoring ones",
2192
+ )
1973
2193
  capped = True
1974
2194
 
1975
2195
  memories_block = None
@@ -2023,18 +2243,29 @@ def main():
2023
2243
  # On by default; HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK=false is the rollback
2024
2244
  # lever. Mutually exclusive with memories_block by construction: a non-empty
2025
2245
  # memories_block requires results, which requires pre_filter_count > 0.
2246
+ #
2247
+ # Switchroom #3757 — `deadline_hit` NO LONGER SUPPRESSES the fallback.
2248
+ # The original gate was written when a deadline meant "the fact layer is
2249
+ # unknown, don't guess". In practice a timeout was the COMMON case (96.8%
2250
+ # of overlord's own-bank recalls over the 7 days to 2026-07-27), and the
2251
+ # gate meant a timed-out turn got neither memories NOR the fallback — the
2252
+ # agent went in blind. A timeout and an outage are different: on a timeout
2253
+ # the banks are healthy and reachable, we simply ran out of time, so the
2254
+ # bounded transcript grep is strictly better than nothing. A hard bank
2255
+ # ERROR still suppresses it (`bank_errored`), because that genuinely can
2256
+ # masquerade as an empty fact layer while the store is down.
2026
2257
  transcript_fallback_block = None
2027
2258
  transcript_fallback_telemetry = dict(_FALLBACK_TELEMETRY_ZERO)
2028
2259
  if (
2029
2260
  config.get("recallTranscriptFallback", True)
2030
2261
  and pre_filter_count == 0
2031
- and not deadline_hit
2032
2262
  and not bank_errored
2033
2263
  ):
2034
2264
  transcript_fallback_block, transcript_fallback_telemetry = _transcript_grep_fallback(
2035
2265
  hook_input.get("transcript_path", ""),
2036
2266
  query,
2037
2267
  config,
2268
+ budget_ms=_remaining_hook_budget_seconds() * 1000.0,
2038
2269
  )
2039
2270
  if transcript_fallback_block:
2040
2271
  debug_log(
@@ -2102,7 +2333,6 @@ def main():
2102
2333
  # doctor's directive-count check will be FAILing too.
2103
2334
  "directives_omitted": count_omitted_directives(directives),
2104
2335
  "demoted_count": demoted_count,
2105
- "overlap_dropped": overlap_dropped,
2106
2336
  "capped": capped,
2107
2337
  "pre_cap_count": pre_cap_count,
2108
2338
  "memory_ids": [
@@ -2115,6 +2345,19 @@ def main():
2115
2345
  # alone can't distinguish "reranker is working" from "8 mediocre
2116
2346
  # memories per turn" now that the overlap gate is near-passthrough.
2117
2347
  **_injected_score_stats(results),
2348
+ # Switchroom — injected BANK COMPOSITION. `result_count` above is a
2349
+ # volume signal and cannot distinguish "6 own-bank memories" from "6
2350
+ # profile-bank memories because the own bank timed out", which is
2351
+ # exactly how the own-bank timeout outage stayed invisible for weeks:
2352
+ # a fully-timed-out own bank still logged result_count == cap. These
2353
+ # two counts always sum to `result_count`, so an own-bank collapse is
2354
+ # readable off the log row without joining `bank_timings`.
2355
+ **_injected_bank_composition(results, bank_id),
2356
+ # Slots the reservation floors handed to a side that would have lost
2357
+ # them on global relevance alone — the observable effect of
2358
+ # `_reserve_bank_slots` (0/0 when the floors are off or non-binding).
2359
+ "reserved_own_slots": reserved_own,
2360
+ "reserved_additional_slots": reserved_additional,
2118
2361
  "cache_hit": False,
2119
2362
  # Switchroom A3 stage-1 telemetry (hindsight-leverage PR 1) — per-bank
2120
2363
  # latency + timeout breakdown, directives-fetch latency, total
@@ -2177,6 +2420,14 @@ def main():
2177
2420
  "transcript_fallback_bytes_read": transcript_fallback_telemetry["bytes_read"],
2178
2421
  "transcript_fallback_elapsed_ms": transcript_fallback_telemetry["elapsed_ms"],
2179
2422
  "transcript_fallback_truncated": transcript_fallback_telemetry["truncated"],
2423
+ # Switchroom structural-fix #7 — WHAT failed, in words. Every other
2424
+ # failure channel on this row is a BOOLEAN (`deadline_hit`,
2425
+ # `bank_errored`, per-bank `timed_out`/`errored`), which says THAT
2426
+ # something broke and never WHICH thing, so an incident could not be
2427
+ # triaged from the log at all. recall.py exits 0 and Claude Code
2428
+ # swallows hook stderr, so this row is the only place the reason can
2429
+ # survive. None on a healthy turn.
2430
+ "error": recall_error_summary(bank_id, bank_timings, directives_timed_out),
2180
2431
  })
2181
2432
 
2182
2433
  # Switchroom #3619 — DEGRADED-RECALL DISCLOSURE. See