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.
- package/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- 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
|
|
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)
|