switchroom 0.21.14 → 0.21.16

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 (36) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +7 -2
  3. package/dist/auth-broker/index.js +7 -2
  4. package/dist/cli/notion-write-pretool.mjs +7 -2
  5. package/dist/cli/switchroom.js +2601 -1009
  6. package/dist/host-control/main.js +8 -3
  7. package/dist/vault/approvals/kernel-server.js +7 -2
  8. package/dist/vault/broker/server.js +7 -2
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +9 -0
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
  12. package/skills/mental-model-curator/SKILL.md +187 -56
  13. package/telegram-plugin/dist/gateway/gateway.js +11 -6
  14. package/vendor/hindsight-memory/hooks/hooks.json +10 -0
  15. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  16. package/vendor/hindsight-memory/scripts/lib/config.py +22 -0
  17. package/vendor/hindsight-memory/scripts/lib/directives.py +45 -7
  18. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
  19. package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
  20. package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
  21. package/vendor/hindsight-memory/scripts/recall.py +520 -5
  22. package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
  23. package/vendor/hindsight-memory/scripts/retain.py +167 -28
  24. package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_directives.py +52 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_query_timestamp.py +376 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -0,0 +1,133 @@
1
+ """M4 P-REC test (e) — cap truncation, OUTCOME test.
2
+
3
+ epic §M4 TDD plan (e): "candidate sets larger than 8/4096 are truncated at
4
+ injection, logged truthfully." The truncation mechanism itself
5
+ (``recallMaxMemories`` head-slice / bank-slot reservation, ``recall.py``
6
+ ~2757-2795) already exists pre-M4; this is the M4-scoped regression guard
7
+ that asserts it on RENDERED output and the truthful log row, per the
8
+ red-team's "assert on transport/rendered output, not a flag" rule — not
9
+ merely that ``capped`` was set internally.
10
+
11
+ Stdlib-only; drives ``recall.main()`` end to end against a fake client, no
12
+ network. Harness mirrors ``tests/test_recall_min_score.py``.
13
+ """
14
+
15
+ import io
16
+ import json
17
+ import os
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+ import unittest
22
+ from unittest.mock import patch
23
+
24
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
25
+ if SCRIPTS_DIR not in sys.path:
26
+ sys.path.insert(0, SCRIPTS_DIR)
27
+
28
+ import recall # noqa: E402
29
+
30
+ OWN = "test-bank"
31
+
32
+
33
+ def _memory(text, mem_id, score):
34
+ return {"text": text, "type": "fact", "mentioned_at": "2026-01-01", "id": mem_id,
35
+ "scores": {"final": score}}
36
+
37
+
38
+ class _Client:
39
+ def __init__(self, results):
40
+ self._results = results
41
+
42
+ def list_directives(self, bank_id, active_only=True, timeout=2):
43
+ return {"items": []}
44
+
45
+ def recall(self, bank_id, query, **kwargs):
46
+ return {"results": [dict(m) for m in self._results]}
47
+
48
+
49
+ class CapTruncationTests(unittest.TestCase):
50
+ def setUp(self):
51
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-cap-test-")
52
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
53
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
54
+
55
+ def tearDown(self):
56
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
57
+ if self._prev is None:
58
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
59
+ else:
60
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
61
+
62
+ def _log_row(self):
63
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
64
+ with open(path, encoding="utf-8") as fh:
65
+ rows = [json.loads(line) for line in fh if line.strip()]
66
+ self.assertTrue(rows, "no recall_log row was written")
67
+ return rows[-1]
68
+
69
+ def _run(self, n_candidates, max_memories):
70
+ # Distinct scores so ordering (and therefore which 8 survive) is
71
+ # deterministic: candidate 0 highest score .. candidate N-1 lowest.
72
+ results = [
73
+ _memory(f"candidate {i}", f"m{i}", score=1.0 - (i * 0.01))
74
+ for i in range(n_candidates)
75
+ ]
76
+ client = _Client(results)
77
+ hook_input = {
78
+ "prompt": "what do you remember about the deploy pipeline",
79
+ "session_id": "test-session",
80
+ "transcript_path": "",
81
+ "cwd": "/tmp",
82
+ }
83
+ config = {
84
+ "autoRecall": True,
85
+ "bankId": OWN,
86
+ "recallMaxTokens": 4096,
87
+ "recallMaxMemories": max_memories,
88
+ "recallBudget": "mid",
89
+ "recallContextTurns": 1,
90
+ "recallMaxQueryChars": 800,
91
+ "recallPromptPreamble": "",
92
+ "recallParallelDeadlineSeconds": 5,
93
+ "directivesCacheTtlSeconds": 0,
94
+ }
95
+ stdout = io.StringIO()
96
+ with patch("recall.load_config", return_value=config), \
97
+ patch("recall.get_api_url", return_value="http://fake"), \
98
+ patch("recall.HindsightClient", return_value=client), \
99
+ patch("recall.ensure_bank_mission"), \
100
+ patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
101
+ patch("sys.stdout", stdout):
102
+ recall.main()
103
+ out = stdout.getvalue()
104
+ ctx = None
105
+ if out:
106
+ parsed = json.loads(out)
107
+ ctx = parsed.get("hookSpecificOutput", {}).get("additionalContext")
108
+ return ctx
109
+
110
+ def test_candidate_set_above_cap_is_truncated_in_rendered_output(self):
111
+ ctx = self._run(n_candidates=12, max_memories=8)
112
+ self.assertIsNotNone(ctx)
113
+ injected = sum(1 for i in range(12) if f"candidate {i}" in ctx)
114
+ self.assertEqual(injected, 8, "rendered output must contain exactly the cap's worth of memories")
115
+
116
+ def test_log_row_reports_capped_true_with_truthful_pre_cap_count(self):
117
+ self._run(n_candidates=12, max_memories=8)
118
+ row = self._log_row()
119
+ self.assertTrue(row["capped"])
120
+ self.assertEqual(row["pre_cap_count"], 12)
121
+
122
+ def test_candidate_set_at_or_below_cap_is_not_truncated(self):
123
+ ctx = self._run(n_candidates=8, max_memories=8)
124
+ self.assertIsNotNone(ctx)
125
+ for i in range(8):
126
+ self.assertIn(f"candidate {i}", ctx)
127
+ row = self._log_row()
128
+ self.assertFalse(row["capped"])
129
+ self.assertEqual(row["pre_cap_count"], 8)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ unittest.main()
@@ -0,0 +1,168 @@
1
+ """M4 P-REC test (d) — junk gate for `<task-notification>` envelopes.
2
+
3
+ carve-M4.md packet P-REC, test (d): a `<task-notification>`-prefixed prompt
4
+ must not run the noisy NON-directive recall — no `client.recall`, and no
5
+ non-directive memory in the emitted transport.
6
+
7
+ #4756 F2 directive exemption: DIRECTIVES are the one memory class that must
8
+ SURVIVE the gate — an agent's standing rules apply on every turn, synthetic or
9
+ not. So a gated turn STILL fetches + injects the `<active_directives>` block
10
+ (`client.list_directives`), while the `recall` result set stays suppressed.
11
+ Asserts on TRANSPORT (what reaches stdout + which client methods are touched),
12
+ not on an internal flag, per the red-team's "assert transport, not a flag" rule.
13
+
14
+ Distinct from the gateway's `<channel source=...>` envelope (a real human
15
+ message) — this only matches the CLI-native task-notification wrapper.
16
+ """
17
+
18
+ import io
19
+ import json
20
+ import os
21
+ import shutil
22
+ import sys
23
+ import tempfile
24
+ import unittest
25
+ from unittest.mock import patch
26
+
27
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
28
+ if SCRIPTS_DIR not in sys.path:
29
+ sys.path.insert(0, SCRIPTS_DIR)
30
+
31
+ import recall # noqa: E402
32
+
33
+
34
+ class _DirectiveExemptClient:
35
+ """Models a gated task-notification turn after the #4756 F2 fix.
36
+
37
+ `list_directives` answers with one active directive (which MUST survive the
38
+ gate and be injected). `recall` returns a real memory but records that it
39
+ was called — the gate must NEVER call it, so a hit on `recall_called` (or
40
+ the memory text appearing in transport) is a regression to the pre-fix
41
+ behavior where the gate dropped directives too.
42
+ """
43
+
44
+ def __init__(self):
45
+ self.recall_called = False
46
+
47
+ def list_directives(self, bank_id, active_only=True, timeout=2):
48
+ return {"items": [{
49
+ "id": "d1", "name": "always-sign-off", "priority": 10,
50
+ "content": "always end replies with a wave",
51
+ }]}
52
+
53
+ def recall(self, bank_id, query, **kwargs):
54
+ self.recall_called = True
55
+ return {"results": [{
56
+ "text": "a real memory", "type": "fact", "mentioned_at": "2026-01-01",
57
+ "id": "m1", "scores": {"final": 0.9},
58
+ }]}
59
+
60
+
61
+ class _AnsweringClient:
62
+ """A normal client that DOES answer, used to prove the gate did NOT fire."""
63
+
64
+ def list_directives(self, bank_id, active_only=True, timeout=2):
65
+ return {"items": []}
66
+
67
+ def recall(self, bank_id, query, **kwargs):
68
+ return {"results": [{
69
+ "text": "a real memory", "type": "fact", "mentioned_at": "2026-01-01",
70
+ "id": "m1", "scores": {"final": 0.9},
71
+ }]}
72
+
73
+
74
+ class JunkGateTests(unittest.TestCase):
75
+ def setUp(self):
76
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-junk-gate-test-")
77
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
78
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
79
+
80
+ def tearDown(self):
81
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
82
+ if self._prev is None:
83
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
84
+ else:
85
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
86
+
87
+ def _run(self, prompt, client):
88
+ hook_input = {
89
+ "prompt": prompt,
90
+ "session_id": "test-session",
91
+ "transcript_path": "",
92
+ "cwd": "/tmp",
93
+ }
94
+ config = {
95
+ "autoRecall": True,
96
+ "bankId": "test-bank",
97
+ "recallMaxTokens": 4096,
98
+ "recallBudget": "mid",
99
+ "recallContextTurns": 1,
100
+ "recallMaxQueryChars": 800,
101
+ "recallPromptPreamble": "",
102
+ "recallParallelDeadlineSeconds": 5,
103
+ "directivesCacheTtlSeconds": 0,
104
+ }
105
+ stdout = io.StringIO()
106
+ with patch("recall.load_config", return_value=config), \
107
+ patch("recall.get_api_url", return_value="http://fake"), \
108
+ patch("recall.HindsightClient", return_value=client), \
109
+ patch("recall.ensure_bank_mission"), \
110
+ patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
111
+ patch("sys.stdout", stdout):
112
+ recall.main()
113
+ return stdout.getvalue()
114
+
115
+ def test_task_notification_gate_injects_directives_but_suppresses_recall(self):
116
+ # #4756 F2: on a gated task-notification turn, DIRECTIVES must be
117
+ # injected while the non-directive `recall` result set is suppressed.
118
+ # A test that would pass under the OLD bug (gate emits nothing) is
119
+ # invalid — so assert the directive text is PRESENT (the bug emitted
120
+ # ""), the memory text is ABSENT, and `recall` was never called.
121
+ client = _DirectiveExemptClient()
122
+ out = self._run(
123
+ "<task-notification>a background task finished</task-notification>",
124
+ client,
125
+ )
126
+ self.assertTrue(out, "gated turn must still emit the directives block")
127
+ ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
128
+ self.assertIn("<active_directives>", ctx)
129
+ self.assertIn("always end replies with a wave", ctx)
130
+ self.assertNotIn(
131
+ "a real memory", ctx,
132
+ "gate must suppress the non-directive recall result set",
133
+ )
134
+ self.assertFalse(
135
+ client.recall_called,
136
+ "gated turn must never run the memory recall HTTP call",
137
+ )
138
+
139
+ def test_task_notification_gate_with_no_directives_emits_nothing(self):
140
+ # No active directives → no empty wrapper, no recall: the gate stays a
141
+ # true no-op on the transport (byte-identical to pre-fix when the bank
142
+ # has nothing to inject).
143
+ client = _AnsweringClient() # list_directives returns items: []
144
+ out = self._run(
145
+ "<task-notification>a background task finished</task-notification>",
146
+ client,
147
+ )
148
+ self.assertEqual(
149
+ out, "",
150
+ "gated turn with no directives must emit no transport output",
151
+ )
152
+
153
+ def test_ordinary_prompt_with_task_notification_substring_midstring_is_not_gated(self):
154
+ # Only a PREFIX match gates — a real user prompt that happens to
155
+ # mention the phrase must still recall normally and reach the
156
+ # network (proven here by using an ANSWERING client and asserting
157
+ # the real memory shows up in the rendered transport).
158
+ out = self._run(
159
+ "what does a <task-notification> even look like in our hooks?",
160
+ _AnsweringClient(),
161
+ )
162
+ self.assertTrue(out)
163
+ ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
164
+ self.assertIn("a real memory", ctx)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ unittest.main()
@@ -0,0 +1,124 @@
1
+ """M4 P-REC test (f) — no score floor applied outside degraded mode.
2
+
3
+ epic §M4 TDD plan (f): "a regression test asserting no score floor is
4
+ applied outside degraded mode (guards against the refused recommendation
5
+ sneaking in)." E-13 measured a real rank-1 hit scoring ~0.001 and any floor
6
+ emptied 28% of healthy recalls (#3761) — the floor MUST stay scoped to
7
+ degraded turns only (``recallMinScoreScope`` default ``"degraded"``).
8
+
9
+ This is a regression TRIPWIRE, not new behaviour: ``recall.py`` already
10
+ scopes the floor correctly (``2731-2735``). Per the red-team audit (Finding:
11
+ test (f) leaned on the ``min_score_applied`` flag alone), this asserts on
12
+ BOTH the flag AND the rendered injected content — the full candidate set
13
+ must actually reach ``additionalContext`` on a healthy turn even when a
14
+ floor is configured, so a future "add a real floor" PR that drops rows
15
+ while leaving the flag mis-set still fails RED.
16
+
17
+ Stdlib-only; drives ``recall.main()`` end to end. Reuses the harness shape
18
+ from ``tests/test_recall_min_score.py`` (kept in a dedicated M4 file per the
19
+ carve's named test-file convention).
20
+ """
21
+
22
+ import io
23
+ import json
24
+ import os
25
+ import shutil
26
+ import sys
27
+ import tempfile
28
+ import unittest
29
+ from unittest.mock import patch
30
+
31
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
32
+ if SCRIPTS_DIR not in sys.path:
33
+ sys.path.insert(0, SCRIPTS_DIR)
34
+
35
+ import recall # noqa: E402
36
+
37
+ OWN = "test-bank"
38
+ NOISE = 0.0006 # E-13's measured rank-1-hit-that-scores-almost-zero regime
39
+
40
+
41
+ def _memory(text, mem_id, score):
42
+ return {"text": text, "type": "fact", "mentioned_at": "2026-01-01", "id": mem_id,
43
+ "scores": {"final": score}}
44
+
45
+
46
+ class _Client:
47
+ def __init__(self, results):
48
+ self._results = results
49
+
50
+ def list_directives(self, bank_id, active_only=True, timeout=2):
51
+ return {"items": []}
52
+
53
+ def recall(self, bank_id, query, **kwargs):
54
+ return {"results": [dict(m) for m in self._results]}
55
+
56
+
57
+ class NoScoreFloorOutsideDegradedTests(unittest.TestCase):
58
+ def setUp(self):
59
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-no-floor-test-")
60
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
61
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
62
+
63
+ def tearDown(self):
64
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
65
+ if self._prev is None:
66
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
67
+ else:
68
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
69
+
70
+ def _log_row(self):
71
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
72
+ with open(path, encoding="utf-8") as fh:
73
+ rows = [json.loads(line) for line in fh if line.strip()]
74
+ self.assertTrue(rows)
75
+ return rows[-1]
76
+
77
+ def test_healthy_turn_ignores_a_configured_floor_flag_and_rendered_output(self):
78
+ # 6 noise-scoring candidates, a floor set well above their score, own
79
+ # bank answers cleanly (no timeout/error) — a HEALTHY turn.
80
+ results = [_memory(f"noise fact {i}", f"m{i}", NOISE) for i in range(6)]
81
+ client = _Client(results)
82
+ hook_input = {
83
+ "prompt": "what did we decide about the auth flow",
84
+ "session_id": "test-session",
85
+ "transcript_path": "",
86
+ "cwd": "/tmp",
87
+ }
88
+ config = {
89
+ "autoRecall": True,
90
+ "bankId": OWN,
91
+ "recallMaxTokens": 4096,
92
+ "recallMinScore": 0.5, # configured floor, well above NOISE
93
+ "recallBudget": "mid",
94
+ "recallContextTurns": 1,
95
+ "recallMaxQueryChars": 800,
96
+ "recallPromptPreamble": "",
97
+ "recallParallelDeadlineSeconds": 5,
98
+ "directivesCacheTtlSeconds": 0,
99
+ }
100
+ stdout = io.StringIO()
101
+ with patch("recall.load_config", return_value=config), \
102
+ patch("recall.get_api_url", return_value="http://fake"), \
103
+ patch("recall.HindsightClient", return_value=client), \
104
+ patch("recall.ensure_bank_mission"), \
105
+ patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
106
+ patch("sys.stdout", stdout):
107
+ recall.main()
108
+ out = stdout.getvalue()
109
+ self.assertTrue(out)
110
+ ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
111
+
112
+ # Rendered-output assertion (the redteam-mandated strengthening): the
113
+ # FULL pre-floor candidate set reaches the prompt, not just a flag.
114
+ for i in range(6):
115
+ self.assertIn(f"noise fact {i}", ctx,
116
+ "a score floor silently dropped a healthy-turn candidate")
117
+
118
+ row = self._log_row()
119
+ self.assertFalse(row["min_score_applied"])
120
+ self.assertEqual(row["dropped_below_min_score"], 0)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()