switchroom 0.19.2 → 0.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -0,0 +1,439 @@
1
+ """Switchroom hindsight-leverage PR5 — unit tests for subagent_retain.py.
2
+
3
+ Covers the SubagentStop sidechain retain: transcript resolution (first-class
4
+ ``agent_transcript_path`` + directory-scan fallback), the volume gate, the
5
+ window slice + deterministic dedup id, and the pending-retains enqueue on
6
+ failure. Stdlib-only; runs under ``python3 -m unittest discover tests/``.
7
+ """
8
+
9
+ import contextlib
10
+ import json
11
+ import os
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import unittest
16
+ from unittest import mock
17
+
18
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
19
+ if SCRIPTS_DIR not in sys.path:
20
+ sys.path.insert(0, SCRIPTS_DIR)
21
+
22
+ import subagent_retain # noqa: E402
23
+
24
+
25
+ CONFIG = {
26
+ "autoRetain": True,
27
+ "retainMode": "chunked",
28
+ "retainRoles": ["user", "assistant"],
29
+ "retainToolCalls": True,
30
+ "retainTags": ["{session_id}"],
31
+ "retainMetadata": {},
32
+ "retainContext": "claude-code",
33
+ "hindsightApiToken": None,
34
+ "debug": False,
35
+ }
36
+
37
+
38
+ def _sidechain_line(role: str, text: str, uuid: str, is_tool_result: bool = False) -> str:
39
+ """One nested Claude Code sidechain jsonl entry."""
40
+ if is_tool_result:
41
+ content = [{"type": "tool_result", "tool_use_id": "t1", "content": text}]
42
+ else:
43
+ content = text
44
+ return json.dumps(
45
+ {
46
+ "type": role,
47
+ "isSidechain": True,
48
+ "agentId": "af5fba739c0ee6b38",
49
+ "sessionId": "parentsess",
50
+ "uuid": uuid,
51
+ "message": {"role": role, "content": content},
52
+ }
53
+ )
54
+
55
+
56
+ def _write_sidechain(path: str, num_turns: int, chars_per_msg: int = 500) -> None:
57
+ """Write a sidechain jsonl with num_turns human turns of substantive text."""
58
+ lines = []
59
+ for i in range(num_turns):
60
+ body = f"turn {i} " + ("x" * chars_per_msg)
61
+ lines.append(_sidechain_line("user", body, f"u{i}"))
62
+ lines.append(_sidechain_line("assistant", f"did work {i} " + ("y" * chars_per_msg), f"a{i}"))
63
+ with open(path, "w", encoding="utf-8") as f:
64
+ f.write("\n".join(lines) + "\n")
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def _lock_acquired(blocking=False):
69
+ yield True
70
+
71
+
72
+ class ResolveSidechainTranscript(unittest.TestCase):
73
+ def test_prefers_agent_transcript_path(self):
74
+ with tempfile.TemporaryDirectory() as d:
75
+ sc = os.path.join(d, "agent-abc.jsonl")
76
+ _write_sidechain(sc, 3)
77
+ got = subagent_retain.resolve_sidechain_transcript(
78
+ {"agent_transcript_path": sc, "transcript_path": "/nope", "session_id": "s"}
79
+ )
80
+ self.assertEqual(got, sc)
81
+
82
+ def test_derives_from_session_and_agent_id_when_field_absent(self):
83
+ with tempfile.TemporaryDirectory() as d:
84
+ proj = d
85
+ parent = os.path.join(proj, "sess1.jsonl")
86
+ open(parent, "w").close()
87
+ sub_dir = os.path.join(proj, "sess1", "subagents")
88
+ os.makedirs(sub_dir)
89
+ sc = os.path.join(sub_dir, "agent-XYZ.jsonl")
90
+ _write_sidechain(sc, 2)
91
+ got = subagent_retain.resolve_sidechain_transcript(
92
+ {"transcript_path": parent, "session_id": "sess1", "agent_id": "XYZ"}
93
+ )
94
+ self.assertEqual(got, sc)
95
+
96
+ def test_scan_fallback_picks_newest_sidechain_jsonl(self):
97
+ with tempfile.TemporaryDirectory() as d:
98
+ parent = os.path.join(d, "sess1.jsonl")
99
+ open(parent, "w").close()
100
+ sub_dir = os.path.join(d, "sess1", "subagents")
101
+ os.makedirs(sub_dir)
102
+ older = os.path.join(sub_dir, "agent-old.jsonl")
103
+ newer = os.path.join(sub_dir, "agent-new.jsonl")
104
+ _write_sidechain(older, 2)
105
+ _write_sidechain(newer, 2)
106
+ # Both FRESH (within SIDECHAIN_SCAN_FRESH_WINDOW_S of now), `newer`
107
+ # genuinely newer by mtime.
108
+ now = time.time()
109
+ os.utime(older, (now - 30, now - 30))
110
+ os.utime(newer, (now - 5, now - 5))
111
+ # No agent_id → must scan and pick newest.
112
+ got = subagent_retain.resolve_sidechain_transcript(
113
+ {"transcript_path": parent, "session_id": "sess1"}
114
+ )
115
+ self.assertEqual(got, newer)
116
+
117
+ def test_scan_ignores_stale_sidechain_outside_fresh_window(self):
118
+ with tempfile.TemporaryDirectory() as d:
119
+ parent = os.path.join(d, "sess1.jsonl")
120
+ open(parent, "w").close()
121
+ sub_dir = os.path.join(d, "sess1", "subagents")
122
+ os.makedirs(sub_dir)
123
+ stale = os.path.join(sub_dir, "agent-stale.jsonl")
124
+ _write_sidechain(stale, 2)
125
+ # Older than the freshness window → NOT picked (finding 3).
126
+ old = time.time() - subagent_retain.SIDECHAIN_SCAN_FRESH_WINDOW_S - 60
127
+ os.utime(stale, (old, old))
128
+ got = subagent_retain.resolve_sidechain_transcript(
129
+ {"transcript_path": parent, "session_id": "sess1"}
130
+ )
131
+ self.assertEqual(got, "")
132
+
133
+ def test_rejects_agent_transcript_path_equal_to_parent(self):
134
+ # finding 2: a CLI that sets agent_transcript_path == transcript_path
135
+ # must NOT cause the parent (main-session) transcript to be retained
136
+ # under the sub namespace.
137
+ with tempfile.TemporaryDirectory() as d:
138
+ parent = os.path.join(d, "sess1.jsonl")
139
+ _write_sidechain(parent, 3) # even if it looked like a sidechain
140
+ got = subagent_retain.resolve_sidechain_transcript(
141
+ {"agent_transcript_path": parent, "transcript_path": parent, "session_id": "sess1"}
142
+ )
143
+ self.assertEqual(got, "")
144
+
145
+ def test_rejects_non_sidechain_agent_transcript_path(self):
146
+ # finding 2: agent_transcript_path pointing at a NON-sidechain file
147
+ # (e.g. the parent under a different name) is rejected.
148
+ with tempfile.TemporaryDirectory() as d:
149
+ notsc = os.path.join(d, "agent-notsc.jsonl")
150
+ with open(notsc, "w") as f:
151
+ f.write(json.dumps({"type": "user", "message": {"role": "user", "content": "hi"}}) + "\n")
152
+ got = subagent_retain.resolve_sidechain_transcript(
153
+ {"agent_transcript_path": notsc, "transcript_path": "/nope", "session_id": "s"}
154
+ )
155
+ self.assertEqual(got, "")
156
+
157
+ def test_ignores_non_sidechain_jsonl_in_scan(self):
158
+ with tempfile.TemporaryDirectory() as d:
159
+ parent = os.path.join(d, "sess1.jsonl")
160
+ open(parent, "w").close()
161
+ sub_dir = os.path.join(d, "sess1", "subagents")
162
+ os.makedirs(sub_dir)
163
+ # A non-sidechain file (first line lacks isSidechain).
164
+ plain = os.path.join(sub_dir, "agent-plain.jsonl")
165
+ with open(plain, "w") as f:
166
+ f.write(json.dumps({"type": "user", "message": {"role": "user", "content": "hi"}}) + "\n")
167
+ got = subagent_retain.resolve_sidechain_transcript(
168
+ {"transcript_path": parent, "session_id": "sess1"}
169
+ )
170
+ self.assertEqual(got, "")
171
+
172
+ def test_returns_empty_when_nothing_found(self):
173
+ got = subagent_retain.resolve_sidechain_transcript(
174
+ {"transcript_path": "/does/not/exist.jsonl", "session_id": "x"}
175
+ )
176
+ self.assertEqual(got, "")
177
+
178
+ def test_malformed_transcript_path_does_not_walk_filesystem(self):
179
+ # Regression: a transcript_path whose dirname is "/" must NOT trigger a
180
+ # recursive filesystem walk. The bounded listdir of a non-existent
181
+ # <root>/<session>/subagents returns "" immediately (mock listdir to
182
+ # assert it is never called on "/").
183
+ with mock.patch("subagent_retain.os.walk") as walk:
184
+ got = subagent_retain.resolve_sidechain_transcript(
185
+ {"agent_transcript_path": "/x", "transcript_path": "/nope", "session_id": "s"}
186
+ )
187
+ self.assertEqual(got, "")
188
+ walk.assert_not_called()
189
+
190
+
191
+ class VolumeGate(unittest.TestCase):
192
+ def test_counts_human_turns_excluding_tool_results(self):
193
+ msgs = [
194
+ {"role": "user", "content": "real turn 1"},
195
+ {"role": "assistant", "content": "ok"},
196
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}]},
197
+ {"role": "user", "content": "real turn 2"},
198
+ ]
199
+ self.assertEqual(subagent_retain.count_human_turns(msgs), 2)
200
+
201
+ def test_skip_below_turn_floor(self):
202
+ # 3 turns, plenty of chars → still skipped (turns < 6).
203
+ msgs = []
204
+ for i in range(3):
205
+ msgs.append({"role": "user", "content": "x" * 2000})
206
+ msgs.append({"role": "assistant", "content": "y" * 2000})
207
+ passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
208
+ self.assertFalse(passed)
209
+ self.assertEqual(turns, 3)
210
+
211
+ def test_skip_below_char_floor(self):
212
+ # 8 turns but tiny → skipped (chars < 2000).
213
+ msgs = []
214
+ for i in range(8):
215
+ msgs.append({"role": "user", "content": "hi"})
216
+ msgs.append({"role": "assistant", "content": "ok"})
217
+ passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
218
+ self.assertFalse(passed)
219
+ self.assertGreaterEqual(turns, 6)
220
+ self.assertLess(chars, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
221
+
222
+ def test_tool_result_bodies_do_not_count_toward_char_floor(self):
223
+ # 8 turns whose ONLY bulk is tool_result output → below char floor.
224
+ msgs = []
225
+ for i in range(8):
226
+ msgs.append({"role": "user", "content": "go"})
227
+ msgs.append(
228
+ {
229
+ "role": "assistant",
230
+ "content": [
231
+ {"type": "text", "text": "k"},
232
+ {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}},
233
+ ],
234
+ }
235
+ )
236
+ msgs.append(
237
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "Z" * 5000}]}
238
+ )
239
+ passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
240
+ self.assertFalse(passed)
241
+ self.assertLess(chars, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
242
+
243
+ def test_passes_when_both_floors_met(self):
244
+ msgs = []
245
+ for i in range(7):
246
+ msgs.append({"role": "user", "content": "x" * 400})
247
+ msgs.append({"role": "assistant", "content": "y" * 400})
248
+ passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
249
+ self.assertTrue(passed)
250
+
251
+ def test_char_count_short_circuits_at_floor(self):
252
+ # finding 4: once the floor is met the walk stops — the returned count is
253
+ # a lower bound (>= stop_at), not the full sum, and later messages are
254
+ # not visited.
255
+ msgs = []
256
+ for i in range(20):
257
+ msgs.append({"role": "assistant", "content": "z" * 500})
258
+ total = subagent_retain.non_tool_result_char_count(
259
+ msgs, stop_at=subagent_retain.MIN_NON_TOOL_RESULT_CHARS
260
+ )
261
+ self.assertGreaterEqual(total, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
262
+ # Full sum would be 20*500=10000; short-circuit stops well before.
263
+ self.assertLess(total, 10000)
264
+
265
+
266
+ class BoundedRead(unittest.TestCase):
267
+ def test_read_transcript_max_bytes_reads_only_tail(self):
268
+ from retain import read_transcript
269
+
270
+ with tempfile.TemporaryDirectory() as d:
271
+ p = os.path.join(d, "big.jsonl")
272
+ _write_sidechain(p, 60, chars_per_msg=400) # large file
273
+ full = read_transcript(p)
274
+ tail = read_transcript(p, max_bytes=8000)
275
+ # Bounded read returns fewer messages, and they are the LAST ones
276
+ # (order preserved) — the final turn is present in both.
277
+ self.assertLess(len(tail), len(full))
278
+ self.assertGreater(len(tail), 0)
279
+ last_uuid_full = full[-1].get("uuid")
280
+ self.assertEqual(tail[-1].get("uuid"), last_uuid_full)
281
+
282
+ def test_max_bytes_none_reads_whole_file(self):
283
+ from retain import read_transcript
284
+
285
+ with tempfile.TemporaryDirectory() as d:
286
+ p = os.path.join(d, "x.jsonl")
287
+ _write_sidechain(p, 5)
288
+ self.assertEqual(len(read_transcript(p, max_bytes=None)), len(read_transcript(p)))
289
+
290
+
291
+ class RunSubagentRetain(unittest.TestCase):
292
+ def _run(self, hook_input):
293
+ captured = {}
294
+
295
+ class _Client:
296
+ def __init__(self, *a, **k):
297
+ pass
298
+
299
+ def retain(self, **kwargs):
300
+ captured.update(kwargs)
301
+ return {"ok": True}
302
+
303
+ with mock.patch("subagent_retain.load_config", return_value=dict(CONFIG)), \
304
+ mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
305
+ mock.patch("subagent_retain.ensure_bank_mission"), \
306
+ mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
307
+ mock.patch("subagent_retain.HindsightClient", _Client), \
308
+ mock.patch("subagent_retain.inflight_lock", _lock_acquired):
309
+ result = subagent_retain.run_subagent_retain(hook_input)
310
+ return result, captured
311
+
312
+ def test_substantive_sidechain_retained_with_tags_and_window(self):
313
+ with tempfile.TemporaryDirectory() as d:
314
+ sc = os.path.join(d, "agent-af5.jsonl")
315
+ _write_sidechain(sc, 8, chars_per_msg=400)
316
+ hook_input = {
317
+ "session_id": "parentsess",
318
+ "agent_id": "af5",
319
+ "agent_type": "worker",
320
+ "agent_transcript_path": sc,
321
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
322
+ "cwd": d,
323
+ }
324
+ result, captured = self._run(hook_input)
325
+
326
+ self.assertEqual(result["status"], "ok")
327
+ # Tagged sidechain + parent link.
328
+ self.assertIn("sidechain", captured["tags"])
329
+ self.assertIn("parent_session:parentsess", captured["tags"])
330
+ self.assertIn("agent_type:worker", captured["tags"])
331
+ # Deterministic id in a DISTINCT namespace from parent retains.
332
+ self.assertTrue(captured["document_id"].startswith("parentsess-sub-af5-r"))
333
+ # Process-fact framing header is prepended.
334
+ self.assertIn("extract PROCESS facts", captured["content"])
335
+ # Distinct provenance context.
336
+ self.assertEqual(captured["context"], "claude-code-sidechain")
337
+ self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
338
+
339
+ def test_document_id_is_stable_across_refires(self):
340
+ with tempfile.TemporaryDirectory() as d:
341
+ sc = os.path.join(d, "agent-af5.jsonl")
342
+ _write_sidechain(sc, 8, chars_per_msg=400)
343
+ hook_input = {
344
+ "session_id": "parentsess",
345
+ "agent_id": "af5",
346
+ "agent_transcript_path": sc,
347
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
348
+ "cwd": d,
349
+ }
350
+ r1, c1 = self._run(hook_input)
351
+ r2, c2 = self._run(hook_input)
352
+ self.assertEqual(c1["document_id"], c2["document_id"])
353
+
354
+ def test_window_capped_at_N_turns(self):
355
+ with tempfile.TemporaryDirectory() as d:
356
+ sc = os.path.join(d, "agent-af5.jsonl")
357
+ # More than the window: 50 turns, window is 40.
358
+ _write_sidechain(sc, 50, chars_per_msg=60)
359
+ hook_input = {
360
+ "session_id": "parentsess",
361
+ "agent_id": "af5",
362
+ "agent_transcript_path": sc,
363
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
364
+ "cwd": d,
365
+ }
366
+ result, captured = self._run(hook_input)
367
+ self.assertEqual(result["status"], "ok")
368
+ content = captured["content"]
369
+ # The last turn (49) must be present; an early dropped turn (0) absent.
370
+ self.assertIn("turn 49", content)
371
+ self.assertNotIn("turn 0 ", content)
372
+
373
+ def test_volume_gate_skips_trivial_fork_no_retain(self):
374
+ with tempfile.TemporaryDirectory() as d:
375
+ sc = os.path.join(d, "agent-tiny.jsonl")
376
+ _write_sidechain(sc, 2, chars_per_msg=5) # 2 turns, tiny
377
+ hook_input = {
378
+ "session_id": "parentsess",
379
+ "agent_id": "tiny",
380
+ "agent_transcript_path": sc,
381
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
382
+ "cwd": d,
383
+ }
384
+ result, captured = self._run(hook_input)
385
+ self.assertEqual(result["status"], "skipped")
386
+ self.assertEqual(result["reason"], "volume gate")
387
+ self.assertEqual(captured, {}) # nothing posted
388
+
389
+ def test_no_transcript_skips(self):
390
+ result, captured = self._run(
391
+ {"session_id": "s", "agent_id": "a", "transcript_path": "/no/where.jsonl"}
392
+ )
393
+ self.assertEqual(result["status"], "skipped")
394
+
395
+ def test_lock_busy_returns_failed_payload_for_enqueue(self):
396
+ @contextlib.contextmanager
397
+ def _busy(blocking=False):
398
+ yield False
399
+
400
+ with tempfile.TemporaryDirectory() as d:
401
+ sc = os.path.join(d, "agent-af5.jsonl")
402
+ _write_sidechain(sc, 8, chars_per_msg=400)
403
+ hook_input = {
404
+ "session_id": "parentsess",
405
+ "agent_id": "af5",
406
+ "agent_transcript_path": sc,
407
+ "transcript_path": os.path.join(d, "parentsess.jsonl"),
408
+ "cwd": d,
409
+ }
410
+ with mock.patch("subagent_retain.load_config", return_value=dict(CONFIG)), \
411
+ mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
412
+ mock.patch("subagent_retain.ensure_bank_mission"), \
413
+ mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
414
+ mock.patch("subagent_retain.HindsightClient"), \
415
+ mock.patch("subagent_retain.inflight_lock", _busy):
416
+ result = subagent_retain.run_subagent_retain(hook_input)
417
+ self.assertEqual(result["status"], "failed")
418
+ self.assertIsNotNone(result["payload"])
419
+ # Payload is enqueue-shaped (matches lib.pending expectations).
420
+ for k in ("bank_id", "content", "document_id", "context", "metadata", "tags"):
421
+ self.assertIn(k, result["payload"])
422
+
423
+
424
+ class HookRegistration(unittest.TestCase):
425
+ def test_subagentstop_registered_in_plugin_hooks_json(self):
426
+ hooks_path = os.path.abspath(
427
+ os.path.join(SCRIPTS_DIR, "..", "hooks", "hooks.json")
428
+ )
429
+ with open(hooks_path) as f:
430
+ hooks = json.load(f)
431
+ self.assertIn("SubagentStop", hooks["hooks"])
432
+ entry = hooks["hooks"]["SubagentStop"][0]["hooks"][0]
433
+ self.assertIn("subagent_retain.py", entry["command"])
434
+ self.assertTrue(entry.get("async"))
435
+ self.assertEqual(entry.get("timeout"), 15)
436
+
437
+
438
+ if __name__ == "__main__":
439
+ unittest.main()
@@ -10,8 +10,10 @@
10
10
  "recallMaxTokens": 1024,
11
11
  "recallMaxMemories": 12,
12
12
  "recallTypes": ["world", "experience"],
13
- "recallContextTurns": 1,
13
+ "recallContextTurns": 2,
14
+ "directivesCacheTtlSeconds": 120,
14
15
  "recallMaxQueryChars": 800,
16
+ "recallTranscriptTailBytes": 262144,
15
17
  "recallRoles": ["user", "assistant"],
16
18
  "recallTags": [],
17
19
  "recallTagsMatch": "any",