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.
- package/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- 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
|
|
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
|
-
#
|
|
535
|
-
#
|
|
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 *
|
|
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
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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(
|
|
1361
|
+
query = compose_recall_query(recall_query_text, messages, recall_context_turns, recall_roles)
|
|
992
1362
|
else:
|
|
993
|
-
query =
|
|
1363
|
+
query = recall_query_text
|
|
994
1364
|
|
|
995
|
-
query = truncate_recall_query(query,
|
|
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.
|
|
1009
|
-
# and returns [] on any error.
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
#
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
#
|
|
1058
|
-
#
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
#
|
|
1266
|
-
#
|
|
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
|
#
|