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.
- package/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- 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()
|