switchroom 0.19.26 → 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,464 @@
1
+ """Switchroom #3837 — the opt-in absolute relevance floor (`recallMinScore`).
2
+
3
+ The failure this exists for, measured from the fleet's own `recall_log.jsonl`
4
+ (all agents, `injected_score_max` conditioned on `deadline_hit`):
5
+
6
+ deadline_hit TRUE n=249 median best injected score 0.0006
7
+ deadline_hit FALSE n=205 median best injected score 0.4663
8
+
9
+ 276 of 454 logged turns injected memories whose BEST score was below 0.01. A
10
+ turn whose bank timed out does not inject nothing — it injects side-bank
11
+ residue under the banner "Relevant memories from past conversations", and the
12
+ agent cannot tell that from real recall. Silence with a disclosure is strictly
13
+ better than noise without one.
14
+
15
+ What the floor must NOT become is #3761's removed lexical gate. That gate was
16
+ deleted after a 330-query replay showed every floor value had
17
+ ``top1lost% == zero%``, and the same fleet data says 28.4% of HEALTHY turns
18
+ have a best injected score under 0.01 against 98.4% of degraded ones
19
+ (`src/hindsight-watch/thresholds.ts`). So the floor ships OFF, and when it is
20
+ switched on it binds on degraded turns only unless the operator widens the
21
+ scope deliberately.
22
+
23
+ These tests assert OUTCOMES — what reaches `additionalContext` and what lands
24
+ on the log row — not that a branch was taken:
25
+
26
+ * an all-below-floor set injects NO memory content, and the turn still says
27
+ recall was degraded (the #3619 disclosure) rather than going silent;
28
+ * a mixed set injects the above-floor entries and only those;
29
+ * the log row's `dropped_below_min_score` matches what was actually dropped;
30
+ * the shipped default (floor 0) leaves the injected context byte-identical;
31
+ * the default scope does not touch a HEALTHY turn — the #3761 regression;
32
+ * a score-less result is never dropped (missing score != irrelevant);
33
+ * the withheld notice is per-turn state and never reaches the recall cache.
34
+
35
+ Stdlib-only (unittest + mock); runs under ``python3 -m unittest discover
36
+ tests/``. Harness mirrors ``test_recall_degraded_notice.py``.
37
+ """
38
+
39
+ import io
40
+ import json
41
+ import os
42
+ import shutil
43
+ import socket
44
+ import sys
45
+ import tempfile
46
+ import unittest
47
+ from unittest.mock import patch
48
+
49
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
50
+ if SCRIPTS_DIR not in sys.path:
51
+ sys.path.insert(0, SCRIPTS_DIR)
52
+
53
+ import recall # noqa: E402
54
+ from recall import _filter_by_min_score, min_score_withheld_notice # noqa: E402
55
+
56
+ OWN = "test-bank"
57
+ SIDE = "shared-bank"
58
+
59
+ # The two regimes the fleet measurement separates: what a timed-out turn's
60
+ # residue actually scores, and what a real hit scores.
61
+ NOISE = 0.0006
62
+ REAL = 0.9497
63
+
64
+
65
+ def _memory(text, mem_id, score=None):
66
+ m = {"text": text, "type": "fact", "mentioned_at": "2026-01-01", "id": mem_id}
67
+ if score is not None:
68
+ m["scores"] = {"final": score}
69
+ return m
70
+
71
+
72
+ def _directive(name, content, priority=5):
73
+ return {
74
+ "id": f"id-{name}",
75
+ "bank_id": OWN,
76
+ "name": name,
77
+ "content": content,
78
+ "priority": priority,
79
+ "is_active": True,
80
+ "tags": [],
81
+ }
82
+
83
+
84
+ class _Client:
85
+ """Fake HindsightClient with per-bank results / exceptions."""
86
+
87
+ def __init__(self, bank_results=None, bank_errors=None, directives=None):
88
+ self._bank_results = bank_results or {}
89
+ self._bank_errors = bank_errors or {}
90
+ self._directives = directives or []
91
+
92
+ def list_directives(self, bank_id, active_only=True, timeout=2):
93
+ return {"items": list(self._directives)}
94
+
95
+ def recall(self, bank_id, query, **kwargs):
96
+ exc = self._bank_errors.get(bank_id)
97
+ if exc is not None:
98
+ raise exc
99
+ return {"results": [dict(m) for m in self._bank_results.get(bank_id, [])]}
100
+
101
+
102
+ class FilterUnitTests(unittest.TestCase):
103
+ def test_threshold_zero_is_passthrough(self):
104
+ results = [_memory("a", "m1", NOISE), _memory("b", "m2", REAL)]
105
+ kept, dropped = _filter_by_min_score(results, 0.0)
106
+ self.assertEqual(kept, results)
107
+ self.assertEqual(dropped, 0)
108
+
109
+ def test_drops_only_below_threshold(self):
110
+ low = _memory("low", "m1", NOISE)
111
+ high = _memory("high", "m2", REAL)
112
+ kept, dropped = _filter_by_min_score([low, high, low], 0.01)
113
+ self.assertEqual(kept, [high])
114
+ self.assertEqual(dropped, 2)
115
+
116
+ def test_threshold_is_inclusive(self):
117
+ exact = _memory("exact", "m1", 0.01)
118
+ kept, dropped = _filter_by_min_score([exact], 0.01)
119
+ self.assertEqual(kept, [exact])
120
+ self.assertEqual(dropped, 0)
121
+
122
+ def test_all_below_yields_empty_set(self):
123
+ results = [_memory(f"n{i}", f"m{i}", NOISE) for i in range(6)]
124
+ kept, dropped = _filter_by_min_score(results, 0.01)
125
+ self.assertEqual(kept, [])
126
+ self.assertEqual(dropped, 6)
127
+
128
+ def test_scoreless_result_is_kept_not_dropped(self):
129
+ """`_result_final_score` returns -inf for a malformed / score-less
130
+ entry. Dropping on that sentinel would turn an upstream response-shape
131
+ change into total, silent recall loss — the failure mode is far worse
132
+ than admitting one unranked memory."""
133
+ unscored = _memory("no scores key", "m1")
134
+ broken = {"text": "malformed", "id": "m2", "scores": {"final": "NaN-ish"}}
135
+ kept, dropped = _filter_by_min_score([unscored, broken], 0.5)
136
+ self.assertEqual(kept, [unscored, broken])
137
+ self.assertEqual(dropped, 0)
138
+
139
+
140
+ class WithheldNoticeUnitTests(unittest.TestCase):
141
+ def test_silent_when_nothing_was_dropped(self):
142
+ self.assertEqual(min_score_withheld_notice(0, 0.01), "")
143
+
144
+ def test_names_the_count_and_the_floor(self):
145
+ notice = min_score_withheld_notice(6, 0.01)
146
+ self.assertIn("6", notice)
147
+ self.assertIn("0.01", notice)
148
+
149
+ def test_tells_the_agent_to_treat_absence_as_unknown(self):
150
+ """The notice exists to change behaviour on a thin turn. If it stops
151
+ saying this it is decoration."""
152
+ self.assertIn("UNKNOWN", min_score_withheld_notice(3, 0.01))
153
+
154
+ def test_is_a_single_short_line(self):
155
+ notice = min_score_withheld_notice(3, 0.01)
156
+ self.assertNotIn("\n", notice)
157
+ self.assertLess(len(notice), 400)
158
+
159
+
160
+ class _MainHarness(unittest.TestCase):
161
+ """Drives `recall.main()` end to end with an isolated recall log."""
162
+
163
+ def setUp(self):
164
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-min-score-test-")
165
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
166
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
167
+
168
+ def tearDown(self):
169
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
170
+ if self._prev is None:
171
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
172
+ else:
173
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
174
+
175
+ def _log_row(self):
176
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
177
+ with open(path, encoding="utf-8") as fh:
178
+ rows = [json.loads(line) for line in fh if line.strip()]
179
+ self.assertTrue(rows, "no recall_log row was written")
180
+ return rows[-1]
181
+
182
+ def _run(self, client, config_extra=None):
183
+ hook_input = {
184
+ "prompt": "what did we decide about the auth flow",
185
+ "session_id": "test-session",
186
+ "transcript_path": "",
187
+ "cwd": "/tmp",
188
+ }
189
+ config = {
190
+ "autoRecall": True,
191
+ "bankId": OWN,
192
+ "recallMaxTokens": 1024,
193
+ "recallBudget": "mid",
194
+ "recallContextTurns": 1,
195
+ "recallMaxQueryChars": 800,
196
+ "recallPromptPreamble": "",
197
+ "recallParallelDeadlineSeconds": 5,
198
+ "directivesCacheTtlSeconds": 0,
199
+ }
200
+ if config_extra:
201
+ config.update(config_extra)
202
+ stdout = io.StringIO()
203
+ stderr = io.StringIO()
204
+ self._cached = []
205
+ with patch.object(recall, "load_config", return_value=config), patch.object(
206
+ recall, "get_api_url", return_value="http://localhost:18888"
207
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
208
+ recall, "ensure_bank_mission", return_value=None
209
+ ), patch.object(recall, "write_state", return_value=None), patch.object(
210
+ recall, "_cache_ttl_secs", return_value=300
211
+ ), patch.object(recall, "_cache_lookup", return_value=None), patch.object(
212
+ recall,
213
+ "_cache_store",
214
+ side_effect=lambda key, value: self._cached.append(value),
215
+ ), patch(
216
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
217
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
218
+ recall.main()
219
+ raw = stdout.getvalue()
220
+ if not raw.strip():
221
+ return None
222
+ return json.loads(raw)["hookSpecificOutput"]["additionalContext"]
223
+
224
+ def _degraded_noise_client(self, n=6, score=NOISE, directives=None):
225
+ """The measured shape: own bank times out, a side bank answers with
226
+ residue scoring in the noise band."""
227
+ return _Client(
228
+ bank_results={
229
+ SIDE: [_memory(f"noise fact {i}", f"m{i}", score) for i in range(n)]
230
+ },
231
+ bank_errors={OWN: socket.timeout("timed out")},
232
+ directives=directives,
233
+ )
234
+
235
+
236
+ class AllBelowFloorInjectsNothingButStillDiscloses(_MainHarness):
237
+ def test_no_memory_content_is_injected(self):
238
+ ctx = self._run(
239
+ self._degraded_noise_client(),
240
+ {
241
+ "recallAdditionalBanks": [SIDE],
242
+ "recallMinScore": 0.01,
243
+ },
244
+ )
245
+ self.assertIsNotNone(ctx)
246
+ self.assertNotIn("<hindsight_memories>", ctx)
247
+ for i in range(6):
248
+ self.assertNotIn(f"noise fact {i}", ctx)
249
+
250
+ def test_the_turn_still_says_recall_was_degraded(self):
251
+ """Withholding must not re-create the #3619 ambiguity. An empty recall
252
+ that looks like an empty MEMORY is the bug; the agent has to be told
253
+ its own bank did not answer."""
254
+ ctx = self._run(
255
+ self._degraded_noise_client(),
256
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.01},
257
+ )
258
+ self.assertIsNotNone(ctx)
259
+ self.assertTrue(ctx.startswith("[Hindsight] Memory recall was DEGRADED"))
260
+ self.assertIn(OWN, ctx)
261
+
262
+ def test_the_turn_also_says_candidates_were_withheld(self):
263
+ ctx = self._run(
264
+ self._degraded_noise_client(),
265
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.01},
266
+ )
267
+ self.assertIn("below the configured relevance floor", ctx)
268
+ self.assertIn("UNKNOWN", ctx)
269
+
270
+ def test_log_row_reports_the_drop(self):
271
+ self._run(
272
+ self._degraded_noise_client(),
273
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.01},
274
+ )
275
+ row = self._log_row()
276
+ self.assertEqual(row["dropped_below_min_score"], 6)
277
+ self.assertEqual(row["min_score_floor"], 0.01)
278
+ self.assertEqual(row["min_score_scope"], "degraded")
279
+ self.assertTrue(row["min_score_applied"])
280
+ self.assertEqual(row["result_count"], 0)
281
+ # Nothing was injected, so the quality aggregates have nothing to
282
+ # summarise — the pairing an operator reads as "the floor bound here".
283
+ self.assertIsNone(row["injected_score_max"])
284
+
285
+
286
+ class MixedSetInjectsOnlySurvivors(_MainHarness):
287
+ def _mixed_client(self):
288
+ return _Client(
289
+ bank_results={
290
+ SIDE: [
291
+ _memory("real decision about auth", "m-real", REAL),
292
+ _memory("noise fact 0", "m0", NOISE),
293
+ _memory("noise fact 1", "m1", NOISE),
294
+ ]
295
+ },
296
+ bank_errors={OWN: socket.timeout("timed out")},
297
+ )
298
+
299
+ def test_above_floor_memory_is_injected_and_below_floor_ones_are_not(self):
300
+ ctx = self._run(
301
+ self._mixed_client(),
302
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.01},
303
+ )
304
+ self.assertIsNotNone(ctx)
305
+ self.assertIn("<hindsight_memories>", ctx)
306
+ self.assertIn("real decision about auth", ctx)
307
+ self.assertNotIn("noise fact 0", ctx)
308
+ self.assertNotIn("noise fact 1", ctx)
309
+
310
+ def test_no_withheld_notice_when_survivors_remain(self):
311
+ """A partial drop still injects real memories; the turn does not need
312
+ the extra line and a notice that fires on ordinary turns gets ignored
313
+ exactly when it matters."""
314
+ ctx = self._run(
315
+ self._mixed_client(),
316
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.01},
317
+ )
318
+ self.assertNotIn("below the configured relevance floor", ctx)
319
+
320
+ def test_log_row_counts_only_the_dropped(self):
321
+ self._run(
322
+ self._mixed_client(),
323
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.01},
324
+ )
325
+ row = self._log_row()
326
+ self.assertEqual(row["dropped_below_min_score"], 2)
327
+ self.assertEqual(row["result_count"], 1)
328
+ self.assertEqual(row["memory_ids"], ["m-real"])
329
+
330
+
331
+ class DefaultOffChangesNothing(_MainHarness):
332
+ def test_noise_is_injected_exactly_as_before_when_the_floor_is_unset(self):
333
+ """The shipped default. Every one of the six sub-0.01 memories still
334
+ reaches the agent — this feature is inert until an operator opts in,
335
+ and a non-zero default sneaking in here fails this test."""
336
+ ctx = self._run(
337
+ self._degraded_noise_client(), {"recallAdditionalBanks": [SIDE]}
338
+ )
339
+ self.assertIsNotNone(ctx)
340
+ self.assertIn("<hindsight_memories>", ctx)
341
+ for i in range(6):
342
+ self.assertIn(f"noise fact {i}", ctx)
343
+ self.assertNotIn("below the configured relevance floor", ctx)
344
+ row = self._log_row()
345
+ self.assertEqual(row["dropped_below_min_score"], 0)
346
+ self.assertEqual(row["min_score_floor"], 0.0)
347
+ self.assertFalse(row["min_score_applied"])
348
+ self.assertEqual(row["result_count"], 6)
349
+
350
+ def test_explicit_zero_is_byte_identical_to_unset(self):
351
+ unset = self._run(
352
+ self._degraded_noise_client(), {"recallAdditionalBanks": [SIDE]}
353
+ )
354
+ explicit = self._run(
355
+ self._degraded_noise_client(),
356
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.0},
357
+ )
358
+ self.assertIsNotNone(unset)
359
+ self.assertEqual(unset, explicit)
360
+
361
+ def test_a_non_numeric_floor_is_treated_as_off(self):
362
+ """Config arrives from JSON and from the env cast; a garbage value must
363
+ degrade to disabled, never to "drop everything"."""
364
+ ctx = self._run(
365
+ self._degraded_noise_client(),
366
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": "0.5"},
367
+ )
368
+ self.assertIsNotNone(ctx)
369
+ self.assertIn("noise fact 0", ctx)
370
+ self.assertEqual(self._log_row()["dropped_below_min_score"], 0)
371
+
372
+
373
+ class ScopeDefaultsToDegradedTurnsOnly(_MainHarness):
374
+ """#3761's finding, honoured: a low `scores.final` does not imply noise on
375
+ a HEALTHY turn (28.4% of healthy rows sit under 0.01), so the default scope
376
+ must not touch one."""
377
+
378
+ def _healthy_noise_client(self):
379
+ return _Client(
380
+ bank_results={
381
+ OWN: [_memory(f"noise fact {i}", f"m{i}", NOISE) for i in range(6)]
382
+ }
383
+ )
384
+
385
+ def test_healthy_turn_is_untouched_at_the_default_scope(self):
386
+ ctx = self._run(self._healthy_noise_client(), {"recallMinScore": 0.5})
387
+ self.assertIsNotNone(ctx)
388
+ for i in range(6):
389
+ self.assertIn(f"noise fact {i}", ctx)
390
+ row = self._log_row()
391
+ self.assertEqual(row["dropped_below_min_score"], 0)
392
+ self.assertFalse(row["min_score_applied"])
393
+ self.assertEqual(row["min_score_scope"], "degraded")
394
+
395
+ def test_scope_all_binds_on_a_healthy_turn(self):
396
+ ctx = self._run(
397
+ self._healthy_noise_client(),
398
+ {"recallMinScore": 0.5, "recallMinScoreScope": "all"},
399
+ )
400
+ self.assertIsNotNone(ctx)
401
+ self.assertNotIn("<hindsight_memories>", ctx)
402
+ row = self._log_row()
403
+ self.assertEqual(row["dropped_below_min_score"], 6)
404
+ self.assertTrue(row["min_score_applied"])
405
+ self.assertEqual(row["min_score_scope"], "all")
406
+
407
+ def test_scope_all_emptying_a_healthy_turn_is_never_silent(self):
408
+ """No bank failed, so the #3619 degraded notice does NOT fire — this is
409
+ the case where the withheld notice is the only thing standing between
410
+ the agent and "I remember nothing"."""
411
+ ctx = self._run(
412
+ self._healthy_noise_client(),
413
+ {"recallMinScore": 0.5, "recallMinScoreScope": "all"},
414
+ )
415
+ self.assertIsNotNone(ctx)
416
+ self.assertNotIn("DEGRADED", ctx)
417
+ self.assertIn("below the configured relevance floor", ctx)
418
+
419
+ def test_an_unknown_scope_falls_back_to_degraded(self):
420
+ ctx = self._run(
421
+ self._healthy_noise_client(),
422
+ {"recallMinScore": 0.5, "recallMinScoreScope": "everything"},
423
+ )
424
+ self.assertIn("noise fact 0", ctx)
425
+ self.assertEqual(self._log_row()["min_score_scope"], "degraded")
426
+
427
+
428
+ class ScorelessResultsSurviveTheFloor(_MainHarness):
429
+ def test_a_result_without_scores_is_injected_under_a_high_floor(self):
430
+ client = _Client(
431
+ bank_results={SIDE: [_memory("unranked but real", "m1")]},
432
+ bank_errors={OWN: socket.timeout("timed out")},
433
+ )
434
+ ctx = self._run(
435
+ client, {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.9}
436
+ )
437
+ self.assertIsNotNone(ctx)
438
+ self.assertIn("unranked but real", ctx)
439
+ self.assertEqual(self._log_row()["dropped_below_min_score"], 0)
440
+
441
+
442
+ class WithheldNoticeIsNotCached(_MainHarness):
443
+ def test_cache_write_carries_the_directives_without_the_notice(self):
444
+ """Same discipline as #3619's degraded notice: the withheld line is
445
+ per-turn state. If it reached `context_message` it would be cached and
446
+ replayed on a later healthy hit, telling the agent memories were
447
+ withheld on a turn where none were."""
448
+ client = self._degraded_noise_client(
449
+ directives=[_directive("trailer", "End every response with: [OK]", 10)]
450
+ )
451
+ ctx = self._run(
452
+ client,
453
+ {"recallAdditionalBanks": [SIDE], "recallMinScore": 0.01},
454
+ )
455
+ self.assertIn("below the configured relevance floor", ctx)
456
+ self.assertIn("End every response with: [OK]", ctx)
457
+ self.assertEqual(len(self._cached), 1)
458
+ self.assertNotIn("below the configured relevance floor", self._cached[0])
459
+ self.assertNotIn("DEGRADED", self._cached[0])
460
+ self.assertIn("End every response with: [OK]", self._cached[0])
461
+
462
+
463
+ if __name__ == "__main__":
464
+ unittest.main()
@@ -2,7 +2,7 @@
2
2
  "hindsightApiUrl": "",
3
3
  "bankId": "claude_code",
4
4
  "bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
5
- "retainMission": "Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nNEVER extract:\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- In-flight workflow/process narration (a sub-task started, paused, or is still running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch or session IDs, UUIDs, hashes, or error codes.\n- Hindsight's own errors, retries, backlogs, or internal state — the memory system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Transient state (unread counts, build status, what is running right now) unless the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If nothing durable remains, return an empty facts list.",
5
+ "retainMission": "Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.",
6
6
  "autoRecall": true,
7
7
  "autoRetain": true,
8
8
  "retainMode": "full-session",