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
@@ -42,7 +42,11 @@ from lib import watermark
42
42
  from lib.bank import derive_bank_id
43
43
  from lib.client import HindsightClient
44
44
  from lib.config import debug_log, load_config
45
- from lib.content import _is_tool_result_only_user_message, slice_last_turns_by_user_boundary
45
+ from lib.content import (
46
+ _is_tool_result_only_user_message,
47
+ slice_last_turns_by_user_boundary,
48
+ transcript_first_line_is_sidechain,
49
+ )
46
50
  from lib.daemon import get_api_url
47
51
  from lib.pacing import inflight_lock
48
52
  from lib.pending import enqueue as pending_enqueue
@@ -135,6 +139,7 @@ def reconcile(config: dict | None = None, hook_input: dict | None = None) -> dic
135
139
  "posts_ok": 0,
136
140
  "enqueued": 0, # slices deferred to pending-retains (bounds/failure)
137
141
  "skipped_clean": 0,
142
+ "skipped_sidechain": 0, # sub-agent transcripts (owned by subagent_retain.py)
138
143
  "disabled": False,
139
144
  }
140
145
  if not config.get("autoRetain"):
@@ -184,6 +189,22 @@ def reconcile(config: dict | None = None, hook_input: dict | None = None) -> dic
184
189
  for path in transcripts:
185
190
  summary["scanned"] += 1
186
191
  session_id = _session_id_from_path(path)
192
+
193
+ # Sub-agent (sidechain) transcripts are NOT sessions. The recursive glob
194
+ # above matches <session>/subagents/agent-<id>.jsonl, each of which has a
195
+ # human turn, no watermark, and (fresh) an in-lookback mtime — so without
196
+ # this guard reconcile would treat every worker fork as a pseudo-session
197
+ # and retain it here at boot: an UNTAGGED document
198
+ # (agent-<id>-r{u}-{u}, disjoint namespace from subagent_retain.py's
199
+ # {parent}-sub-{agent} ids ⇒ permanent duplicate), at FULL recall weight
200
+ # (defeating the recallTagWeights sidechain:0.8 demotion), bypassing the
201
+ # volume gate (trivial 10s forks get LLM-extracted at boot). Sidechains
202
+ # are owned SOLELY by subagent_retain.py (SubagentStop) — skip them here.
203
+ if transcript_first_line_is_sidechain(path):
204
+ summary["skipped_sidechain"] += 1
205
+ debug_log(config, f"reconcile_tail: skipping sidechain transcript {path}")
206
+ continue
207
+
187
208
  try:
188
209
  mtime = os.path.getmtime(path)
189
210
  except OSError:
@@ -38,7 +38,7 @@ from lib.pacing import inflight_lock
38
38
  from lib.state import increment_turn_count, track_retention
39
39
 
40
40
 
41
- def read_transcript(transcript_path: str) -> list:
41
+ def read_transcript(transcript_path: str, max_bytes: int | None = None) -> list:
42
42
  """Read a JSONL transcript file and return list of message dicts.
43
43
 
44
44
  Claude Code transcript format nests messages:
@@ -52,12 +52,30 @@ def read_transcript(transcript_path: str) -> list:
52
52
  watermark can key on it. The uuid is stable across compaction and unique
53
53
  per entry. Adding the key is inert for downstream formatting
54
54
  (``lib/content`` reads only ``role``/``content``).
55
+
56
+ ``max_bytes`` (switchroom hindsight-leverage PR5 — bounded read): when set,
57
+ only the LAST ``max_bytes`` of the file are read and the first (possibly
58
+ partial) line of that window is discarded, so a multi-hour worker's
59
+ arbitrarily-large sidechain transcript can never eat the hook budget on the
60
+ read before the volume-gate/POST. Transcript ORDER is preserved (we read the
61
+ tail, in order); a window this covers >> the retain window + gate floors, so
62
+ the "last N turns" slice and PASS/skip decision are unaffected in practice.
63
+ ``None`` (default) reads the whole file — the main Stop/reconcile paths are
64
+ unchanged.
55
65
  """
56
66
  if not transcript_path or not os.path.isfile(transcript_path):
57
67
  return []
58
68
  messages = []
59
69
  try:
60
- with open(transcript_path, encoding="utf-8") as f:
70
+ with open(transcript_path, encoding="utf-8", errors="replace") as f:
71
+ if max_bytes is not None and max_bytes > 0:
72
+ try:
73
+ size = os.path.getsize(transcript_path)
74
+ if size > max_bytes:
75
+ f.seek(size - max_bytes)
76
+ f.readline() # drop the partial first line of the window
77
+ except OSError:
78
+ pass
61
79
  for line in f:
62
80
  line = line.strip()
63
81
  if not line:
@@ -183,6 +201,45 @@ def slice_document_id(session_id: str, messages_slice: list, transcript_text: st
183
201
  return f"{session_id}-r{digest}"
184
202
 
185
203
 
204
+ def detect_lesson_tags(transcript: str, config: dict) -> list:
205
+ """Deterministic lesson / anti-pattern tag detection (switchroom E2 / #398).
206
+
207
+ Scans the formatted transcript slice for explicit lesson / anti-pattern
208
+ markers and returns the sorted, de-duplicated list of tags to attach. This
209
+ is the retain-side half of #398: a transcript that captures a self-recognised
210
+ lesson ("lesson learned", "note to self:") or a failure mode ("anti-pattern:",
211
+ "what not to do") is tagged so recall's per-tag score-penalty weight map
212
+ (recallTagWeights, PR5) can DEMOTE it below clean first-party memories without
213
+ ever hard-dropping it.
214
+
215
+ Detection is a deterministic case-insensitive substring match against the
216
+ configurable ``lessonTagMarkers`` map — NOT model-dependent, so the behaviour
217
+ is reproducible and testable. Returns ``[]`` when tagging is disabled
218
+ (``lessonTagging`` false), the transcript is empty, the marker map is malformed,
219
+ or nothing matches. NON-GOAL (epic-recorded): this fires on NEW retains only;
220
+ the historical corpus is never re-tagged.
221
+ """
222
+ if not config.get("lessonTagging", True):
223
+ return []
224
+ if not isinstance(transcript, str) or not transcript:
225
+ return []
226
+ markers = config.get("lessonTagMarkers")
227
+ if not isinstance(markers, dict) or not markers:
228
+ return []
229
+ hay = transcript.lower()
230
+ tags = set()
231
+ for tag, needles in markers.items():
232
+ if not isinstance(tag, str) or not tag.strip():
233
+ continue
234
+ if not isinstance(needles, list):
235
+ continue
236
+ for needle in needles:
237
+ if isinstance(needle, str) and needle and needle.lower() in hay:
238
+ tags.add(tag.strip())
239
+ break
240
+ return sorted(tags)
241
+
242
+
186
243
  def build_retain_payload(
187
244
  config: dict,
188
245
  session_id: str,
@@ -243,6 +300,18 @@ def build_retain_payload(
243
300
  else:
244
301
  tags = None
245
302
 
303
+ # Switchroom E2 / PR9 (#398) — attach lesson / anti-pattern tags detected in
304
+ # the transcript slice so recall can demote failure-mode-adjacent memories via
305
+ # the PR5 score-penalty weight map. Deterministic, best-effort, never fails a
306
+ # build; applies to both Stop-hook and sidechain retains (shared code path).
307
+ lesson_tags = detect_lesson_tags(transcript, config)
308
+ if lesson_tags:
309
+ merged = list(tags) if tags else []
310
+ for lt in lesson_tags:
311
+ if lt not in merged:
312
+ merged.append(lt)
313
+ tags = merged
314
+
246
315
  metadata = {
247
316
  "retained_at": template_vars["timestamp"],
248
317
  "message_count": str(message_count),
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env python3
2
+ """Auto-retain hook for the SubagentStop event (switchroom hindsight-leverage PR5).
3
+
4
+ Delegated (sub-agent / Task-tool) work is the biggest systematic memory hole:
5
+ the main-session Stop retain only ever reads the parent ``transcript_path``, so
6
+ a worker's hours of process work — the paths it touched, the commands that
7
+ worked, the dead ends — reach memory only as the terse final report the parent
8
+ transcript captures. This hook closes that hole by retaining a bounded window of
9
+ the *sidechain* transcript when a sub-agent terminates.
10
+
11
+ Probe result (Claude Code 2.1.215, PR5 Task 0 — recorded in the PR body):
12
+ the ``SubagentStop`` hook input carries BOTH the parent ``transcript_path`` AND
13
+ a first-class ``agent_transcript_path`` pointing straight at the sidechain
14
+ ``.jsonl`` (``<projectdir>/<session_id>/subagents/agent-<agent_id>.jsonl``),
15
+ plus ``agent_id`` / ``agent_type`` / ``last_assistant_message``. So the design's
16
+ assumed field exists — just named ``agent_transcript_path``, not
17
+ ``transcript_path``. We use it as the primary path and keep the documented
18
+ directory-scan (newest ``isSidechain:true`` jsonl) as a fallback for older CLIs
19
+ that predate the field.
20
+
21
+ Flow:
22
+ 1. Read hook input from stdin (session_id, transcript_path,
23
+ agent_transcript_path, agent_id, cwd, ...).
24
+ 2. Resolve the sidechain transcript (agent_transcript_path → derived
25
+ subagents/ dir → project-dir scan).
26
+ 3. Volume gate: skip sub-agents below the floor (< 6 human turns OR
27
+ < 2,000 chars of non-tool-result text) — SubagentStop fires for every
28
+ Task including 10-second forks, and each retain is an LLM-backed
29
+ extraction.
30
+ 4. Retain a bounded window (last N=40 human turns), tagged ``sidechain`` +
31
+ ``parent_session:<id>``, with a deterministic content-derived document_id
32
+ so re-fires upsert instead of duplicating.
33
+ 5. Failures enqueue to the SAME pending-retains durability queue the Stop
34
+ retain uses (drained at the next SessionStart) — no new machinery.
35
+
36
+ Exit codes:
37
+ 0 — always (graceful degradation on any error). Durability comes from the
38
+ pending-retains enqueue, not the exit code (retain.py mirrors this).
39
+ """
40
+
41
+ import json
42
+ import os
43
+ import sys
44
+ import time
45
+
46
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
47
+
48
+ from lib.bank import derive_bank_id, ensure_bank_mission
49
+ from lib.client import HindsightClient
50
+ from lib.config import debug_log, load_config
51
+ from lib.content import (
52
+ _extract_message_blocks,
53
+ _is_tool_result_only_user_message,
54
+ slice_last_turns_by_user_boundary,
55
+ transcript_first_line_is_sidechain,
56
+ )
57
+ from lib.daemon import get_api_url
58
+ from lib.pacing import inflight_lock
59
+
60
+ # retain.py owns the transcript reader, the deterministic-id recipe and the
61
+ # network-free payload builder; reuse them wholesale so the sidechain path
62
+ # stays byte-identical to the main path where it matters (dedup ids, formatting).
63
+ from retain import build_retain_payload, read_transcript
64
+
65
+ # Retain the last N human turns of the sidechain. Tool-result bodies inside the
66
+ # window are truncated by _extract_message_blocks (content.py) already.
67
+ SIDECHAIN_WINDOW_TURNS = 40
68
+
69
+ # Volume gate floors — SubagentStop fires for every Task, so skip trivial forks.
70
+ MIN_HUMAN_TURNS = 6
71
+ MIN_NON_TOOL_RESULT_CHARS = 2000
72
+
73
+ # Bounded read (review finding 4): cap the sidechain transcript read so a
74
+ # multi-hour worker's arbitrarily-large jsonl can't eat the 15s hook budget on
75
+ # the read before the POST/enqueue. 8 MB comfortably holds >> the 40-turn retain
76
+ # window and the gate floors even with truncated tool_results; the tail is read
77
+ # in order, so the window slice and PASS/skip are unaffected in practice.
78
+ SIDECHAIN_MAX_READ_BYTES = 8 * 1024 * 1024
79
+
80
+ # Fallback scan freshness window (review finding 3): when we have to SCAN for the
81
+ # sidechain (older CLIs with no ``agent_transcript_path``), only accept a file
82
+ # whose mtime is within this many seconds of the hook fire, so a stale sidechain
83
+ # from an earlier turn in the same session dir is never mis-picked. Residual race
84
+ # (documented, unavoidable without the first-class field): two old-CLI workers
85
+ # that BOTH stop inside this window pick the newest by mtime, so the other's
86
+ # sidechain is skipped this fire — it is recovered on no path (old CLIs predate
87
+ # agent_transcript_path); this is strictly better than the pre-field behaviour of
88
+ # no sidechain retain at all, and does not affect any CLI that populates the
89
+ # first-class field (the common path, which never scans).
90
+ SIDECHAIN_SCAN_FRESH_WINDOW_S = 300
91
+
92
+ # Extraction-framing header prepended to the retained content. The retain API
93
+ # has no per-call mission (mission is bank-level), so we bias the consolidation
94
+ # engine toward PROCESS facts with a short in-content note. Deterministic text —
95
+ # it does not change the document_id (that is computed from the raw slice before
96
+ # this is prepended).
97
+ SIDECHAIN_MISSION_HEADER = (
98
+ "[sidechain sub-agent work log — extract PROCESS facts: files/paths touched, "
99
+ "commands that worked, decisions made, dead ends hit and why. Ignore the "
100
+ "restated mission prose and routine tool chatter.]"
101
+ )
102
+
103
+
104
+ def _agent_id_from_path(path: str) -> str:
105
+ """Best-effort agent id from a ``.../subagents/agent-<id>.jsonl`` filename."""
106
+ if not path:
107
+ return ""
108
+ base = os.path.basename(path)
109
+ if base.startswith("agent-") and base.endswith(".jsonl"):
110
+ return base[len("agent-"):-len(".jsonl")]
111
+ return ""
112
+
113
+
114
+ def resolve_sidechain_transcript(hook_input: dict) -> str:
115
+ """Resolve the sidechain transcript path from the SubagentStop hook input.
116
+
117
+ Layered, most-authoritative first:
118
+
119
+ 1. ``agent_transcript_path`` — the first-class field the CLI provides
120
+ (probe-confirmed on 2.1.215). Accepted only after VALIDATION (review
121
+ finding 2): it must exist, actually be a sidechain
122
+ (``transcript_first_line_is_sidechain``), and NOT equal the parent
123
+ ``transcript_path`` — so a CLI that populates the field differently can
124
+ never make us retain the parent's main-session content under the
125
+ sub-agent namespace (systematic double-retain).
126
+ 2. Derived ``<projectdir>/<session_id>/subagents/agent-<agent_id>.jsonl``
127
+ from ``transcript_path`` + ``session_id`` + ``agent_id`` — for CLIs that
128
+ omit ``agent_transcript_path`` but still write the standard layout.
129
+ 3. Newest ``isSidechain:true`` ``.jsonl`` in that exact ``subagents/`` dir
130
+ whose mtime is the most recent AND within ``SIDECHAIN_SCAN_FRESH_WINDOW_S``
131
+ of the hook fire — the design's documented directory-scan fallback (see
132
+ that constant for the residual old-CLI race bound). The scan is
133
+ deliberately confined to the derived ``<session_id>/subagents/`` dir
134
+ (a single, bounded ``listdir``) — NOT a recursive walk of the project
135
+ dir, which for a malformed ``transcript_path`` could resolve to ``/`` and
136
+ walk the whole filesystem.
137
+
138
+ Returns "" when nothing plausible is found.
139
+ """
140
+ parent_transcript = hook_input.get("transcript_path", "") or ""
141
+
142
+ # 1. First-class field — validated (finding 2).
143
+ p = hook_input.get("agent_transcript_path")
144
+ if (
145
+ isinstance(p, str)
146
+ and p
147
+ and p != parent_transcript
148
+ and os.path.isfile(p)
149
+ and transcript_first_line_is_sidechain(p)
150
+ ):
151
+ return p
152
+
153
+ session_id = hook_input.get("session_id", "") or ""
154
+ agent_id = hook_input.get("agent_id", "") or ""
155
+
156
+ # The parent transcript lives at <projectdir>/<session_id>.jsonl; the
157
+ # sidechains sit under <projectdir>/<session_id>/subagents/.
158
+ project_dir = os.path.dirname(parent_transcript) if parent_transcript else ""
159
+ subagents_dir = ""
160
+ if project_dir and session_id:
161
+ subagents_dir = os.path.join(project_dir, session_id, "subagents")
162
+
163
+ # 2. Derived exact path from agent_id — still validated as a sidechain and
164
+ # not the parent (defensive symmetry with path 1).
165
+ if subagents_dir and agent_id:
166
+ cand = os.path.join(subagents_dir, f"agent-{agent_id}.jsonl")
167
+ if (
168
+ os.path.isfile(cand)
169
+ and cand != parent_transcript
170
+ and transcript_first_line_is_sidechain(cand)
171
+ ):
172
+ return cand
173
+
174
+ # 3. Newest fresh isSidechain jsonl in the bounded subagents dir (never a
175
+ # recursive project-dir walk — see the docstring).
176
+ return _newest_sidechain_jsonl(subagents_dir, exclude=parent_transcript) if subagents_dir else ""
177
+
178
+
179
+ def _newest_sidechain_jsonl(root: str, exclude: str = "") -> str:
180
+ """Newest (by mtime) ``.jsonl`` directly in ``root`` whose first line is a
181
+ sidechain entry AND whose mtime is within ``SIDECHAIN_SCAN_FRESH_WINDOW_S``
182
+ of now. Non-recursive: a single bounded ``listdir`` of the derived
183
+ ``<session_id>/subagents/`` dir.
184
+
185
+ ``exclude`` skips a specific path (the parent transcript). Returns "" if none
186
+ / dir missing. The freshness window (finding 3) keeps a stale sidechain from
187
+ a prior turn out of contention; see the constant for the residual race bound.
188
+ """
189
+ if not root or not os.path.isdir(root):
190
+ return ""
191
+ cutoff = time.time() - SIDECHAIN_SCAN_FRESH_WINDOW_S
192
+ best_path = ""
193
+ best_mtime = -1.0
194
+ try:
195
+ names = os.listdir(root)
196
+ except OSError:
197
+ return ""
198
+ for name in names:
199
+ if not name.endswith(".jsonl"):
200
+ continue
201
+ full = os.path.join(root, name)
202
+ if exclude and os.path.abspath(full) == os.path.abspath(exclude):
203
+ continue
204
+ try:
205
+ mtime = os.path.getmtime(full)
206
+ except OSError:
207
+ continue
208
+ if mtime < cutoff or mtime <= best_mtime:
209
+ continue
210
+ if transcript_first_line_is_sidechain(full):
211
+ best_path, best_mtime = full, mtime
212
+ return best_path
213
+
214
+
215
+ def count_human_turns(messages: list) -> int:
216
+ """Genuine human-turn count (tool_result-only user messages don't count)."""
217
+ n = 0
218
+ for m in messages:
219
+ if not isinstance(m, dict):
220
+ continue
221
+ if m.get("role") == "user" and not _is_tool_result_only_user_message(m):
222
+ n += 1
223
+ return n
224
+
225
+
226
+ def non_tool_result_char_count(messages: list, stop_at: int | None = None) -> int:
227
+ """Total chars of non-tool-result content across the transcript.
228
+
229
+ Sums the extracted text + tool_use (command/input) blocks and EXCLUDES
230
+ tool_result bodies — the same "text vs tool output" split
231
+ ``_extract_message_blocks`` already draws. This is the signal the volume
232
+ gate wants: a 10-second fork with almost no narration/commands falls under
233
+ the floor even if it emitted a large tool_result, while a real worker's
234
+ commands and decisions count.
235
+
236
+ ``stop_at`` (review finding 4 — early short-circuit): return as soon as the
237
+ running total reaches this many chars. The gate only needs to know whether
238
+ the floor is CLEARED, not the exact size — so on a large worker transcript
239
+ we stop the block walk the moment the floor is met (the returned value is
240
+ then a floor, ``>= stop_at``, sufficient for the ``>=`` comparison and the
241
+ skip log's "chars>=N" read).
242
+ """
243
+ total = 0
244
+ for m in messages:
245
+ if not isinstance(m, dict):
246
+ continue
247
+ blocks = _extract_message_blocks(m.get("content", ""), role=m.get("role", ""))
248
+ for b in blocks:
249
+ if not isinstance(b, dict) or b.get("type") == "tool_result":
250
+ continue
251
+ if b.get("type") == "text":
252
+ total += len(b.get("text", ""))
253
+ elif b.get("type") == "tool_use":
254
+ # Command / input is a process fact; count its serialized size.
255
+ total += len(b.get("name", "")) + len(json.dumps(b.get("input", {}), ensure_ascii=False))
256
+ if stop_at is not None and total >= stop_at:
257
+ return total
258
+ return total
259
+
260
+
261
+ def passes_volume_gate(messages: list, config: dict) -> tuple:
262
+ """Return ``(passed, human_turns, char_count)`` for the volume gate.
263
+
264
+ Skip sub-agents below EITHER floor: < ``MIN_HUMAN_TURNS`` human turns OR
265
+ < ``MIN_NON_TOOL_RESULT_CHARS`` chars of non-tool-result text. The char walk
266
+ short-circuits at the floor (finding 4) — ``char_count`` is exact when below
267
+ the floor and a lower bound (``>= floor``) once cleared.
268
+ """
269
+ turns = count_human_turns(messages)
270
+ chars = non_tool_result_char_count(messages, stop_at=MIN_NON_TOOL_RESULT_CHARS)
271
+ passed = turns >= MIN_HUMAN_TURNS and chars >= MIN_NON_TOOL_RESULT_CHARS
272
+ return passed, turns, chars
273
+
274
+
275
+ def run_subagent_retain(hook_input: dict) -> dict:
276
+ """Retain a bounded window of the sidechain transcript.
277
+
278
+ Returns a status dict shaped like ``retain.run_retain``::
279
+
280
+ {"status": "ok" | "skipped" | "failed",
281
+ "payload": {...}, # only when status == "failed" (for enqueue)
282
+ "error": Exception} # only when status == "failed"
283
+ """
284
+ config = load_config()
285
+
286
+ if not config.get("autoRetain"):
287
+ debug_log(config, "Auto-retain disabled, exiting subagent retain")
288
+ return {"status": "skipped", "reason": "autoRetain disabled"}
289
+
290
+ # Blocked-Stop-style re-fire guard: harmless here (deterministic id upserts),
291
+ # but skip a re-fire to avoid a redundant LLM extraction.
292
+ #
293
+ # KNOWN LIMITATION (review finding 5): if another SubagentStop hook BLOCKS
294
+ # the stop, the sub-agent continues and SubagentStop re-fires carrying
295
+ # ``stop_hook_active: true``; we skip that fire, so any turns the sub-agent
296
+ # ADDED after the block are not retained on the re-fire. Accepted, because
297
+ # (a) the deterministic ``{session}-sub-{agent}-r{start}-{end}`` id means the
298
+ # eventual non-blocked fire (or a later re-dispatch) upserts the fuller
299
+ # window, and (b) skipping avoids a duplicate LLM extraction on every blocked
300
+ # continuation. No sidechain currently registers a blocking SubagentStop, so
301
+ # this is latent; revisit if one is added.
302
+ if hook_input.get("stop_hook_active"):
303
+ debug_log(config, "SubagentStop re-fire (stop_hook_active) — skipping")
304
+ return {"status": "skipped", "reason": "stop_hook_active"}
305
+
306
+ session_id = hook_input.get("session_id", "unknown")
307
+ agent_id = hook_input.get("agent_id", "") or ""
308
+ agent_type = hook_input.get("agent_type", "") or ""
309
+
310
+ transcript_path = resolve_sidechain_transcript(hook_input)
311
+ if not transcript_path:
312
+ debug_log(
313
+ config,
314
+ f"SubagentStop: no sidechain transcript resolved "
315
+ f"(session={session_id}, agent={agent_id}) — skipping",
316
+ )
317
+ return {"status": "skipped", "reason": "no sidechain transcript"}
318
+
319
+ if not agent_id:
320
+ agent_id = _agent_id_from_path(transcript_path) or "unknown"
321
+
322
+ # Bounded read (finding 4): cap the read so an arbitrarily-large worker
323
+ # transcript can't eat the hook budget before the gate/POST. The tail is read
324
+ # in order and covers >> the retain window + gate floors.
325
+ all_messages = read_transcript(transcript_path, max_bytes=SIDECHAIN_MAX_READ_BYTES)
326
+ if not all_messages:
327
+ debug_log(config, f"SubagentStop: empty sidechain transcript {transcript_path}")
328
+ return {"status": "skipped", "reason": "empty transcript"}
329
+
330
+ # Volume gate — skip trivial forks, log the skip for coverage auditing.
331
+ passed, turns, chars = passes_volume_gate(all_messages, config)
332
+ if not passed:
333
+ debug_log(
334
+ config,
335
+ f"SubagentStop volume-gate SKIP: session={session_id} agent={agent_id} "
336
+ f"turns={turns} (min {MIN_HUMAN_TURNS}) chars={chars} "
337
+ f"(min {MIN_NON_TOOL_RESULT_CHARS})",
338
+ )
339
+ return {"status": "skipped", "reason": "volume gate", "turns": turns, "chars": chars}
340
+
341
+ # Bounded window: last N human turns (extends to end, so the sub-agent's
342
+ # final report is always included).
343
+ messages_to_retain = slice_last_turns_by_user_boundary(all_messages, SIDECHAIN_WINDOW_TURNS)
344
+
345
+ # Resolve API URL + client.
346
+ def _dbg(*a):
347
+ debug_log(config, *a)
348
+
349
+ try:
350
+ api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=True)
351
+ except RuntimeError as e:
352
+ print(f"[Hindsight] {e}", file=sys.stderr)
353
+ return {"status": "failed", "error": e, "payload": None}
354
+
355
+ api_token = config.get("hindsightApiToken")
356
+ try:
357
+ client = HindsightClient(
358
+ api_url,
359
+ api_token,
360
+ request_timeout_override=config.get("requestTimeoutSeconds"),
361
+ )
362
+ except ValueError as e:
363
+ print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
364
+ return {"status": "failed", "error": e, "payload": None}
365
+
366
+ # Bank == the parent agent's bank (derive_bank_id keys on cwd/session, both
367
+ # shared with the parent), so sidechain memories land alongside the agent's
368
+ # own memory rather than a stray per-worker bank.
369
+ bank_id = derive_bank_id(hook_input, config)
370
+ ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
371
+
372
+ # Deterministic content-derived document_id in a DISTINCT namespace from the
373
+ # main-session retains: reuse retain.py's ``slice_document_id`` recipe via a
374
+ # composite session key so (a) re-fires of the SAME sub-agent window upsert
375
+ # server-side, and (b) it never collides with the parent's own
376
+ # ``{session_id}-r...`` documents. Client-side diffing against the parent's
377
+ # final-report retain is deliberately NOT attempted — overlap is fine, and
378
+ # hindsight's consolidation dedups (design item 4).
379
+ sub_session_id = f"{session_id}-sub-{agent_id}"
380
+
381
+ # Sidechain tags + a topic-friendly parent link. Reuse the config-driven tag
382
+ # machinery by augmenting a copy of retainTags (template {session_id} →
383
+ # sub_session_id). ``sidechain`` is the recall-side weight key
384
+ # (recallTagWeights); ``parent_session:<id>`` lets a fresh session pull a
385
+ # worker's process facts by parent.
386
+ sub_config = dict(config)
387
+ base_tags = list(config.get("retainTags") or [])
388
+ extra_tags = ["sidechain", f"parent_session:{session_id}"]
389
+ if agent_type:
390
+ extra_tags.append(f"agent_type:{agent_type}")
391
+ sub_config["retainTags"] = base_tags + extra_tags
392
+
393
+ built = build_retain_payload(
394
+ sub_config,
395
+ sub_session_id,
396
+ messages_to_retain,
397
+ all_messages,
398
+ bank_id=bank_id,
399
+ api_url=api_url,
400
+ api_token=api_token,
401
+ retain_full_window=True,
402
+ document_id=None, # content-derived from the slice's first/last uuids
403
+ )
404
+ if built is None:
405
+ debug_log(config, "SubagentStop: empty transcript after formatting, skipping")
406
+ return {"status": "skipped", "reason": "empty transcript after formatting"}
407
+
408
+ payload = built["payload"]
409
+ # Prepend the process-fact extraction framing (does not affect document_id,
410
+ # which was computed from the raw slice inside build_retain_payload).
411
+ payload["content"] = SIDECHAIN_MISSION_HEADER + "\n\n" + payload["content"]
412
+ payload["context"] = "claude-code-sidechain"
413
+ payload["metadata"]["parent_session_id"] = session_id
414
+ payload["metadata"]["agent_id"] = agent_id
415
+ if agent_type:
416
+ payload["metadata"]["agent_type"] = agent_type
417
+
418
+ document_id = built["document_id"]
419
+ debug_log(
420
+ config,
421
+ f"SubagentStop retain: bank='{bank_id}' doc='{document_id}' "
422
+ f"turns={turns} chars={chars} msgs={built['message_count']}",
423
+ )
424
+
425
+ # POST under the shared fleet pacing lock, NON-BLOCKING (an async Stop hook
426
+ # must not wait on a boot reconcile / backfill). On a busy lock we defer to
427
+ # pending-retains, mirroring retain.py exactly.
428
+ with inflight_lock(blocking=False) as acquired:
429
+ if not acquired:
430
+ debug_log(config, "retain-inflight lock busy; deferring sidechain retain to pending-retains")
431
+ return {
432
+ "status": "failed",
433
+ "error": RuntimeError("retain-inflight lock busy; deferring to pending-retains"),
434
+ "payload": payload,
435
+ }
436
+ try:
437
+ response = client.retain(
438
+ bank_id=bank_id,
439
+ content=payload["content"],
440
+ document_id=document_id,
441
+ context=payload["context"],
442
+ metadata=payload["metadata"],
443
+ tags=payload["tags"],
444
+ timeout=15,
445
+ async_processing=False,
446
+ )
447
+ except Exception as e:
448
+ print(f"[Hindsight] Sidechain retain failed: {e}", file=sys.stderr)
449
+ return {"status": "failed", "error": e, "payload": payload}
450
+
451
+ debug_log(config, f"Sidechain retain response: {json.dumps(response)[:200]}")
452
+ return {"status": "ok", "response": response}
453
+
454
+
455
+ def main():
456
+ try:
457
+ hook_input = json.load(sys.stdin)
458
+ except (json.JSONDecodeError, EOFError):
459
+ print("[Hindsight] Failed to read SubagentStop hook input", file=sys.stderr)
460
+ return
461
+
462
+ result = run_subagent_retain(hook_input) or {}
463
+ # On a failed retain WITH a payload, durably enqueue to pending-retains so
464
+ # the next SessionStart drain replays it — identical durability path to
465
+ # retain.py's Stop entrypoint. The deterministic content-derived id means a
466
+ # queued entry and a later re-fire collide on id ⇒ upsert, not duplicate.
467
+ if result.get("status") == "failed" and result.get("payload"):
468
+ try:
469
+ from lib.pending import MAX_ENTRIES, count as pending_count, enqueue as pending_enqueue
470
+
471
+ err = result.get("error") or RuntimeError("subagent retain failed")
472
+ queued = pending_enqueue(result["payload"], err)
473
+ if queued is None:
474
+ print(
475
+ f"[Hindsight] pending-retains queue full ({MAX_ENTRIES} entries); "
476
+ f"dropping this SubagentStop retain. Operator: drain manually, "
477
+ f"then run `switchroom doctor`.",
478
+ file=sys.stderr,
479
+ )
480
+ else:
481
+ print(
482
+ f"[Hindsight] SubagentStop retain failed: queued to pending-retains "
483
+ f"(error: {type(err).__name__}: {err}, pending={pending_count()}). "
484
+ f"Will retry on next SessionStart.",
485
+ file=sys.stderr,
486
+ )
487
+ except Exception as e: # pragma: no cover - defensive
488
+ print(f"[Hindsight] SubagentStop retain enqueue failed: {e}", file=sys.stderr)
489
+
490
+
491
+ if __name__ == "__main__":
492
+ try:
493
+ main()
494
+ except Exception as e:
495
+ print(f"[Hindsight] Unexpected error in subagent_retain: {e}", file=sys.stderr)
496
+ try:
497
+ from lib.config import load_config
498
+
499
+ sys.exit(2 if load_config().get("debug") else 0)
500
+ except Exception:
501
+ sys.exit(0)