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