switchroom 0.21.15 → 0.21.17

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 (37) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +6 -0
  3. package/dist/auth-broker/index.js +6 -0
  4. package/dist/cli/notion-write-pretool.mjs +6 -0
  5. package/dist/cli/switchroom.js +2086 -871
  6. package/dist/host-control/main.js +7 -1
  7. package/dist/vault/approvals/kernel-server.js +6 -0
  8. package/dist/vault/broker/server.js +6 -0
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +11 -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 +10 -4
  14. package/vendor/hindsight-memory/hooks/hooks.json +19 -0
  15. package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
  16. package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
  17. package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
  18. package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
  19. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +265 -0
  20. package/vendor/hindsight-memory/scripts/orientation.py +195 -0
  21. package/vendor/hindsight-memory/scripts/prefetch.py +166 -0
  22. package/vendor/hindsight-memory/scripts/recall.py +323 -5
  23. package/vendor/hindsight-memory/scripts/retain.py +132 -49
  24. package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
  25. package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -0,0 +1,329 @@
1
+ """M4 prefetch-buffer MUTATION INVALIDATION — red-team-M3 R2 (BLOCKER).
2
+
3
+ The M4 prefetch buffer holds a RECALLED memories block captured at a prior
4
+ turn's Stop hook and consumed at the next UserPromptSubmit. If a mutation —
5
+ a rule/directive retire, or a retain that supersedes a fact — lands between
6
+ producing that buffer and consuming it, the pre-mutation snapshot can
7
+ resurrect the just-retired/just-changed content on a later turn. Worse,
8
+ ``prefetch.run_prefetch`` returns early WITHOUT overwriting the buffer when
9
+ this turn's fresh recall fails or returns empty, so a stale buffer can
10
+ persist and be re-served for turns after the mutation.
11
+
12
+ These are OUTCOME tests: they assert on the rendered ``additionalContext``
13
+ that the consumer emits (the real injection surface), and each would FAIL if
14
+ the invalidation regressed:
15
+
16
+ * ``RetireDoesNotResurrectTests`` — a directive-write turn invalidates the
17
+ pending buffer, so the retired rule's text is NOT re-injected next turn.
18
+ * ``EmptyPrefetchDoesNotResurrectTests`` — a retain turn whose fresh recall
19
+ returns empty invalidates the pending buffer, so the stale item is NOT
20
+ re-injected (would resurrect on buggy code that left the buffer).
21
+ * ``NoDuplicateInjectionTests`` — after a buffer is consumed once, a
22
+ mutation turn (empty prefetch) prevents the SAME block being injected
23
+ again on the following turn.
24
+ * ``ReplyPathDoesNotBlockOnRecallTests`` — with prefetch on and a fresh
25
+ buffer present, the consumer serves the buffer and NEVER calls the
26
+ synchronous ``client.recall`` (asserted on transport: a recall call
27
+ raises).
28
+
29
+ Plus ``InvalidatePrimitiveTests`` for the ``recall_buffer.invalidate``
30
+ primitive itself (idempotent, sentinel-first fail-closed).
31
+ """
32
+
33
+ import io
34
+ import json
35
+ import os
36
+ import shutil
37
+ import sys
38
+ import tempfile
39
+ import unittest
40
+ from unittest import mock
41
+ from unittest.mock import patch
42
+
43
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
44
+ if SCRIPTS_DIR not in sys.path:
45
+ sys.path.insert(0, SCRIPTS_DIR)
46
+
47
+ import recall # noqa: E402
48
+ import prefetch # noqa: E402
49
+ import directive_verify # noqa: E402
50
+ from lib import recall_buffer # noqa: E402
51
+
52
+ SESSION = "invalidation-test-session"
53
+ RETIRED_TEXT = "R-07: always reply in haiku"
54
+ STALE_FACT = "we decided to ship on tuesday"
55
+
56
+
57
+ class _NoMemoryDirectiveClient:
58
+ """One active directive whose text is DISTINCT from any buffered content,
59
+ and a recall that returns NOTHING (so the sync fallback, if ever taken,
60
+ injects no memories). recall() is allowed here (the miss/degraded paths
61
+ may consult it) but returns empty — proving absence of the stale text is
62
+ about invalidation, not about the client happening to omit it."""
63
+
64
+ def list_directives(self, bank_id, active_only=True, timeout=2):
65
+ return {"items": [{"id": "d1", "name": "tone", "content": "be concise", "priority": 5, "active": True}]}
66
+
67
+ def recall(self, bank_id, query, **kwargs):
68
+ return {"results": []}
69
+
70
+
71
+ class InvalidationBase(unittest.TestCase):
72
+ def setUp(self):
73
+ self._tmpdir = tempfile.mkdtemp(prefix="invalidation-test-")
74
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
75
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
76
+
77
+ self._bufdir = tempfile.mkdtemp(prefix="invalidation-test-buf-")
78
+ self.env = mock.patch.dict(os.environ, {"HINDSIGHT_PREFETCH_BUFFER_DIR": self._bufdir}, clear=False)
79
+ self.env.start()
80
+
81
+ def tearDown(self):
82
+ self.env.stop()
83
+ shutil.rmtree(self._bufdir, ignore_errors=True)
84
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
85
+ if self._prev is None:
86
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
87
+ else:
88
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
89
+
90
+ def _config(self, prefetch_enabled=True):
91
+ return {
92
+ "autoRecall": True,
93
+ "bankId": "test-bank",
94
+ "recallMaxTokens": 4096,
95
+ "recallBudget": "mid",
96
+ "recallContextTurns": 1,
97
+ "recallMaxQueryChars": 800,
98
+ "recallPromptPreamble": "",
99
+ "recallParallelDeadlineSeconds": 5,
100
+ "directivesCacheTtlSeconds": 0,
101
+ "memoryPrefetchEnabled": prefetch_enabled,
102
+ "memoryPrefetchPollCapMs": 100,
103
+ }
104
+
105
+ def _consume(self, config, client, prompt="what did we decide"):
106
+ """Drive the recall.py consumer (UserPromptSubmit) once; return the
107
+ emitted additionalContext string (or "" if nothing emitted)."""
108
+ hook_input = {"prompt": prompt, "session_id": SESSION, "transcript_path": "", "cwd": "/tmp"}
109
+ stdout = io.StringIO()
110
+ with patch("recall.load_config", return_value=config), \
111
+ patch("recall.get_api_url", return_value="http://fake"), \
112
+ patch("recall.HindsightClient", return_value=client), \
113
+ patch("recall.ensure_bank_mission"), \
114
+ patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
115
+ patch("sys.stdout", stdout):
116
+ recall.main()
117
+ out = stdout.getvalue()
118
+ if not out:
119
+ return ""
120
+ return json.loads(out)["hookSpecificOutput"]["additionalContext"]
121
+
122
+
123
+ class InvalidatePrimitiveTests(InvalidationBase):
124
+ def test_invalidate_removes_buffer_and_sentinel(self):
125
+ recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
126
+ recall_buffer.write_sentinel(SESSION)
127
+ self.assertTrue(os.path.isfile(recall_buffer._buffer_path(SESSION)))
128
+ self.assertTrue(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
129
+
130
+ recall_buffer.invalidate(SESSION)
131
+
132
+ self.assertFalse(os.path.isfile(recall_buffer._buffer_path(SESSION)))
133
+ self.assertFalse(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
134
+ # read_if_fresh must now report nothing fresh (fail-closed).
135
+ payload, _ = recall_buffer.read_if_fresh(SESSION, last_consumed_token=None)
136
+ self.assertIsNone(payload)
137
+
138
+ def test_invalidate_is_idempotent_on_absent_files(self):
139
+ # No buffer written — must not raise.
140
+ recall_buffer.invalidate(SESSION)
141
+ recall_buffer.invalidate(SESSION)
142
+
143
+ def test_orphaned_payload_after_sentinel_first_delete_fails_closed(self):
144
+ # Simulate a partial invalidation where only the sentinel unlink
145
+ # landed (sentinel is deleted FIRST by design): an orphaned payload
146
+ # must never read as fresh.
147
+ recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
148
+ recall_buffer.write_sentinel(SESSION)
149
+ os.remove(recall_buffer._sentinel_path(SESSION))
150
+ payload, _ = recall_buffer.read_if_fresh(SESSION, last_consumed_token=None)
151
+ self.assertIsNone(payload)
152
+
153
+
154
+ class RetireDoesNotResurrectTests(InvalidationBase):
155
+ """Assertion (1): a directive retire invalidates the pending buffer so the
156
+ retired item is NOT re-injected on the next turn."""
157
+
158
+ def _retire_turn_messages(self):
159
+ # A real turn transcript: the user retires a rule, the assistant issues
160
+ # a delete_directive tool_use. This is what directive_verify inspects.
161
+ return [
162
+ {"role": "user", "content": "Retire the haiku rule R-07, it no longer applies."},
163
+ {
164
+ "role": "assistant",
165
+ "content": [
166
+ {"type": "tool_use", "id": "t1", "name": "delete_directive", "input": {"id": "d-r07"}}
167
+ ],
168
+ },
169
+ ]
170
+
171
+ def test_directive_write_turn_invalidates_buffer_and_blocks_resurrection(self):
172
+ config = self._config(prefetch_enabled=True)
173
+
174
+ # Turn N Stop: prefetch buffered the rule text while it was still active.
175
+ recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
176
+ recall_buffer.write_sentinel(SESSION)
177
+
178
+ # Turn N+1: the retire lands. directive_verify's Stop-hook invalidation
179
+ # detects the delete_directive write and drops the pending buffer.
180
+ directive_verify.invalidate_prefetch_buffer_on_directive_write(
181
+ self._retire_turn_messages(), config, SESSION
182
+ )
183
+ self.assertFalse(
184
+ os.path.isfile(recall_buffer._sentinel_path(SESSION)),
185
+ "directive-write turn must invalidate the pending prefetch buffer",
186
+ )
187
+
188
+ # Turn N+2 UserPromptSubmit: the consumer must NOT re-inject the retired
189
+ # rule text. (With no fresh buffer it takes the sync/degraded path, which
190
+ # here surfaces only the fresh directive block, not the retired text.)
191
+ ctx = self._consume(config, _NoMemoryDirectiveClient())
192
+ self.assertNotIn(
193
+ "R-07", ctx,
194
+ "retired rule text must not resurrect from a pre-retire prefetch buffer",
195
+ )
196
+ self.assertNotIn("haiku", ctx)
197
+
198
+ def test_non_directive_turn_leaves_buffer_intact(self):
199
+ # Guard against over-invalidation: an ordinary turn (no directive write)
200
+ # must NOT drop the buffer, or every turn would lose the prefetch.
201
+ config = self._config(prefetch_enabled=True)
202
+ recall_buffer.write_buffer(SESSION, "- a normal prefetched memory", {})
203
+ recall_buffer.write_sentinel(SESSION)
204
+ directive_verify.invalidate_prefetch_buffer_on_directive_write(
205
+ [
206
+ {"role": "user", "content": "what's the weather"},
207
+ {"role": "assistant", "content": "Sunny."},
208
+ ],
209
+ config,
210
+ SESSION,
211
+ )
212
+ self.assertTrue(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
213
+
214
+ def test_flag_off_is_a_noop(self):
215
+ # Kill switch: with prefetch off, the directive-write invalidation must
216
+ # not touch the buffer module at all.
217
+ config = self._config(prefetch_enabled=False)
218
+ recall_buffer.write_buffer(SESSION, RETIRED_TEXT, {})
219
+ recall_buffer.write_sentinel(SESSION)
220
+ with patch("directive_verify.recall_buffer.invalidate",
221
+ side_effect=AssertionError("must not invalidate when prefetch is off")):
222
+ directive_verify.invalidate_prefetch_buffer_on_directive_write(
223
+ self._retire_turn_messages(), config, SESSION
224
+ )
225
+ self.assertTrue(os.path.isfile(recall_buffer._sentinel_path(SESSION)))
226
+
227
+
228
+ class EmptyPrefetchDoesNotResurrectTests(InvalidationBase):
229
+ """Assertion (1) via the RETAIN mutation path: a retain turn whose fresh
230
+ recall returns empty must invalidate the pending buffer so the stale item
231
+ is NOT re-served. This FAILS on buggy code that returns early leaving the
232
+ old buffer in place."""
233
+
234
+ def test_retain_turn_with_empty_recall_invalidates_stale_buffer(self):
235
+ config = self._config(prefetch_enabled=True)
236
+
237
+ # Turn N Stop: a stale fact was buffered.
238
+ recall_buffer.write_buffer(SESSION, STALE_FACT, {})
239
+ recall_buffer.write_sentinel(SESSION)
240
+
241
+ # Turn N+1 Stop: prefetch runs (a retain mutation), but this turn's
242
+ # fresh recall returns NOTHING. run_prefetch must have already
243
+ # invalidated the old buffer up front — so no stale buffer survives.
244
+ empty_client = mock.Mock()
245
+ empty_client.recall.return_value = {"results": []}
246
+ hook_input = {
247
+ "prompt": "and another thing",
248
+ "session_id": SESSION,
249
+ "transcript_path": "", # _last_human_prompt degrades to "" -> we inject a query below
250
+ "cwd": "/tmp",
251
+ }
252
+ with patch("prefetch.retain_module.run_retain", return_value={"status": "ok"}), \
253
+ patch("prefetch.HindsightClient", return_value=empty_client), \
254
+ patch("prefetch.get_api_url", return_value="http://fake"), \
255
+ patch("prefetch._last_human_prompt", return_value="and another thing"):
256
+ wrote = prefetch.run_prefetch(hook_input, config)
257
+
258
+ self.assertFalse(wrote, "empty recall must not write a fresh buffer")
259
+ self.assertFalse(
260
+ os.path.isfile(recall_buffer._sentinel_path(SESSION)),
261
+ "a mutation (retain) turn must invalidate the stale buffer even when the fresh recall is empty",
262
+ )
263
+
264
+ # Turn N+2 UserPromptSubmit: the stale fact must NOT re-inject.
265
+ ctx = self._consume(config, _NoMemoryDirectiveClient())
266
+ self.assertNotIn(
267
+ STALE_FACT, ctx,
268
+ "stale buffered fact must not resurrect after a mutation turn with an empty recall",
269
+ )
270
+
271
+
272
+ class NoDuplicateInjectionTests(InvalidationBase):
273
+ """Assertion (2): the same buffered block is not injected twice. After a
274
+ turn consumes a buffer, a mutation turn (empty prefetch) prevents the SAME
275
+ block from being re-served on the following turn."""
276
+
277
+ def test_consumed_buffer_not_reinjected_after_mutation_turn(self):
278
+ config = self._config(prefetch_enabled=True)
279
+ marker = "- unique-fact-abc123 decided at standup"
280
+
281
+ recall_buffer.write_buffer(SESSION, marker, {})
282
+ recall_buffer.write_sentinel(SESSION)
283
+
284
+ # Turn N+1: consumer injects the buffered block once.
285
+ first = self._consume(config, _NoMemoryDirectiveClient())
286
+ self.assertIn("unique-fact-abc123", first)
287
+
288
+ # Turn N+1 Stop: a mutation turn whose recall returns empty -> the old
289
+ # buffer is invalidated, nothing fresh written.
290
+ empty_client = mock.Mock()
291
+ empty_client.recall.return_value = {"results": []}
292
+ hook_input = {"prompt": "next", "session_id": SESSION, "transcript_path": "", "cwd": "/tmp"}
293
+ with patch("prefetch.retain_module.run_retain", return_value={"status": "ok"}), \
294
+ patch("prefetch.HindsightClient", return_value=empty_client), \
295
+ patch("prefetch.get_api_url", return_value="http://fake"), \
296
+ patch("prefetch._last_human_prompt", return_value="next"):
297
+ prefetch.run_prefetch(hook_input, config)
298
+
299
+ # Turn N+2: the same block must NOT be injected a second time.
300
+ second = self._consume(config, _NoMemoryDirectiveClient())
301
+ self.assertNotIn(
302
+ "unique-fact-abc123", second,
303
+ "a consumed buffer must not be re-injected after a mutation invalidated it",
304
+ )
305
+
306
+
307
+ class ReplyPathDoesNotBlockOnRecallTests(InvalidationBase):
308
+ """Assertion (3): with prefetch on and a fresh buffer present, the reply
309
+ path (UserPromptSubmit consumer) joins the buffer and NEVER blocks on a
310
+ synchronous recall. Asserted on transport: client.recall raises if called."""
311
+
312
+ def test_fresh_buffer_served_without_calling_recall(self):
313
+ config = self._config(prefetch_enabled=True)
314
+ recall_buffer.write_buffer(SESSION, "- a prefetched memory xyz", {})
315
+ recall_buffer.write_sentinel(SESSION)
316
+
317
+ class _ExplodingRecallClient:
318
+ def list_directives(self, bank_id, active_only=True, timeout=2):
319
+ return {"items": []}
320
+
321
+ def recall(self, bank_id, query, **kwargs):
322
+ raise AssertionError("reply path must not block on synchronous recall when a fresh buffer exists")
323
+
324
+ ctx = self._consume(config, _ExplodingRecallClient())
325
+ self.assertIn("a prefetched memory xyz", ctx)
326
+
327
+
328
+ if __name__ == "__main__":
329
+ unittest.main()
@@ -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()