switchroom 0.19.38 → 0.19.40

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 (45) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +15 -6
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +1141 -566
  5. package/dist/host-control/main.js +220 -8
  6. package/dist/vault/approvals/kernel-server.js +15 -6
  7. package/dist/vault/broker/server.js +15 -6
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/dist/bridge/bridge.js +8 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +450 -206
  12. package/telegram-plugin/dist/server.js +8 -0
  13. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  14. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  15. package/telegram-plugin/gateway/gateway.ts +3 -3
  16. package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
  17. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  18. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  19. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  20. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  21. package/telegram-plugin/gateway/stream-render.ts +277 -18
  22. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  23. package/telegram-plugin/hooks/hooks.json +11 -0
  24. package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
  25. package/telegram-plugin/session-tail.ts +20 -0
  26. package/telegram-plugin/silence-poke.ts +28 -0
  27. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
  28. package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
  29. package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
  30. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  31. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  32. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  33. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  34. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  35. package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  37. package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
  38. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  39. package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
  40. package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +19 -15
  42. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
  43. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
  45. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
@@ -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