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.
- package/bin/rules-sentinel-hook.sh +101 -0
- package/dist/agent-scheduler/index.js +7 -2
- package/dist/auth-broker/index.js +7 -2
- package/dist/cli/notion-write-pretool.mjs +7 -2
- package/dist/cli/switchroom.js +2601 -1009
- package/dist/host-control/main.js +8 -3
- package/dist/vault/approvals/kernel-server.js +7 -2
- package/dist/vault/broker/server.js +7 -2
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +9 -0
- 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 +11 -6
- package/vendor/hindsight-memory/hooks/hooks.json +10 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +22 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +45 -7
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
- package/vendor/hindsight-memory/scripts/recall.py +520 -5
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
- package/vendor/hindsight-memory/scripts/retain.py +167 -28
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +52 -0
- package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
- package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -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_recall_query_timestamp.py +376 -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,293 @@
|
|
|
1
|
+
"""Switchroom memory-RFC P1 — incremental SessionEnd sweep, OUTCOME tests.
|
|
2
|
+
|
|
3
|
+
Before P1 the SessionEnd hook called ``run_retain(force=True)`` which retained
|
|
4
|
+
the WHOLE transcript, duplicating content the per-window retains already landed
|
|
5
|
+
(RFC §1.2). P1 makes the forced chunked sweep slice only the transcript tail
|
|
6
|
+
after the committed watermark, degrading to the whole transcript on any failure
|
|
7
|
+
so the §4.3 hazard (a raise here DELETES a turn) is never triggered.
|
|
8
|
+
|
|
9
|
+
Stdlib-only (`python3 -m unittest discover tests/`). Every test drives the real
|
|
10
|
+
hook code against a FAKE in-process daemon (no network, no LLM) and asserts
|
|
11
|
+
OUTCOMES — the bytes/turns that actually landed in the bank — not that a code
|
|
12
|
+
path ran.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import unittest
|
|
21
|
+
from unittest import mock
|
|
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
|
+
import retain # noqa: E402
|
|
28
|
+
from lib import watermark # noqa: E402
|
|
29
|
+
from lib.client import HindsightClient # noqa: E402
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeDaemon:
|
|
33
|
+
"""Records retained documents by document_id with upsert semantics."""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.docs = {} # document_id -> content
|
|
37
|
+
self.posts = [] # [(document_id, async_processing)]
|
|
38
|
+
|
|
39
|
+
def retain(self, bank_id, content, document_id="conversation", context=None,
|
|
40
|
+
metadata=None, tags=None, timeout=15, async_processing=True,
|
|
41
|
+
observation_scopes=None):
|
|
42
|
+
self.posts.append((document_id, async_processing))
|
|
43
|
+
# Upsert: same document_id overwrites (the daemon contract §1).
|
|
44
|
+
self.docs[document_id] = content
|
|
45
|
+
return {"ok": True}
|
|
46
|
+
|
|
47
|
+
def content_blob(self):
|
|
48
|
+
return "\n".join(self.docs.values())
|
|
49
|
+
|
|
50
|
+
def bytes_for(self, session_prefix: str) -> int:
|
|
51
|
+
"""Total stored content bytes across every document for a session."""
|
|
52
|
+
return sum(
|
|
53
|
+
len(c.encode("utf-8"))
|
|
54
|
+
for doc_id, c in self.docs.items()
|
|
55
|
+
if doc_id.startswith(session_prefix)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def last_post_content(self) -> str:
|
|
59
|
+
return self.docs[self.posts[-1][0]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _write_transcript(path, n_turns, prefix):
|
|
63
|
+
"""Flat-format JSONL: n_turns human turns, each user+assistant with a uuid."""
|
|
64
|
+
lines = []
|
|
65
|
+
for i in range(n_turns):
|
|
66
|
+
lines.append(json.dumps(
|
|
67
|
+
{"role": "user", "content": f"user turn {i}", "uuid": f"{prefix}-u{i}"}))
|
|
68
|
+
lines.append(json.dumps(
|
|
69
|
+
{"role": "assistant", "content": f"assistant turn {i}", "uuid": f"{prefix}-a{i}"}))
|
|
70
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
71
|
+
f.write("\n".join(lines))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _stdin(obj):
|
|
75
|
+
import io
|
|
76
|
+
return io.StringIO(json.dumps(obj))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class IncrementalSweepBase(unittest.TestCase):
|
|
80
|
+
def setUp(self):
|
|
81
|
+
self.tmp = tempfile.mkdtemp(prefix="hs-rfc-p1-")
|
|
82
|
+
self.plugin_root = os.path.join(self.tmp, "plugin_root")
|
|
83
|
+
self.home = os.path.join(self.tmp, "home")
|
|
84
|
+
self.data = os.path.join(self.tmp, "data")
|
|
85
|
+
self.transcripts = os.path.join(self.tmp, "transcripts")
|
|
86
|
+
for d in (self.plugin_root, self.home, self.data, self.transcripts):
|
|
87
|
+
os.makedirs(d)
|
|
88
|
+
|
|
89
|
+
self._write_settings(8) # default cadence for these tests; overridable
|
|
90
|
+
|
|
91
|
+
self.env = mock.patch.dict(os.environ, {
|
|
92
|
+
"CLAUDE_PLUGIN_ROOT": self.plugin_root,
|
|
93
|
+
"CLAUDE_PLUGIN_DATA": self.data,
|
|
94
|
+
"HOME": self.home,
|
|
95
|
+
"HINDSIGHT_PENDING_DIR": os.path.join(self.home, ".hindsight", "pending-retains"),
|
|
96
|
+
"HINDSIGHT_RETAINED_DIR": os.path.join(self.home, ".hindsight", "retained"),
|
|
97
|
+
"HINDSIGHT_INFLIGHT_LOCK": os.path.join(self.home, ".hindsight", "retain-inflight.lock"),
|
|
98
|
+
"HINDSIGHT_TRANSCRIPTS_DIR": self.transcripts,
|
|
99
|
+
}, clear=False)
|
|
100
|
+
self.env.start()
|
|
101
|
+
for k in list(os.environ):
|
|
102
|
+
if k.startswith("HINDSIGHT_") and k not in (
|
|
103
|
+
"HINDSIGHT_PENDING_DIR", "HINDSIGHT_RETAINED_DIR",
|
|
104
|
+
"HINDSIGHT_INFLIGHT_LOCK", "HINDSIGHT_TRANSCRIPTS_DIR",
|
|
105
|
+
):
|
|
106
|
+
os.environ.pop(k, None)
|
|
107
|
+
|
|
108
|
+
self.daemon = FakeDaemon()
|
|
109
|
+
self._patches = [
|
|
110
|
+
mock.patch.object(HindsightClient, "retain", self._fake_retain),
|
|
111
|
+
mock.patch("retain.get_api_url", return_value="http://fake"),
|
|
112
|
+
]
|
|
113
|
+
for p in self._patches:
|
|
114
|
+
p.start()
|
|
115
|
+
|
|
116
|
+
def _write_settings(self, every_n_turns: int):
|
|
117
|
+
settings = {
|
|
118
|
+
"autoRetain": True,
|
|
119
|
+
"retainMode": "chunked",
|
|
120
|
+
"retainEveryNTurns": every_n_turns,
|
|
121
|
+
"retainOverlapTurns": 0,
|
|
122
|
+
"bankId": "test-bank",
|
|
123
|
+
}
|
|
124
|
+
with open(os.path.join(self.plugin_root, "settings.json"), "w") as f:
|
|
125
|
+
json.dump(settings, f)
|
|
126
|
+
|
|
127
|
+
def _fake_retain(self, *a, **kw):
|
|
128
|
+
return self.daemon.retain(*a, **kw)
|
|
129
|
+
|
|
130
|
+
def tearDown(self):
|
|
131
|
+
for p in self._patches:
|
|
132
|
+
p.stop()
|
|
133
|
+
self.env.stop()
|
|
134
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
135
|
+
|
|
136
|
+
def _hook(self, session):
|
|
137
|
+
return {
|
|
138
|
+
"session_id": session,
|
|
139
|
+
"transcript_path": os.path.join(self.transcripts, f"{session}.jsonl"),
|
|
140
|
+
"cwd": "/x",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _fire_window(self, session, transcript_turns, turn_count):
|
|
144
|
+
"""Drive a live per-window Stop retain (advances the watermark)."""
|
|
145
|
+
_write_transcript(self._hook(session)["transcript_path"], transcript_turns, session)
|
|
146
|
+
with mock.patch("retain.increment_turn_count", return_value=turn_count), \
|
|
147
|
+
mock.patch("sys.stdin", _stdin(self._hook(session))):
|
|
148
|
+
retain.main()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestIncrementalSweep(IncrementalSweepBase):
|
|
152
|
+
|
|
153
|
+
def test_two_turn_no_watermark_sweep_retains_both_turns(self):
|
|
154
|
+
# A short session that never fired a per-window retain has no committed
|
|
155
|
+
# watermark, so the forced sweep must flush the WHOLE (2-turn) transcript
|
|
156
|
+
# (RFC §4.2 — short sessions still land on disk).
|
|
157
|
+
session = "sessShort"
|
|
158
|
+
_write_transcript(self._hook(session)["transcript_path"], 2, session)
|
|
159
|
+
self.assertIsNone(watermark.load(session)) # precondition: no watermark
|
|
160
|
+
|
|
161
|
+
result = retain.run_retain(self._hook(session), force=True)
|
|
162
|
+
|
|
163
|
+
self.assertEqual(result.get("status"), "ok")
|
|
164
|
+
blob = self.daemon.content_blob()
|
|
165
|
+
self.assertIn("user turn 0", blob)
|
|
166
|
+
self.assertIn("user turn 1", blob)
|
|
167
|
+
|
|
168
|
+
def test_thirty_turn_sweep_is_incremental_and_smaller_than_full(self):
|
|
169
|
+
# Windows fire at turns 8/16/24 (n=8, overlap 0), then SessionEnd sweeps.
|
|
170
|
+
# The sweep must contain ONLY turns after the last window's tail uuid,
|
|
171
|
+
# and the total bytes stored for the session must be strictly less than
|
|
172
|
+
# the pre-change (full-session sweep) total. This is the test that fails
|
|
173
|
+
# on the bug it guards: a non-incremental sweep re-stores all 30 turns,
|
|
174
|
+
# so the totals become equal and assertLess fires.
|
|
175
|
+
inc = "sessInc"
|
|
176
|
+
for fire_at in (8, 16, 24):
|
|
177
|
+
self._fire_window(inc, fire_at, fire_at)
|
|
178
|
+
# The last window committed its tail uuid as the watermark.
|
|
179
|
+
wm = watermark.load(inc)
|
|
180
|
+
self.assertIsNotNone(wm, "expected the per-window retains to commit a watermark")
|
|
181
|
+
self.assertEqual(wm["last_uuid"], f"{inc}-a23")
|
|
182
|
+
|
|
183
|
+
_write_transcript(self._hook(inc)["transcript_path"], 30, inc)
|
|
184
|
+
result = retain.run_retain(self._hook(inc), force=True)
|
|
185
|
+
self.assertEqual(result.get("status"), "ok")
|
|
186
|
+
|
|
187
|
+
sweep = self.daemon.last_post_content()
|
|
188
|
+
# Only turns AFTER the watermark (24..29); nothing at/ before it.
|
|
189
|
+
for i in range(24, 30):
|
|
190
|
+
self.assertIn(f"user turn {i}", sweep, f"turn {i} missing from incremental sweep")
|
|
191
|
+
for i in (0, 15, 23):
|
|
192
|
+
self.assertNotIn(f"user turn {i}", sweep,
|
|
193
|
+
f"turn {i} leaked into the incremental sweep")
|
|
194
|
+
|
|
195
|
+
# Pre-change baseline: identical scenario, but the SessionEnd sweep runs
|
|
196
|
+
# full-session (watermark unseen), on a distinct session id in the same
|
|
197
|
+
# daemon so window docs are directly comparable.
|
|
198
|
+
full = "sessFull"
|
|
199
|
+
for fire_at in (8, 16, 24):
|
|
200
|
+
self._fire_window(full, fire_at, fire_at)
|
|
201
|
+
_write_transcript(self._hook(full)["transcript_path"], 30, full)
|
|
202
|
+
with mock.patch("retain.watermark.load", return_value=None):
|
|
203
|
+
self.assertEqual(retain.run_retain(self._hook(full), force=True).get("status"), "ok")
|
|
204
|
+
|
|
205
|
+
self.assertLess(
|
|
206
|
+
self.daemon.bytes_for(inc),
|
|
207
|
+
self.daemon.bytes_for(full),
|
|
208
|
+
"incremental sweep did not reduce total retained bytes vs full sweep",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def test_corrupt_watermark_json_sweeps_whole_transcript_no_raise(self):
|
|
212
|
+
# A watermark file corrupted to invalid JSON must degrade to a whole
|
|
213
|
+
# -transcript sweep, and no exception may escape run_retain (§4.3).
|
|
214
|
+
session = "sessCorrupt"
|
|
215
|
+
_write_transcript(self._hook(session)["transcript_path"], 4, session)
|
|
216
|
+
retained_dir = os.environ["HINDSIGHT_RETAINED_DIR"]
|
|
217
|
+
os.makedirs(retained_dir, exist_ok=True)
|
|
218
|
+
with open(os.path.join(retained_dir, f"{session}.json"), "w") as f:
|
|
219
|
+
f.write("{ this is not valid json ]]]")
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
result = retain.run_retain(self._hook(session), force=True)
|
|
223
|
+
except Exception as e: # noqa: BLE001 - the whole point is nothing escapes
|
|
224
|
+
self.fail(f"run_retain raised into the SessionEnd seam: {e!r}")
|
|
225
|
+
|
|
226
|
+
self.assertEqual(result.get("status"), "ok")
|
|
227
|
+
blob = self.daemon.content_blob()
|
|
228
|
+
for i in range(4):
|
|
229
|
+
self.assertIn(f"user turn {i}", blob)
|
|
230
|
+
|
|
231
|
+
def test_watermark_load_raising_degrades_to_full_sweep(self):
|
|
232
|
+
# Belt-and-braces for the §4.3 catch-all: even if the watermark READ
|
|
233
|
+
# itself raises an unexpected error, run_retain must NOT propagate it —
|
|
234
|
+
# it degrades to the whole-transcript sweep.
|
|
235
|
+
session = "sessBoom"
|
|
236
|
+
_write_transcript(self._hook(session)["transcript_path"], 4, session)
|
|
237
|
+
|
|
238
|
+
def _boom(_):
|
|
239
|
+
raise RuntimeError("simulated watermark read explosion")
|
|
240
|
+
|
|
241
|
+
with mock.patch("retain.watermark.load", side_effect=_boom):
|
|
242
|
+
try:
|
|
243
|
+
result = retain.run_retain(self._hook(session), force=True)
|
|
244
|
+
except Exception as e: # noqa: BLE001
|
|
245
|
+
self.fail(f"run_retain propagated a watermark read failure: {e!r}")
|
|
246
|
+
|
|
247
|
+
self.assertEqual(result.get("status"), "ok")
|
|
248
|
+
blob = self.daemon.content_blob()
|
|
249
|
+
for i in range(4):
|
|
250
|
+
self.assertIn(f"user turn {i}", blob)
|
|
251
|
+
|
|
252
|
+
def test_compacted_watermark_uuid_sweeps_whole_transcript(self):
|
|
253
|
+
# The watermark anchor was compacted out of the transcript: tail_after
|
|
254
|
+
# cannot find it and returns the whole transcript (a safe re-upsert).
|
|
255
|
+
session = "sessCompact"
|
|
256
|
+
_write_transcript(self._hook(session)["transcript_path"], 4, session)
|
|
257
|
+
# Commit a watermark whose uuid is NOT present in the transcript.
|
|
258
|
+
watermark.commit(session, "ghost-uuid-not-in-transcript", "doc-ghost",
|
|
259
|
+
ordered_uuids=["ghost-uuid-not-in-transcript"])
|
|
260
|
+
self.assertEqual(watermark.load(session)["last_uuid"], "ghost-uuid-not-in-transcript")
|
|
261
|
+
|
|
262
|
+
result = retain.run_retain(self._hook(session), force=True)
|
|
263
|
+
self.assertEqual(result.get("status"), "ok")
|
|
264
|
+
blob = self.daemon.content_blob()
|
|
265
|
+
for i in range(4):
|
|
266
|
+
self.assertIn(f"user turn {i}", blob)
|
|
267
|
+
|
|
268
|
+
def test_every_n_turns_1_is_byte_identical_full_session(self):
|
|
269
|
+
# At retainEveryNTurns==1 the document id is {session_id} and a tail slice
|
|
270
|
+
# would TRUNCATE it (RFC §4.2). The n==1 path must keep the full-session
|
|
271
|
+
# sweep even when a committed watermark exists — byte-identical to before.
|
|
272
|
+
self._write_settings(1)
|
|
273
|
+
session = "sessN1"
|
|
274
|
+
_write_transcript(self._hook(session)["transcript_path"], 4, session)
|
|
275
|
+
# A watermark exists (a prior every-turn fire) sitting mid-transcript.
|
|
276
|
+
watermark.commit(session, f"{session}-a1", "doc-prior",
|
|
277
|
+
ordered_uuids=[f"{session}-u{i//2}" if i % 2 == 0 else f"{session}-a{i//2}"
|
|
278
|
+
for i in range(8)])
|
|
279
|
+
self.assertEqual(watermark.load(session)["last_uuid"], f"{session}-a1")
|
|
280
|
+
|
|
281
|
+
result = retain.run_retain(self._hook(session), force=True)
|
|
282
|
+
self.assertEqual(result.get("status"), "ok")
|
|
283
|
+
|
|
284
|
+
# Whole transcript, under the plain {session_id} document id — the tail
|
|
285
|
+
# slice must NOT have applied.
|
|
286
|
+
self.assertIn(session, self.daemon.docs, "n==1 sweep must post under the {session_id} id")
|
|
287
|
+
content = self.daemon.docs[session]
|
|
288
|
+
for i in range(4):
|
|
289
|
+
self.assertIn(f"user turn {i}", content, f"turn {i} missing — n==1 sweep was truncated")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
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()
|