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.
Files changed (59) hide show
  1. package/dist/agent-scheduler/index.js +20 -7
  2. package/dist/auth-broker/index.js +93 -28
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +20 -6
  7. package/dist/cli/switchroom.js +3091 -1435
  8. package/dist/host-control/main.js +92 -29
  9. package/dist/vault/approvals/kernel-server.js +92 -28
  10. package/dist/vault/broker/server.js +258 -71
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/cron-session.sh.hbs +6 -0
  14. package/profiles/_base/start.sh.hbs +92 -17
  15. package/skills/switchroom-health/SKILL.md +19 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  18. package/telegram-plugin/dist/gateway/gateway.js +6981 -6747
  19. package/telegram-plugin/gateway/gateway.ts +53 -52
  20. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  21. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  22. package/telegram-plugin/quota-bar-format.ts +4 -1
  23. package/telegram-plugin/status-no-truncate.ts +49 -0
  24. package/telegram-plugin/status-pin-driver.ts +28 -0
  25. package/telegram-plugin/status-pin.ts +33 -4
  26. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  27. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  28. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  29. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  30. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  31. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  32. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  33. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  34. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  35. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  36. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  37. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  38. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  39. package/telegram-plugin/tier-downgrade.ts +3 -2
  40. package/telegram-plugin/tool-activity-summary.ts +61 -18
  41. package/telegram-plugin/uat/assertions.ts +21 -2
  42. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  43. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  44. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  45. package/telegram-plugin/worker-activity-feed.ts +38 -17
  46. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  47. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  48. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  49. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  59. package/vendor/hindsight-memory/tests/test_content.py +218 -0
@@ -0,0 +1,473 @@
1
+ """Switchroom #3757 — the recall hook must not put an unbounded BM25 query on
2
+ the wire, and the per-bank timeout must be configurable.
3
+
4
+ The bug: recall.py composed a query from the last 2 turns (up to
5
+ ``recallMaxQueryChars`` = 800 chars, ~110 tokens) and Hindsight OR-joined every
6
+ token into one ``to_tsquery``. Postgres native FTS cannot top-k from the GIN
7
+ index, so it ranked the whole matched set before the top-60 heapsort. Measured
8
+ on the live ``overlord`` bank (135,565 memory_units, 3 fact-type arms):
9
+
10
+ as shipped 96 terms 119,510 rows ranked 14.0 s
11
+ role labels/header removed 93 terms 86,653 rows ranked 11.8 s
12
+ + capped to 24 terms 24 terms 48,433 rows ranked 2.7 s
13
+
14
+ (`exec` is the best of three EXPLAIN ANALYZE runs on a live, loaded host; the
15
+ unshaped query ranged 14.0-94.1 s across those runs while the shaped one held
16
+ 2.54-2.76 s, so the cap removes the variance as well as the mean.)
17
+
18
+ Two of the tokens were scaffolding the hook added itself — ``user`` matched
19
+ 67,363 rows (50% of the bank) and ``assistant`` 29,942 (22%). The 8s hardcoded
20
+ client timeout then fired on 96.8% of that agent's own-bank recalls, so the
21
+ model got zero memories on ~3 turns in 4.
22
+
23
+ Acceptance guarantees (outcomes, not code paths):
24
+
25
+ 1. **The query on the wire is term-capped.** Whatever the composed query, the
26
+ string passed to ``client.recall`` tokenizes to at most
27
+ ``recallQueryMaxTokens`` distinct BM25 terms.
28
+
29
+ 2. **Role labels never reach the wire.** ``user`` / ``assistant`` are absent
30
+ from the wire query's tokens even when the composed context contains both
31
+ roles.
32
+
33
+ 3. **The cap is configurable, not hardcoded** — including ``0`` to disable
34
+ shaping entirely (the rollback lever).
35
+
36
+ 4. **The per-bank timeout is configurable** and defaults to 12s, not the old
37
+ hardcoded 8.
38
+
39
+ 5. **Shaping does not leak into the client-side lexical gate.** The
40
+ ``recallMinOverlap`` containment gate keeps measuring against the user's
41
+ real words, so this change cannot silently move an operator's threshold.
42
+
43
+ Stdlib-only (unittest); runs under ``python3 -m unittest discover tests/``
44
+ from ``scripts/``.
45
+ """
46
+
47
+ import io
48
+ import json
49
+ import os
50
+ import shutil
51
+ import sys
52
+ import tempfile
53
+ import unittest
54
+ from unittest.mock import patch
55
+
56
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
57
+ if SCRIPTS_DIR not in sys.path:
58
+ sys.path.insert(0, SCRIPTS_DIR)
59
+
60
+ import recall # noqa: E402
61
+ from lib.content import ( # noqa: E402
62
+ BM25_STOPWORDS,
63
+ _selectivity_score,
64
+ common_english_words,
65
+ tokenize_for_bm25,
66
+ )
67
+
68
+ # A prior turn plus a latest turn, together well past the term budget. Written
69
+ # as real prose because the point is a production-shaped query, not a synthetic
70
+ # token soup.
71
+ PRIOR_USER = (
72
+ "the v0.19.24 rollout went out this morning and the reaper never swept the "
73
+ "orphaned worktrees on the build host, which left eleven stale claims behind"
74
+ )
75
+ PRIOR_ASSISTANT = (
76
+ "I compared the published manifest digest against what the agent container "
77
+ "actually pulled and they diverge, so the restart raced the tag and the "
78
+ "container is running the previous image entirely"
79
+ )
80
+ LATEST = (
81
+ "why did recall for the v0.19.24 rollout return v0.18.15 instead of the "
82
+ "release notes from this morning"
83
+ )
84
+
85
+
86
+ def _nested_line(role, text):
87
+ return json.dumps({
88
+ "type": role,
89
+ "uuid": f"u-{abs(hash((role, text))) % 10_000_000}",
90
+ "message": {"role": role, "content": text},
91
+ })
92
+
93
+
94
+ class _Client:
95
+ """Fake HindsightClient that records exactly what went on the wire."""
96
+
97
+ def __init__(self, results=None):
98
+ self._results = results or []
99
+ self.calls = []
100
+
101
+ def list_directives(self, bank_id, active_only=True, timeout=2):
102
+ return {"items": []}
103
+
104
+ def recall(self, bank_id, query, **kwargs):
105
+ self.calls.append({"bank_id": bank_id, "query": query, **kwargs})
106
+ return {"results": [dict(r) for r in self._results]}
107
+
108
+
109
+ class _Harness(unittest.TestCase):
110
+ def setUp(self):
111
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-shaping-test-")
112
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
113
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
114
+
115
+ def tearDown(self):
116
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
117
+ if self._prev is None:
118
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
119
+ else:
120
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
121
+
122
+ def _write_transcript(self):
123
+ path = os.path.join(self._tmpdir, "transcript.jsonl")
124
+ with open(path, "w", encoding="utf-8") as f:
125
+ f.write("\n".join([
126
+ _nested_line("user", PRIOR_USER),
127
+ _nested_line("assistant", PRIOR_ASSISTANT),
128
+ _nested_line("user", LATEST),
129
+ ]) + "\n")
130
+ return path
131
+
132
+ def _run(self, client, config_extra=None, prompt=LATEST, with_transcript=True):
133
+ hook_input = {
134
+ "prompt": prompt,
135
+ "session_id": "test-session",
136
+ "transcript_path": self._write_transcript() if with_transcript else "",
137
+ "cwd": "/tmp",
138
+ }
139
+ config = {
140
+ "autoRecall": True,
141
+ "bankId": "own-bank",
142
+ "recallMaxTokens": 1024,
143
+ "recallBudget": "low",
144
+ # 2 turns is the fleet default and the shape that produced the bug.
145
+ "recallContextTurns": 2,
146
+ "recallMaxQueryChars": 800,
147
+ "recallPromptPreamble": "",
148
+ "recallParallelDeadlineSeconds": 5,
149
+ "recallTranscriptFallback": False,
150
+ }
151
+ if config_extra:
152
+ config.update(config_extra)
153
+ stdout, stderr = io.StringIO(), io.StringIO()
154
+ with patch.object(recall, "load_config", return_value=config), patch.object(
155
+ recall, "get_api_url", return_value="http://localhost:18888"
156
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
157
+ recall, "ensure_bank_mission", return_value=None
158
+ ), patch.object(recall, "write_state", return_value=None), patch(
159
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
160
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
161
+ recall.main()
162
+ raw = stdout.getvalue()
163
+ context = None
164
+ if raw.strip():
165
+ context = json.loads(raw)["hookSpecificOutput"]["additionalContext"]
166
+ return context
167
+
168
+ def _wire_terms(self, client):
169
+ self.assertTrue(client.calls, "recall never reached the client")
170
+ return set(tokenize_for_bm25(client.calls[0]["query"]))
171
+
172
+
173
+ class WireQueryIsTermCapped(_Harness):
174
+ def test_defaults_to_24_terms(self):
175
+ client = _Client()
176
+ self._run(client)
177
+ self.assertLessEqual(len(self._wire_terms(client)), 24)
178
+
179
+ def test_cap_is_configurable(self):
180
+ for cap in (6, 12, 40):
181
+ client = _Client()
182
+ self._run(client, config_extra={"recallQueryMaxTokens": cap})
183
+ self.assertLessEqual(
184
+ len(self._wire_terms(client)), cap, f"cap={cap} not honoured"
185
+ )
186
+
187
+ def test_zero_disables_shaping(self):
188
+ client = _Client()
189
+ self._run(client, config_extra={"recallQueryMaxTokens": 0})
190
+ # Rollback lever: the full composed query goes out unshaped.
191
+ self.assertGreater(len(self._wire_terms(client)), 24)
192
+
193
+ def test_latest_turn_survives_the_cap(self):
194
+ # The cap must not cost the user the question they just asked.
195
+ #
196
+ # #3764: this deliberately asserts on ORDINARY English words from the
197
+ # latest turn, not on `v0.19.24`. A version string carries a digit AND
198
+ # is a compound, so it scores 6.0 before recency is considered and
199
+ # survives under every candidate ordering — asserting on it cannot fail
200
+ # on a recency regression. `recall` / `release` / `notes` score 0.0 on
201
+ # shape and are outnumbered by prior-context terms, so they are in the
202
+ # query only because recency is weighted.
203
+ client = _Client()
204
+ self._run(client, config_extra={"recallQueryMaxTokens": 24})
205
+ terms = self._wire_terms(client)
206
+ for word in ("recall", "release", "notes"):
207
+ self.assertIn(word, terms, f"latest-turn term {word!r} lost to prior context")
208
+
209
+
210
+ class RecencyIsAWeightNotATier(_Harness):
211
+ """#3760 review, Blocker 2. Making recency an ABSOLUTE tier meant any latest
212
+ turn with >= max_tokens surviving terms took every slot, so a conversational
213
+ follow-up whose subject lives only in the prior turn produced a query with
214
+ no subject in it at all — a query about nothing, which BM25-matches a broad
215
+ near-random slice of the bank and embeds to a near-meaningless vector."""
216
+
217
+ # The reviewer's exact reproduction.
218
+ SUBJECT_PRIOR = (
219
+ "We were debugging the Coolify deploy for the webkite container and the "
220
+ "nginx TLS cert."
221
+ )
222
+ SUBJECT_LATEST = (
223
+ "Right, so continuing from where we left off, could you please have another "
224
+ "careful look and tell me whether the thing we were discussing previously is "
225
+ "actually still broken, because honestly the whole situation seems rather "
226
+ "confusing and I would really appreciate a clear explanation of what exactly "
227
+ "is happening underneath and whether anything changed recently."
228
+ )
229
+
230
+ def _write_transcript(self):
231
+ path = os.path.join(self._tmpdir, "transcript.jsonl")
232
+ with open(path, "w", encoding="utf-8") as f:
233
+ f.write("\n".join([
234
+ _nested_line("user", self.SUBJECT_PRIOR),
235
+ _nested_line("user", self.SUBJECT_LATEST),
236
+ ]) + "\n")
237
+ return path
238
+
239
+ def test_subject_from_prior_turn_survives_a_long_latest_turn(self):
240
+ client = _Client()
241
+ self._run(client, prompt=self.SUBJECT_LATEST)
242
+ terms = self._wire_terms(client)
243
+ # The latest turn alone yields well over 24 content terms, so under the
244
+ # old absolute tier every one of these was dropped.
245
+ for word in ("coolify", "webkite", "nginx", "tls"):
246
+ self.assertIn(word, terms, f"subject term {word!r} was crowded out")
247
+
248
+ # A prior turn whose subject is ORDINARY ENGLISH — no identifier, no digit,
249
+ # no compound. The recency weight alone cannot save it (every term scores
250
+ # 0.0 against 1.5 for every latest-turn term), so only the reserve can. This
251
+ # is the general form of Blocker 2: with `recallContextTurns: 2` shipped on,
252
+ # any latest turn holding >= max_tokens terms took every slot.
253
+ PLAIN_PRIOR = (
254
+ "The landlord refused to return the bond after the final inspection and "
255
+ "the tribunal hearing was adjourned."
256
+ )
257
+
258
+ def test_all_english_prior_turn_is_still_represented(self):
259
+ path = os.path.join(self._tmpdir, "transcript.jsonl")
260
+ with open(path, "w", encoding="utf-8") as f:
261
+ f.write("\n".join([
262
+ _nested_line("user", self.PLAIN_PRIOR),
263
+ _nested_line("user", self.SUBJECT_LATEST),
264
+ ]) + "\n")
265
+ self._write_transcript = lambda: path
266
+ client = _Client()
267
+ self._run(client, prompt=self.SUBJECT_LATEST)
268
+ terms = self._wire_terms(client)
269
+ prior_terms = set(tokenize_for_bm25(self.PLAIN_PRIOR)) & terms
270
+ self.assertGreaterEqual(
271
+ len(prior_terms),
272
+ 24 // 3,
273
+ f"prior context was starved: only {sorted(prior_terms)} survived",
274
+ )
275
+
276
+ def test_reserve_scales_with_the_cap_and_never_starves_the_latest_turn(self):
277
+ client = _Client()
278
+ self._run(client, prompt=self.SUBJECT_LATEST, config_extra={"recallQueryMaxTokens": 24})
279
+ terms = self._wire_terms(client)
280
+ prior_terms = set(tokenize_for_bm25(self.SUBJECT_PRIOR)) & terms
281
+ latest_terms = set(tokenize_for_bm25(self.SUBJECT_LATEST)) & terms
282
+ # The reserve is a ceiling, not an allocation: prior context is
283
+ # guaranteed representation but must not take the majority of slots.
284
+ self.assertGreaterEqual(len(prior_terms), 4)
285
+ self.assertGreater(len(latest_terms), len(prior_terms))
286
+
287
+ # Two turns of ORDINARY English on both sides, the common conversational
288
+ # case: every candidate scores 0.0 on shape, so neither reserve can decide
289
+ # anything past its own third and the remaining budget is settled purely by
290
+ # the recency weight. Drop `_SCORE_RECENCY` and the tie falls back to
291
+ # `first_seen`, which orders by position in `Prior context: <old> … <new>`
292
+ # — i.e. the STALEST turn wins the leftover budget and the tail of the
293
+ # user's actual question is dropped. This is the fill stage, and it is where
294
+ # recency-as-a-weight (rather than as a tier) does its work.
295
+ FILL_PRIOR = (
296
+ "The removalists arrived before the inspection finished so the landlord "
297
+ "postponed the handover until the following afternoon and the neighbours "
298
+ "complained about the noise in the stairwell again."
299
+ )
300
+ FILL_LATEST = (
301
+ "Could you summarise whether the tribunal accepted the amended evidence "
302
+ "bundle and confirm the hearing date they eventually settled on."
303
+ )
304
+
305
+ def test_leftover_budget_goes_to_the_latest_turn_on_equal_merit(self):
306
+ path = os.path.join(self._tmpdir, "transcript.jsonl")
307
+ with open(path, "w", encoding="utf-8") as f:
308
+ f.write("\n".join([
309
+ _nested_line("user", self.FILL_PRIOR),
310
+ _nested_line("user", self.FILL_LATEST),
311
+ ]) + "\n")
312
+ self._write_transcript = lambda: path
313
+ client = _Client()
314
+ self._run(client, prompt=self.FILL_LATEST, config_extra={"recallQueryMaxTokens": 24})
315
+ terms = self._wire_terms(client)
316
+ latest_terms = {
317
+ t for t in tokenize_for_bm25(self.FILL_LATEST)
318
+ if len(t) > 1 and t not in BM25_STOPWORDS
319
+ }
320
+ # The latest turn holds more terms than its own reserve (24 // 3 = 8),
321
+ # so the surplus can only be here because the fill stage preferred it.
322
+ self.assertGreater(len(latest_terms), 24 // 3)
323
+ missing = sorted(latest_terms - terms)
324
+ self.assertEqual(
325
+ missing, [], f"the latest turn lost the leftover budget to stale context: {missing}"
326
+ )
327
+
328
+
329
+ class SelectivityRewardsIdentifiersNotLongWords(_Harness):
330
+ """#3760 review, Blocker 1. The previous revision scored `+min(len, 12)/4`,
331
+ so `understanding` (3.0) and `configuration` (3.0) tied the maximum awarded
332
+ to a compound or a digit while `pkce` (1.0) and `zod` (0.75) sat near the
333
+ floor. Measured median `overlord` df by token length is flat-to-rising, so
334
+ length predicts nothing; English-word membership does."""
335
+
336
+ IDENTIFIER_LATEST = (
337
+ "I have been thinking particularly carefully about this and essentially my "
338
+ "understanding of the currently shipped configuration documentation basically "
339
+ "describes something different from the implementation, so probably the "
340
+ "important information here is that generally, additionally, previously "
341
+ "mentioned considerations regarding authentication mechanisms suggest we "
342
+ "should use PKCE rather than something else entirely"
343
+ )
344
+
345
+ def test_short_identifier_beats_long_common_english(self):
346
+ client = _Client()
347
+ self._run(
348
+ client,
349
+ prompt=self.IDENTIFIER_LATEST,
350
+ with_transcript=False,
351
+ config_extra={"recallContextTurns": 1, "recallQueryMaxTokens": 6},
352
+ )
353
+ terms = self._wire_terms(client)
354
+ # Six slots for a 60-word turn. `pkce` is the last word of the sentence
355
+ # and the shortest content token in it, so under the old length rule
356
+ # (score 1.0, against 3.0 for every long abstract noun before it) it was
357
+ # dropped. It must now take the first slot on merit.
358
+ self.assertIn("pkce", terms, "the only identifier in the turn was dropped")
359
+ # The long English words are DEMOTED, not dropped — with six slots and
360
+ # nothing more selective competing, some of them legitimately fill the
361
+ # remainder. What must not happen is any of them outranking `pkce`.
362
+ for filler in ("understanding", "particularly", "configuration", "documentation"):
363
+ self.assertLess(
364
+ _selectivity_score(filler),
365
+ _selectivity_score("pkce"),
366
+ f"{filler!r} scores at or above an identifier",
367
+ )
368
+
369
+ def test_length_is_not_rewarded(self):
370
+ # The regression this guards: `score += min(len(token), 12) / 4.0`.
371
+ # Measured median `overlord` df by token length is flat-to-rising, so a
372
+ # longer common word is not more selective than a shorter one.
373
+ self.assertEqual(
374
+ _selectivity_score("configuration"), _selectivity_score("thing")
375
+ )
376
+ # ...and the same holds for two identifiers of very different length.
377
+ self.assertEqual(_selectivity_score("zod"), _selectivity_score("kubernetes"))
378
+
379
+ def test_compound_shape_is_rewarded_on_top_of_rarity(self):
380
+ # A path/version/module compound is worth MORE than a merely unknown
381
+ # word, and the compound bonus is what supplies that. Measured on the
382
+ # live `overlord` bank, `.`/`/`/`-` joined tokens are the second-most
383
+ # reliable low-df shape after digits — and unlike a bare rare word the
384
+ # server shreds a compound into its fragments too, so one kept compound
385
+ # buys several matching surfaces. `docs/setup` and `wibbleton` are BOTH
386
+ # absent from the dictionary, so the +2.0 rarity bonus cancels and only
387
+ # the compound bonus can separate them.
388
+ self.assertNotIn("docs/setup", common_english_words())
389
+ self.assertNotIn("wibbleton", common_english_words())
390
+ self.assertGreater(
391
+ _selectivity_score("docs/setup"),
392
+ _selectivity_score("wibbleton"),
393
+ "a compound scores no better than an unknown bare word",
394
+ )
395
+ # And it must still beat a compound-shaped token's own fragments, which
396
+ # are ordinary dictionary words on their own.
397
+ self.assertGreater(_selectivity_score("docs/setup"), _selectivity_score("setup"))
398
+
399
+
400
+ class RoleLabelsNeverReachTheWire(_Harness):
401
+ def test_user_and_assistant_are_not_bm25_terms(self):
402
+ client = _Client()
403
+ self._run(client)
404
+ terms = self._wire_terms(client)
405
+ # These two were 50% and 22% of the overlord bank's document frequency.
406
+ self.assertNotIn("user", terms)
407
+ self.assertNotIn("assistant", terms)
408
+ # ...and the composed query's own section header.
409
+ self.assertNotIn("prior", terms)
410
+ self.assertNotIn("context", terms)
411
+
412
+ def test_still_absent_with_shaping_disabled(self):
413
+ # Fix at source: compose_recall_query no longer emits the labels, so
414
+ # even the rollback lever cannot put them back on the wire.
415
+ client = _Client()
416
+ self._run(client, config_extra={"recallQueryMaxTokens": 0})
417
+ terms = self._wire_terms(client)
418
+ self.assertNotIn("user", terms)
419
+ self.assertNotIn("assistant", terms)
420
+
421
+ def test_prior_context_content_still_reaches_the_wire(self):
422
+ # Dropping the labels must not drop the turns they labelled.
423
+ client = _Client()
424
+ self._run(client, config_extra={"recallQueryMaxTokens": 0})
425
+ terms = self._wire_terms(client)
426
+ self.assertIn("reaper", terms)
427
+ self.assertIn("manifest", terms)
428
+
429
+
430
+ class PerBankTimeoutIsConfigurable(_Harness):
431
+ def test_defaults_to_12_seconds(self):
432
+ client = _Client()
433
+ self._run(client)
434
+ self.assertEqual(client.calls[0]["timeout"], 12.0)
435
+
436
+ def test_honours_config(self):
437
+ client = _Client()
438
+ self._run(client, config_extra={"recallRequestTimeoutSeconds": 20})
439
+ self.assertEqual(client.calls[0]["timeout"], 20.0)
440
+
441
+ def test_invalid_value_falls_back_to_default(self):
442
+ for bad in ("banana", None, 0, -3):
443
+ client = _Client()
444
+ self._run(client, config_extra={"recallRequestTimeoutSeconds": bad})
445
+ self.assertEqual(client.calls[0]["timeout"], 12.0, f"bad={bad!r}")
446
+
447
+
448
+ class ShapingDoesNotMoveTheOverlapGate(_Harness):
449
+ """The `recallMinOverlap` containment gate is |Q ∩ M| / |M|. If it measured
450
+ against the SHAPED query, shrinking Q would shrink every memory's score and
451
+ silently tighten every operator's configured threshold. It must keep
452
+ measuring against the user's real words."""
453
+
454
+ def test_memory_matching_only_stopwords_of_the_prompt(self):
455
+ # "did we decide about the auth flow" — every shared term with the
456
+ # memory below is a stopword that shaping removes. With the gate on the
457
+ # UNSHAPED query the memory clears a 0.5 threshold; against the shaped
458
+ # query it would score 0 and be dropped.
459
+ memory = {"text": "did we decide about the", "type": "fact",
460
+ "mentioned_at": "2026-01-01", "id": "m1"}
461
+ client = _Client(results=[memory])
462
+ context = self._run(
463
+ client,
464
+ prompt="did we decide about the auth flow",
465
+ with_transcript=False,
466
+ config_extra={"recallMinOverlap": 0.5, "recallContextTurns": 1},
467
+ )
468
+ self.assertIsNotNone(context)
469
+ self.assertIn("did we decide about the", context)
470
+
471
+
472
+ if __name__ == "__main__": # pragma: no cover
473
+ unittest.main()