switchroom 0.19.2 → 0.19.3

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 (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -43,8 +43,10 @@ import hashlib
43
43
  import json
44
44
  import os
45
45
  import re
46
+ import socket
46
47
  import sys
47
48
  import time
49
+ import urllib.error
48
50
 
49
51
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
50
52
 
@@ -52,19 +54,32 @@ from lib.bank import derive_bank_id, ensure_bank_mission
52
54
  from lib.client import HindsightClient
53
55
  from lib.config import debug_log, load_config
54
56
  from lib.content import (
57
+ _extract_text_content,
55
58
  compose_recall_query,
56
59
  format_current_time,
57
60
  format_memories,
61
+ strip_channel_envelope,
62
+ strip_memory_tags,
58
63
  truncate_recall_query,
59
64
  )
60
65
  from lib.daemon import get_api_url
61
- from lib.directives import fetch_active_directives, format_active_directives_block
66
+ from lib.directives import (
67
+ DIRECTIVES_CACHE_TTL_SECONDS,
68
+ fetch_active_directives_cached,
69
+ format_active_directives_block,
70
+ )
62
71
  from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prompt, extract_user_from_prompt, update_placeholder
72
+ from lib.parallel_recall import run_parallel
63
73
  from lib.state import read_state, write_state
64
74
 
65
75
  LAST_RECALL_STATE = "last_recall.json"
66
76
  RECALL_CACHE_STATE = "recall_cache.json"
67
77
 
78
+ # Switchroom hindsight-leverage A3 — label for the directives fetch slot in the
79
+ # parallel fan-out. Distinct from any bank_id (banks can't start with "__") so a
80
+ # bank named "directives" never collides with the directives slot.
81
+ DIRECTIVES_SLOT = "__directives__"
82
+
68
83
  # Switchroom #424 phase 4.1 — per-session recall cache.
69
84
  #
70
85
  # Caching is opt-in via env var: HINDSIGHT_RECALL_CACHE_TTL_SECS=N. Set N
@@ -173,6 +188,15 @@ def _summarise_source_topics(results: list) -> dict:
173
188
  # `switchroom memory recall-log <agent>`.
174
189
  RECALL_LOG_FILE = "recall_log.jsonl"
175
190
  RECALL_LOG_MAX_LINES = 5000
191
+ # Honest per-line upper-bound estimate for the size-gated trim (hindsight-
192
+ # leverage PR 1, review finding 5). The A3-telemetry row (bounded query
193
+ # excerpt ≤200 chars + bank_timings + memory_ids) runs ~700-900 bytes worst
194
+ # case; 1024 is a safe upper bound. The size gate only reads+trims the file
195
+ # when it plausibly exceeds the line cap, so it must OVER-estimate row size
196
+ # (threshold = cap × upper-bound) — otherwise a file over the old 250 B/line
197
+ # threshold but still under the line cap triggers a full-file read on every
198
+ # hook (critical-path thrash) that never actually trims.
199
+ RECALL_LOG_BYTES_PER_LINE_EST = 1024
176
200
 
177
201
 
178
202
  def _cache_ttl_secs() -> int:
@@ -509,6 +533,124 @@ def _sort_by_final_score(results):
509
533
  return results
510
534
 
511
535
 
536
+ def _is_timeout_error(exc: BaseException) -> bool:
537
+ """True if `exc` is (or wraps) a network read/connect timeout.
538
+
539
+ Switchroom A3 stage-1 telemetry (hindsight-leverage PR 1). The recall
540
+ HTTP path is `urllib.request.urlopen(..., timeout=8)`. On a hard timeout
541
+ urlopen raises either a bare ``socket.timeout`` / ``TimeoutError`` or a
542
+ ``urllib.error.URLError`` whose ``.reason`` is one of those. We classify
543
+ both so the per-bank ``timed_out`` flag (and the derived ``deadline_hit``)
544
+ distinguishes a bank that hit its deadline from one that failed fast
545
+ (5xx, connection refused, malformed response). Best-effort: anything we
546
+ can't positively identify as a timeout is treated as a non-timeout error.
547
+ """
548
+ if isinstance(exc, (socket.timeout, TimeoutError)):
549
+ return True
550
+
551
+ # An HTTP status response is NEVER a client-side read deadline, even when
552
+ # its body says "upstream timed out" / "gateway timeout" (502/504). Rule
553
+ # these out BEFORE the message sniff below (review finding 4), both when
554
+ # the HTTPError is raised directly and when lib.client wraps it as
555
+ # `RuntimeError(f"HTTP {code} from {url}: {body}")` — whose body can carry
556
+ # a proxy's "timed out" text and would otherwise be misclassified.
557
+ cause = getattr(exc, "__cause__", None)
558
+ if isinstance(exc, urllib.error.HTTPError) or isinstance(cause, urllib.error.HTTPError):
559
+ return False
560
+ if re.match(r"HTTP \d+ from ", str(exc)):
561
+ return False
562
+
563
+ # A non-HTTP URLError (DNS, connection, read timeout) — inspect the reason.
564
+ if isinstance(exc, urllib.error.URLError):
565
+ reason = getattr(exc, "reason", None)
566
+ if isinstance(reason, (socket.timeout, TimeoutError)):
567
+ return True
568
+ # Some stdlib versions stringify the timeout into the reason.
569
+ if isinstance(reason, str) and "timed out" in reason.lower():
570
+ return True
571
+
572
+ # Fall back to a message sniff for RuntimeError wrappers from lib.client
573
+ # (non-HTTP failures — the HTTP-wrapper shape was ruled out above).
574
+ return "timed out" in str(exc).lower()
575
+
576
+
577
+ def _apply_tag_weights(results, tag_weights) -> int:
578
+ """Multiply each result's ``scores.final`` by a per-tag weight in place.
579
+
580
+ Switchroom hindsight-leverage PR5 — the recall-side counterpart to the
581
+ ``sidechain`` retain tag. A NEW mechanism, deliberately distinct from the
582
+ demote-tag DROP filter (`_is_demoted_memory`): that path removes a tagged
583
+ memory from recall entirely, which cannot express "keep it, but rank it
584
+ lower". This step DOWN-WEIGHTS a memory's engine relevance score so a
585
+ penalised memory sorts below an equal-scoring un-penalised one at the sort
586
+ below, yet is still returned when it is the only relevant hit (the cap sees
587
+ the full penalised set, not a filtered one).
588
+
589
+ ``tag_weights`` is a ``{tag: multiplier}`` map. For each result, the
590
+ multipliers of ALL its tags that appear in the map are multiplied together
591
+ and applied to ``scores.final`` (so a memory carrying two penalised tags is
592
+ penalised compoundly — intended). Tags are matched case-sensitively after
593
+ ``strip()`` (consistent with ``_is_demoted_memory``). Results whose
594
+ ``scores.final`` is absent or non-numeric are left untouched — a score-less
595
+ entry already sorts last and there is nothing to scale. A weight of exactly
596
+ 1.0 (or a non-positive / non-numeric weight) is a no-op for that tag.
597
+
598
+ Returns the number of results whose score was actually changed (for the
599
+ debug log). Mutation is intentional: the effective (post-weight) score is
600
+ what the sort, the cap, and the recall-log ranking should all reflect.
601
+ """
602
+ if not isinstance(tag_weights, dict) or not tag_weights:
603
+ return 0
604
+ changed = 0
605
+ for m in results:
606
+ if not isinstance(m, dict):
607
+ continue
608
+ tags = m.get("tags")
609
+ if not isinstance(tags, list):
610
+ continue
611
+ factor = 1.0
612
+ for tag in tags:
613
+ if not isinstance(tag, str):
614
+ continue
615
+ w = tag_weights.get(tag.strip())
616
+ if isinstance(w, (int, float)) and not isinstance(w, bool) and w > 0:
617
+ factor *= float(w)
618
+ if factor == 1.0:
619
+ continue
620
+ scores = m.get("scores")
621
+ if not isinstance(scores, dict):
622
+ continue
623
+ val = scores.get("final")
624
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
625
+ scores["final"] = float(val) * factor
626
+ changed += 1
627
+ return changed
628
+
629
+
630
+ def _effective_tag_weights(config) -> dict:
631
+ """Merge the built-in lesson/anti-pattern demotion weights UNDER the operator's
632
+ ``recallTagWeights`` (switchroom E2 / PR9, #398).
633
+
634
+ The retain side (``detect_lesson_tags``) tags failure-mode-adjacent transcripts
635
+ ``lesson`` / ``anti-pattern``; this composes those tags into the same PR5
636
+ score-penalty map so they are DEMOTED out of the box. Precedence: an explicit
637
+ ``recallTagWeights`` entry WINS over the built-in for the same tag (operator
638
+ override), and the PR5 ``sidechain`` seed still composes cleanly since it lives
639
+ only in ``recallTagWeights``. When ``lessonDemotion`` is false the built-ins are
640
+ dropped entirely (rollback lever) and only ``recallTagWeights`` applies.
641
+ """
642
+ configured = config.get("recallTagWeights")
643
+ configured = configured if isinstance(configured, dict) else {}
644
+ if not config.get("lessonDemotion", True):
645
+ return configured
646
+ builtin = config.get("lessonDemotionWeights")
647
+ builtin = builtin if isinstance(builtin, dict) else {}
648
+ if not builtin:
649
+ return configured
650
+ # builtin first, operator override wins on key conflict.
651
+ return {**builtin, **configured}
652
+
653
+
512
654
  def _write_recall_log(entry: dict) -> None:
513
655
  """Append a JSONL line to recall_log.jsonl. Bounded by line count.
514
656
 
@@ -531,13 +673,15 @@ def _write_recall_log(entry: dict) -> None:
531
673
  # under the cap and the trim path is a no-op.
532
674
  with open(log_path, "a", encoding="utf-8") as f:
533
675
  f.write(line)
534
- # Cheap rolling trim every ~50 writes (estimated by file size
535
- # vs. 200 bytes/line average) to amortize the read cost.
676
+ # Size-gated trim: only pay the full-file read when the byte size
677
+ # says we plausibly exceed the line cap. The estimate is a per-line
678
+ # UPPER bound (see RECALL_LOG_BYTES_PER_LINE_EST) so we don't read on
679
+ # every hook once rows grew past the old 200 B/line assumption.
536
680
  try:
537
681
  size = os.path.getsize(log_path)
538
682
  except OSError:
539
683
  return
540
- if size > RECALL_LOG_MAX_LINES * 250:
684
+ if size > RECALL_LOG_MAX_LINES * RECALL_LOG_BYTES_PER_LINE_EST:
541
685
  try:
542
686
  with open(log_path, "r", encoding="utf-8") as f:
543
687
  lines = f.readlines()
@@ -552,40 +696,213 @@ def _write_recall_log(entry: dict) -> None:
552
696
  pass
553
697
 
554
698
 
555
- def read_transcript_messages(transcript_path: str) -> list:
699
+ def _read_transcript_lines(transcript_path: str, tail_bytes: int):
700
+ """Yield the transcript's trailing lines, byte-bounded.
701
+
702
+ Switchroom hindsight-leverage A2 (PR2): the latency bound for multi-turn
703
+ recall. When ``tail_bytes > 0`` we seek to ``EOF - tail_bytes`` and read
704
+ forward, discarding the first (possibly partial) line so every yielded line
705
+ is complete JSON. This caps the read+parse cost at O(tail_bytes) regardless
706
+ of how large the session ``.jsonl`` has grown — the last few human turns we
707
+ slice for context always live at the very tail. ``tail_bytes <= 0`` reads
708
+ the whole file (pre-A2 behaviour / rollback lever).
709
+
710
+ Reads bytes (not text) so the seek offset is exact; decodes with
711
+ ``errors="ignore"`` so a multi-byte character split by the tail boundary
712
+ can't crash the read (that byte lands in the discarded partial first line
713
+ anyway when the file exceeds the bound).
714
+ """
715
+ if tail_bytes and tail_bytes > 0:
716
+ size = os.path.getsize(transcript_path)
717
+ if size > tail_bytes:
718
+ with open(transcript_path, "rb") as f:
719
+ f.seek(size - tail_bytes)
720
+ chunk = f.read()
721
+ text = chunk.decode("utf-8", errors="ignore")
722
+ # Drop the first line — it may be a partial record from mid-file.
723
+ newline = text.find("\n")
724
+ if newline != -1:
725
+ text = text[newline + 1 :]
726
+ yield from text.splitlines()
727
+ return
728
+ with open(transcript_path, encoding="utf-8") as f:
729
+ yield from f
730
+
731
+
732
+ def read_transcript_messages(transcript_path: str, tail_bytes: int = 0) -> list:
556
733
  """Read messages from a JSONL transcript file for multi-turn context.
557
734
 
558
735
  Claude Code transcript format nests messages:
559
736
  {type: "user", message: {role: "user", content: "..."}, uuid: "...", ...}
560
737
  Also supports flat format for testing:
561
738
  {role: "user", content: "..."}
739
+
740
+ ``tail_bytes`` (Switchroom A2) byte-bounds the read: when > 0 and the file
741
+ is larger, only the trailing ``tail_bytes`` (complete lines) are parsed, so
742
+ the added per-recall transcript read stays cheap on long sessions. 0 reads
743
+ the whole file. The #3369 grep fallback reuses this same tail-reader (see
744
+ ``_transcript_grep_fallback``) rather than shipping its own seek logic, so
745
+ there is a single byte-bounded transcript reader.
562
746
  """
563
747
  if not transcript_path or not os.path.isfile(transcript_path):
564
748
  return []
565
749
  messages = []
566
750
  try:
567
- with open(transcript_path, encoding="utf-8") as f:
568
- for line in f:
569
- line = line.strip()
570
- if not line:
571
- continue
572
- try:
573
- entry = json.loads(line)
574
- # Claude Code nested format: {type: "user", message: {role, content}}
575
- if entry.get("type") in ("user", "assistant"):
576
- msg = entry.get("message", {})
577
- if isinstance(msg, dict) and msg.get("role"):
578
- messages.append(msg)
579
- # Flat format (testing / future compatibility)
580
- elif "role" in entry and "content" in entry:
581
- messages.append(entry)
582
- except json.JSONDecodeError:
583
- continue
751
+ for line in _read_transcript_lines(transcript_path, tail_bytes):
752
+ line = line.strip()
753
+ if not line:
754
+ continue
755
+ try:
756
+ entry = json.loads(line)
757
+ # Claude Code nested format: {type: "user", message: {role, content}}
758
+ if entry.get("type") in ("user", "assistant"):
759
+ msg = entry.get("message", {})
760
+ if isinstance(msg, dict) and msg.get("role"):
761
+ messages.append(msg)
762
+ # Flat format (testing / future compatibility)
763
+ elif "role" in entry and "content" in entry:
764
+ messages.append(entry)
765
+ except json.JSONDecodeError:
766
+ continue
584
767
  except OSError:
585
768
  pass
586
769
  return messages
587
770
 
588
771
 
772
+ # Switchroom hindsight-leverage E1 / PR8 (#3369) — bounded transcript-grep
773
+ # fallback telemetry shape. A zeroed record is the "did not fire" default,
774
+ # emitted on the recall_log row whenever the fallback is gated off, skipped, or
775
+ # produced no match — so the log schema is uniform and a downstream query can
776
+ # always read `transcript_fallback` without a KeyError.
777
+ _FALLBACK_TELEMETRY_ZERO = {
778
+ "fired": False,
779
+ "matched_turns": 0,
780
+ "chars": 0,
781
+ "elapsed_ms": 0,
782
+ "bytes_read": 0,
783
+ "truncated": False,
784
+ }
785
+
786
+ _FALLBACK_BLOCK_PREAMBLE = (
787
+ "No stored memories matched this query — the fact layer may not have "
788
+ "reconciled this session yet (e.g. an abrupt session death before boot "
789
+ "reconciliation). The following are VERBATIM excerpts from recent turns of "
790
+ "THIS session that mention related terms. Treat them as lower-confidence "
791
+ "than stored memory — they are raw transcript, not synthesized fact:"
792
+ )
793
+
794
+
795
+ def _transcript_grep_fallback(transcript_path, query, config):
796
+ """Bounded transcript-grep fallback for the empty-fact-layer window (#3369).
797
+
798
+ Reads the CURRENT session transcript's tail (bounded bytes), keeps the most
799
+ recent user/assistant turns whose terms lexically overlap the recall query,
800
+ and returns a clearly-labelled, size-bounded fallback context block plus a
801
+ telemetry dict. Every dimension is bounded: bytes read
802
+ (``recallTranscriptFallbackMaxBytes``), matched turns
803
+ (``recallTranscriptFallbackMaxTurns``), emitted characters
804
+ (``recallTranscriptFallbackMaxChars``), and grep wall-time
805
+ (``recallTranscriptFallbackDeadlineMs``). The caller only invokes this when
806
+ all banks returned zero AND no slot hit its deadline, so a timed-out bank can
807
+ never masquerade as a genuinely empty fact layer.
808
+
809
+ Returns ``(block_or_None, telemetry)``. Failure-safe: any error path returns
810
+ ``(None, zeroed-telemetry)`` so the fallback can never break recall.
811
+ """
812
+ telemetry = dict(_FALLBACK_TELEMETRY_ZERO)
813
+ start = time.monotonic()
814
+
815
+ if not transcript_path or not os.path.isfile(transcript_path):
816
+ return None, telemetry
817
+
818
+ def _int_cfg(key, default):
819
+ try:
820
+ val = int(config.get(key, default))
821
+ except (TypeError, ValueError):
822
+ return default
823
+ return val
824
+
825
+ max_bytes = _int_cfg("recallTranscriptFallbackMaxBytes", 262144)
826
+ max_turns = _int_cfg("recallTranscriptFallbackMaxTurns", 6)
827
+ max_chars = _int_cfg("recallTranscriptFallbackMaxChars", 2000)
828
+ deadline_ms = _int_cfg("recallTranscriptFallbackDeadlineMs", 1500)
829
+
830
+ if max_turns <= 0 or max_chars <= 0 or max_bytes <= 0:
831
+ return None, telemetry
832
+
833
+ query_tokens = _overlap_tokens(query)
834
+ if not query_tokens:
835
+ return None, telemetry
836
+
837
+ # TODO(consolidation, #3450 / epic #3430): two bounded transcript tail-readers now
838
+ # coexist post-merge — retain's, and recall's shared ``_read_transcript_lines``
839
+ # (#3443, which this #3369 fallback now reuses via ``read_transcript_messages``).
840
+ # They each re-implement "read the last N bytes of the session JSONL and parse
841
+ # turns". Consolidate onto a single shared bounded tail-reader helper. Tracked
842
+ # in follow-up #3450 (tied to epic #3430).
843
+ messages = read_transcript_messages(transcript_path, tail_bytes=max_bytes)
844
+
845
+ # Review finding — record bytes_read only AFTER the read succeeds, so a
846
+ # failed/partial read doesn't report bytes we never actually consumed.
847
+ try:
848
+ telemetry["bytes_read"] = min(os.path.getsize(transcript_path), max_bytes)
849
+ except OSError:
850
+ pass
851
+
852
+ matched = []
853
+ total_chars = 0
854
+ # Newest-first so, under the turn/char/time bounds, we keep the MOST RECENT
855
+ # relevant turns; the collected list is reversed back to chronological order
856
+ # before formatting.
857
+ for msg in reversed(messages):
858
+ if (time.monotonic() - start) * 1000.0 > deadline_ms:
859
+ telemetry["truncated"] = True
860
+ break
861
+ if not isinstance(msg, dict):
862
+ continue
863
+ role = msg.get("role")
864
+ if role not in ("user", "assistant"):
865
+ continue
866
+ text = _extract_text_content(msg.get("content", ""), role=role)
867
+ text = strip_memory_tags(strip_channel_envelope(text)).strip()
868
+ if not text:
869
+ continue
870
+ if not (_overlap_tokens(text) & query_tokens):
871
+ continue
872
+ entry = f"[{role}] {text}"
873
+ remaining = max_chars - total_chars
874
+ if remaining <= 0:
875
+ telemetry["truncated"] = True
876
+ break
877
+ if len(entry) > remaining:
878
+ # Review finding — reserve 1 char for the ellipsis so the emitted
879
+ # entry is EXACTLY `remaining` chars, not remaining+1 (the "…" is a
880
+ # single code point). Keeps the char budget an exact bound.
881
+ entry = entry[:remaining - 1].rstrip() + "…"
882
+ telemetry["truncated"] = True
883
+ matched.append(entry)
884
+ total_chars += len(entry)
885
+ if len(matched) >= max_turns:
886
+ break
887
+
888
+ telemetry["elapsed_ms"] = int((time.monotonic() - start) * 1000)
889
+
890
+ if not matched:
891
+ return None, telemetry
892
+
893
+ matched.reverse() # restore chronological order for the model
894
+ block = (
895
+ "<hindsight_transcript_fallback>\n"
896
+ f"{_FALLBACK_BLOCK_PREAMBLE}\n\n"
897
+ + "\n".join(matched)
898
+ + "\n</hindsight_transcript_fallback>"
899
+ )
900
+ telemetry["fired"] = True
901
+ telemetry["matched_turns"] = len(matched)
902
+ telemetry["chars"] = len(block)
903
+ return block, telemetry
904
+
905
+
589
906
  # Switchroom Phase 6a — stateless-prompt classifier for the recall skip.
590
907
  # Returns True ONLY for prompts that provably never need user memory: the
591
908
  # current time/date/day, or a bare greeting. Biased hard toward False —
@@ -959,11 +1276,30 @@ def main():
959
1276
  "bank_id": bank_id,
960
1277
  "additional_banks": additional_banks,
961
1278
  "query_chars": len(prompt),
1279
+ "query": None, # no recall query composed on a cache hit
962
1280
  "result_count": None, # not known on cache hit
963
1281
  "directive_count": None,
964
1282
  "demoted_count": 0,
965
1283
  "capped": False,
966
1284
  "cache_hit": True,
1285
+ # A3 stage-1 telemetry keys kept present for a uniformly
1286
+ # queryable schema; a cache hit issues no bank HTTP, so there
1287
+ # is no per-bank timing and no deadline pressure to record.
1288
+ # All are None/[] (NOT False) so a cache-hit row is never
1289
+ # miscounted as an observed no-timeout recall in the breach
1290
+ # baseline — `deadline_hit is False` means "banks ran, none
1291
+ # timed out"; `deadline_hit is None` means "no banks ran"
1292
+ # (review finding 3).
1293
+ "total_elapsed_ms": None,
1294
+ "directives_elapsed_ms": None,
1295
+ "bank_timings": [],
1296
+ "deadline_hit": None,
1297
+ # A3 — no banks ran on a cache hit, so mode/deadline are null
1298
+ # for a uniformly queryable schema (never "serial"/"parallel").
1299
+ "recall_mode": None,
1300
+ "deadline_budget_ms": None,
1301
+ "deadline_effective_ms": None,
1302
+ "directives_timed_out": None,
967
1303
  # PR6 — record the active topic on cache hits too so the
968
1304
  # log is uniformly queryable (cache_key now includes
969
1305
  # active_thread_id, so a hit means the prior recall was
@@ -972,10 +1308,30 @@ def main():
972
1308
  "active_topic_alias": active_topic_alias,
973
1309
  "topic_filter_mode": _topic_filter_mode(),
974
1310
  "directive_nudge": bool(nudge_block),
1311
+ # E1 / PR8 (#3369) — no banks ran on a cache hit, so the
1312
+ # transcript fallback never fires; carry the zeroed fields for a
1313
+ # uniformly queryable schema.
1314
+ "transcript_fallback": False,
1315
+ "transcript_fallback_turns": 0,
1316
+ "transcript_fallback_chars": 0,
1317
+ "transcript_fallback_bytes_read": 0,
1318
+ "transcript_fallback_elapsed_ms": 0,
1319
+ "transcript_fallback_truncated": False,
1320
+ # PR8 (#3369) — no banks ran on a cache hit, so no bank raised a
1321
+ # hard error. None (NOT False) so a cache-hit row is never
1322
+ # miscounted as an observed no-error recall — matching the
1323
+ # deadline_hit / directives_timed_out convention above.
1324
+ "bank_errored": None,
975
1325
  })
976
1326
  return
977
1327
  debug_log(config, f"Recall cache MISS (key={cache_key[:12]}…)")
978
1328
 
1329
+ # Switchroom A3 stage-1 telemetry (hindsight-leverage PR 1). Wall-clock
1330
+ # start for the recall critical path (mission ensure + transcript read +
1331
+ # directives fetch + every bank recall). Feeds `total_elapsed_ms` in the
1332
+ # log so a fresh pre-parallelism (pre-A3) breach baseline can accrue.
1333
+ recall_start_monotonic = time.monotonic()
1334
+
979
1335
  # Set bank mission on first use
980
1336
  ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
981
1337
 
@@ -984,15 +1340,29 @@ def main():
984
1340
  recall_max_query_chars = config.get("recallMaxQueryChars", 800)
985
1341
  recall_roles = config.get("recallRoles", ["user", "assistant"])
986
1342
 
1343
+ # Switchroom A1 (hindsight-leverage PR 1) — strip the <channel …> transport
1344
+ # envelope from the prompt ONCE, before both the single-turn and multi-turn
1345
+ # branches, so ~100-200 chars of chat_id/ts/user XML noise never reach the
1346
+ # embedding or consume the recallMaxQueryChars cap. `prompt` itself is left
1347
+ # untouched (the ack/nudge gates and cache-hit log-length field still want
1348
+ # the raw form); only the value fed to the recall query is stripped.
1349
+ # compose_recall_query also strips its latest_query internally (defence in
1350
+ # depth), but we strip here too so the single-turn path and
1351
+ # truncate_recall_query's latest_query arg are both envelope-free.
1352
+ recall_query_text = strip_channel_envelope(prompt)
1353
+
987
1354
  if recall_context_turns > 1:
988
1355
  transcript_path = hook_input.get("transcript_path", "")
989
- messages = read_transcript_messages(transcript_path)
1356
+ # A2 latency bound: only parse the transcript tail (last N bytes)
1357
+ # the human turns we slice for context live at the end.
1358
+ recall_transcript_tail_bytes = config.get("recallTranscriptTailBytes", 262144)
1359
+ messages = read_transcript_messages(transcript_path, recall_transcript_tail_bytes)
990
1360
  debug_log(config, f"Multi-turn context: {recall_context_turns} turns, {len(messages)} messages from transcript")
991
- query = compose_recall_query(prompt, messages, recall_context_turns, recall_roles)
1361
+ query = compose_recall_query(recall_query_text, messages, recall_context_turns, recall_roles)
992
1362
  else:
993
- query = prompt
1363
+ query = recall_query_text
994
1364
 
995
- query = truncate_recall_query(query, prompt, recall_max_query_chars)
1365
+ query = truncate_recall_query(query, recall_query_text, recall_max_query_chars)
996
1366
 
997
1367
  # Final defensive cap (mirrors Openclaw)
998
1368
  if len(query) > recall_max_query_chars:
@@ -1005,58 +1375,71 @@ def main():
1005
1375
  # surfaced every turn). Workaround for upstream bug
1006
1376
  # vectorize-io/hindsight#1269 (tagged directives silently dropped from
1007
1377
  # `reflect`); `list_directives` itself works correctly upstream, so this
1008
- # is a pure client-side surface. fetch_active_directives is failure-safe
1009
- # and returns [] on any error.
1010
- directives = fetch_active_directives(client, bank_id)
1011
- directives_block = format_active_directives_block(directives) if directives else None
1012
- if directives_block:
1013
- debug_log(config, f"Injecting {len(directives)} active directives")
1014
-
1015
- # Call Hindsight recall API
1016
- results = []
1017
- try:
1018
- response = client.recall(
1019
- bank_id=bank_id,
1020
- query=query,
1021
- max_tokens=config.get("recallMaxTokens", 1024),
1022
- budget=config.get("recallBudget", "mid"),
1023
- types=config.get("recallTypes"),
1024
- # Upstream 962140eef optional tag filters (resolved above the
1025
- # cache check; part of the cache key).
1026
- tags=recall_tags,
1027
- tags_match=tags_match,
1028
- tag_groups=tag_groups,
1029
- # Switchroom Phase-1 precision — prefer deduped observation
1030
- # statements over the raw facts they supersede, backfilling freed
1031
- # slots for denser coverage inside the same budget. On by default;
1032
- # operators can pin off via `recallPreferObservations: false`.
1033
- prefer_observations=config.get("recallPreferObservations", True),
1034
- # 8s in-script timeout leaves 4s headroom inside the 12s
1035
- # UserPromptSubmit hook ceiling (see hooks.json:20) for cache
1036
- # write + block formatting. Tightened from 10s in switchroom
1037
- # v0.13.22: the 2026-05-24 audit showed 17-26% of turns
1038
- # breaching the 12s hook timeout on heavy agents (finn /
1039
- # gymbro / klanker), which dropped the recall entirely; an
1040
- # earlier-hard-timeout failure returns cleanly with no
1041
- # memories instead of blowing past the hook ceiling.
1042
- timeout=8,
1378
+ # is a pure client-side surface. fetch_active_directives_cached is
1379
+ # failure-safe and returns [] on any error.
1380
+ #
1381
+ # A4: cache the directives list with a short TTL (invalidated in-session by
1382
+ # directive_verify.py on a directive write) so the common no-write turn
1383
+ # skips the HTTP round-trip. TTL=0 disables the cache (live every turn).
1384
+ #
1385
+ # Switchroom hindsight-leverage A3 — the directives fetch and every bank
1386
+ # recall run CONCURRENTLY (daemon threads) under ONE shared deadline instead
1387
+ # of serially. Serially their latencies SUM (own bank + N extra banks +
1388
+ # directives), and a heavy multi-bank agent can breach the 12s
1389
+ # UserPromptSubmit ceiling — dropping recall for the turn. Parallel makes the
1390
+ # critical path the SLOWEST slot, bounded by `recallParallelDeadlineSeconds`
1391
+ # (the hook ceiling minus 2s headroom). A slot still running at the deadline
1392
+ # is abandoned (daemon thread, reaped on process exit) and marked timed_out —
1393
+ # a straggler bank can never push the hook past its ceiling.
1394
+ # HINDSIGHT_RECALL_PARALLEL=false restores the serial path (rollback lever).
1395
+ # The directives slot composes with the A4 cache: on a cache HIT it returns
1396
+ # near-instantly with no HTTP, so directives_elapsed_ms reads ~0.
1397
+
1398
+ def _directives_task():
1399
+ return fetch_active_directives_cached(
1400
+ client,
1401
+ bank_id,
1402
+ ttl_seconds=config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS),
1043
1403
  )
1044
- results = response.get("results", [])
1045
- except Exception as e:
1046
- print(f"[Hindsight] Recall failed: {e}", file=sys.stderr)
1047
- # Fall through — we still want to emit the directives block if we
1048
- # have one, so a recall API failure doesn't blind the agent to
1049
- # its own active directives.
1050
-
1051
- # Also recall from any additional banks (e.g. shared user profile bank).
1052
- # `additional_banks` was already extracted above the cache check so the
1053
- # cache key reflects every bank queried; reuse that local instead of
1054
- # re-reading config.
1404
+
1405
+ def _make_bank_task(target_bank_id, b_tags, b_tags_match, b_tag_groups):
1406
+ def _bank_task():
1407
+ return client.recall(
1408
+ bank_id=target_bank_id,
1409
+ query=query,
1410
+ max_tokens=config.get("recallMaxTokens", 1024),
1411
+ budget=config.get("recallBudget", "mid"),
1412
+ types=config.get("recallTypes"),
1413
+ # Upstream 962140eef optional per-bank tag filters (resolved
1414
+ # above the cache check; part of the cache key).
1415
+ tags=b_tags,
1416
+ tags_match=b_tags_match,
1417
+ tag_groups=b_tag_groups,
1418
+ # Switchroom Phase-1 precision — prefer deduped observation
1419
+ # statements over the raw facts they supersede, backfilling freed
1420
+ # slots for denser coverage inside the same budget. On by default;
1421
+ # operators can pin off via `recallPreferObservations: false`.
1422
+ prefer_observations=config.get("recallPreferObservations", True),
1423
+ # 8s in-script per-request timeout: even parallelised, each bank
1424
+ # carries its own hard deadline so a single hung bank returns
1425
+ # cleanly with no memories rather than sitting on the shared
1426
+ # deadline. Tightened from 10s in v0.13.22 (2026-05-24 breach
1427
+ # audit); the shared deadline below is the outer ceiling guard.
1428
+ timeout=8,
1429
+ )
1430
+ return _bank_task
1431
+
1432
+ # Resolve (bank_id, tags, tags_match, tag_groups) for every bank we query —
1433
+ # own bank first, then each additional bank in config order. This order is
1434
+ # preserved for bank_timings and the result-merge sequence regardless of
1435
+ # thread completion order, so the emitted telemetry is deterministic.
1436
+ # `additional_banks` was extracted above the cache check (so the cache key
1437
+ # reflects every bank queried); reuse that local.
1438
+ bank_specs = [(bank_id, recall_tags, tags_match, tag_groups)]
1055
1439
  for extra_bank_id in additional_banks:
1056
- # Upstream 962140eef — per-bank tag-filter overrides; fall back to
1057
- # the global filters when the bank has no entry. Applies uniformly
1058
- # to config-listed banks and sender banks appended by
1059
- # _resolve_sender_bank (both flow through `additional_banks`).
1440
+ # Upstream 962140eef — per-bank tag-filter overrides; fall back to the
1441
+ # global filters when the bank has no entry. Applies uniformly to
1442
+ # config-listed banks and sender banks appended by _resolve_sender_bank.
1060
1443
  extra_filter = additional_bank_filters.get(extra_bank_id, {})
1061
1444
  if not isinstance(extra_filter, dict):
1062
1445
  extra_filter = {}
@@ -1066,36 +1449,154 @@ def main():
1066
1449
  "recallTagsMatch",
1067
1450
  tags_match if extra_tags or extra_tag_groups else None,
1068
1451
  )
1069
- try:
1070
- extra_response = client.recall(
1071
- bank_id=extra_bank_id,
1072
- query=query,
1073
- max_tokens=config.get("recallMaxTokens", 1024),
1074
- budget=config.get("recallBudget", "mid"),
1075
- types=config.get("recallTypes"),
1076
- tags=extra_tags,
1077
- tags_match=extra_tags_match,
1078
- tag_groups=extra_tag_groups,
1079
- # Switchroom Phase-1 precision — prefer deduped observation
1080
- # statements here too so additional banks contribute their
1081
- # densest statements to the merged, score-sorted set.
1082
- prefer_observations=config.get("recallPreferObservations", True),
1083
- # 8s in-script timeout leaves 4s headroom inside the 12s
1084
- # UserPromptSubmit hook ceiling (see hooks.json:20) for cache
1085
- # write + block formatting. Tightened from 10s in switchroom
1086
- # v0.13.22: the 2026-05-24 audit showed 17-26% of turns
1087
- # breaching the 12s hook timeout on heavy agents (finn /
1088
- # gymbro / klanker), which dropped the recall entirely; an
1089
- # earlier-hard-timeout failure returns cleanly with no
1090
- # memories instead of blowing past the hook ceiling.
1091
- timeout=8,
1452
+ bank_specs.append((extra_bank_id, extra_tags, extra_tags_match, extra_tag_groups))
1453
+
1454
+ recall_parallel = bool(config.get("recallParallel", True))
1455
+ try:
1456
+ deadline_seconds = float(config.get("recallParallelDeadlineSeconds", 10))
1457
+ except (TypeError, ValueError):
1458
+ deadline_seconds = 10.0
1459
+
1460
+ directives = []
1461
+ directives_timed_out = False
1462
+ bank_timings = []
1463
+ results = []
1464
+
1465
+ if recall_parallel:
1466
+ recall_mode = "parallel"
1467
+ deadline_budget_ms = int(deadline_seconds * 1000)
1468
+ # The shared deadline is measured from recall_start_monotonic (the top
1469
+ # of the critical path), so mission-ensure + transcript read already
1470
+ # spent budget: subtract what elapsed so the remaining wait still
1471
+ # respects the ceiling-minus-2s guarantee.
1472
+ already_spent = time.monotonic() - recall_start_monotonic
1473
+ remaining_deadline = max(0.0, deadline_seconds - already_spent)
1474
+ # `deadline_budget_ms` is the CONFIGURED budget; the wait the slots
1475
+ # actually get is `remaining_deadline` after pre-fan-out spend. Log both
1476
+ # so the breach baseline can tell "configured" from "effectively granted".
1477
+ deadline_effective_ms = int(remaining_deadline * 1000)
1478
+
1479
+ tasks = {DIRECTIVES_SLOT: _directives_task}
1480
+ for spec in bank_specs:
1481
+ tasks[spec[0]] = _make_bank_task(spec[0], spec[1], spec[2], spec[3])
1482
+ outcomes = run_parallel(tasks, remaining_deadline)
1483
+
1484
+ # Directives slot. A slot that hit the deadline OR raised yields [] —
1485
+ # the same failure-safe contract fetch_active_directives_cached honours.
1486
+ d_outcome = outcomes[DIRECTIVES_SLOT]
1487
+ directives_elapsed_ms = d_outcome.elapsed_ms if d_outcome.elapsed_ms is not None else 0
1488
+ directives_timed_out = not d_outcome.completed
1489
+ if d_outcome.completed and isinstance(d_outcome.value, list):
1490
+ directives = d_outcome.value
1491
+ elif directives_timed_out:
1492
+ debug_log(config, "Directives slot hit the shared recall deadline")
1493
+ elif d_outcome.error is not None:
1494
+ debug_log(config, f"Directives fetch failed: {d_outcome.error}")
1495
+
1496
+ # Bank slots, own-bank first in config order. A bank is timed_out if it
1497
+ # did not complete before the shared deadline OR completed by raising a
1498
+ # hard timeout error (the finalized `deadline_hit` semantics — an
1499
+ # abandoned straggler now counts, which the serial-era per-request-only
1500
+ # flag could not express).
1501
+ for spec in bank_specs:
1502
+ b_id = spec[0]
1503
+ b_outcome = outcomes[b_id]
1504
+ b_timed_out = (not b_outcome.completed) or (
1505
+ b_outcome.error is not None and _is_timeout_error(b_outcome.error)
1092
1506
  )
1093
- extra_results = extra_response.get("results", [])
1094
- if extra_results:
1095
- debug_log(config, f"Got {len(extra_results)} memories from additional bank '{extra_bank_id}'")
1096
- results = results + extra_results
1097
- except Exception as e:
1098
- debug_log(config, f"Recall from additional bank '{extra_bank_id}' failed: {e}")
1507
+ # Switchroom review finding (PR8 gating hole) — a bank that raised a
1508
+ # HARD (non-timeout) error: connection refused, 5xx, daemon down,
1509
+ # malformed response. Distinct from `timed_out` so the transcript
1510
+ # fallback can be suppressed on a genuine outage (the fact layer was
1511
+ # unreachable, not "empty because nothing reconciled yet").
1512
+ b_errored = b_outcome.error is not None and not _is_timeout_error(b_outcome.error)
1513
+ if b_outcome.completed and b_outcome.error is None:
1514
+ bank_results = (
1515
+ b_outcome.value.get("results", [])
1516
+ if isinstance(b_outcome.value, dict)
1517
+ else []
1518
+ )
1519
+ if bank_results:
1520
+ debug_log(config, f"Got {len(bank_results)} memories from bank '{b_id}'")
1521
+ results = results + bank_results
1522
+ elif b_outcome.error is not None:
1523
+ # Own bank failure surfaces on stderr (journald signal); extra
1524
+ # banks are debug-only, matching the pre-A3 serial behaviour.
1525
+ if b_id == bank_id:
1526
+ print(f"[Hindsight] Recall failed: {b_outcome.error}", file=sys.stderr)
1527
+ else:
1528
+ debug_log(config, f"Recall from additional bank '{b_id}' failed: {b_outcome.error}")
1529
+ elif not b_outcome.completed:
1530
+ debug_log(config, f"Recall from bank '{b_id}' hit the shared deadline")
1531
+ bank_timings.append({
1532
+ "bank_id": b_id,
1533
+ "elapsed_ms": b_outcome.elapsed_ms if b_outcome.elapsed_ms is not None else 0,
1534
+ "timed_out": b_timed_out,
1535
+ "errored": b_errored,
1536
+ })
1537
+ else:
1538
+ # Pre-A3 serial path (rollback lever, HINDSIGHT_RECALL_PARALLEL=false).
1539
+ # Directives first, then each bank in turn — total latency is the SUM of
1540
+ # the round-trips. Behaviourally equivalent to the pre-parallelism path
1541
+ # (log rows stay comparable), NOT byte-for-byte: this path emits an extra
1542
+ # own-bank debug line, logs the directives block after the bank loop
1543
+ # rather than before, and __main__ still os._exit(0)s on completion.
1544
+ recall_mode = "serial"
1545
+ deadline_budget_ms = None
1546
+ deadline_effective_ms = None
1547
+ _directives_start = time.monotonic()
1548
+ directives = fetch_active_directives_cached(
1549
+ client,
1550
+ bank_id,
1551
+ ttl_seconds=config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS),
1552
+ )
1553
+ directives_elapsed_ms = int((time.monotonic() - _directives_start) * 1000)
1554
+ for spec in bank_specs:
1555
+ b_id, b_tags, b_tags_match, b_tag_groups = spec
1556
+ _bank_start = time.monotonic()
1557
+ _bank_timed_out = False
1558
+ _bank_errored = False
1559
+ try:
1560
+ response = _make_bank_task(b_id, b_tags, b_tags_match, b_tag_groups)()
1561
+ bank_results = response.get("results", []) if isinstance(response, dict) else []
1562
+ if bank_results:
1563
+ debug_log(config, f"Got {len(bank_results)} memories from bank '{b_id}'")
1564
+ results = results + bank_results
1565
+ except Exception as e:
1566
+ _bank_timed_out = _is_timeout_error(e)
1567
+ # Non-timeout error → hard outage (see parallel-path note above).
1568
+ _bank_errored = not _bank_timed_out
1569
+ if b_id == bank_id:
1570
+ print(f"[Hindsight] Recall failed: {e}", file=sys.stderr)
1571
+ else:
1572
+ debug_log(config, f"Recall from additional bank '{b_id}' failed: {e}")
1573
+ bank_timings.append({
1574
+ "bank_id": b_id,
1575
+ "elapsed_ms": int((time.monotonic() - _bank_start) * 1000),
1576
+ "timed_out": _bank_timed_out,
1577
+ "errored": _bank_errored,
1578
+ })
1579
+
1580
+ # Switchroom hindsight-leverage A3 — FINALIZED `deadline_hit`: True when ANY
1581
+ # slot on the critical path hit its deadline (a bank that raised a hard
1582
+ # per-request timeout, or — parallel mode — a bank/directives slot abandoned
1583
+ # at the shared deadline). Hoisted here (was inline in the recall_log write)
1584
+ # so the E1 / PR8 transcript fallback can gate on it: the #3369 fallback must
1585
+ # NOT fire when a bank timed out, only when the fact layer is genuinely empty.
1586
+ deadline_hit = any(bt["timed_out"] for bt in bank_timings) or directives_timed_out
1587
+
1588
+ # Switchroom review finding (PR8 gating hole) — True when ANY bank raised a
1589
+ # HARD (non-timeout) error. A connection-refused / 5xx / daemon-down outage
1590
+ # contributes zero results with `deadline_hit` False, which would otherwise
1591
+ # let the empty-fact-layer transcript fallback fire on every turn for the
1592
+ # whole outage — mislabelling "fact layer unreachable" as "nothing
1593
+ # reconciled yet" and flooding telemetry. Gated on below so the fallback
1594
+ # only fires when all banks genuinely returned zero: no timeout AND no error.
1595
+ bank_errored = any(bt.get("errored") for bt in bank_timings)
1596
+
1597
+ directives_block = format_active_directives_block(directives) if directives else None
1598
+ if directives_block:
1599
+ debug_log(config, f"Injecting {len(directives)} active directives")
1099
1600
 
1100
1601
  # Switchroom #432 phase 4.4 — drop demote-tagged memories before
1101
1602
  # the cap. Filtering early means the cap kicks in over the
@@ -1148,6 +1649,17 @@ def main():
1148
1649
  else:
1149
1650
  overlap_dropped = 0
1150
1651
 
1652
+ # Switchroom hindsight-leverage PR5 — per-tag score penalty. Applied
1653
+ # IMMEDIATELY before the relevance sort so a down-weighted tag (e.g.
1654
+ # `sidechain: 0.8`) reorders the merged set without dropping anything. This
1655
+ # is the "reduced weight" the demote-tag DROP filter above cannot express:
1656
+ # a penalised memory ranks below equal-scoring untagged memories yet still
1657
+ # survives the cap when it is the only relevant hit. See _apply_tag_weights.
1658
+ tag_weights = _effective_tag_weights(config)
1659
+ weighted = _apply_tag_weights(results, tag_weights)
1660
+ if weighted > 0:
1661
+ debug_log(config, f"Applied recallTagWeights to {weighted} memories: {tag_weights}")
1662
+
1151
1663
  # Switchroom Phase-1 precision — sort the merged primary + additional-bank
1152
1664
  # result set by the engine's relevance score (`scores.final`) descending
1153
1665
  # BEFORE the head-slice cap below. Previously additional-bank results were
@@ -1216,6 +1728,47 @@ def main():
1216
1728
  else:
1217
1729
  debug_log(config, "No memories found")
1218
1730
 
1731
+ # Switchroom hindsight-leverage E1 / PR8 (#3369) — bounded transcript-grep
1732
+ # fallback. Fires ONLY when every bank returned zero results (pre_filter_count
1733
+ # == 0 — the merged pre-demote bank count) AND no slot hit its deadline
1734
+ # (deadline_hit False, so a timed-out bank can't masquerade as an empty fact
1735
+ # layer — the #3369 sequencing constraint on A3's telemetry) AND no bank
1736
+ # raised a hard error (bank_errored False, so a connection-refused / 5xx /
1737
+ # daemon-down outage can't masquerade as an empty fact layer either — the
1738
+ # PR8 gating-hole fix). This recovers
1739
+ # the crash-loss window between an abrupt session death and the next boot
1740
+ # reconciliation, where live recall would otherwise return nothing for the
1741
+ # lost turns. Everything is bounded inside the helper (bytes/turns/chars/time).
1742
+ # On by default; HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK=false is the rollback
1743
+ # lever. Mutually exclusive with memories_block by construction: a non-empty
1744
+ # memories_block requires results, which requires pre_filter_count > 0.
1745
+ transcript_fallback_block = None
1746
+ transcript_fallback_telemetry = dict(_FALLBACK_TELEMETRY_ZERO)
1747
+ if (
1748
+ config.get("recallTranscriptFallback", True)
1749
+ and pre_filter_count == 0
1750
+ and not deadline_hit
1751
+ and not bank_errored
1752
+ ):
1753
+ transcript_fallback_block, transcript_fallback_telemetry = _transcript_grep_fallback(
1754
+ hook_input.get("transcript_path", ""),
1755
+ query,
1756
+ config,
1757
+ )
1758
+ if transcript_fallback_block:
1759
+ debug_log(
1760
+ config,
1761
+ f"Transcript-grep fallback fired: {transcript_fallback_telemetry}",
1762
+ )
1763
+ elif pre_filter_count == 0 and bank_errored:
1764
+ # Suppressed: a bank outage (hard error), not a genuinely empty fact
1765
+ # layer. Logged so the gate decision is visible during an outage.
1766
+ debug_log(
1767
+ config,
1768
+ "Transcript-grep fallback suppressed: bank_errored (hard bank error, "
1769
+ "not an empty fact layer)",
1770
+ )
1771
+
1219
1772
  # Switchroom #303 — recall is done, model is about to start the long
1220
1773
  # TTFT. Update the placeholder so the user doesn't keep staring at
1221
1774
  # `📚 recalling memories` for the next 15–20 s of opus thinking.
@@ -1224,22 +1777,134 @@ def main():
1224
1777
  if placeholder_chat_id:
1225
1778
  update_placeholder(placeholder_chat_id, "💭 thinking")
1226
1779
 
1780
+ # Switchroom #432 phase 4.3 — telemetry log. memory IDs (when
1781
+ # available) let an operator confirm what was injected on a given turn.
1782
+ # Failure-tolerant.
1783
+ #
1784
+ # Hoisted ABOVE the empty-block early-return (hindsight-leverage PR 1,
1785
+ # review finding 1): a turn where every bank times out AND directives
1786
+ # fail produces no directives_block and no memories_block — precisely the
1787
+ # breach event the A3 baseline counts. Logging here (not after the return)
1788
+ # guarantees every cache-MISS recall attempt records bank_timings /
1789
+ # deadline_hit / total_elapsed_ms.
1790
+ #
1791
+ # NOTE on the PR-3 baseline denominator (review finding 2): a hook the
1792
+ # Claude Code UserPromptSubmit ceiling *kills* mid-flight (sequential
1793
+ # worst case ~18s > the 12s ceiling) never reaches this line, so a true
1794
+ # ceiling breach manifests as a MISSING row, not a logged one. The
1795
+ # baseline method is therefore two-signal: (a) ceiling-breach (total-drop)
1796
+ # rate = recall-log rows-missing vs the UserPromptSubmit turn count over
1797
+ # the window; (b) `deadline_hit` here = per-bank hard-timeout among the
1798
+ # hooks that survived long enough to log. Neither number alone is the
1799
+ # breach rate; PR 3's before/after must cite both.
1800
+ _write_recall_log({
1801
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1802
+ "session_id": (session_id or "")[:32],
1803
+ "bank_id": bank_id,
1804
+ "additional_banks": additional_banks,
1805
+ "query_chars": len(query),
1806
+ # Switchroom A1 (hindsight-leverage PR 1) — a BOUNDED excerpt (≤200
1807
+ # chars, review finding 5) of the envelope-stripped, truncated query
1808
+ # actually sent to recall. Enough to assert no `<channel` substring
1809
+ # reaches the embedding and to pair queries with hits (D2) without
1810
+ # bloating each row (which would thrash the size-based trim below).
1811
+ # `query_chars` above carries the full untruncated length.
1812
+ "query": query[:200],
1813
+ "result_count": len(results),
1814
+ "directive_count": len(directives),
1815
+ "demoted_count": demoted_count,
1816
+ "overlap_dropped": overlap_dropped,
1817
+ "capped": capped,
1818
+ "pre_cap_count": pre_cap_count,
1819
+ "memory_ids": [
1820
+ m.get("id") for m in results
1821
+ if isinstance(m, dict) and m.get("id")
1822
+ ],
1823
+ "cache_hit": False,
1824
+ # Switchroom A3 stage-1 telemetry (hindsight-leverage PR 1) — per-bank
1825
+ # latency + timeout breakdown, directives-fetch latency, total
1826
+ # critical-path wall time, and a derived `deadline_hit` (any bank hit
1827
+ # its hard per-request timeout). Accrues the fresh pre-A3 baseline the
1828
+ # parallelism change measures against; the 17-26% figure it replaces is
1829
+ # the stale 2026-05-24 pre-fix audit.
1830
+ "total_elapsed_ms": int((time.monotonic() - recall_start_monotonic) * 1000),
1831
+ "directives_elapsed_ms": directives_elapsed_ms,
1832
+ "bank_timings": bank_timings,
1833
+ # Switchroom hindsight-leverage A3 — FINALIZED `deadline_hit` semantics
1834
+ # (PR 1 shipped the interim per-bank-only form pending this PR). It is
1835
+ # now True when ANY slot on the critical path hit its deadline: a bank
1836
+ # that raised a hard per-request timeout OR (parallel mode) a bank/
1837
+ # directives slot abandoned when the shared deadline elapsed. In serial
1838
+ # (rollback) mode `directives_timed_out` is always False and each bank's
1839
+ # `timed_out` is per-request only, so this reduces to the pre-A3 form —
1840
+ # keeping the two modes' rows directly comparable in the breach baseline.
1841
+ "deadline_hit": deadline_hit,
1842
+ # Switchroom review finding (PR8 gating hole) — True when any bank raised
1843
+ # a hard (non-timeout) error this turn. Gates the transcript fallback
1844
+ # (suppressed on a real outage) and makes the outage visible per-row so a
1845
+ # spike in empty-result turns can be attributed to unreachable banks
1846
+ # rather than a genuinely empty fact layer.
1847
+ "bank_errored": bank_errored,
1848
+ # A3 — recall execution mode ("parallel"/"serial") and the shared
1849
+ # deadline budget (ms; None in serial mode). These distinguish pre-A3
1850
+ # (serial) from post-A3 (parallel) rows so the ≥3-day breach baseline
1851
+ # can segment by mode without guessing from timing.
1852
+ "recall_mode": recall_mode,
1853
+ "deadline_budget_ms": deadline_budget_ms,
1854
+ # A3 — effective deadline actually granted to the slots after pre-fan-out
1855
+ # spend (mission-ensure + transcript read). `deadline_budget_ms` is the
1856
+ # configured ceiling; this is the smaller wait the slots really got.
1857
+ # None in serial mode and on cache hits (no fan-out).
1858
+ "deadline_effective_ms": deadline_effective_ms,
1859
+ "directives_timed_out": directives_timed_out,
1860
+ # PR6 — instrumentation for binding-failure analysis.
1861
+ # `active_thread_id`: the current prompt's topic (null on
1862
+ # DM / fleet-shared). `source_topics`: distribution of
1863
+ # source thread_ids in the recall set (before optional
1864
+ # hard-filter). `topic_filter_mode`: "soft-preamble" or
1865
+ # "hard-filter". `topic_dropped`: count dropped by hard
1866
+ # filter. From these fields we can derive the cross-topic
1867
+ # recall rate over time and decide whether to flip to
1868
+ # hard-filter mode based on real data.
1869
+ "active_thread_id": active_thread_id,
1870
+ "active_topic_alias": active_topic_alias,
1871
+ "source_topics": source_topic_summary,
1872
+ "topic_filter_mode": topic_filter_mode,
1873
+ "topic_dropped": topic_dropped,
1874
+ "directive_nudge": bool(nudge_block),
1875
+ # Switchroom hindsight-leverage E1 / PR8 (#3369) — transcript-grep
1876
+ # fallback telemetry so its firing (and its bounds) are visible per turn
1877
+ # in recall_log.jsonl. `transcript_fallback` True only on an all-zero,
1878
+ # no-deadline-hit turn where the grep found ≥1 matching session turn.
1879
+ "transcript_fallback": transcript_fallback_telemetry["fired"],
1880
+ "transcript_fallback_turns": transcript_fallback_telemetry["matched_turns"],
1881
+ "transcript_fallback_chars": transcript_fallback_telemetry["chars"],
1882
+ "transcript_fallback_bytes_read": transcript_fallback_telemetry["bytes_read"],
1883
+ "transcript_fallback_elapsed_ms": transcript_fallback_telemetry["elapsed_ms"],
1884
+ "transcript_fallback_truncated": transcript_fallback_telemetry["truncated"],
1885
+ })
1886
+
1227
1887
  # If neither block has content, there's nothing to inject — exit
1228
1888
  # silently to avoid emitting an empty hookSpecificOutput. #2848: unless
1229
1889
  # the directive-capture nudge fired, in which case emit the nudge alone
1230
1890
  # (a correction with no memories/directives still needs the reminder).
1231
- if not directives_block and not memories_block:
1891
+ if not directives_block and not memories_block and not transcript_fallback_block:
1232
1892
  if nudge_block:
1233
1893
  _emit_cached_context(nudge_block)
1234
1894
  return
1235
1895
 
1236
1896
  # Compose final context. Directives block goes ABOVE memories so the
1237
- # agent reads HARD RULES before low-signal recall traces.
1897
+ # agent reads HARD RULES before low-signal recall traces. The E1/PR8
1898
+ # transcript fallback (#3369) goes LAST — it is the lowest-confidence
1899
+ # signal (raw transcript, not synthesized fact) and only present when
1900
+ # memories_block is empty by construction.
1238
1901
  parts = []
1239
1902
  if directives_block:
1240
1903
  parts.append(directives_block)
1241
1904
  if memories_block:
1242
1905
  parts.append(memories_block)
1906
+ if transcript_fallback_block:
1907
+ parts.append(transcript_fallback_block)
1243
1908
  context_message = "\n\n".join(parts)
1244
1909
 
1245
1910
  # Save last recall to state for diagnostics
@@ -1262,42 +1927,8 @@ def main():
1262
1927
  except Exception as e:
1263
1928
  debug_log(config, f"Recall cache write failed (non-fatal): {e}")
1264
1929
 
1265
- # Switchroom #432 phase 4.3 telemetry log. memory IDs (when
1266
- # available) let an operator confirm what was injected on a given
1267
- # turn. Failure-tolerant.
1268
- _write_recall_log({
1269
- "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1270
- "session_id": (session_id or "")[:32],
1271
- "bank_id": bank_id,
1272
- "additional_banks": additional_banks,
1273
- "query_chars": len(query),
1274
- "result_count": len(results),
1275
- "directive_count": len(directives),
1276
- "demoted_count": demoted_count,
1277
- "overlap_dropped": overlap_dropped,
1278
- "capped": capped,
1279
- "pre_cap_count": pre_cap_count,
1280
- "memory_ids": [
1281
- m.get("id") for m in results
1282
- if isinstance(m, dict) and m.get("id")
1283
- ],
1284
- "cache_hit": False,
1285
- # PR6 — instrumentation for binding-failure analysis.
1286
- # `active_thread_id`: the current prompt's topic (null on
1287
- # DM / fleet-shared). `source_topics`: distribution of
1288
- # source thread_ids in the recall set (before optional
1289
- # hard-filter). `topic_filter_mode`: "soft-preamble" or
1290
- # "hard-filter". `topic_dropped`: count dropped by hard
1291
- # filter. From these fields we can derive the cross-topic
1292
- # recall rate over time and decide whether to flip to
1293
- # hard-filter mode based on real data.
1294
- "active_thread_id": active_thread_id,
1295
- "active_topic_alias": active_topic_alias,
1296
- "source_topics": source_topic_summary,
1297
- "topic_filter_mode": topic_filter_mode,
1298
- "topic_dropped": topic_dropped,
1299
- "directive_nudge": bool(nudge_block),
1300
- })
1930
+ # (Telemetry log already written above, before the empty-block return, so
1931
+ # total-failure turns are recorded hindsight-leverage PR 1 finding 1.)
1301
1932
 
1302
1933
  # Output JSON for Claude Code hook system. #2848: append the
1303
1934
  # directive-capture nudge (if it fired) at emit time — it's kept out of
@@ -1396,6 +2027,21 @@ def _record_issue_safely(detail: str, class_name: str) -> None:
1396
2027
  if __name__ == "__main__":
1397
2028
  try:
1398
2029
  main()
2030
+ # Switchroom hindsight-leverage A3 — hard, immediate process exit.
2031
+ #
2032
+ # The parallel recall path spawns daemon threads. Daemon threads already
2033
+ # do not block a normal interpreter shutdown, but two things still can:
2034
+ # (a) a non-daemon thread a client library might spawn, and (b) atexit /
2035
+ # interpreter-shutdown thread-join bookkeeping. A straggler bank still
2036
+ # blocked on an 8s socket read must NEVER hold the UserPromptSubmit hook
2037
+ # open past its 12s ceiling. os._exit skips all of that and returns
2038
+ # control to Claude Code immediately — but it also skips stdout buffer
2039
+ # flushing, so flush FIRST or the hookSpecificOutput JSON is lost.
2040
+ try:
2041
+ sys.stdout.flush()
2042
+ except Exception:
2043
+ pass
2044
+ os._exit(0)
1399
2045
  except Exception as e:
1400
2046
  # Switchroom #1070 (redo per #1085 review).
1401
2047
  #