switchroom 0.21.15 → 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.
- package/bin/rules-sentinel-hook.sh +101 -0
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1924 -841
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
- package/skills/mental-model-curator/SKILL.md +187 -56
- package/telegram-plugin/dist/gateway/gateway.js +5 -4
- package/vendor/hindsight-memory/hooks/hooks.json +10 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
- package/vendor/hindsight-memory/scripts/recall.py +185 -0
- package/vendor/hindsight-memory/scripts/retain.py +132 -49
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""M4 P-PRE — prefetch.py producer pipeline.
|
|
2
|
+
|
|
3
|
+
Tests:
|
|
4
|
+
(a-integration) call order: delta retain runs BEFORE the speculative
|
|
5
|
+
recall, which runs BEFORE the buffer/sentinel write — proven via a
|
|
6
|
+
shared event-order list, not mocked call assertions alone.
|
|
7
|
+
(c, red-team STRENGTHENED) a fact retained at turn N-2 is STILL
|
|
8
|
+
recallable after further retains at N-1/N — guards against the
|
|
9
|
+
truncation bug (Fix A), not merely "present in the latest delta".
|
|
10
|
+
Fix C (producer side): `memoryPrefetchEnabled` off is a hard no-op —
|
|
11
|
+
no retain, no recall, no buffer file written.
|
|
12
|
+
Junk gate: a `<task-notification>` turn is skipped (no retain, no
|
|
13
|
+
recall, no buffer write) same as recall.py's consumer-side gate.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import unittest
|
|
22
|
+
from unittest import mock
|
|
23
|
+
from unittest.mock import patch
|
|
24
|
+
|
|
25
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
26
|
+
if SCRIPTS_DIR not in sys.path:
|
|
27
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
28
|
+
|
|
29
|
+
import prefetch # noqa: E402
|
|
30
|
+
from lib import recall_buffer # noqa: E402
|
|
31
|
+
|
|
32
|
+
SESSION = "prefetch-test-session"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_transcript(path, turns):
|
|
36
|
+
"""turns: list of (role, text) tuples, in order."""
|
|
37
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
38
|
+
for i, (role, text) in enumerate(turns):
|
|
39
|
+
entry = {
|
|
40
|
+
"type": role,
|
|
41
|
+
"uuid": f"u{i}",
|
|
42
|
+
"message": {"role": role, "content": text},
|
|
43
|
+
}
|
|
44
|
+
f.write(json.dumps(entry) + "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Daemon:
|
|
48
|
+
"""A tiny in-memory fake standing in for the whole retain+recall
|
|
49
|
+
round trip, so the truncation-regression test (c) is a REAL round
|
|
50
|
+
trip through retain.run_retain + a recall against retained content,
|
|
51
|
+
not two independently-mocked legs."""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.documents = {} # document_id -> text
|
|
55
|
+
|
|
56
|
+
def retain(self, bank_id, content, document_id=None, **kwargs):
|
|
57
|
+
self.documents[document_id or bank_id] = content
|
|
58
|
+
return {"status": "ok", "document_id": document_id or bank_id}
|
|
59
|
+
|
|
60
|
+
def recall(self, bank_id, query, **kwargs):
|
|
61
|
+
# Naive substring match across all retained documents — enough to
|
|
62
|
+
# prove presence/absence, not a real ranking engine.
|
|
63
|
+
hits = []
|
|
64
|
+
for i, (doc_id, text) in enumerate(self.documents.items()):
|
|
65
|
+
hits.append({
|
|
66
|
+
"text": text, "type": "fact", "mentioned_at": "2026-01-01",
|
|
67
|
+
"id": f"r{i}", "scores": {"final": 1.0 - i * 0.01},
|
|
68
|
+
})
|
|
69
|
+
return {"results": hits}
|
|
70
|
+
|
|
71
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
72
|
+
return {"items": []}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PrefetchPipelineBase(unittest.TestCase):
|
|
76
|
+
def setUp(self):
|
|
77
|
+
self._tmpdir = tempfile.mkdtemp(prefix="prefetch-test-")
|
|
78
|
+
self.plugin_root = os.path.join(self._tmpdir, "plugin_root")
|
|
79
|
+
self.data = os.path.join(self._tmpdir, "data")
|
|
80
|
+
self.home = os.path.join(self._tmpdir, "home")
|
|
81
|
+
for d in (self.plugin_root, self.data, self.home):
|
|
82
|
+
os.makedirs(d)
|
|
83
|
+
self._write_settings(prefetch_enabled=True)
|
|
84
|
+
|
|
85
|
+
self._bufdir = tempfile.mkdtemp(prefix="prefetch-test-buf-")
|
|
86
|
+
self.env = mock.patch.dict(os.environ, {
|
|
87
|
+
"CLAUDE_PLUGIN_ROOT": self.plugin_root,
|
|
88
|
+
"CLAUDE_PLUGIN_DATA": self.data,
|
|
89
|
+
"HOME": self.home,
|
|
90
|
+
"HINDSIGHT_PREFETCH_BUFFER_DIR": self._bufdir,
|
|
91
|
+
"HINDSIGHT_PENDING_DIR": os.path.join(self.home, ".hindsight", "pending-retains"),
|
|
92
|
+
"HINDSIGHT_RETAINED_DIR": os.path.join(self.home, ".hindsight", "retained"),
|
|
93
|
+
"HINDSIGHT_INFLIGHT_LOCK": os.path.join(self.home, ".hindsight", "retain-inflight.lock"),
|
|
94
|
+
"HINDSIGHT_TRANSCRIPTS_DIR": os.path.join(self._tmpdir, "transcripts"),
|
|
95
|
+
}, clear=False)
|
|
96
|
+
self.env.start()
|
|
97
|
+
for k in list(os.environ):
|
|
98
|
+
if k.startswith("HINDSIGHT_") and k not in (
|
|
99
|
+
"HINDSIGHT_PREFETCH_BUFFER_DIR", "HINDSIGHT_PENDING_DIR",
|
|
100
|
+
"HINDSIGHT_RETAINED_DIR", "HINDSIGHT_INFLIGHT_LOCK", "HINDSIGHT_TRANSCRIPTS_DIR",
|
|
101
|
+
):
|
|
102
|
+
os.environ.pop(k, None)
|
|
103
|
+
|
|
104
|
+
self.transcript_path = os.path.join(self._tmpdir, "transcript.jsonl")
|
|
105
|
+
self.daemon = _Daemon()
|
|
106
|
+
|
|
107
|
+
def _write_settings(self, prefetch_enabled=True):
|
|
108
|
+
settings = {
|
|
109
|
+
"autoRetain": True,
|
|
110
|
+
"bankId": "test-bank",
|
|
111
|
+
"retainMode": "chunked",
|
|
112
|
+
"retainEveryNTurns": 1,
|
|
113
|
+
"retainOverlapTurns": 0,
|
|
114
|
+
"memoryPrefetchEnabled": prefetch_enabled,
|
|
115
|
+
}
|
|
116
|
+
with open(os.path.join(self.plugin_root, "settings.json"), "w") as f:
|
|
117
|
+
json.dump(settings, f)
|
|
118
|
+
|
|
119
|
+
def tearDown(self):
|
|
120
|
+
self.env.stop()
|
|
121
|
+
shutil.rmtree(self._bufdir, ignore_errors=True)
|
|
122
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
123
|
+
|
|
124
|
+
def _config(self, prefetch_enabled=True):
|
|
125
|
+
return {
|
|
126
|
+
"bankId": "test-bank",
|
|
127
|
+
"memoryPrefetchEnabled": prefetch_enabled,
|
|
128
|
+
"retainMode": "chunked",
|
|
129
|
+
"retainEveryNTurns": 1,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def _hook_input(self, prompt="what's the deploy status"):
|
|
133
|
+
return {
|
|
134
|
+
"prompt": prompt,
|
|
135
|
+
"session_id": SESSION,
|
|
136
|
+
"transcript_path": self.transcript_path,
|
|
137
|
+
"cwd": "/tmp",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CallOrderTests(PrefetchPipelineBase):
|
|
142
|
+
def test_retain_then_recall_then_sentinel_in_that_order(self):
|
|
143
|
+
_write_transcript(self.transcript_path, [("user", "we decided to ship on friday")])
|
|
144
|
+
order = []
|
|
145
|
+
|
|
146
|
+
def _spy_run_retain(hook_input, force=False, delta=False):
|
|
147
|
+
order.append("retain")
|
|
148
|
+
return {"status": "ok"}
|
|
149
|
+
|
|
150
|
+
def _spy_write_buffer(session_id, context, telemetry=None):
|
|
151
|
+
order.append("buffer")
|
|
152
|
+
|
|
153
|
+
def _spy_write_sentinel(session_id):
|
|
154
|
+
order.append("sentinel")
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
client = mock.Mock()
|
|
158
|
+
client.recall.side_effect = lambda *a, **kw: (order.append("recall") or {"results": [
|
|
159
|
+
{"text": "we decided to ship on friday", "type": "fact", "mentioned_at": "2026-01-01",
|
|
160
|
+
"id": "r1", "scores": {"final": 0.9}},
|
|
161
|
+
]})
|
|
162
|
+
|
|
163
|
+
with patch("prefetch.retain_module.run_retain", side_effect=_spy_run_retain), \
|
|
164
|
+
patch("prefetch.HindsightClient", return_value=client), \
|
|
165
|
+
patch("prefetch.get_api_url", return_value="http://fake"), \
|
|
166
|
+
patch("prefetch.recall_buffer.write_buffer", side_effect=_spy_write_buffer), \
|
|
167
|
+
patch("prefetch.recall_buffer.write_sentinel", side_effect=_spy_write_sentinel):
|
|
168
|
+
wrote = prefetch.run_prefetch(self._hook_input(), self._config())
|
|
169
|
+
|
|
170
|
+
self.assertTrue(wrote)
|
|
171
|
+
self.assertEqual(order, ["retain", "recall", "buffer", "sentinel"])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TruncationRegressionTests(PrefetchPipelineBase):
|
|
175
|
+
def test_fact_retained_two_turns_ago_still_recallable_after_further_retains(self):
|
|
176
|
+
# This is the STRONGER, red-team-mandated version of test (c): not
|
|
177
|
+
# "the latest delta contains the newest fact" but "an OLDER fact
|
|
178
|
+
# retained at turn N-2 survives further retains at N-1 and N" — the
|
|
179
|
+
# exact shape of bug Fix A closes (a naive tail-slice retain would
|
|
180
|
+
# have overwritten/truncated the older fact's document).
|
|
181
|
+
client = self.daemon
|
|
182
|
+
|
|
183
|
+
# Drive the REAL retain.run_retain (prefetch.retain_module IS the
|
|
184
|
+
# `retain` module object, unpatched here) against the daemon fake —
|
|
185
|
+
# a genuine round trip through the actual delta-retain logic, not a
|
|
186
|
+
# mocked short-circuit. Only the network-facing leaves (the client
|
|
187
|
+
# + api url) are faked, on both retain's and prefetch's own module
|
|
188
|
+
# references (they resolve to the same underlying calls).
|
|
189
|
+
with patch("retain.HindsightClient", return_value=client), \
|
|
190
|
+
patch("retain.get_api_url", return_value="http://fake"), \
|
|
191
|
+
patch("prefetch.HindsightClient", return_value=client), \
|
|
192
|
+
patch("prefetch.get_api_url", return_value="http://fake"):
|
|
193
|
+
# Turn N-2
|
|
194
|
+
_write_transcript(self.transcript_path, [("user", "fact alpha from turn N-2")])
|
|
195
|
+
prefetch.run_prefetch(self._hook_input("fact alpha from turn N-2"), self._config())
|
|
196
|
+
# Turn N-1
|
|
197
|
+
_write_transcript(self.transcript_path, [
|
|
198
|
+
("user", "fact alpha from turn N-2"),
|
|
199
|
+
("assistant", "ack"),
|
|
200
|
+
("user", "fact beta from turn N-1"),
|
|
201
|
+
])
|
|
202
|
+
prefetch.run_prefetch(self._hook_input("fact beta from turn N-1"), self._config())
|
|
203
|
+
# Turn N
|
|
204
|
+
_write_transcript(self.transcript_path, [
|
|
205
|
+
("user", "fact alpha from turn N-2"),
|
|
206
|
+
("assistant", "ack"),
|
|
207
|
+
("user", "fact beta from turn N-1"),
|
|
208
|
+
("assistant", "ack"),
|
|
209
|
+
("user", "fact gamma from turn N"),
|
|
210
|
+
])
|
|
211
|
+
prefetch.run_prefetch(self._hook_input("fact gamma from turn N"), self._config())
|
|
212
|
+
|
|
213
|
+
all_retained_content = " ".join(client.documents.values())
|
|
214
|
+
self.assertIn("fact alpha from turn N-2", all_retained_content,
|
|
215
|
+
"an older delta must not be truncated/overwritten by a later delta retain")
|
|
216
|
+
self.assertIn("fact beta from turn N-1", all_retained_content)
|
|
217
|
+
self.assertIn("fact gamma from turn N", all_retained_content)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class KillSwitchOffTests(PrefetchPipelineBase):
|
|
221
|
+
def test_flag_off_is_a_hard_no_op(self):
|
|
222
|
+
_write_transcript(self.transcript_path, [("user", "anything")])
|
|
223
|
+
# The flag GATE lives in main(), before hook_input is even read —
|
|
224
|
+
# prove the whole mechanism is a no-op at that entrypoint.
|
|
225
|
+
import io
|
|
226
|
+
with patch("prefetch.retain_module.run_retain", side_effect=AssertionError("must not retain when flag is off")), \
|
|
227
|
+
patch("prefetch.HindsightClient", side_effect=AssertionError("must not touch client when flag is off")), \
|
|
228
|
+
patch("prefetch.load_config", return_value=self._config(prefetch_enabled=False)), \
|
|
229
|
+
patch("sys.stdin", io.StringIO(json.dumps(self._hook_input()))):
|
|
230
|
+
prefetch.main()
|
|
231
|
+
self.assertFalse(os.path.isfile(recall_buffer._buffer_path(SESSION)))
|
|
232
|
+
self.assertFalse(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class JunkGateTests(PrefetchPipelineBase):
|
|
236
|
+
def test_task_notification_turn_is_skipped(self):
|
|
237
|
+
_write_transcript(self.transcript_path, [("user", "irrelevant")])
|
|
238
|
+
with patch("prefetch.retain_module.run_retain", side_effect=AssertionError("must not retain a task-notification turn")):
|
|
239
|
+
wrote = prefetch.run_prefetch(
|
|
240
|
+
self._hook_input("<task-notification>done</task-notification>"), self._config()
|
|
241
|
+
)
|
|
242
|
+
self.assertFalse(wrote)
|
|
243
|
+
self.assertFalse(os.path.isfile(recall_buffer._buffer_path(SESSION)))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
unittest.main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""M4 P0 — buffer + sentinel primitive, OUTCOME tests (RED test a).
|
|
2
|
+
|
|
3
|
+
The sentinel/poll race is the whole safety story for M4's prefetch producer
|
|
4
|
+
(carve §4 note 1, red-team-verified sound). This module is the FOUNDATION
|
|
5
|
+
every other M4 packet depends on: P-REC's join logic and P-PRE's producer
|
|
6
|
+
both build on the read-after-write guarantee proven here.
|
|
7
|
+
|
|
8
|
+
Contract under test:
|
|
9
|
+
(i) ``buffer.done`` is written strictly AFTER the buffer payload
|
|
10
|
+
(mtime/content ordering — proven via a monotonic token, not wall
|
|
11
|
+
clock, since two writes can land in the same clock tick).
|
|
12
|
+
(ii) ``read_if_fresh(last_consumed_token)`` returns ``(None, token)``
|
|
13
|
+
when the sentinel is absent, or not newer than ``last_consumed``.
|
|
14
|
+
(iii) A torn write (payload present, sentinel absent) reads as ``None``
|
|
15
|
+
— fail-closed, never a stale/malformed payload leaking through.
|
|
16
|
+
(iv) ``poll_for_sentinel`` is clock-bounded: never exceeds its cap, no
|
|
17
|
+
busy-spin (sleeps sum to <= cap, with generous tolerance).
|
|
18
|
+
|
|
19
|
+
Stdlib-only (``python3 -m unittest discover tests/``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import unittest
|
|
28
|
+
from unittest import mock
|
|
29
|
+
|
|
30
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
31
|
+
if SCRIPTS_DIR not in sys.path:
|
|
32
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
33
|
+
|
|
34
|
+
from lib import recall_buffer # noqa: E402
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RecallBufferBase(unittest.TestCase):
|
|
38
|
+
def setUp(self):
|
|
39
|
+
self.tmp = tempfile.mkdtemp(prefix="hs-m4-buffer-")
|
|
40
|
+
self.env = mock.patch.dict(
|
|
41
|
+
os.environ, {"HINDSIGHT_PREFETCH_BUFFER_DIR": self.tmp}, clear=False
|
|
42
|
+
)
|
|
43
|
+
self.env.start()
|
|
44
|
+
|
|
45
|
+
def tearDown(self):
|
|
46
|
+
self.env.stop()
|
|
47
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestSentinelOrdering(RecallBufferBase):
|
|
51
|
+
def test_sentinel_written_strictly_after_payload(self):
|
|
52
|
+
session = "sess-order"
|
|
53
|
+
write_order = []
|
|
54
|
+
real_replace = os.replace
|
|
55
|
+
|
|
56
|
+
def _spy_replace(src, dst):
|
|
57
|
+
write_order.append(os.path.basename(dst))
|
|
58
|
+
return real_replace(src, dst)
|
|
59
|
+
|
|
60
|
+
with mock.patch("os.replace", side_effect=_spy_replace):
|
|
61
|
+
recall_buffer.write_buffer(session, "<hindsight_memories>x</hindsight_memories>", {"k": "v"})
|
|
62
|
+
recall_buffer.write_sentinel(session)
|
|
63
|
+
|
|
64
|
+
buffer_writes = [w for w in write_order if "buffer.json" in w]
|
|
65
|
+
sentinel_writes = [w for w in write_order if "buffer.done" in w]
|
|
66
|
+
self.assertEqual(len(buffer_writes), 1)
|
|
67
|
+
self.assertEqual(len(sentinel_writes), 1)
|
|
68
|
+
self.assertLess(
|
|
69
|
+
write_order.index(buffer_writes[0]),
|
|
70
|
+
write_order.index(sentinel_writes[0]),
|
|
71
|
+
"sentinel must be written strictly AFTER the buffer payload",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def test_read_if_fresh_returns_none_when_sentinel_absent(self):
|
|
75
|
+
session = "sess-nosentinel"
|
|
76
|
+
recall_buffer.write_buffer(session, "content", {})
|
|
77
|
+
ctx, token = recall_buffer.read_if_fresh(session, last_consumed_token=None)
|
|
78
|
+
self.assertIsNone(ctx)
|
|
79
|
+
|
|
80
|
+
def test_read_if_fresh_returns_none_when_sentinel_not_newer(self):
|
|
81
|
+
session = "sess-stale"
|
|
82
|
+
recall_buffer.write_buffer(session, "content", {})
|
|
83
|
+
token = recall_buffer.write_sentinel(session)
|
|
84
|
+
# Already consumed this exact token -> not fresh.
|
|
85
|
+
ctx, new_token = recall_buffer.read_if_fresh(session, last_consumed_token=token)
|
|
86
|
+
self.assertIsNone(ctx)
|
|
87
|
+
self.assertEqual(new_token, token)
|
|
88
|
+
|
|
89
|
+
def test_read_if_fresh_returns_payload_when_sentinel_is_newer(self):
|
|
90
|
+
session = "sess-fresh"
|
|
91
|
+
recall_buffer.write_buffer(session, "<hindsight_memories>fact</hindsight_memories>", {"telemetry": 1})
|
|
92
|
+
token = recall_buffer.write_sentinel(session)
|
|
93
|
+
ctx, new_token = recall_buffer.read_if_fresh(session, last_consumed_token=None)
|
|
94
|
+
self.assertIsNotNone(ctx)
|
|
95
|
+
self.assertIn("fact", ctx["context"])
|
|
96
|
+
self.assertEqual(ctx["telemetry"], {"telemetry": 1})
|
|
97
|
+
self.assertEqual(new_token, token)
|
|
98
|
+
|
|
99
|
+
def test_torn_write_payload_present_sentinel_absent_reads_as_none(self):
|
|
100
|
+
# Simulate a crash between write_buffer() and write_sentinel(): the
|
|
101
|
+
# payload landed, the sentinel never did. Must fail-closed to None,
|
|
102
|
+
# NEVER read the torn/possibly-incomplete payload as fresh.
|
|
103
|
+
session = "sess-torn"
|
|
104
|
+
recall_buffer.write_buffer(session, "<hindsight_memories>partial</hindsight_memories>", {})
|
|
105
|
+
# No write_sentinel() call — this IS the torn state.
|
|
106
|
+
ctx, token = recall_buffer.read_if_fresh(session, last_consumed_token=None)
|
|
107
|
+
self.assertIsNone(ctx, "torn write (payload without sentinel) must read as None, fail-closed")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestPollCap(RecallBufferBase):
|
|
111
|
+
def test_poll_returns_true_when_sentinel_appears(self):
|
|
112
|
+
session = "sess-poll-hit"
|
|
113
|
+
recall_buffer.write_buffer(session, "content", {})
|
|
114
|
+
recall_buffer.write_sentinel(session)
|
|
115
|
+
found = recall_buffer.poll_for_sentinel(session, last_consumed_token=None, cap_ms=500)
|
|
116
|
+
self.assertTrue(found)
|
|
117
|
+
|
|
118
|
+
def test_poll_never_exceeds_cap(self):
|
|
119
|
+
# No sentinel ever appears — poll must give up within cap_ms
|
|
120
|
+
# (generous tolerance for test-host scheduling jitter).
|
|
121
|
+
session = "sess-poll-miss"
|
|
122
|
+
cap_ms = 200
|
|
123
|
+
start = time.monotonic()
|
|
124
|
+
found = recall_buffer.poll_for_sentinel(session, last_consumed_token=None, cap_ms=cap_ms)
|
|
125
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
126
|
+
self.assertFalse(found)
|
|
127
|
+
self.assertLess(elapsed_ms, cap_ms + 150, "poll exceeded its cap by more than tolerance")
|
|
128
|
+
|
|
129
|
+
def test_poll_does_not_busy_spin(self):
|
|
130
|
+
# A busy-spin would burn far more than a handful of read attempts in
|
|
131
|
+
# the cap window; assert sleep is actually happening by checking the
|
|
132
|
+
# elapsed time is close to (not far below) the cap when nothing
|
|
133
|
+
# ever shows up.
|
|
134
|
+
session = "sess-poll-nospin"
|
|
135
|
+
cap_ms = 150
|
|
136
|
+
start = time.monotonic()
|
|
137
|
+
recall_buffer.poll_for_sentinel(session, last_consumed_token=None, cap_ms=cap_ms)
|
|
138
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
139
|
+
self.assertGreaterEqual(elapsed_ms, cap_ms * 0.5, "poll returned suspiciously fast — looks like it skipped sleeping")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
unittest.main()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""M4 P-REC test (b) + red-team Fix B / Fix C — prefetch buffer join.
|
|
2
|
+
|
|
3
|
+
Asserts on RENDERED transport (`additionalContext`), never on an internal
|
|
4
|
+
flag alone, per the red-team's explicit strengthening. Covers:
|
|
5
|
+
|
|
6
|
+
* Fix C (kill switch): `memoryPrefetchEnabled` OFF (the default) leaves
|
|
7
|
+
the mechanism completely inert — synchronous recall runs exactly as
|
|
8
|
+
before, `lib.recall_buffer` is never touched.
|
|
9
|
+
* Fresh-hit: a producer-written buffer is joined and rendered as-is.
|
|
10
|
+
* Fix B (BINDING): the stale-buffer fallback (no fresh sentinel, but a
|
|
11
|
+
prior `LAST_RECALL_STATE` exists) renders memories WITHOUT any
|
|
12
|
+
directives block, even when a directive is active for the bank —
|
|
13
|
+
directives stay on the synchronous fetch path, never the stale cache.
|
|
14
|
+
* Cold-start short circuit: no sentinel has EVER existed for the session
|
|
15
|
+
-> no poll wait, falls through to the degraded notice / sync path
|
|
16
|
+
without blocking for the poll cap.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
import unittest
|
|
27
|
+
from unittest import mock
|
|
28
|
+
from unittest.mock import patch
|
|
29
|
+
|
|
30
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
31
|
+
if SCRIPTS_DIR not in sys.path:
|
|
32
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
33
|
+
|
|
34
|
+
import recall # noqa: E402
|
|
35
|
+
from lib import recall_buffer # noqa: E402
|
|
36
|
+
from lib.state import write_state # noqa: E402
|
|
37
|
+
|
|
38
|
+
SESSION = "test-session"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _DirectiveClient:
|
|
42
|
+
"""Answers with ONE active directive and no memories (recall not
|
|
43
|
+
expected to be called on the fresh-hit/stale-fallback fast paths, but
|
|
44
|
+
directive fetch IS expected — always synchronous per M3)."""
|
|
45
|
+
|
|
46
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
47
|
+
return {"items": [{"id": "d1", "name": "haiku-rule", "content": "always reply in haiku", "priority": 5, "active": True}]}
|
|
48
|
+
|
|
49
|
+
def recall(self, bank_id, query, **kwargs):
|
|
50
|
+
raise AssertionError("buffer-join fast path must not call recall() directly")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BufferJoinBase(unittest.TestCase):
|
|
54
|
+
def setUp(self):
|
|
55
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-bufjoin-test-")
|
|
56
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
57
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
58
|
+
|
|
59
|
+
self._bufdir = tempfile.mkdtemp(prefix="recall-bufjoin-buf-")
|
|
60
|
+
self.env = mock.patch.dict(os.environ, {"HINDSIGHT_PREFETCH_BUFFER_DIR": self._bufdir}, clear=False)
|
|
61
|
+
self.env.start()
|
|
62
|
+
|
|
63
|
+
def tearDown(self):
|
|
64
|
+
self.env.stop()
|
|
65
|
+
shutil.rmtree(self._bufdir, ignore_errors=True)
|
|
66
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
67
|
+
if self._prev is None:
|
|
68
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
69
|
+
else:
|
|
70
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
71
|
+
|
|
72
|
+
def _config(self, prefetch_enabled):
|
|
73
|
+
return {
|
|
74
|
+
"autoRecall": True,
|
|
75
|
+
"bankId": "test-bank",
|
|
76
|
+
"recallMaxTokens": 4096,
|
|
77
|
+
"recallBudget": "mid",
|
|
78
|
+
"recallContextTurns": 1,
|
|
79
|
+
"recallMaxQueryChars": 800,
|
|
80
|
+
"recallPromptPreamble": "",
|
|
81
|
+
"recallParallelDeadlineSeconds": 5,
|
|
82
|
+
"directivesCacheTtlSeconds": 0,
|
|
83
|
+
"memoryPrefetchEnabled": prefetch_enabled,
|
|
84
|
+
"memoryPrefetchPollCapMs": 100,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def _run(self, config, client, prompt="what did we decide about deploys"):
|
|
88
|
+
hook_input = {"prompt": prompt, "session_id": SESSION, "transcript_path": "", "cwd": "/tmp"}
|
|
89
|
+
stdout = io.StringIO()
|
|
90
|
+
with patch("recall.load_config", return_value=config), \
|
|
91
|
+
patch("recall.get_api_url", return_value="http://fake"), \
|
|
92
|
+
patch("recall.HindsightClient", return_value=client), \
|
|
93
|
+
patch("recall.ensure_bank_mission"), \
|
|
94
|
+
patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
|
|
95
|
+
patch("sys.stdout", stdout):
|
|
96
|
+
recall.main()
|
|
97
|
+
return stdout.getvalue()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FreshHitTests(BufferJoinBase):
|
|
101
|
+
def test_fresh_buffer_hit_is_rendered_with_directives_layered_on(self):
|
|
102
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory", {})
|
|
103
|
+
recall_buffer.write_sentinel(SESSION)
|
|
104
|
+
|
|
105
|
+
out = self._run(self._config(prefetch_enabled=True), _DirectiveClient())
|
|
106
|
+
self.assertTrue(out)
|
|
107
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
108
|
+
self.assertIn("a prefetched memory", ctx)
|
|
109
|
+
self.assertIn("always reply in haiku", ctx)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class StaleFallbackFixBTests(BufferJoinBase):
|
|
113
|
+
def test_stale_fallback_renders_memories_without_any_directives_block(self):
|
|
114
|
+
# No fresh sentinel this turn (buffer is cold), but a prior turn's
|
|
115
|
+
# cache DOES exist and DOES include memories_context (directive-free
|
|
116
|
+
# per Fix B) plus a bundled directive in `context` that must NOT
|
|
117
|
+
# leak through the stale path.
|
|
118
|
+
write_state(
|
|
119
|
+
recall.LAST_RECALL_STATE,
|
|
120
|
+
{
|
|
121
|
+
"context": "## Active Directives\n- always reply in haiku\n\n- a stale cached memory",
|
|
122
|
+
"memories_context": "- a stale cached memory",
|
|
123
|
+
"saved_at": "2026-01-01T00:00:00Z",
|
|
124
|
+
"bank_id": "test-bank",
|
|
125
|
+
"result_count": 1,
|
|
126
|
+
"directive_count": 1,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
out = self._run(self._config(prefetch_enabled=True), _DirectiveClient())
|
|
130
|
+
self.assertTrue(out)
|
|
131
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
132
|
+
self.assertIn("a stale cached memory", ctx)
|
|
133
|
+
self.assertIn("stale", ctx.lower())
|
|
134
|
+
# Fix B, BINDING: the fresh directive fetch (synchronous, M3 rule)
|
|
135
|
+
# IS allowed to appear — what must NEVER appear is the STALE
|
|
136
|
+
# directives text sourced from the cached `context` field. Prove
|
|
137
|
+
# this by using a client whose directive text differs from what a
|
|
138
|
+
# stale-context leak would have produced, and confirming there is
|
|
139
|
+
# exactly one occurrence (the fresh fetch), not a duplicate/leaked
|
|
140
|
+
# second copy from the cache.
|
|
141
|
+
self.assertEqual(ctx.count("always reply in haiku"), 1)
|
|
142
|
+
|
|
143
|
+
def test_stale_fallback_directive_free_field_never_leaks_bare_context_directives(self):
|
|
144
|
+
# A cache row is deliberately malformed/legacy-shaped (missing the
|
|
145
|
+
# M4 `memories_context` field entirely, i.e. pre-M4 cache on disk).
|
|
146
|
+
# The fallback must degrade to "nothing stale to show", NEVER fall
|
|
147
|
+
# back to the directive-contaminated `context` field.
|
|
148
|
+
write_state(
|
|
149
|
+
recall.LAST_RECALL_STATE,
|
|
150
|
+
{
|
|
151
|
+
"context": "## Active Directives\n- always reply in haiku\n\n- a stale cached memory",
|
|
152
|
+
"saved_at": "2026-01-01T00:00:00Z",
|
|
153
|
+
"bank_id": "test-bank",
|
|
154
|
+
"result_count": 1,
|
|
155
|
+
"directive_count": 1,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
client = _DirectiveClient()
|
|
159
|
+
out = self._run(self._config(prefetch_enabled=True), client)
|
|
160
|
+
self.assertTrue(out)
|
|
161
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
162
|
+
self.assertNotIn("a stale cached memory", ctx, "must not fall back to the directive-contaminated context field")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ColdStartShortCircuitTests(BufferJoinBase):
|
|
166
|
+
def test_no_sentinel_ever_written_skips_the_poll_wait(self):
|
|
167
|
+
config = self._config(prefetch_enabled=True)
|
|
168
|
+
config["memoryPrefetchPollCapMs"] = 5000 # would dominate the test if NOT short-circuited
|
|
169
|
+
start = time.monotonic()
|
|
170
|
+
out = self._run(config, _DirectiveClient())
|
|
171
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
172
|
+
self.assertTrue(out)
|
|
173
|
+
self.assertLess(elapsed_ms, 1000, "cold-start (no sentinel ever) must not wait out the poll cap")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class KillSwitchOffTests(BufferJoinBase):
|
|
177
|
+
def test_flag_off_never_touches_the_buffer_module(self):
|
|
178
|
+
recall_buffer.write_buffer(SESSION, "- a prefetched memory", {})
|
|
179
|
+
recall_buffer.write_sentinel(SESSION)
|
|
180
|
+
|
|
181
|
+
with patch("recall.recall_buffer.read_if_fresh", side_effect=AssertionError("must not be called when flag is off")):
|
|
182
|
+
client = _DirectiveClient()
|
|
183
|
+
client.recall = lambda *a, **kw: {"results": []} # sync path IS allowed to call recall
|
|
184
|
+
out = self._run(self._config(prefetch_enabled=False), client)
|
|
185
|
+
self.assertTrue(out)
|
|
186
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
187
|
+
# Byte-identical-behaviour proof: the buffer's content must NOT
|
|
188
|
+
# appear (it was never consulted) even though it exists on disk.
|
|
189
|
+
self.assertNotIn("a prefetched memory", ctx)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
unittest.main()
|