switchroom 0.19.38 → 0.19.39

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.
@@ -6,8 +6,30 @@ variable overrides. Full config schema matching Openclaw's 30+ options.
6
6
 
7
7
  import json
8
8
  import os
9
+ import re
9
10
  import sys
10
11
 
12
+ #: Switchroom-local: strategies for the per-row curated observation scope.
13
+ #: ``curated`` (default) strips volatile provenance tags from the consolidation
14
+ #: scope, keeping stable semantic ones; ``shared`` pools every retain into one
15
+ #: bank-wide untagged scope; ``combined`` / ``off`` emit no per-row scope (the
16
+ #: pre-feature engine default). See ``compute_observation_scopes``.
17
+ OBSERVATION_SCOPE_STRATEGIES = ("curated", "shared", "combined", "off")
18
+
19
+ #: Tag patterns treated as VOLATILE per-session provenance by ``curated``:
20
+ #: ``parent_session:<id>`` and a bare RFC-4122 UUID (what the ``{session_id}``
21
+ #: retain tag resolves to on the parent path). The UUID pattern also matches
22
+ #: the sidechain-derived ``<uuid>-sub-<agent_id>`` form, because
23
+ #: ``subagent_retain.py`` sets ``sub_session_id = f"{session_id}-sub-{agent_id}"``
24
+ #: and the ``{session_id}`` retain tag resolves to that — a per-invocation-unique
25
+ #: value that, if left in scope, defeats cross-session dedup on the dominant
26
+ #: (sidechain) observation path. A tag matching ANY of these is dropped from the
27
+ #: consolidation scope but LEFT on the source fact.
28
+ DEFAULT_VOLATILE_SCOPE_PATTERNS = (
29
+ r"^parent_session:",
30
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(-sub-.+)?$",
31
+ )
32
+
11
33
  DEFAULTS = {
12
34
  # Recall
13
35
  "autoRecall": True,
@@ -181,6 +203,37 @@ DEFAULTS = {
181
203
  # through `defaults.memory.observation_scopes`) via
182
204
  # HINDSIGHT_OBSERVATION_SCOPES, exported ONLY when the operator opted in.
183
205
  "observationScopes": None,
206
+ # Switchroom hindsight-leverage — CURATED observation scopes (default ON).
207
+ # `combined` (the pre-feature engine default) makes consolidation dedup an
208
+ # observation only against others carrying the IDENTICAL tag set. Because
209
+ # switchroom stamps volatile per-session provenance on every retain
210
+ # (`{session_id}` → a bare UUID tag, and `parent_session:<uuid>` /
211
+ # `sidechain` / `agent_type:*` on sub-agent retains), that turns every
212
+ # session into its own dedup island — cross-session dedup never happens.
213
+ #
214
+ # `curated` (default) strips ONLY the volatile provenance tags
215
+ # (`observationScopeVolatilePatterns`) from the CONSOLIDATION scope, keeping
216
+ # the stable semantic ones (`lesson`, `anti-pattern`, `agent_type:*`,
217
+ # `sidechain`, entities). The stripped tags STAY on the source fact (still
218
+ # queryable / recall-filterable) — only the observation's dedup scope is
219
+ # narrowed. Non-empty stable set → the explicit scope `[[stable…]]`; empty
220
+ # stable set (e.g. a retain tagged only with a session id) → `"shared"`,
221
+ # one bank-wide untagged scope. This preserves recall tag-weighting on the
222
+ # observation layer (the kept tags still ride the observation) while giving
223
+ # cross-session dedup. Docs: https://hindsight.vectorize.io/developer/api/retain
224
+ # ("shared") and /developer/observations (scope isolation via all_strict).
225
+ #
226
+ # Opt-out: set `observationScopeStrategy` to `combined` (or `off`) — both
227
+ # emit no per-row scope, restoring the exact pre-feature engine default. A
228
+ # manually-pinned `observationScopes` (memory.observation_scopes) STILL wins
229
+ # over the strategy, so operators who set `per_tag` / `all_combinations` /
230
+ # `shared` keep that behaviour unchanged.
231
+ "observationScopeStrategy": "curated",
232
+ # Tag patterns treated as VOLATILE (stripped from the curated scope, kept on
233
+ # the source fact). Defaults: `parent_session:<id>` and a bare RFC-4122
234
+ # UUID (what `{session_id}` resolves to). Overridable via settings.json /
235
+ # ~/.hindsight/claude-code.json for a bank with an unusual provenance tag.
236
+ "observationScopeVolatilePatterns": list(DEFAULT_VOLATILE_SCOPE_PATTERNS),
184
237
  # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
185
238
  # tagging at retain time. When on (default), build_retain_payload scans the
186
239
  # formatted transcript slice for explicit lesson / anti-pattern markers and
@@ -333,6 +386,11 @@ ENV_OVERRIDES = {
333
386
  # defaults.memory.observation_scopes) ONLY when the operator set it; unset
334
387
  # leaves `observationScopes` None and the field off the wire entirely.
335
388
  "HINDSIGHT_OBSERVATION_SCOPES": ("observationScopes", str),
389
+ # Opt-out / override the curated default. `combined` or `off` restore the
390
+ # pre-feature engine default; `curated` (the shipped default) / `shared`
391
+ # select the computed strategies. Off-list values fall back to `curated`
392
+ # (shouted, never raised) — see compute_observation_scopes.
393
+ "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY": ("observationScopeStrategy", str),
336
394
  # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
337
395
  # + recall demotion toggles and overrides.
338
396
  "HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
@@ -513,6 +571,99 @@ def resolve_observation_scopes(config: dict):
513
571
  return value
514
572
 
515
573
 
574
+ def _volatile_scope_matchers(config: dict):
575
+ """Compile ``observationScopeVolatilePatterns`` to regexes, skipping bad ones.
576
+
577
+ NEVER raises: a malformed pattern is dropped (and shouted about) rather than
578
+ allowed to kill a retain. Falls back to the built-in defaults when the
579
+ config value is absent or not a list.
580
+ """
581
+ raw = config.get("observationScopeVolatilePatterns")
582
+ if not isinstance(raw, (list, tuple)):
583
+ raw = DEFAULT_VOLATILE_SCOPE_PATTERNS
584
+ matchers = []
585
+ for pat in raw:
586
+ if not isinstance(pat, str):
587
+ continue
588
+ try:
589
+ matchers.append(re.compile(pat))
590
+ except re.error as e:
591
+ print(
592
+ f"[Hindsight] observationScopeVolatilePatterns entry {pat!r} is not "
593
+ f"a valid regex ({e}); ignoring it for scope curation.",
594
+ file=sys.stderr,
595
+ )
596
+ return matchers
597
+
598
+
599
+ def _is_volatile_scope_tag(tag: str, matchers) -> bool:
600
+ return any(m.search(tag) for m in matchers)
601
+
602
+
603
+ def compute_observation_scopes(tags, config: dict):
604
+ """Resolve the per-row ``observation_scopes`` value for one retain.
605
+
606
+ Returns ``(value, error)`` — exactly like :func:`classify_observation_scopes`
607
+ — and MUST NEVER RAISE (a bad config must degrade the SCOPE, never lose the
608
+ turn; see ``retain.build_retain_payload``). ``value`` is what goes on the
609
+ wire: ``None`` (omit the field entirely → engine default), a bare string
610
+ (``"shared"`` / an operator-pinned Hindsight scope), or an explicit
611
+ ``list[list[str]]`` tag matrix. The wire body is byte-identical to the
612
+ pre-feature client whenever ``value`` is ``None``.
613
+
614
+ Precedence:
615
+
616
+ 1. A manually-pinned ``observationScopes`` (``memory.observation_scopes``)
617
+ WINS — its classified value (or its degrade-to-None-with-error on a typo)
618
+ is returned unchanged, so existing operator overrides keep working.
619
+ 2. Otherwise ``observationScopeStrategy`` decides:
620
+
621
+ * ``combined`` / ``off`` → ``None`` (pre-feature engine default; opt-out).
622
+ * ``shared`` → ``"shared"`` uniformly.
623
+ * ``curated`` (default) → strip volatile provenance tags from the scope
624
+ (keeping them on the source fact); non-empty stable set → ``[[stable…]]``
625
+ (deterministically sorted), empty stable set → ``"shared"``.
626
+ * anything else → treated as ``curated`` and shouted about.
627
+
628
+ Docs: https://hindsight.vectorize.io/developer/api/retain (``shared`` == the
629
+ explicit ``[[]]`` scope; a custom ``list[list[str]]`` is consolidated with
630
+ ``all_strict`` matching so scopes stay isolated) and /developer/observations.
631
+ """
632
+ # 1. Operator-pinned scope wins (back-compat). Only fall through to the
633
+ # strategy when observationScopes is genuinely UNSET — a typo'd pin
634
+ # degrades to the engine default WITH its error, never silently curated.
635
+ manual, manual_error = classify_observation_scopes(config)
636
+ if manual is not None or manual_error is not None:
637
+ return manual, manual_error
638
+
639
+ strategy_raw = config.get("observationScopeStrategy")
640
+ strategy = strategy_raw.strip().lower() if isinstance(strategy_raw, str) else ""
641
+ if not strategy:
642
+ strategy = "curated"
643
+
644
+ error = None
645
+ if strategy not in OBSERVATION_SCOPE_STRATEGIES:
646
+ error = (
647
+ f"observationScopeStrategy={strategy_raw!r} is not one of "
648
+ f"{', '.join(OBSERVATION_SCOPE_STRATEGIES)}; using 'curated'."
649
+ )
650
+ strategy = "curated"
651
+
652
+ if strategy in ("combined", "off"):
653
+ return None, error
654
+ if strategy == "shared":
655
+ return "shared", error
656
+
657
+ # curated
658
+ matchers = _volatile_scope_matchers(config)
659
+ stable = sorted(
660
+ {t for t in (tags or []) if isinstance(t, str) and t and not _is_volatile_scope_tag(t, matchers)}
661
+ )
662
+ if stable:
663
+ return [stable], error
664
+ return "shared", error
665
+
666
+
516
667
  def _cast_env(value: str, typ):
517
668
  """Cast environment variable string to target type. Returns None on failure."""
518
669
  try:
@@ -28,7 +28,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
28
  from lib import watermark
29
29
  from lib.bank import derive_bank_id, ensure_bank_mission
30
30
  from lib.client import HindsightClient
31
- from lib.config import classify_observation_scopes, debug_log, load_config
31
+ from lib.config import compute_observation_scopes, debug_log, load_config
32
32
  from lib.content import (
33
33
  prepare_retention_transcript,
34
34
  slice_last_turns_by_user_boundary,
@@ -267,13 +267,13 @@ def build_retain_payload(
267
267
  Returns ``{payload, document_id, message_count, last_uuid, ordered_uuids,
268
268
  transcript}`` or ``None`` when the slice formats to nothing.
269
269
 
270
- NEVER raises on a bad ``observationScopes``. Every retain producer builds
271
- its payload here, so this seam sees the typo — but it is also the seam the
272
- memory itself is made at, and a config typo must not be able to destroy
273
- one. An off-list value is dropped from the payload (so the engine's own
274
- default stands, exactly as before this feature existed) and shouted about
275
- on stderr; the memory is still built, still POSTed, still queued on
276
- failure. See ``lib.config.classify_observation_scopes``.
270
+ NEVER raises on a bad ``observationScopes`` / ``observationScopeStrategy``.
271
+ Every retain producer builds its payload here, so this seam sees the typo —
272
+ but it is also the seam the memory itself is made at, and a config typo must
273
+ not be able to destroy one. An off-list value degrades the SCOPE (the
274
+ engine's own default stands for a bad pin; ``curated`` stands for a bad
275
+ strategy) and is shouted about on stderr; the memory is still built, still
276
+ POSTed, still queued on failure. See ``lib.config.compute_observation_scopes``.
277
277
  """
278
278
  retain_roles = config.get("retainRoles", ["user", "assistant"])
279
279
  include_tool_calls = config.get("retainToolCalls", True)
@@ -367,12 +367,16 @@ def build_retain_payload(
367
367
  except Exception:
368
368
  pass
369
369
 
370
- # Per-row observation scope (switchroom). None unless the operator set
371
- # memory.observation_scopes and a None is dropped at the wire by
372
- # HindsightClient._retain_one, so the default request body is unchanged.
373
- # Carried ON THE PAYLOAD so it survives the pending-retains queue: a retain
374
- # that fails and drains hours later must land in the SAME scope it would
375
- # have landed in inline.
370
+ # Per-row observation scope (switchroom). Default `curated`: volatile
371
+ # per-session provenance tags are stripped from the CONSOLIDATION scope
372
+ # (kept on the source fact) so observations dedup across sessions instead of
373
+ # per-session a non-empty stable tag set yields an explicit `[[stable…]]`
374
+ # scope, an all-volatile/empty set yields `"shared"`. A None (opt-out via
375
+ # observationScopeStrategy=combined/off, or a manual observationScopes typo)
376
+ # is dropped at the wire by HindsightClient._retain_one, so that request
377
+ # body is byte-identical to the pre-feature default. Carried ON THE PAYLOAD
378
+ # so it survives the pending-retains queue: a retain that fails and drains
379
+ # hours later must land in the SAME scope it would have landed in inline.
376
380
  #
377
381
  # CLASSIFIED, NOT VALIDATED. This is the one seam every retain producer
378
382
  # funnels through, which makes it the tempting place to reject a typo — and
@@ -388,7 +392,7 @@ def build_retain_payload(
388
392
  # So a bad value degrades to the PRE-FEATURE behaviour (field omitted, the
389
393
  # engine's own default scope stands) and is shouted about on stderr. Wrong
390
394
  # scope is recoverable; a lost turn is not.
391
- scope, scope_error = classify_observation_scopes(config)
395
+ scope, scope_error = compute_observation_scopes(tags, config)
392
396
  if scope_error:
393
397
  print(
394
398
  f"[Hindsight] observation_scopes IGNORED for this retain: {scope_error} "
@@ -172,12 +172,25 @@ class TestBackfill(BackfillTestBase):
172
172
  self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
173
173
 
174
174
  # -- switchroom: per-row observation scope on the backfill path ---------
175
- def test_backfill_omits_the_scope_when_unconfigured(self):
175
+ def test_backfill_omits_the_scope_when_opted_out(self):
176
+ # The curated default now emits a scope on every path; the opt-out
177
+ # (observationScopeStrategy=combined) must stay byte-identical to the
178
+ # pre-feature body — no scope on the wire, engine default in force.
179
+ os.environ["HINDSIGHT_OBSERVATION_SCOPE_STRATEGY"] = "combined"
180
+ self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY", None)
176
181
  self._transcript("clerk", "sess-plain", 4)
177
182
  bf.Backfill(self._config(), commit=True, delay_ms=0).run()
178
183
  self.assertTrue(self.daemon.observation_scopes_seen)
179
184
  self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
180
185
 
186
+ def test_backfill_curates_the_scope_by_default(self):
187
+ # Default ON: every recovered slice carries a curated scope; a miss here
188
+ # would silently drop the backfill path back to the pre-feature default.
189
+ self._transcript("clerk", "sess-plain", 4)
190
+ bf.Backfill(self._config(), commit=True, delay_ms=0).run()
191
+ self.assertTrue(self.daemon.observation_scopes_seen)
192
+ self.assertTrue(all(s is not None for s in self.daemon.observation_scopes_seen))
193
+
181
194
  def test_backfill_posts_the_configured_scope(self):
182
195
  # The backfill enumerates its own retain kwargs; a miss here would
183
196
  # scatter every recovered historical slice into per-tag scopes while
@@ -38,6 +38,8 @@ import retain # noqa: E402
38
38
  from lib.client import HindsightClient # noqa: E402
39
39
  from lib.config import ( # noqa: E402
40
40
  OBSERVATION_SCOPES_VALUES,
41
+ OBSERVATION_SCOPE_STRATEGIES,
42
+ compute_observation_scopes,
41
43
  load_config,
42
44
  resolve_observation_scopes,
43
45
  )
@@ -134,6 +136,18 @@ class ConfigResolution(unittest.TestCase):
134
136
  clear=True):
135
137
  self.assertEqual(load_config().get("observationScopes"), "shared")
136
138
 
139
+ def test_strategy_default_is_curated(self):
140
+ # switchroom ships curated ON out of the box — a fresh install with no
141
+ # settings.json key and no env override still gets the curated scope.
142
+ with mock.patch.dict(os.environ, {}, clear=True):
143
+ self.assertEqual(load_config().get("observationScopeStrategy"), "curated")
144
+
145
+ def test_strategy_env_override_opts_out(self):
146
+ with mock.patch.dict(
147
+ os.environ, {"HINDSIGHT_OBSERVATION_SCOPE_STRATEGY": "combined"}, clear=True
148
+ ):
149
+ self.assertEqual(load_config().get("observationScopeStrategy"), "combined")
150
+
137
151
 
138
152
  class PayloadBuild(unittest.TestCase):
139
153
  """``build_retain_payload`` is the single producer for every retain path."""
@@ -147,10 +161,33 @@ class PayloadBuild(unittest.TestCase):
147
161
  bank_id="bank", api_url="http://fake", api_token=None,
148
162
  )["payload"]
149
163
 
150
- def test_payload_carries_none_when_unconfigured(self):
151
- self.assertIsNone(self._build({})["observation_scopes"])
164
+ def test_payload_omits_scope_when_opted_out(self):
165
+ # Opt-out (strategy=combined) is byte-identical to the pre-feature body:
166
+ # no scope on the payload, so the engine default stands.
167
+ self.assertIsNone(
168
+ self._build({"observationScopeStrategy": "combined"})["observation_scopes"]
169
+ )
170
+
171
+ def test_payload_curates_the_scope_by_default(self):
172
+ # No strategy key at all → the shipped default (curated) fires, so a
173
+ # scope IS carried. A retain carrying only volatile/no stable tags
174
+ # curates down to the bank-wide "shared" scope.
175
+ self.assertEqual(self._build({})["observation_scopes"], "shared")
176
+
177
+ def test_payload_curates_stable_tags_into_an_explicit_scope(self):
178
+ # A stable semantic tag survives onto the consolidation scope as an
179
+ # explicit list-of-lists, while a volatile session-id tag is stripped.
180
+ payload = self._build(
181
+ {"retainTags": ["team:acme", "{session_id}"]}
182
+ )
183
+ # {session_id} → "sess" (not a UUID / parent_session:*), so it is NOT
184
+ # volatile here and rides the scope alongside the stable tag.
185
+ self.assertEqual(
186
+ sorted(payload["observation_scopes"][0]), ["sess", "team:acme"]
187
+ )
152
188
 
153
- def test_payload_carries_the_configured_scope(self):
189
+ def test_payload_carries_the_manually_pinned_scope(self):
190
+ # A hand-pinned observationScopes still wins over the strategy.
154
191
  self.assertEqual(
155
192
  self._build({"observationScopes": "shared"})["observation_scopes"],
156
193
  "shared",
@@ -205,6 +242,16 @@ class DrainOfQueuedEntries(unittest.TestCase):
205
242
  drain_pending._retry_one(entry, timeout=15)
206
243
  self.assertEqual(self.calls[0]["observation_scopes"], "shared")
207
244
 
245
+ def test_curated_list_scope_survives_the_queue_round_trip(self):
246
+ # The curated default emits a list[list[str]] scope. A retain that fails
247
+ # inline and drains hours later must land in the SAME curated scope, so
248
+ # the list value has to survive JSON on disk and reach client.retain
249
+ # intact — otherwise a retried turn shards away from its inline siblings.
250
+ scope = [["agent_type:worker", "sidechain"]]
251
+ entry = json.loads(json.dumps(dict(self._LEGACY, observation_scopes=scope)))
252
+ drain_pending._retry_one(entry, timeout=15)
253
+ self.assertEqual(self.calls[0]["observation_scopes"], scope)
254
+
208
255
 
209
256
  class ValueValidation(unittest.TestCase):
210
257
  """An off-list scope must not reach the wire — and must not cost a memory.
@@ -321,5 +368,147 @@ class ValueValidation(unittest.TestCase):
321
368
  resolve_observation_scopes(load_config())
322
369
 
323
370
 
371
+ class ComputeCuratedScopes(unittest.TestCase):
372
+ """``compute_observation_scopes`` — the curated-default resolver, asserted as
373
+ exact (input tags, config) → emitted ``observation_scopes`` value mappings.
374
+
375
+ This is the load-bearing new behaviour: which tags survive onto the
376
+ consolidation scope and which are stripped as volatile provenance. The
377
+ function NEVER raises — a bad config degrades the scope, never the turn.
378
+ """
379
+
380
+ _UUID = "0291c461-864d-4284-b2b3-3fba9bf3142c"
381
+
382
+ def _compute(self, tags, **config):
383
+ return compute_observation_scopes(tags, config)
384
+
385
+ # --- default (curated) ------------------------------------------------
386
+ def test_curated_strips_parent_session_keeps_stable(self):
387
+ value, err = self._compute(["parent_session:abc", "sidechain", "agent_type:worker"])
388
+ self.assertIsNone(err)
389
+ self.assertEqual(value, [["agent_type:worker", "sidechain"]]) # sorted, stripped
390
+
391
+ def test_curated_strips_bare_uuid_session_tag(self):
392
+ value, err = self._compute([self._UUID, "lesson"])
393
+ self.assertIsNone(err)
394
+ self.assertEqual(value, [["lesson"]])
395
+
396
+ def test_curated_strips_sidechain_sub_session_tag(self):
397
+ # The dominant observation path: subagent_retain sets
398
+ # `sub_session_id = f"{session_id}-sub-{agent_id}"`, and `retainTags:
399
+ # ["{session_id}"]` resolves to `<parent-uuid>-sub-<agent_id>`. That is
400
+ # per-invocation-unique, so if it survives into the scope every sidechain
401
+ # retain gets its own island and never dedups. It must be treated as
402
+ # volatile (like the bare UUID it derives from) and stripped, while the
403
+ # stable semantic tags stay in scope.
404
+ sub_tag = f"{self._UUID}-sub-af5fba739c0ee6b38"
405
+ value, err = self._compute([sub_tag, "sidechain", "agent_type:worker"])
406
+ self.assertIsNone(err)
407
+ self.assertEqual(value, [["agent_type:worker", "sidechain"]])
408
+
409
+ def test_curated_all_volatile_falls_back_to_shared(self):
410
+ # A retain tagged ONLY with volatile provenance has no stable scope, so
411
+ # it pools into the one bank-wide untagged scope instead of an island.
412
+ value, err = self._compute([self._UUID, "parent_session:xyz"])
413
+ self.assertIsNone(err)
414
+ self.assertEqual(value, "shared")
415
+
416
+ def test_curated_no_tags_is_shared(self):
417
+ self.assertEqual(self._compute([])[0], "shared")
418
+ self.assertEqual(self._compute(None)[0], "shared")
419
+
420
+ def test_curated_scope_is_deterministically_sorted(self):
421
+ # Same tag set in any order → identical scope (so it dedups, not shards).
422
+ a, _ = self._compute(["z:1", "a:2", "m:3"])
423
+ b, _ = self._compute(["m:3", "z:1", "a:2"])
424
+ self.assertEqual(a, b)
425
+ self.assertEqual(a, [["a:2", "m:3", "z:1"]])
426
+
427
+ def test_curated_dedups_repeated_tags(self):
428
+ value, _ = self._compute(["lesson", "lesson", "sidechain"])
429
+ self.assertEqual(value, [["lesson", "sidechain"]])
430
+
431
+ # --- opt-out ----------------------------------------------------------
432
+ def test_combined_emits_no_scope(self):
433
+ self.assertEqual(
434
+ self._compute(["lesson"], observationScopeStrategy="combined"), (None, None)
435
+ )
436
+
437
+ def test_off_emits_no_scope(self):
438
+ self.assertEqual(
439
+ self._compute(["lesson"], observationScopeStrategy="off"), (None, None)
440
+ )
441
+
442
+ def test_shared_strategy_is_uniform(self):
443
+ value, err = self._compute(["lesson", "sidechain"], observationScopeStrategy="shared")
444
+ self.assertIsNone(err)
445
+ self.assertEqual(value, "shared")
446
+
447
+ # --- precedence / robustness -----------------------------------------
448
+ def test_manual_pin_wins_over_strategy(self):
449
+ # An operator who pinned per_tag keeps it even though curated is default.
450
+ value, err = self._compute(
451
+ ["lesson"], observationScopes="per_tag", observationScopeStrategy="curated"
452
+ )
453
+ self.assertIsNone(err)
454
+ self.assertEqual(value, "per_tag")
455
+
456
+ def test_manual_pin_typo_degrades_to_none_with_error_not_curated(self):
457
+ # A typo'd pin must NOT silently fall through to curated — it degrades to
458
+ # the engine default (None) and carries an error, exactly like the
459
+ # pre-strategy behaviour, so the misconfiguration stays visible.
460
+ value, err = self._compute(["lesson"], observationScopes="shred")
461
+ self.assertIsNone(value)
462
+ self.assertIsNotNone(err)
463
+ self.assertIn("shred", err)
464
+
465
+ def test_unknown_strategy_degrades_to_curated_with_error(self):
466
+ value, err = self._compute(["lesson"], observationScopeStrategy="curatd")
467
+ self.assertEqual(value, [["lesson"]])
468
+ self.assertIsNotNone(err)
469
+ self.assertIn("curatd", err)
470
+ for s in OBSERVATION_SCOPE_STRATEGIES:
471
+ self.assertIn(s, err)
472
+
473
+ def test_empty_strategy_string_is_curated(self):
474
+ # An empty export hands authority back to the default, same idiom as
475
+ # the bare-string scope path.
476
+ self.assertEqual(self._compute(["lesson"], observationScopeStrategy="")[0], [["lesson"]])
477
+ self.assertEqual(self._compute(["lesson"], observationScopeStrategy=" ")[0], [["lesson"]])
478
+
479
+ def test_strategy_is_case_insensitive(self):
480
+ self.assertEqual(
481
+ self._compute(["lesson"], observationScopeStrategy="COMBINED"), (None, None)
482
+ )
483
+
484
+ def test_custom_volatile_patterns_override_defaults(self):
485
+ # An operator can declare a bank-specific provenance tag volatile.
486
+ value, err = self._compute(
487
+ ["run:42", "lesson"],
488
+ observationScopeVolatilePatterns=[r"^run:"],
489
+ )
490
+ self.assertIsNone(err)
491
+ self.assertEqual(value, [["lesson"]])
492
+
493
+ def test_bad_volatile_pattern_is_skipped_not_fatal(self):
494
+ # A malformed regex must not kill the retain — it is dropped and the
495
+ # remaining (valid) matchers still curate.
496
+ err_out = io.StringIO()
497
+ with contextlib.redirect_stderr(err_out):
498
+ value, err = self._compute(
499
+ [self._UUID, "lesson"],
500
+ observationScopeVolatilePatterns=["(", r"^parent_session:",
501
+ r"^[0-9a-fA-F-]{36}$"],
502
+ )
503
+ self.assertIsNone(err)
504
+ self.assertEqual(value, [["lesson"]])
505
+
506
+ def test_never_raises_on_junk_tags(self):
507
+ # Non-string tags in the list must be ignored, not explode.
508
+ value, err = self._compute(["lesson", None, 42, "", "sidechain"])
509
+ self.assertIsNone(err)
510
+ self.assertEqual(value, [["lesson", "sidechain"]])
511
+
512
+
324
513
  if __name__ == "__main__": # pragma: no cover
325
514
  unittest.main()
@@ -414,8 +414,15 @@ class TestObservationScopes(DurabilityTestBase):
414
414
  os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = value
415
415
  self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
416
416
 
417
- # -- default: nothing changes -------------------------------------------
418
- def test_unconfigured_stop_retain_posts_no_scope(self):
417
+ def _set_strategy(self, value):
418
+ os.environ["HINDSIGHT_OBSERVATION_SCOPE_STRATEGY"] = value
419
+ self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY", None)
420
+
421
+ # -- opt-out: byte-identical to pre-feature -----------------------------
422
+ def test_opted_out_stop_retain_posts_no_scope(self):
423
+ # observationScopeStrategy=combined restores the pre-feature body: no
424
+ # scope on the wire so the engine default stands.
425
+ self._set_strategy("combined")
419
426
  hook = self._hook("plainsess")
420
427
  with mock.patch("retain.increment_turn_count", return_value=3), \
421
428
  mock.patch("sys.stdin", _stdin(hook)):
@@ -423,6 +430,15 @@ class TestObservationScopes(DurabilityTestBase):
423
430
  self.assertTrue(self.daemon.observation_scopes_seen)
424
431
  self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
425
432
 
433
+ # -- default ON: a curated scope reaches the Stop-hook POST --------------
434
+ def test_default_stop_retain_posts_a_curated_scope(self):
435
+ hook = self._hook("plainsess")
436
+ with mock.patch("retain.increment_turn_count", return_value=3), \
437
+ mock.patch("sys.stdin", _stdin(hook)):
438
+ retain.main()
439
+ self.assertTrue(self.daemon.observation_scopes_seen)
440
+ self.assertTrue(all(s is not None for s in self.daemon.observation_scopes_seen))
441
+
426
442
  # -- Stop hook (retain.py) ----------------------------------------------
427
443
  def test_configured_stop_retain_posts_the_scope(self):
428
444
  self._set_scope("shared")
@@ -339,9 +339,10 @@ class RunSubagentRetain(unittest.TestCase):
339
339
  self.assertEqual(captured["context"], "claude-code-sidechain")
340
340
  self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
341
341
 
342
- def test_sidechain_retain_omits_the_scope_when_unconfigured(self):
343
- # switchroom — default behaviour must be byte-identical: the sidechain
344
- # POST carries no scope, so the engine default stands.
342
+ def test_sidechain_retain_omits_the_scope_when_opted_out(self):
343
+ # switchroom — opt-out (observationScopeStrategy=combined) must be
344
+ # byte-identical to the pre-feature body: the sidechain POST carries no
345
+ # scope, so the engine default stands.
345
346
  with tempfile.TemporaryDirectory() as d:
346
347
  sc = os.path.join(d, "agent-af5.jsonl")
347
348
  _write_sidechain(sc, 8, chars_per_msg=400)
@@ -352,10 +353,62 @@ class RunSubagentRetain(unittest.TestCase):
352
353
  "transcript_path": os.path.join(d, "parentsess.jsonl"),
353
354
  "cwd": d,
354
355
  }
355
- result, captured = self._run(hook_input)
356
+ result, captured = self._run(
357
+ hook_input, config_extra={"observationScopeStrategy": "combined"}
358
+ )
356
359
  self.assertEqual(result["status"], "ok")
357
360
  self.assertIsNone(captured["observation_scopes"])
358
361
 
362
+ def test_sidechain_retain_curates_the_scope_by_default(self):
363
+ # switchroom default ON: every VOLATILE per-session provenance tag is
364
+ # STRIPPED from the consolidation scope so sidechain observations dedup
365
+ # across parent sessions, while the stable semantic tags (`sidechain`,
366
+ # `agent_type:*`) are KEPT on the scope (so recall's sidechain:0.8
367
+ # demotion still fires on the observation).
368
+ #
369
+ # Two volatile tags must be stripped, and the second is the one the
370
+ # Fable review caught leaking:
371
+ # 1. `parent_session:<uuid>` — the explicit parent link.
372
+ # 2. `<uuid>-sub-<agent_id>` — what `retainTags: ["{session_id}"]`
373
+ # resolves to on the sidechain path, since subagent_retain sets
374
+ # `sub_session_id = f"{session_id}-sub-{agent_id}"`. This is
375
+ # per-invocation-unique; if it survives into the scope, sidechain
376
+ # retains (the dominant observation volume) never dedup and the
377
+ # feature silently fails on its primary path.
378
+ #
379
+ # The session_id is a real RFC-4122 UUID here (not a synthetic slug)
380
+ # precisely because the strip is anchored on the UUID shape — a slug
381
+ # would not exercise the volatile pattern and the test would pass on the
382
+ # pre-fix code. Assert EXACT scope equality so the test fails on the bug
383
+ # it guards, not merely on a membership check.
384
+ session_id = "a1b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d"
385
+ with tempfile.TemporaryDirectory() as d:
386
+ sc = os.path.join(d, "agent-af5.jsonl")
387
+ _write_sidechain(sc, 8, chars_per_msg=400)
388
+ hook_input = {
389
+ "session_id": session_id,
390
+ "agent_id": "af5",
391
+ "agent_type": "worker",
392
+ "agent_transcript_path": sc,
393
+ "transcript_path": os.path.join(d, f"{session_id}.jsonl"),
394
+ "cwd": d,
395
+ }
396
+ result, captured = self._run(hook_input)
397
+ self.assertEqual(result["status"], "ok")
398
+ scope = captured["observation_scopes"]
399
+ # EXACT curated scope: only the stable semantic tags survive, sorted.
400
+ self.assertEqual(scope, [["agent_type:worker", "sidechain"]])
401
+
402
+ # And the specific volatile tags are provably gone from the scope.
403
+ scope_tags = {t for group in scope for t in group}
404
+ sub_session_tag = f"{session_id}-sub-af5"
405
+ self.assertNotIn(f"parent_session:{session_id}", scope_tags)
406
+ self.assertNotIn(sub_session_tag, scope_tags)
407
+
408
+ # The source-fact tags are untouched — the stripped tags stay queryable.
409
+ self.assertIn(f"parent_session:{session_id}", captured["tags"])
410
+ self.assertIn(sub_session_tag, captured["tags"])
411
+
359
412
  def test_sidechain_retain_posts_the_configured_scope(self):
360
413
  # switchroom — sidechain retains are their own hand-enumerated kwarg
361
414
  # list; without this pin they would silently keep per-tag scopes while