switchroom 0.19.16 → 0.19.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8307 -7620
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -23,10 +23,33 @@ from typing import Optional
23
23
 
24
24
  from .state import list_state_names, read_state, remove_state, write_state
25
25
 
26
- # Sanity cap on how many directives we ever inject into the prompt. Banks
27
- # with more active directives than this are pathological; truncate with a
28
- # footer so the agent knows there are more.
29
- MAX_DIRECTIVES = 15
26
+ # Sanity cap on how many directives we ever inject into the prompt.
27
+ #
28
+ # This number is a COST TRADEOFF, not an arbitrary limit, and the real cost is
29
+ # larger than "one block": `recall.py` rebuilds the <active_directives> block
30
+ # on EVERY UserPromptSubmit (the `format_active_directives_block` call there)
31
+ # with no per-session dedupe and no "unchanged since last turn" suppression,
32
+ # and `additionalContext` is APPENDED into the conversation. So the block is
33
+ # re-paid every turn and accumulates — roughly (block size) x (turn count) over
34
+ # a session, not once.
35
+ #
36
+ # Measured live 2026-07-25 (fleet REST `/directives`):
37
+ # overlord 12 active ~9.8 KB ~816 chars avg (one 2,981-char outlier)
38
+ # klanker 17 active ~9.9 KB ~585 chars avg
39
+ # gymbro 9 active ~9.3 KB ~1038 chars avg
40
+ # At the ~700-char fleet average, a bank sitting at this cap injects ~21 KB —
41
+ # on the order of 5,000-6,000 tokens per injection, per turn, cumulative across
42
+ # the session. 30 is chosen to clear the observed fleet maximum with headroom;
43
+ # it is NOT a size at which a runaway bank becomes harmless. The actual defence
44
+ # against pile-up is the doctor's WARN/FAIL on the active directive count
45
+ # (src/cli/doctor-memory.ts), not this cap. Raising it further is a legitimate
46
+ # call — make it deliberately, with the per-turn-times-turns cost in mind, and
47
+ # move the doctor thresholds with it.
48
+ #
49
+ # Banks with more active directives than this are pathological; we truncate
50
+ # with an in-prompt footer, a `directives_omitted` field on the recall_log row,
51
+ # and a stderr warning (see `format_active_directives_block`).
52
+ MAX_DIRECTIVES = 30
30
53
 
31
54
  # Hard timeout for the list_directives call. The recall hook is on the
32
55
  # UserPromptSubmit critical path — we cannot block it for long.
@@ -208,6 +231,16 @@ def invalidate_directives_cache(bank_id: Optional[str] = None) -> None:
208
231
  remove_state(name)
209
232
 
210
233
 
234
+ def count_omitted_directives(directives: list, max_directives: int = MAX_DIRECTIVES) -> int:
235
+ """How many directives `format_active_directives_block` would DROP.
236
+
237
+ Pure counterpart of the truncation branch below, so `recall.py` can put the
238
+ number on the recall_log row without re-deriving the cap. 0 when nothing is
239
+ dropped.
240
+ """
241
+ return max(0, len(directives) - max_directives)
242
+
243
+
211
244
  def format_active_directives_block(directives: list, max_directives: int = MAX_DIRECTIVES) -> Optional[str]:
212
245
  """Format directives into the <active_directives> block string.
213
246
 
@@ -252,6 +285,31 @@ def format_active_directives_block(directives: list, max_directives: int = MAX_D
252
285
  if omitted > 0:
253
286
  lines.append("")
254
287
  lines.append(f"(+{omitted} more, omitted)")
288
+ # The in-prompt footer above only tells the AGENT. This stderr warn is
289
+ # the same channel every other operational failure in this module uses
290
+ # (see `_fetch_directives_with_status`), and it is a LAST-RESORT
291
+ # breadcrumb only — do NOT rely on it reaching an operator.
292
+ #
293
+ # Measured 2026-07-25: `docker logs --tail 20000` across all 12 running
294
+ # agent containers returns ZERO `[Hindsight]` lines, and nothing under
295
+ # ~/.switchroom/logs/ contains them either, despite months of runtime
296
+ # and several long-standing stderr paths in recall.py. Claude Code
297
+ # appears to swallow hook stderr on a zero exit, so hook stderr is not
298
+ # an operator-visible channel.
299
+ #
300
+ # The channels that DO reach an operator:
301
+ # * the `directives_omitted` field on the recall_log row
302
+ # (state/recall_log.jsonl — see `count_omitted_directives`), and
303
+ # * `switchroom doctor`'s WARN/FAIL on the bank's active directive
304
+ # count (src/cli/doctor-memory.ts `classifyDirectiveCount`), which
305
+ # reads the count from the same REST surface this module fetches.
306
+ print(
307
+ f"[Hindsight] directive truncation: {total} active directives exceeds "
308
+ f"MAX_DIRECTIVES={max_directives} — {omitted} lowest-priority "
309
+ f"directive(s) were DROPPED from this turn's prompt. Merge or retire "
310
+ f"directives (mental-model-curator) or raise MAX_DIRECTIVES.",
311
+ file=sys.stderr,
312
+ )
255
313
 
256
314
  lines.append("</active_directives>")
257
315
  return "\n".join(lines)
@@ -65,6 +65,7 @@ from lib.content import (
65
65
  from lib.daemon import get_api_url
66
66
  from lib.directives import (
67
67
  DIRECTIVES_CACHE_TTL_SECONDS,
68
+ count_omitted_directives,
68
69
  fetch_active_directives_cached,
69
70
  format_active_directives_block,
70
71
  )
@@ -405,11 +406,124 @@ def _is_demoted_memory(memory) -> bool:
405
406
  # regardless of which bank they came from. This gate is a *complementary*,
406
407
  # opt-in absolute precision floor: `scores.final` is a relative rank that
407
408
  # still orders weakly-matching memories rather than excluding them, so on a
408
- # low-relevance prompt the top-N could still be low-signal. The Jaccard
409
+ # low-relevance prompt the top-N could still be low-signal. The lexical
409
410
  # overlap between the user's query terms and each memory's text terms is a
410
- # query-independent absolute measure that drops memories below a
411
+ # rank-independent absolute measure that drops memories below a
411
412
  # configurable threshold outright — something the relative sort does not do.
412
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.)
461
+ #
462
+ # --- what the 0.10 threshold does, and does not, buy ---
463
+ #
464
+ # Be honest about the retained default: at 0.10, containment is close to a
465
+ # PASSTHROUGH. Because the denominator is the memory's own token count, a
466
+ # memory of <= 10 content tokens clears 0.10 on a SINGLE shared word. Only
467
+ # longer memories are meaningfully filtered (a 40-token memory still needs
468
+ # 4 shared terms). Measured on the production-shaped prompt used in the
469
+ # regression tests, a wholly off-topic memory that merely reuses words
470
+ # from the prior-context preamble scores 0.857 — HIGHER than the genuinely
471
+ # relevant memory at 0.571.
472
+ #
473
+ # That inversion is the reason the threshold is NOT being raised to
474
+ # compensate. Raising it would drop the relevant memory before the
475
+ # off-topic one, because on a preamble-heavy prompt containment is not
476
+ # monotone in relevance. Lexical overlap cannot make that distinction at
477
+ # any threshold; the engine's reranker can, and does.
478
+ #
479
+ # The threshold was nevertheless re-derived from data rather than inherited
480
+ # from the Jaccard era. 31 real production queries (reconstructed from
481
+ # transcripts with the hook's own compose+truncate, validated by matching
482
+ # 320 of them verbatim against logged recall rows) were replayed against
483
+ # the live engine; the 202 returned candidates were rescored offline:
484
+ #
485
+ # containment survival% zero-result% mean kept/query
486
+ # 0.05 99.5% 0.0% 6.5
487
+ # 0.10 86.1% 6.5% 5.6 <- shipped
488
+ # 0.20 65.8% 41.9% 4.3
489
+ # 0.30 58.4% 41.9% 3.8
490
+ # 0.40 52.5% 54.8% 3.4
491
+ #
492
+ # (Jaccard @0.10 on the same candidates: 70.3% survival, 32.3%
493
+ # zero-result — i.e. the fix cuts empty recalls from ~1 turn in 3 to
494
+ # ~1 in 15 on this sample.)
495
+ #
496
+ # 0.30 and 0.40 leave 42-55% of turns with NO memories at all — they
497
+ # re-create the exact failure #3541 is about, for a precision gain the
498
+ # inversion above says is illusory. 0.10 is the shipped value.
499
+ #
500
+ # Sample caveat, stated plainly: these are single-bank (overlord) replays
501
+ # and the engine returned ~6.5 already-top-ranked candidates per query,
502
+ # not the ~35-candidate pools seen in the fleet logs. The ABSOLUTE
503
+ # survival percentages are therefore optimistic versus production; the
504
+ # RANKING of the thresholds, and the zero-result cliff above 0.10, are the
505
+ # load-bearing results.
506
+ #
507
+ # So the honest statement of the design after #3541 is: the effective
508
+ # precision control is the engine rerank plus the `recallMaxMemories`
509
+ # head-slice, and this gate is a cheap floor that removes only candidates
510
+ # with (near-)zero lexical relationship to the prompt. It is no longer
511
+ # doing the job the #475 note above describes — "on a low-relevance prompt
512
+ # the top-N could still be low-signal" is a real concern that this gate
513
+ # does not actually address. Doing so needs a relevance-score floor
514
+ # (`scores.final`), not a lexical one; that is deliberately out of scope
515
+ # here and wants its own measured change.
516
+ #
517
+ # This is safe to run permissively: the gate is a FLOOR, not a ranker.
518
+ # `_sort_by_final_score` orders the survivors by the engine's reranked
519
+ # relevance score immediately afterwards, and only then does
520
+ # `recallMaxMemories` head-slice. So admitting more candidates cannot
521
+ # lower the quality of what is injected — it can only give the
522
+ # score-sort a non-empty set to choose the top-N from. And because
523
+ # `|Q ∩ M| / |M| >= |Q ∩ M| / |Q ∪ M|` always, at a fixed threshold this
524
+ # metric admits a superset of what the deployed Jaccard gate admits: no
525
+ # memory that survives in production today can be dropped by this change.
526
+ #
413
527
  # Threshold default is 0.0 (disabled) so the gate is opt-in initially.
414
528
  # Operators tune via `memory.recall.min_overlap` in switchroom.yaml or
415
529
  # `HINDSIGHT_RECALL_MIN_OVERLAP=0.15` env. Telemetry surfaces the dropped
@@ -465,22 +579,60 @@ def _overlap_tokens(text) -> set:
465
579
  return out
466
580
 
467
581
 
468
- def jaccard_overlap(query: str, memory_text: str) -> float:
469
- """Jaccard similarity between two texts, after stop-word + punctuation
470
- stripping. Returns a float in [0.0, 1.0]. Empty/degenerate inputs
471
- return 0.0 — it's safer to drop than retain when we can't compute.
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.
472
626
  """
473
627
  a = _overlap_tokens(query)
474
628
  b = _overlap_tokens(memory_text)
475
629
  if not a or not b:
476
630
  return 0.0
477
- inter = len(a & b)
478
- union = len(a | b)
479
- return inter / union if union else 0.0
631
+ return len(a & b) / len(b)
480
632
 
481
633
 
482
634
  def _filter_by_overlap(results, query: str, threshold: float):
483
- """Drop memories whose Jaccard overlap with the query is below the
635
+ """Drop memories whose containment overlap with the query is below the
484
636
  threshold. Threshold <= 0 short-circuits to passthrough (no
485
637
  iteration cost).
486
638
 
@@ -492,7 +644,7 @@ def _filter_by_overlap(results, query: str, threshold: float):
492
644
  dropped = 0
493
645
  for m in results:
494
646
  text = m.get("text", "") if isinstance(m, dict) else ""
495
- if jaccard_overlap(query, text) >= threshold:
647
+ if containment_overlap(query, text) >= threshold:
496
648
  kept.append(m)
497
649
  else:
498
650
  dropped += 1
@@ -517,6 +669,74 @@ def _result_final_score(m) -> float:
517
669
  return float("-inf")
518
670
 
519
671
 
672
+ def _injected_score_stats(results) -> dict:
673
+ """Relevance-score aggregates for the INJECTED set (post-head-slice).
674
+
675
+ Switchroom #3541 review finding — recall-quality telemetry.
676
+ `recall_log.jsonl` records volume and plumbing only (`overlap_dropped`,
677
+ `capped`, `pre_cap_count`, `memory_ids`, `deadline_hit`). With the
678
+ overlap gate deliberately near-passthrough at 0.10, 100% of precision now
679
+ rests on the engine's `scores.final` plus the `recallMaxMemories`
680
+ head-slice — and no field observes that. Post-rollout, `overlap_dropped`
681
+ collapsing to 0 while `result_count` rises to the cap reads as
682
+ unambiguous success on every existing dashboard whether the reranker is
683
+ good OR whether every agent is being fed 8 mediocre memories per turn.
684
+ These three fields are what distinguishes those two worlds.
685
+
686
+ Returns ``{"injected_score_min", "injected_score_median",
687
+ "injected_score_max"}``. Values are floats rounded to 4dp, or None when
688
+ the set is empty or no result carried a usable score (results missing
689
+ `scores.final` are excluded rather than counted as a sentinel, so a
690
+ single malformed entry cannot drag the aggregate).
691
+
692
+ Aggregates ONLY — no query text and no memory text is recorded here.
693
+
694
+ READING THESE ACROSS THE CE-DAMPING ROLLOUT (#3579). `scores.final` is the
695
+ engine's `combined_score`, and #3579 changes how that number is composed:
696
+ ``CE * boost`` becomes ``CE * boost**k`` with k ≈ 0.0395 at the engine's
697
+ default alphas. So these three fields SHIFT ON DEPLOY as a scale artifact,
698
+ not as a quality change. Measured against the pinned upstream image on a
699
+ saturated 100-result band (CE 0.9800-0.9999), the injected top-8 moved from
700
+ min/median/max 1.0822/1.0843/1.1136 (spread 0.0314) to 0.9977/0.9979/0.9981
701
+ (spread 0.0004): the level drops ~8% and the spread collapses ~78x as
702
+ combined_score converges onto the raw cross-encoder score. Two consequences:
703
+
704
+ * Any threshold or dashboard band calibrated on pre-#3579 data is invalid
705
+ afterwards, and a before/after comparison across the deploy boundary
706
+ measures the rescale, not recall quality. Re-baseline after rollout.
707
+ * These aggregates are PERMUTATION-INVARIANT over the injected set, so
708
+ they cannot see a pure re-ordering of the head-slice - which is exactly
709
+ what #3579 does. They move only when the head-slice MEMBERSHIP changes
710
+ (in the measured band it changed completely: 0 of 8 ids in common).
711
+ Membership churn is therefore the signal to watch, not the level.
712
+
713
+ Setting `HINDSIGHT_CE_DECISIVE_RELATIVE_GAP` at or above ~0.651 clamps k to
714
+ 1.0 and restores the pre-#3579 scale exactly, which is also how to get a
715
+ like-for-like reading back.
716
+ """
717
+ empty = {
718
+ "injected_score_min": None,
719
+ "injected_score_median": None,
720
+ "injected_score_max": None,
721
+ }
722
+ try:
723
+ scores = [s for s in (_result_final_score(m) for m in results or []) if s != float("-inf")]
724
+ if not scores:
725
+ return empty
726
+ scores.sort()
727
+ n = len(scores)
728
+ mid = n // 2
729
+ median = scores[mid] if n % 2 else (scores[mid - 1] + scores[mid]) / 2.0
730
+ return {
731
+ "injected_score_min": round(scores[0], 4),
732
+ "injected_score_median": round(median, 4),
733
+ "injected_score_max": round(scores[-1], 4),
734
+ }
735
+ except Exception:
736
+ # Telemetry must never take recall down.
737
+ return empty
738
+
739
+
520
740
  def _sort_by_final_score(results):
521
741
  """Sort merged multi-bank results by `scores.final` descending, in place.
522
742
 
@@ -1279,8 +1499,16 @@ def main():
1279
1499
  "query": None, # no recall query composed on a cache hit
1280
1500
  "result_count": None, # not known on cache hit
1281
1501
  "directive_count": None,
1502
+ # No directives block is built on a cache hit.
1503
+ "directives_omitted": None,
1282
1504
  "demoted_count": 0,
1283
1505
  "capped": False,
1506
+ # #3541 quality telemetry — present for a uniformly queryable
1507
+ # schema. A cache hit replays a formatted context block, not a
1508
+ # result set, so no per-memory scores exist to aggregate.
1509
+ "injected_score_min": None,
1510
+ "injected_score_median": None,
1511
+ "injected_score_max": None,
1284
1512
  "cache_hit": True,
1285
1513
  # A3 stage-1 telemetry keys kept present for a uniformly
1286
1514
  # queryable schema; a cache hit issues no bank HTTP, so there
@@ -1631,9 +1859,12 @@ def main():
1631
1859
  )
1632
1860
 
1633
1861
  # Switchroom #475 — lexical-overlap relevance gate. Drops memories
1634
- # whose Jaccard overlap with the query is below
1862
+ # whose containment overlap with the query is below
1635
1863
  # `recallMinOverlap` (default 0.0 = disabled). Runs after the
1636
1864
  # demote filter so the threshold sees the operator-curated set.
1865
+ # #3541: the metric is containment, NOT Jaccard — see the design
1866
+ # note above `_OVERLAP_STOPWORDS`. Jaccard made the gate a function
1867
+ # of prompt length and it discarded 97.1% of reranked candidates.
1637
1868
  overlap_threshold = config.get("recallMinOverlap", 0.0)
1638
1869
  if isinstance(overlap_threshold, (int, float)) and overlap_threshold > 0:
1639
1870
  pre_overlap_count = len(results)
@@ -1812,6 +2043,14 @@ def main():
1812
2043
  "query": query[:200],
1813
2044
  "result_count": len(results),
1814
2045
  "directive_count": len(directives),
2046
+ # Switchroom 2026-07-25 review finding 2 — how many active directives
2047
+ # MAX_DIRECTIVES dropped from this turn's <active_directives> block.
2048
+ # The stderr warning in lib/directives.py is NOT operator-visible
2049
+ # (Claude Code swallows hook stderr on a zero exit — verified fleet-wide),
2050
+ # so this row is the durable, queryable record that real hard rules
2051
+ # never reached the agent. >0 here means the bank is over cap and the
2052
+ # doctor's directive-count check will be FAILing too.
2053
+ "directives_omitted": count_omitted_directives(directives),
1815
2054
  "demoted_count": demoted_count,
1816
2055
  "overlap_dropped": overlap_dropped,
1817
2056
  "capped": capped,
@@ -1820,6 +2059,12 @@ def main():
1820
2059
  m.get("id") for m in results
1821
2060
  if isinstance(m, dict) and m.get("id")
1822
2061
  ],
2062
+ # Switchroom #3541 — recall QUALITY telemetry, alongside the volume
2063
+ # fields above. min/median/max of `scores.final` over the injected
2064
+ # (post-head-slice) set. See `_injected_score_stats` for why volume
2065
+ # alone can't distinguish "reranker is working" from "8 mediocre
2066
+ # memories per turn" now that the overlap gate is near-passthrough.
2067
+ **_injected_score_stats(results),
1823
2068
  "cache_hit": False,
1824
2069
  # Switchroom A3 stage-1 telemetry (hindsight-leverage PR 1) — per-bank
1825
2070
  # latency + timeout breakdown, directives-fetch latency, total
@@ -117,8 +117,9 @@ def select_retain_window(
117
117
  SWITCHROOM DIVERGENCE (Phase 6b — candidate to upstream to
118
118
  vectorize-io/hindsight): the chunked sliding-window is decoupled from
119
119
  the ``retainEveryNTurns > 1`` throttle. Upstream only sliced a window
120
- when ``retain_every_n > 1``; with ``retainEveryNTurns=1`` (switchroom's
121
- every-turn crash-durability setting, applied in scaffold.ts) chunked
120
+ when ``retain_every_n > 1``; at ``retainEveryNTurns=1`` (switchroom's
121
+ historical every-turn crash-durability setting; the current scaffold.ts
122
+ default is 3) chunked
122
123
  mode fell through to full-session and re-consolidated the ENTIRE
123
124
  accumulated transcript on every Stop fire — an unbounded, per-turn cost.
124
125
 
@@ -127,9 +128,11 @@ def select_retain_window(
127
128
  (still owned by run_retain, unchanged); this function only decides *what*
128
129
  to retain once a fire happens. A chunked window of
129
130
  ``max(retain_every_n, 1) + overlap_turns`` turns is correct for any
130
- ``retain_every_n >= 1``. With ``retain_every_n=1, overlap=2`` the window
131
- is the 3 most-recent HUMAN turns (tool_result messages don't count as
132
- turns — see slice_last_turns_by_user_boundary).
131
+ ``retain_every_n >= 1``. At the current switchroom defaults
132
+ (``retain_every_n=3, overlap=1``) the window is the 4 most-recent HUMAN
133
+ turns; at ``retain_every_n=1, overlap=2`` it is the 3 most-recent
134
+ (tool_result messages don't count as turns — see
135
+ slice_last_turns_by_user_boundary).
133
136
 
134
137
  ``force=True`` (SessionEnd final retain) widens chunked mode to a
135
138
  full-session sweep — belt-and-braces so a graceful shutdown always flushes
@@ -429,7 +432,10 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
429
432
  debug_log(config, f"Read {len(all_messages)} messages from transcript")
430
433
 
431
434
  # Retention mode: full session (vendor default) or chunked. Switchroom
432
- # runs chunked at retainEveryNTurns=1 (see select_retain_window / scaffold.ts).
435
+ # overrides to chunked in scaffold.ts, at the cascaded
436
+ # memory.retain.every_n_turns (default 3) / .overlap_turns (default 1) —
437
+ # a fire every 3rd turn over a ~4-turn window. See select_retain_window
438
+ # and scaffold.ts (HINDSIGHT_DEFAULT_RETAIN_*).
433
439
  retain_mode = config.get("retainMode", "full-session")
434
440
  retain_every_n = max(1, config.get("retainEveryNTurns", 1))
435
441
  retain_full_window = False
@@ -24,6 +24,7 @@ from lib.directives import ( # noqa: E402
24
24
  DIRECTIVES_CACHE_TTL_SECONDS,
25
25
  MAX_DIRECTIVES,
26
26
  _cache_name,
27
+ count_omitted_directives,
27
28
  fetch_active_directives,
28
29
  fetch_active_directives_cached,
29
30
  format_active_directives_block,
@@ -181,20 +182,89 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
181
182
  self.assertIn("Line one.\nLine two.\nLine three.", out)
182
183
 
183
184
  def test_truncates_at_cap_with_footer(self):
184
- # 20 synthetic directives — should truncate to MAX_DIRECTIVES with
185
- # a "(+N more, omitted)" footer.
185
+ # MAX_DIRECTIVES + 5 synthetic directives — should truncate to
186
+ # MAX_DIRECTIVES with a "(+N more, omitted)" footer.
187
+ total = MAX_DIRECTIVES + 5
186
188
  directives = [
187
- _directive(f"d{i}", f"content {i}", priority=20 - i) for i in range(20)
189
+ _directive(f"d{i}", f"content {i}", priority=total - i) for i in range(total)
188
190
  ]
189
191
  out = format_active_directives_block(directives)
190
- # Cap should be 15 by default.
191
- self.assertEqual(MAX_DIRECTIVES, 15)
192
- self.assertIn("1. [P20] d0", out)
192
+ self.assertIn(f"1. [P{total}] d0", out)
193
193
  self.assertIn(f"{MAX_DIRECTIVES}. [P", out)
194
- # 16th item should NOT appear.
194
+ # The first item past the cap must NOT appear.
195
195
  self.assertNotIn(f"{MAX_DIRECTIVES + 1}. [P", out)
196
196
  # Footer with the right omitted count.
197
- self.assertIn(f"(+{20 - MAX_DIRECTIVES} more, omitted)", out)
197
+ self.assertIn("(+5 more, omitted)", out)
198
+
199
+ def test_cap_is_30_and_clears_the_observed_fleet_maximum(self):
200
+ """The cap must clear the busiest real bank (24 active directives).
201
+
202
+ Regression: at MAX_DIRECTIVES=15 a 24-directive bank had 9 of its
203
+ rules dropped from every turn's prompt. This asserts the OUTCOME —
204
+ all 24 directives are rendered, and no truncation footer/warning is
205
+ produced — not merely that the constant changed.
206
+ """
207
+ self.assertEqual(MAX_DIRECTIVES, 30)
208
+ directives = [
209
+ _directive(f"d{i}", f"content {i}", priority=24 - i) for i in range(24)
210
+ ]
211
+ with patch("sys.stderr", new=StringIO()) as fake_err:
212
+ out = format_active_directives_block(directives)
213
+ for i in range(24):
214
+ self.assertIn(f"d{i}: content {i}", out)
215
+ self.assertIn("24. [P", out)
216
+ self.assertNotIn("more, omitted", out)
217
+ self.assertEqual(fake_err.getvalue(), "")
218
+
219
+ def test_truncation_emits_a_stderr_breadcrumb_naming_the_dropped_count(self):
220
+ """Truncation writes a `[Hindsight]` line to stderr naming total, cap
221
+ and dropped count, alongside the in-prompt "(+N more, omitted)" footer.
222
+
223
+ Scope note (2026-07-25 review finding 2): this asserts ONLY that the
224
+ line is written to stderr. It does NOT — and cannot — show that an
225
+ operator ever sees it; hook stderr is swallowed by Claude Code on a
226
+ zero exit (zero `[Hindsight]` lines across all 12 live containers).
227
+ The operator-visible signals are the `directives_omitted` recall_log
228
+ field and `switchroom doctor`'s directive-count row, tested elsewhere.
229
+ """
230
+ total = MAX_DIRECTIVES + 4
231
+ directives = [
232
+ _directive(f"d{i}", f"c{i}", priority=total - i) for i in range(total)
233
+ ]
234
+ with patch("sys.stderr", new=StringIO()) as fake_err:
235
+ out = format_active_directives_block(directives)
236
+ err = fake_err.getvalue()
237
+ self.assertNotEqual(err, "", "truncation must write a stderr breadcrumb")
238
+ self.assertIn("[Hindsight]", err)
239
+ self.assertIn(str(total), err)
240
+ self.assertIn(f"MAX_DIRECTIVES={MAX_DIRECTIVES}", err)
241
+ self.assertIn("4", err) # the dropped count
242
+ # The in-prompt footer is still emitted (agent-facing signal).
243
+ self.assertIn("(+4 more, omitted)", out)
244
+
245
+ def test_count_omitted_directives_matches_the_rendered_footer(self):
246
+ """The recall_log's `directives_omitted` number must equal what the
247
+ block actually dropped — it is the operator-visible record of it."""
248
+ total = MAX_DIRECTIVES + 4
249
+ directives = [
250
+ _directive(f"d{i}", f"c{i}", priority=total - i) for i in range(total)
251
+ ]
252
+ with patch("sys.stderr", new=StringIO()):
253
+ out = format_active_directives_block(directives)
254
+ self.assertEqual(count_omitted_directives(directives), 4)
255
+ self.assertIn("(+4 more, omitted)", out)
256
+ # Under cap → nothing omitted, and no footer to disagree with.
257
+ under = directives[:2]
258
+ self.assertEqual(count_omitted_directives(under), 0)
259
+ self.assertNotIn("more, omitted", format_active_directives_block(under))
260
+ # Honours a custom cap the same way the formatter does.
261
+ self.assertEqual(count_omitted_directives(directives, max_directives=5), total - 5)
262
+
263
+ def test_no_warning_when_nothing_is_truncated(self):
264
+ directives = [_directive("only", "single", priority=5)]
265
+ with patch("sys.stderr", new=StringIO()) as fake_err:
266
+ format_active_directives_block(directives)
267
+ self.assertEqual(fake_err.getvalue(), "")
198
268
 
199
269
  def test_no_footer_when_under_cap(self):
200
270
  directives = [_directive("only", "single", priority=5)]
@@ -208,7 +278,8 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
208
278
 
209
279
  def test_custom_cap_respected(self):
210
280
  directives = [_directive(f"d{i}", f"c{i}", priority=10) for i in range(5)]
211
- out = format_active_directives_block(directives, max_directives=2)
281
+ with patch("sys.stderr", new=StringIO()):
282
+ out = format_active_directives_block(directives, max_directives=2)
212
283
  self.assertIn("1. [P10] d0", out)
213
284
  self.assertIn("2. [P10] d1", out)
214
285
  self.assertNotIn("3. [P10] d2", out)