switchroom 0.19.2 → 0.19.3

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 (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -25,6 +25,7 @@ Stdlib-only.
25
25
  import os
26
26
  import sys
27
27
  import unittest
28
+ import unittest.mock
28
29
 
29
30
  SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
30
31
  if SCRIPTS_DIR not in sys.path:
@@ -32,11 +33,14 @@ if SCRIPTS_DIR not in sys.path:
32
33
 
33
34
  from directive_verify import ( # noqa: E402
34
35
  _VERIFY_BLOCK_REASON,
36
+ _is_directive_write_tool,
35
37
  directive_recorded_after,
36
38
  evaluate,
37
39
  find_last_human_turn,
40
+ invalidate_cache_on_directive_write,
38
41
  is_synthetic_inbound,
39
42
  looks_like_durable_directive,
43
+ turn_contains_directive_write,
40
44
  )
41
45
  from recall import looks_like_standing_rule # noqa: E402
42
46
 
@@ -512,5 +516,170 @@ class TestBlockReason(unittest.TestCase):
512
516
  self.assertIn("<directive_capture_verify>", _VERIFY_BLOCK_REASON)
513
517
 
514
518
 
519
+ class TestDirectiveWriteDetection(unittest.TestCase):
520
+ """A4 — the Stop-hook detector that decides whether the just-ended turn
521
+ wrote a directive (and should therefore invalidate the recall cache)."""
522
+
523
+ def test_write_tool_names_recognised(self):
524
+ for name in (
525
+ "create_directive",
526
+ "update_directive",
527
+ "delete_directive",
528
+ "mcp__hindsight__create_directive",
529
+ "mcp__hindsight__update_directive",
530
+ ):
531
+ self.assertTrue(_is_directive_write_tool(name), name)
532
+
533
+ def test_read_and_unrelated_tools_ignored(self):
534
+ for name in ("list_directives", "recall", "retain", "reflect", "", None):
535
+ self.assertFalse(_is_directive_write_tool(name), name)
536
+
537
+ def _assistant_tool_use(self, tool_name):
538
+ return {
539
+ "role": "assistant",
540
+ "content": [{"type": "tool_use", "id": "t1", "name": tool_name, "input": {}}],
541
+ }
542
+
543
+ def test_turn_with_create_directive_detected(self):
544
+ messages = [
545
+ {"role": "user", "content": "From now on call me Ken."},
546
+ self._assistant_tool_use("mcp__hindsight__create_directive"),
547
+ ]
548
+ self.assertTrue(turn_contains_directive_write(messages, 0))
549
+
550
+ def test_turn_without_write_not_detected(self):
551
+ messages = [
552
+ {"role": "user", "content": "What did we decide?"},
553
+ self._assistant_tool_use("mcp__hindsight__list_directives"),
554
+ ]
555
+ self.assertFalse(turn_contains_directive_write(messages, 0))
556
+
557
+ def test_write_before_start_index_ignored(self):
558
+ # A directive write in a PRIOR turn (before the current human turn) does
559
+ # not count — only writes after start_index are this turn's.
560
+ messages = [
561
+ self._assistant_tool_use("create_directive"), # prior turn
562
+ {"role": "user", "content": "Now do something else."},
563
+ {"role": "assistant", "content": "Sure."},
564
+ ]
565
+ self.assertFalse(turn_contains_directive_write(messages, 1))
566
+
567
+
568
+ class TestCacheInvalidationHook(unittest.TestCase):
569
+ """A4 — invalidate_cache_on_directive_write end-to-end: a real transcript
570
+ containing a directive write deletes a seeded cache file."""
571
+
572
+ def setUp(self):
573
+ import tempfile
574
+
575
+ self._tmp = tempfile.TemporaryDirectory()
576
+ self.addCleanup(self._tmp.cleanup)
577
+ self._patch = unittest.mock.patch.dict(
578
+ os.environ, {"CLAUDE_PLUGIN_DATA": self._tmp.name}
579
+ )
580
+ self._patch.start()
581
+ self.addCleanup(self._patch.stop)
582
+
583
+ def _seed_cache(self, bank_id="bank1"):
584
+ from lib.directives import _cache_name
585
+ from lib.state import read_state, write_state
586
+
587
+ write_state(_cache_name(bank_id), {"ts": 1000.0, "bank_id": bank_id, "directives": []})
588
+ self.assertIsNotNone(read_state(_cache_name(bank_id), None))
589
+
590
+ def _cache_present(self, bank_id="bank1"):
591
+ from lib.directives import _cache_name
592
+ from lib.state import read_state
593
+
594
+ return read_state(_cache_name(bank_id), None) is not None
595
+
596
+ def test_directive_write_invalidates_cache(self):
597
+ self._seed_cache()
598
+ messages = [
599
+ {"role": "user", "content": "From now on call me Ken."},
600
+ {
601
+ "role": "assistant",
602
+ "content": [
603
+ {"type": "tool_use", "id": "t1", "name": "create_directive", "input": {}}
604
+ ],
605
+ },
606
+ ]
607
+ invalidate_cache_on_directive_write(messages, {})
608
+ self.assertFalse(self._cache_present())
609
+
610
+ def test_no_write_leaves_cache_intact(self):
611
+ self._seed_cache()
612
+ messages = [
613
+ {"role": "user", "content": "What's the weather?"},
614
+ {"role": "assistant", "content": "Sunny."},
615
+ ]
616
+ invalidate_cache_on_directive_write(messages, {})
617
+ self.assertTrue(self._cache_present())
618
+
619
+ def test_empty_messages_is_noop(self):
620
+ self._seed_cache()
621
+ invalidate_cache_on_directive_write([], {})
622
+ self.assertTrue(self._cache_present())
623
+
624
+
625
+ class TestMainSingleTranscriptRead(unittest.TestCase):
626
+ """A4 finding 2 — main() must parse the transcript AT MOST ONCE (shared by
627
+ cache-invalidation + capture-verify), and not at all when nothing needs it.
628
+ A Stop hook on a multi-MB session cannot afford a doubled multi-second
629
+ parse inside its 10s budget."""
630
+
631
+ def _run_main_counting_reads(self, config, stop_hook_active=False):
632
+ import io
633
+ import json
634
+
635
+ import directive_verify as dv
636
+
637
+ calls = {"n": 0}
638
+
639
+ def _counting_read(_path):
640
+ calls["n"] += 1
641
+ return []
642
+
643
+ hook_input = {"transcript_path": "/some/transcript", "stop_hook_active": stop_hook_active}
644
+ with unittest.mock.patch.object(dv, "read_transcript", side_effect=_counting_read), \
645
+ unittest.mock.patch.object(dv, "load_config", return_value=config), \
646
+ unittest.mock.patch("sys.stdin", new=io.StringIO(json.dumps(hook_input))), \
647
+ unittest.mock.patch("sys.stdout", new=io.StringIO()):
648
+ dv.main()
649
+ return calls["n"]
650
+
651
+ def test_reads_once_when_cache_and_verify_on(self):
652
+ n = self._run_main_counting_reads({
653
+ "directivesCacheTtlSeconds": 120,
654
+ "directiveCaptureNudge": True,
655
+ "directiveCaptureVerify": True,
656
+ })
657
+ self.assertEqual(n, 1)
658
+
659
+ def test_reads_once_when_only_cache_on(self):
660
+ n = self._run_main_counting_reads({
661
+ "directivesCacheTtlSeconds": 120,
662
+ "directiveCaptureNudge": False,
663
+ "directiveCaptureVerify": False,
664
+ })
665
+ self.assertEqual(n, 1)
666
+
667
+ def test_reads_once_when_only_verify_on(self):
668
+ n = self._run_main_counting_reads({
669
+ "directivesCacheTtlSeconds": 0,
670
+ "directiveCaptureNudge": True,
671
+ "directiveCaptureVerify": True,
672
+ })
673
+ self.assertEqual(n, 1)
674
+
675
+ def test_reads_zero_when_all_disabled(self):
676
+ n = self._run_main_counting_reads({
677
+ "directivesCacheTtlSeconds": 0,
678
+ "directiveCaptureNudge": False,
679
+ "directiveCaptureVerify": False,
680
+ })
681
+ self.assertEqual(n, 0)
682
+
683
+
515
684
  if __name__ == "__main__":
516
685
  unittest.main()
@@ -10,6 +10,7 @@ Run from the repo root:
10
10
 
11
11
  import os
12
12
  import sys
13
+ import tempfile
13
14
  import unittest
14
15
  from io import StringIO
15
16
  from unittest.mock import patch
@@ -20,12 +21,17 @@ if SCRIPTS_DIR not in sys.path:
20
21
  sys.path.insert(0, SCRIPTS_DIR)
21
22
 
22
23
  from lib.directives import ( # noqa: E402
24
+ DIRECTIVES_CACHE_TTL_SECONDS,
23
25
  MAX_DIRECTIVES,
26
+ _cache_name,
24
27
  fetch_active_directives,
28
+ fetch_active_directives_cached,
25
29
  format_active_directives_block,
30
+ invalidate_directives_cache,
26
31
  parse_active_directives_block,
27
32
  rule_already_captured,
28
33
  )
34
+ from lib.state import read_state, write_state # noqa: E402
29
35
 
30
36
 
31
37
  class _StubClient:
@@ -294,5 +300,176 @@ class TestDirectiveDedup(unittest.TestCase):
294
300
  )
295
301
 
296
302
 
303
+ class _CountingClient:
304
+ """List-directives stub that can vary its behaviour per call.
305
+
306
+ ``responses`` / ``excs`` are per-call sequences (last value repeats). Counts
307
+ total calls so a test can assert cache hits saved the HTTP round-trip.
308
+ """
309
+
310
+ def __init__(self, response=None, exc=None):
311
+ self._response = response
312
+ self._exc = exc
313
+ self.calls = 0
314
+
315
+ def list_directives(self, bank_id, active_only=True, timeout=2):
316
+ self.calls += 1
317
+ if self._exc is not None:
318
+ raise self._exc
319
+ return self._response
320
+
321
+
322
+ class DirectivesCacheTests(unittest.TestCase):
323
+ """A4 — directives-list cache: hit/miss, TTL expiry, invalidation on write,
324
+ corrupted-cache fallback. State is isolated to a temp CLAUDE_PLUGIN_DATA."""
325
+
326
+ def setUp(self):
327
+ self._tmp = tempfile.TemporaryDirectory()
328
+ self._env = patch.dict(os.environ, {"CLAUDE_PLUGIN_DATA": self._tmp.name})
329
+ self._env.start()
330
+
331
+ def tearDown(self):
332
+ self._env.stop()
333
+ self._tmp.cleanup()
334
+
335
+ def _resp(self, *names):
336
+ return {"items": [_directive(n, f"content {n}", priority=5) for n in names]}
337
+
338
+ def test_cache_hit_skips_second_http_call(self):
339
+ # Acceptance (a): two consecutive recalls with no write → exactly one
340
+ # list_directives call; both return the same content.
341
+ client = _CountingClient(response=self._resp("alpha"))
342
+ first = fetch_active_directives_cached(client, "bank1", now=1000.0)
343
+ second = fetch_active_directives_cached(client, "bank1", now=1000.5)
344
+ self.assertEqual(client.calls, 1)
345
+ self.assertEqual([d["name"] for d in first], ["alpha"])
346
+ self.assertEqual([d["name"] for d in second], ["alpha"])
347
+
348
+ def test_cache_miss_when_no_prior_entry(self):
349
+ client = _CountingClient(response=self._resp("alpha"))
350
+ result = fetch_active_directives_cached(client, "bank1", now=1000.0)
351
+ self.assertEqual(client.calls, 1)
352
+ self.assertEqual([d["name"] for d in result], ["alpha"])
353
+ # Cache file was written.
354
+ cached = read_state(_cache_name("bank1"), None)
355
+ self.assertIsInstance(cached, dict)
356
+ self.assertEqual(cached["ts"], 1000.0)
357
+
358
+ def test_ttl_expiry_refetches(self):
359
+ client = _CountingClient(response=self._resp("alpha"))
360
+ fetch_active_directives_cached(client, "bank1", ttl_seconds=120, now=1000.0)
361
+ # Just inside TTL → hit.
362
+ fetch_active_directives_cached(client, "bank1", ttl_seconds=120, now=1000.0 + 119)
363
+ self.assertEqual(client.calls, 1)
364
+ # Just past TTL → miss → refetch.
365
+ fetch_active_directives_cached(client, "bank1", ttl_seconds=120, now=1000.0 + 121)
366
+ self.assertEqual(client.calls, 2)
367
+
368
+ def test_ttl_zero_disables_cache(self):
369
+ # Rollback lever: TTL=0 → live fetch every time, nothing written.
370
+ client = _CountingClient(response=self._resp("alpha"))
371
+ fetch_active_directives_cached(client, "bank1", ttl_seconds=0, now=1000.0)
372
+ fetch_active_directives_cached(client, "bank1", ttl_seconds=0, now=1000.1)
373
+ self.assertEqual(client.calls, 2)
374
+ self.assertIsNone(read_state(_cache_name("bank1"), None))
375
+
376
+ def test_invalidation_forces_refetch(self):
377
+ # Acceptance (a) in-session: a directive write invalidates the cache, so
378
+ # the next recall re-fetches (fresh directive becomes visible).
379
+ client = _CountingClient(response=self._resp("alpha"))
380
+ fetch_active_directives_cached(client, "bank1", now=1000.0)
381
+ self.assertEqual(client.calls, 1)
382
+ invalidate_directives_cache() # bank-agnostic sweep (Stop-hook path)
383
+ fetch_active_directives_cached(client, "bank1", now=1000.1)
384
+ self.assertEqual(client.calls, 2)
385
+
386
+ def test_invalidation_specific_bank_only(self):
387
+ client = _CountingClient(response=self._resp("alpha"))
388
+ fetch_active_directives_cached(client, "bankA", now=1000.0)
389
+ fetch_active_directives_cached(client, "bankB", now=1000.0)
390
+ self.assertEqual(client.calls, 2)
391
+ invalidate_directives_cache("bankA")
392
+ # bankA re-fetches, bankB still served from cache.
393
+ fetch_active_directives_cached(client, "bankA", now=1000.1)
394
+ self.assertEqual(client.calls, 3)
395
+ fetch_active_directives_cached(client, "bankB", now=1000.1)
396
+ self.assertEqual(client.calls, 3)
397
+
398
+ def test_corrupted_cache_falls_back_to_live(self):
399
+ # A non-JSON cache file → read_state returns default → live fetch.
400
+ from lib.state import _state_file # noqa: PLC0415
401
+
402
+ path = _state_file(_cache_name("bank1"))
403
+ with open(path, "w") as f:
404
+ f.write("{ this is not valid json ]")
405
+ client = _CountingClient(response=self._resp("alpha"))
406
+ result = fetch_active_directives_cached(client, "bank1", now=1000.0)
407
+ self.assertEqual(client.calls, 1)
408
+ self.assertEqual([d["name"] for d in result], ["alpha"])
409
+
410
+ def test_wrong_shape_cache_falls_back_to_live(self):
411
+ # Valid JSON but missing the ts/directives envelope → treated as a miss.
412
+ write_state(_cache_name("bank1"), {"directives": [{"name": "stale"}]})
413
+ client = _CountingClient(response=self._resp("fresh"))
414
+ result = fetch_active_directives_cached(client, "bank1", now=1000.0)
415
+ self.assertEqual(client.calls, 1)
416
+ self.assertEqual([d["name"] for d in result], ["fresh"])
417
+
418
+ def test_bool_timestamp_rejected_as_corrupt(self):
419
+ # bool is an int subclass — guard must not accept True as a timestamp.
420
+ write_state(_cache_name("bank1"), {"ts": True, "directives": []})
421
+ client = _CountingClient(response=self._resp("fresh"))
422
+ fetch_active_directives_cached(client, "bank1", now=1000.0)
423
+ self.assertEqual(client.calls, 1)
424
+
425
+ def test_failed_fetch_not_cached(self):
426
+ # A transient fetch FAILURE must not poison the cache with [] — the next
427
+ # call must retry live rather than serving a cached empty list.
428
+ failing = _CountingClient(exc=RuntimeError("HTTP 503"))
429
+ with patch("sys.stderr", new=StringIO()):
430
+ first = fetch_active_directives_cached(failing, "bank1", now=1000.0)
431
+ self.assertEqual(first, [])
432
+ self.assertIsNone(read_state(_cache_name("bank1"), None))
433
+ # A subsequent successful fetch is served live and then cached.
434
+ ok = _CountingClient(response=self._resp("alpha"))
435
+ result = fetch_active_directives_cached(ok, "bank1", now=1000.1)
436
+ self.assertEqual(ok.calls, 1)
437
+ self.assertEqual([d["name"] for d in result], ["alpha"])
438
+
439
+ def test_empty_bank_is_cached(self):
440
+ # A genuinely empty bank (items: []) is a successful fetch → cacheable,
441
+ # so a directive-free agent also saves the round-trip.
442
+ client = _CountingClient(response={"items": []})
443
+ fetch_active_directives_cached(client, "bank1", now=1000.0)
444
+ fetch_active_directives_cached(client, "bank1", now=1000.1)
445
+ self.assertEqual(client.calls, 1)
446
+
447
+ def test_bank_id_mismatch_falls_back_to_live(self):
448
+ # _safe_filename can collapse two bank ids onto one cache file; an
449
+ # envelope whose embedded bank_id != the requested one must be rejected
450
+ # (a live fetch) rather than serving another bank's directives.
451
+ write_state(
452
+ _cache_name("bank1"),
453
+ {"ts": 1000.0, "bank_id": "a-DIFFERENT-bank", "directives": [{"name": "leaked"}]},
454
+ )
455
+ client = _CountingClient(response=self._resp("mine"))
456
+ result = fetch_active_directives_cached(client, "bank1", now=1000.0)
457
+ self.assertEqual(client.calls, 1)
458
+ self.assertEqual([d["name"] for d in result], ["mine"])
459
+
460
+ def test_future_timestamp_treated_as_expired(self):
461
+ # A stored ts in the future (wall-clock step-back) → negative age → NOT
462
+ # served as fresh; the cache re-fetches instead of pinning stale.
463
+ client = _CountingClient(response=self._resp("alpha"))
464
+ fetch_active_directives_cached(client, "bank1", ttl_seconds=120, now=5000.0)
465
+ self.assertEqual(client.calls, 1)
466
+ # Clock steps back well before the cached ts → age is negative.
467
+ fetch_active_directives_cached(client, "bank1", ttl_seconds=120, now=1000.0)
468
+ self.assertEqual(client.calls, 2)
469
+
470
+ def test_default_ttl_constant(self):
471
+ self.assertEqual(DIRECTIVES_CACHE_TTL_SECONDS, 120)
472
+
473
+
297
474
  if __name__ == "__main__":
298
475
  unittest.main()
@@ -0,0 +1,200 @@
1
+ """Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
2
+ at retain time + recall-side demotion via the PR5 score-penalty weight map.
3
+
4
+ Two halves, both asserted on OUTCOMES:
5
+
6
+ * retain: ``detect_lesson_tags`` tags the intended transcript shapes (explicit
7
+ lesson / anti-pattern markers), stays silent on ordinary transcripts, and is
8
+ fully togglable via config; ``build_retain_payload`` merges the tags onto the
9
+ retain without clobbering configured ``retainTags``.
10
+ * recall: ``_effective_tag_weights`` composes the built-in lesson/anti-pattern
11
+ demotion weights under ``recallTagWeights`` so that, after
12
+ ``_apply_tag_weights`` + ``_sort_by_final_score``, a lesson-tagged memory
13
+ ranks BELOW an equal-score untagged one yet is NEVER hard-dropped — and the
14
+ operator override / rollback levers work.
15
+
16
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
17
+ """
18
+
19
+ import os
20
+ import sys
21
+ import unittest
22
+
23
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
24
+ if SCRIPTS_DIR not in sys.path:
25
+ sys.path.insert(0, SCRIPTS_DIR)
26
+
27
+ from lib.config import DEFAULTS # noqa: E402
28
+ from retain import build_retain_payload, detect_lesson_tags # noqa: E402
29
+ from recall import ( # noqa: E402
30
+ _apply_tag_weights,
31
+ _effective_tag_weights,
32
+ _sort_by_final_score,
33
+ )
34
+
35
+
36
+ def _cfg(**over):
37
+ """A config dict seeded with the real lesson defaults, plus overrides."""
38
+ c = {
39
+ "lessonTagging": DEFAULTS["lessonTagging"],
40
+ "lessonTagMarkers": DEFAULTS["lessonTagMarkers"],
41
+ "lessonDemotion": DEFAULTS["lessonDemotion"],
42
+ "lessonDemotionWeights": DEFAULTS["lessonDemotionWeights"],
43
+ "recallTagWeights": {},
44
+ }
45
+ c.update(over)
46
+ return c
47
+
48
+
49
+ def _mem(text, final, tags=None):
50
+ return {"text": text, "tags": tags or [], "scores": {"final": final}}
51
+
52
+
53
+ class DetectLessonTags(unittest.TestCase):
54
+ def test_lesson_marker_tags_lesson(self):
55
+ t = "The lesson learned here is to always dispatch before narrating."
56
+ self.assertEqual(detect_lesson_tags(t, _cfg()), ["lesson"])
57
+
58
+ def test_note_to_self_tags_lesson(self):
59
+ t = "Note to self: run the scoped suite before pushing next time."
60
+ self.assertEqual(detect_lesson_tags(t, _cfg()), ["lesson"])
61
+
62
+ def test_anti_pattern_marker_tags_anti_pattern(self):
63
+ t = "anti-pattern: announcing a dispatch without invoking the Agent tool."
64
+ self.assertEqual(detect_lesson_tags(t, _cfg()), ["anti-pattern"])
65
+
66
+ def test_what_not_to_do_tags_anti_pattern(self):
67
+ t = "Here is what not to do: reply 'dispatching' and never dispatch."
68
+ self.assertEqual(detect_lesson_tags(t, _cfg()), ["anti-pattern"])
69
+
70
+ def test_both_markers_yield_both_tags_sorted(self):
71
+ t = "Lesson learned from this anti-pattern: verify before asserting."
72
+ self.assertEqual(detect_lesson_tags(t, _cfg()), ["anti-pattern", "lesson"])
73
+
74
+ def test_case_insensitive(self):
75
+ t = "LESSON LEARNED: read the ground truth."
76
+ self.assertEqual(detect_lesson_tags(t, _cfg()), ["lesson"])
77
+
78
+ def test_ordinary_transcript_untagged(self):
79
+ t = "User asked to refactor the auth module. Assistant edited login.ts."
80
+ self.assertEqual(detect_lesson_tags(t, _cfg()), [])
81
+
82
+ def test_bare_word_lesson_does_not_overtag(self):
83
+ # A passing mention of "lesson" without an explicit marker must NOT tag.
84
+ t = "The history lesson from yesterday's chat was interesting."
85
+ self.assertEqual(detect_lesson_tags(t, _cfg()), [])
86
+
87
+ def test_disabled_is_noop(self):
88
+ t = "lesson learned: always branch off fresh main."
89
+ self.assertEqual(detect_lesson_tags(t, _cfg(lessonTagging=False)), [])
90
+
91
+ def test_empty_or_bad_inputs(self):
92
+ self.assertEqual(detect_lesson_tags("", _cfg()), [])
93
+ self.assertEqual(detect_lesson_tags(None, _cfg()), [])
94
+ self.assertEqual(detect_lesson_tags("lesson learned", _cfg(lessonTagMarkers={})), [])
95
+
96
+ def test_custom_markers_override(self):
97
+ cfg = _cfg(lessonTagMarkers={"gotcha": ["heads up:"]})
98
+ self.assertEqual(detect_lesson_tags("Heads up: the port shifts.", cfg), ["gotcha"])
99
+
100
+
101
+ class BuildRetainPayloadTagMerge(unittest.TestCase):
102
+ def _payload_tags(self, transcript_text, **cfg_over):
103
+ cfg = _cfg(
104
+ retainRoles=["user", "assistant"],
105
+ retainToolCalls=True,
106
+ retainContext="claude-code",
107
+ retainMetadata={},
108
+ retainTags=["{session_id}"],
109
+ **cfg_over,
110
+ )
111
+ msgs = [
112
+ {"role": "user", "content": "how do I dispatch?", "uuid": "u1"},
113
+ {"role": "assistant", "content": transcript_text, "uuid": "a1"},
114
+ ]
115
+ built = build_retain_payload(
116
+ cfg,
117
+ "sess-123",
118
+ msgs,
119
+ msgs,
120
+ bank_id="bank",
121
+ api_url="http://x",
122
+ api_token=None,
123
+ )
124
+ self.assertIsNotNone(built)
125
+ return built["payload"]["tags"]
126
+
127
+ def test_lesson_tag_appended_alongside_configured_tags(self):
128
+ tags = self._payload_tags("anti-pattern: narrate without executing")
129
+ self.assertIn("sess-123", tags) # configured retainTag survives
130
+ self.assertIn("anti-pattern", tags) # detected tag added
131
+
132
+ def test_no_lesson_tag_on_ordinary_transcript(self):
133
+ tags = self._payload_tags("Edited the login handler and ran tests.")
134
+ self.assertEqual(tags, ["sess-123"])
135
+
136
+ def test_tagging_disabled_leaves_only_configured(self):
137
+ tags = self._payload_tags("lesson learned: pull before branching",
138
+ lessonTagging=False)
139
+ self.assertEqual(tags, ["sess-123"])
140
+
141
+
142
+ class EffectiveTagWeights(unittest.TestCase):
143
+ def test_builtins_present_by_default(self):
144
+ w = _effective_tag_weights(_cfg())
145
+ self.assertEqual(w["lesson"], 0.85)
146
+ self.assertEqual(w["anti-pattern"], 0.5)
147
+
148
+ def test_recall_tag_weights_compose_and_override(self):
149
+ # sidechain seed composes; an explicit lesson override wins over builtin.
150
+ w = _effective_tag_weights(
151
+ _cfg(recallTagWeights={"sidechain": 0.8, "lesson": 0.95})
152
+ )
153
+ self.assertEqual(w["sidechain"], 0.8)
154
+ self.assertEqual(w["lesson"], 0.95) # operator override wins
155
+ self.assertEqual(w["anti-pattern"], 0.5) # builtin retained
156
+
157
+ def test_rollback_drops_builtins(self):
158
+ w = _effective_tag_weights(
159
+ _cfg(lessonDemotion=False, recallTagWeights={"sidechain": 0.8})
160
+ )
161
+ self.assertEqual(w, {"sidechain": 0.8})
162
+ self.assertNotIn("lesson", w)
163
+
164
+
165
+ class RecallDemotionOutcome(unittest.TestCase):
166
+ """End-to-end recall outcome: tag → weight → sort."""
167
+
168
+ def test_lesson_ranks_below_equal_score_untagged(self):
169
+ neutral = _mem("clean session fact", 0.50, [])
170
+ lesson = _mem("lesson transcript", 0.50, ["lesson"])
171
+ results = [lesson, neutral] # lesson first pre-weight
172
+ w = _effective_tag_weights(_cfg())
173
+ _apply_tag_weights(results, w)
174
+ _sort_by_final_score(results)
175
+ self.assertEqual(results[0]["text"], "clean session fact")
176
+ self.assertEqual(results[1]["text"], "lesson transcript")
177
+
178
+ def test_anti_pattern_never_dropped_when_only_hit(self):
179
+ only = _mem("the only relevant fact", 0.42, ["anti-pattern"])
180
+ results = [only]
181
+ w = _effective_tag_weights(_cfg())
182
+ _apply_tag_weights(results, w)
183
+ _sort_by_final_score(results)
184
+ self.assertEqual(len(results), 1) # re-rank, never a hard drop
185
+ self.assertAlmostEqual(results[0]["scores"]["final"], 0.42 * 0.5)
186
+
187
+ def test_operator_override_changes_ordering(self):
188
+ # With lesson override to 1.0 (no penalty), the lesson memory keeps parity.
189
+ a = _mem("neutral", 0.50, [])
190
+ b = _mem("lesson", 0.50, ["lesson"])
191
+ results = [b, a]
192
+ w = _effective_tag_weights(_cfg(recallTagWeights={"lesson": 1.0}))
193
+ _apply_tag_weights(results, w)
194
+ _sort_by_final_score(results)
195
+ # Stable sort keeps pre-sort order on the tie → lesson stays first.
196
+ self.assertEqual(results[0]["text"], "lesson")
197
+
198
+
199
+ if __name__ == "__main__":
200
+ unittest.main()