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.
- package/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8307 -7620
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- 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.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
469
|
-
"""
|
|
470
|
-
stripping
|
|
471
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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``;
|
|
121
|
-
every-turn crash-durability setting
|
|
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``.
|
|
131
|
-
is the
|
|
132
|
-
turns
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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=
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
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
|
-
|
|
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)
|