switchroom 0.12.26 → 0.12.28
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +359 -357
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +2 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +368 -209
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +55 -40
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- package/vendor/hindsight-memory/tests/test_state.py +125 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Auto-retain hook for Stop event.
|
|
3
|
+
|
|
4
|
+
Port of: agent_end handler in Openclaw index.js
|
|
5
|
+
Adapted for Claude Code hooks (ephemeral process, JSON stdin/stdout).
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
1. Read hook input from stdin (session_id, transcript_path, cwd)
|
|
9
|
+
2. Read conversation transcript from transcript_path
|
|
10
|
+
3. Apply chunked retention logic (retainEveryNTurns + overlap window)
|
|
11
|
+
4. Resolve API URL (external, existing local, or auto-start daemon)
|
|
12
|
+
5. Derive bank ID and ensure mission
|
|
13
|
+
6. Format transcript (strip memory tags, filter roles)
|
|
14
|
+
7. POST to Hindsight retain API (async)
|
|
15
|
+
|
|
16
|
+
Exit codes:
|
|
17
|
+
0 — always (graceful degradation on any error)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
|
|
27
|
+
from lib.bank import derive_bank_id, ensure_bank_mission
|
|
28
|
+
from lib.client import HindsightClient
|
|
29
|
+
from lib.config import debug_log, load_config
|
|
30
|
+
from lib.content import (
|
|
31
|
+
prepare_retention_transcript,
|
|
32
|
+
slice_last_turns_by_user_boundary,
|
|
33
|
+
)
|
|
34
|
+
from lib.daemon import get_api_url
|
|
35
|
+
from lib.state import increment_turn_count, track_retention
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_transcript(transcript_path: str) -> list:
|
|
39
|
+
"""Read a JSONL transcript file and return list of message dicts.
|
|
40
|
+
|
|
41
|
+
Claude Code transcript format nests messages:
|
|
42
|
+
{type: "user", message: {role: "user", content: "..."}, uuid: "...", ...}
|
|
43
|
+
Also supports flat format for testing:
|
|
44
|
+
{role: "user", content: "..."}
|
|
45
|
+
"""
|
|
46
|
+
if not transcript_path or not os.path.isfile(transcript_path):
|
|
47
|
+
return []
|
|
48
|
+
messages = []
|
|
49
|
+
try:
|
|
50
|
+
with open(transcript_path, encoding="utf-8") as f:
|
|
51
|
+
for line in f:
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if not line:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
entry = json.loads(line)
|
|
57
|
+
# Claude Code nested format: {type: "user", message: {role, content}}
|
|
58
|
+
if entry.get("type") in ("user", "assistant"):
|
|
59
|
+
msg = entry.get("message", {})
|
|
60
|
+
if isinstance(msg, dict) and msg.get("role"):
|
|
61
|
+
messages.append(msg)
|
|
62
|
+
# Flat format (testing / future compatibility)
|
|
63
|
+
elif "role" in entry and "content" in entry:
|
|
64
|
+
messages.append(entry)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
continue
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
return messages
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
73
|
+
"""Run the auto-retain flow.
|
|
74
|
+
|
|
75
|
+
Returns a status dict::
|
|
76
|
+
|
|
77
|
+
{"status": "ok" | "skipped" | "failed",
|
|
78
|
+
"payload": {...}, # only when status == "failed"
|
|
79
|
+
"error": Exception} # only when status == "failed"
|
|
80
|
+
|
|
81
|
+
The ``payload`` field carries the exact arguments that were going to
|
|
82
|
+
be sent to ``client.retain()`` plus the resolved ``api_url`` /
|
|
83
|
+
``api_token`` — sufficient to retry the call from a different
|
|
84
|
+
process (the SessionStart drainer, see ``drain_pending.py`` and
|
|
85
|
+
``lib/pending.py``). ``status="skipped"`` is the normal early-exit
|
|
86
|
+
cases (auto-retain disabled, empty transcript, throttled chunk).
|
|
87
|
+
"""
|
|
88
|
+
config = load_config()
|
|
89
|
+
|
|
90
|
+
if not config.get("autoRetain"):
|
|
91
|
+
debug_log(config, "Auto-retain disabled, exiting")
|
|
92
|
+
return {"status": "skipped", "reason": "autoRetain disabled"}
|
|
93
|
+
|
|
94
|
+
debug_log(config, f"Retain hook_input keys: {list(hook_input.keys())} force={force}")
|
|
95
|
+
|
|
96
|
+
session_id = hook_input.get("session_id", "unknown")
|
|
97
|
+
transcript_path = hook_input.get("transcript_path", "")
|
|
98
|
+
|
|
99
|
+
# Read full transcript
|
|
100
|
+
all_messages = read_transcript(transcript_path)
|
|
101
|
+
if not all_messages:
|
|
102
|
+
debug_log(config, "No messages in transcript, skipping retain")
|
|
103
|
+
return {"status": "skipped", "reason": "empty transcript"}
|
|
104
|
+
|
|
105
|
+
debug_log(config, f"Read {len(all_messages)} messages from transcript")
|
|
106
|
+
|
|
107
|
+
# Retention mode: full session (default) or chunked (legacy)
|
|
108
|
+
retain_mode = config.get("retainMode", "full-session")
|
|
109
|
+
retain_every_n = max(1, config.get("retainEveryNTurns", 1))
|
|
110
|
+
retain_full_window = False
|
|
111
|
+
messages_to_retain = all_messages
|
|
112
|
+
|
|
113
|
+
# Respect retainEveryNTurns in both modes, unless force=True (SessionEnd final retain)
|
|
114
|
+
if retain_every_n > 1 and not force:
|
|
115
|
+
turn_count = increment_turn_count(session_id)
|
|
116
|
+
if turn_count % retain_every_n != 0:
|
|
117
|
+
next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
|
|
118
|
+
debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
|
|
119
|
+
return {"status": "skipped", "reason": "throttled"}
|
|
120
|
+
|
|
121
|
+
if retain_mode == "chunked" and retain_every_n > 1:
|
|
122
|
+
# Sliding window: N turns + configured overlap
|
|
123
|
+
overlap_turns = config.get("retainOverlapTurns", 0)
|
|
124
|
+
window_turns = retain_every_n + overlap_turns
|
|
125
|
+
messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
|
|
126
|
+
retain_full_window = True
|
|
127
|
+
debug_log(
|
|
128
|
+
config,
|
|
129
|
+
f"Chunked retain firing (window: {window_turns} turns, {len(messages_to_retain)} messages)",
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
# Full session mode: retain all messages, always as full window
|
|
133
|
+
retain_full_window = True
|
|
134
|
+
debug_log(config, f"Full session retain: {len(all_messages)} messages")
|
|
135
|
+
|
|
136
|
+
# Format transcript
|
|
137
|
+
retain_roles = config.get("retainRoles", ["user", "assistant"])
|
|
138
|
+
include_tool_calls = config.get("retainToolCalls", True)
|
|
139
|
+
transcript, message_count = prepare_retention_transcript(
|
|
140
|
+
messages_to_retain, retain_roles, retain_full_window, include_tool_calls=include_tool_calls
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if not transcript:
|
|
144
|
+
debug_log(config, "Empty transcript after formatting, skipping retain")
|
|
145
|
+
return {"status": "skipped", "reason": "empty transcript after formatting"}
|
|
146
|
+
|
|
147
|
+
# Resolve API URL
|
|
148
|
+
def _dbg(*a):
|
|
149
|
+
debug_log(config, *a)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=True)
|
|
153
|
+
except RuntimeError as e:
|
|
154
|
+
print(f"[Hindsight] {e}", file=sys.stderr)
|
|
155
|
+
return {"status": "failed", "error": e, "payload": None}
|
|
156
|
+
|
|
157
|
+
api_token = config.get("hindsightApiToken")
|
|
158
|
+
try:
|
|
159
|
+
client = HindsightClient(api_url, api_token)
|
|
160
|
+
except ValueError as e:
|
|
161
|
+
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
|
|
162
|
+
return {"status": "failed", "error": e, "payload": None}
|
|
163
|
+
|
|
164
|
+
# Derive bank ID and ensure mission
|
|
165
|
+
bank_id = derive_bank_id(hook_input, config)
|
|
166
|
+
ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
|
|
167
|
+
|
|
168
|
+
# Document ID strategy:
|
|
169
|
+
# - Chunked mode: each chunk gets a timestamped document_id.
|
|
170
|
+
# - Full-session mode: uses session_id as base, but tracks message count
|
|
171
|
+
# to detect compaction. When Claude Code compacts the conversation the
|
|
172
|
+
# transcript shrinks — if we kept the same document_id we'd overwrite the
|
|
173
|
+
# pre-compaction document with a shorter one, losing context. Instead we
|
|
174
|
+
# increment a chunk counter so the old document is preserved.
|
|
175
|
+
if retain_mode == "chunked" and retain_every_n > 1:
|
|
176
|
+
document_id = f"{session_id}-{int(time.time() * 1000)}"
|
|
177
|
+
else:
|
|
178
|
+
chunk_index, compacted = track_retention(session_id, len(all_messages))
|
|
179
|
+
if compacted:
|
|
180
|
+
debug_log(
|
|
181
|
+
config,
|
|
182
|
+
f"Compaction detected for session {session_id}: transcript shrank, "
|
|
183
|
+
f"advancing to chunk {chunk_index} to preserve prior document",
|
|
184
|
+
)
|
|
185
|
+
# chunk 0 → plain session_id (backwards compatible with existing docs)
|
|
186
|
+
document_id = session_id if chunk_index == 0 else f"{session_id}-c{chunk_index}"
|
|
187
|
+
|
|
188
|
+
# Resolve template variables in tags and metadata.
|
|
189
|
+
# Supported variables: {session_id}, {bank_id}, {timestamp}, {user_id}
|
|
190
|
+
template_vars = {
|
|
191
|
+
"session_id": session_id,
|
|
192
|
+
"bank_id": bank_id,
|
|
193
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
194
|
+
"user_id": os.environ.get("HINDSIGHT_USER_ID", ""),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def _resolve_template(value: str) -> str:
|
|
198
|
+
for k, v in template_vars.items():
|
|
199
|
+
value = value.replace(f"{{{k}}}", v)
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
# Tags from config with template resolution.
|
|
203
|
+
# Drop tags whose resolved form ends in an empty namespace part (e.g. "user:"
|
|
204
|
+
# when HINDSIGHT_USER_ID is unset). Tags without ':' are preserved as-is.
|
|
205
|
+
raw_tags = config.get("retainTags", [])
|
|
206
|
+
if raw_tags:
|
|
207
|
+
tags = []
|
|
208
|
+
for original in raw_tags:
|
|
209
|
+
resolved = _resolve_template(original)
|
|
210
|
+
if ":" in resolved and resolved.split(":", 1)[1] == "":
|
|
211
|
+
debug_log(config, f"Dropping tag '{original}' -> '{resolved}' (empty content after ':')")
|
|
212
|
+
continue
|
|
213
|
+
tags.append(resolved)
|
|
214
|
+
if not tags:
|
|
215
|
+
tags = None
|
|
216
|
+
else:
|
|
217
|
+
tags = None
|
|
218
|
+
|
|
219
|
+
# Metadata: merge built-in defaults with user-configured extras
|
|
220
|
+
metadata = {
|
|
221
|
+
"retained_at": template_vars["timestamp"],
|
|
222
|
+
"message_count": str(message_count),
|
|
223
|
+
"session_id": session_id,
|
|
224
|
+
}
|
|
225
|
+
for k, v in config.get("retainMetadata", {}).items():
|
|
226
|
+
metadata[k] = _resolve_template(str(v))
|
|
227
|
+
|
|
228
|
+
debug_log(
|
|
229
|
+
config, f"Retaining to bank '{bank_id}', doc '{document_id}', {message_count} messages, {len(transcript)} chars"
|
|
230
|
+
)
|
|
231
|
+
if tags:
|
|
232
|
+
debug_log(config, f"Tags: {tags}")
|
|
233
|
+
|
|
234
|
+
# Build the full payload up-front so we can hand it to the pending-
|
|
235
|
+
# retains queue verbatim if the POST fails. The payload mirrors the
|
|
236
|
+
# client.retain() kwargs plus connection info (api_url, api_token)
|
|
237
|
+
# so the drainer can reconstruct the call from a different process.
|
|
238
|
+
payload = {
|
|
239
|
+
"api_url": api_url,
|
|
240
|
+
"api_token": api_token,
|
|
241
|
+
"bank_id": bank_id,
|
|
242
|
+
"content": transcript,
|
|
243
|
+
"document_id": document_id,
|
|
244
|
+
"context": config.get("retainContext", "claude-code"),
|
|
245
|
+
"metadata": metadata,
|
|
246
|
+
"tags": tags,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# POST to Hindsight retain API
|
|
250
|
+
try:
|
|
251
|
+
response = client.retain(
|
|
252
|
+
bank_id=bank_id,
|
|
253
|
+
content=transcript,
|
|
254
|
+
document_id=document_id,
|
|
255
|
+
context=payload["context"],
|
|
256
|
+
metadata=metadata,
|
|
257
|
+
tags=tags,
|
|
258
|
+
timeout=15,
|
|
259
|
+
)
|
|
260
|
+
debug_log(config, f"Retain response: {json.dumps(response)[:200]}")
|
|
261
|
+
return {"status": "ok", "response": response}
|
|
262
|
+
except Exception as e:
|
|
263
|
+
print(f"[Hindsight] Retain failed: {e}", file=sys.stderr)
|
|
264
|
+
return {"status": "failed", "error": e, "payload": payload}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main():
|
|
268
|
+
try:
|
|
269
|
+
hook_input = json.load(sys.stdin)
|
|
270
|
+
except (json.JSONDecodeError, EOFError):
|
|
271
|
+
print("[Hindsight] Failed to read hook input", file=sys.stderr)
|
|
272
|
+
return
|
|
273
|
+
run_retain(hook_input, force=False)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
try:
|
|
278
|
+
main()
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f"[Hindsight] Unexpected error in retain: {e}", file=sys.stderr)
|
|
281
|
+
try:
|
|
282
|
+
from lib.config import load_config
|
|
283
|
+
|
|
284
|
+
sys.exit(2 if load_config().get("debug") else 0)
|
|
285
|
+
except Exception:
|
|
286
|
+
sys.exit(0)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionEnd hook: final retain + daemon cleanup.
|
|
3
|
+
|
|
4
|
+
Fires once when a Claude Code session terminates. Two jobs:
|
|
5
|
+
|
|
6
|
+
1. **Final retain.** Forces a retain pass so short sessions (fewer
|
|
7
|
+
turns than ``retainEveryNTurns``) still land on disk.
|
|
8
|
+
2. **Daemon stop.** Tears down the auto-started hindsight-embed
|
|
9
|
+
daemon, if any.
|
|
10
|
+
|
|
11
|
+
Silent data-loss guard (#1071)
|
|
12
|
+
------------------------------
|
|
13
|
+
Before this change the final retain would print its error to stderr
|
|
14
|
+
and exit 0 — the operator sees nothing in journald (already noisy),
|
|
15
|
+
the agent thinks the turn was saved, the *next* session can't recall
|
|
16
|
+
the turn. Silent memory loss.
|
|
17
|
+
|
|
18
|
+
Now: on retain failure we serialize the full retain payload to
|
|
19
|
+
``~/.hindsight/pending-retains/<unix-ms>-<uuid>.json`` (see
|
|
20
|
+
``lib/pending.py``) and exit non-zero so ``bin/run-hook.sh`` routes
|
|
21
|
+
the failure through the ``switchroom issues`` sink (#424). The next
|
|
22
|
+
SessionStart drains the queue (see ``drain_pending.py``).
|
|
23
|
+
|
|
24
|
+
Pairs with #1070 (recall.py exit-code fix) — same threat class.
|
|
25
|
+
|
|
26
|
+
Port of: Openclaw's service.stop() in index.js.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
|
|
35
|
+
from lib.config import debug_log, load_config
|
|
36
|
+
from lib.daemon import stop_daemon
|
|
37
|
+
from lib.pending import MAX_ENTRIES, count as pending_count, enqueue as pending_enqueue
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Exit codes:
|
|
41
|
+
# 0 — success (or retain skipped for benign reasons)
|
|
42
|
+
# 1 — retain failed AND was queued to pending-retains (recoverable)
|
|
43
|
+
# 2 — retain failed AND the queue rejected it (chronic backlog)
|
|
44
|
+
EXIT_OK = 0
|
|
45
|
+
EXIT_QUEUED = 1
|
|
46
|
+
EXIT_DROPPED = 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> int:
|
|
50
|
+
config = load_config()
|
|
51
|
+
|
|
52
|
+
# Consume stdin
|
|
53
|
+
try:
|
|
54
|
+
hook_input = json.load(sys.stdin)
|
|
55
|
+
except (json.JSONDecodeError, EOFError):
|
|
56
|
+
hook_input = {}
|
|
57
|
+
|
|
58
|
+
debug_log(config, f"SessionEnd hook, reason: {hook_input.get('reason', 'unknown')}")
|
|
59
|
+
|
|
60
|
+
exit_code = EXIT_OK
|
|
61
|
+
|
|
62
|
+
# Force a final retain before stopping the daemon — guarantees short sessions
|
|
63
|
+
# (fewer turns than retainEveryNTurns) still land on disk.
|
|
64
|
+
if config.get("autoRetain") and hook_input.get("transcript_path"):
|
|
65
|
+
try:
|
|
66
|
+
from retain import run_retain
|
|
67
|
+
|
|
68
|
+
result = run_retain(hook_input, force=True) or {}
|
|
69
|
+
except Exception as e:
|
|
70
|
+
# Belt-and-braces — run_retain itself shouldn't raise, but
|
|
71
|
+
# if it does we treat it the same as a failed POST: queue
|
|
72
|
+
# what we can (nothing, since we have no payload) and exit
|
|
73
|
+
# non-zero so the issue sink picks it up.
|
|
74
|
+
print(f"[Hindsight] SessionEnd final retain error: {e}", file=sys.stderr)
|
|
75
|
+
result = {"status": "failed", "error": e, "payload": None}
|
|
76
|
+
|
|
77
|
+
if result.get("status") == "failed":
|
|
78
|
+
payload = result.get("payload")
|
|
79
|
+
err = result.get("error") or RuntimeError("unknown retain failure")
|
|
80
|
+
if payload:
|
|
81
|
+
queued = pending_enqueue(payload, err)
|
|
82
|
+
if queued is None:
|
|
83
|
+
print(
|
|
84
|
+
f"[Hindsight] pending-retains queue full ({MAX_ENTRIES} entries); "
|
|
85
|
+
f"dropping this retain. Operator: drain manually, then run "
|
|
86
|
+
f"`switchroom doctor`.",
|
|
87
|
+
file=sys.stderr,
|
|
88
|
+
)
|
|
89
|
+
exit_code = EXIT_DROPPED
|
|
90
|
+
else:
|
|
91
|
+
debug_log(config, f"SessionEnd retain queued to {queued} (pending={pending_count()})")
|
|
92
|
+
print(
|
|
93
|
+
f"[Hindsight] SessionEnd retain failed: queued to pending-retains "
|
|
94
|
+
f"(error: {type(err).__name__}: {err}). Will retry on next "
|
|
95
|
+
f"SessionStart.",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
exit_code = EXIT_QUEUED
|
|
99
|
+
else:
|
|
100
|
+
# No payload to queue — the failure happened before we
|
|
101
|
+
# finished building one (e.g. URL resolution).
|
|
102
|
+
exit_code = EXIT_QUEUED
|
|
103
|
+
|
|
104
|
+
# Stop daemon if we started it. Always runs, even on retain failure,
|
|
105
|
+
# so we don't leak a daemon process.
|
|
106
|
+
def _dbg(*a):
|
|
107
|
+
debug_log(config, *a)
|
|
108
|
+
|
|
109
|
+
stop_daemon(config, debug_fn=_dbg)
|
|
110
|
+
return exit_code
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
try:
|
|
115
|
+
sys.exit(main())
|
|
116
|
+
except Exception as e:
|
|
117
|
+
print(f"[Hindsight] SessionEnd error: {e}", file=sys.stderr)
|
|
118
|
+
# Surface the unexpected failure to the issue sink rather than
|
|
119
|
+
# swallowing it (per #424). Stays non-zero in both debug and
|
|
120
|
+
# non-debug paths so the recall.py-style silent-failure trap
|
|
121
|
+
# (#1070) doesn't recur here.
|
|
122
|
+
sys.exit(2)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook: health check + session logging.
|
|
3
|
+
|
|
4
|
+
Fires once when a Claude Code session begins. Uses additionalContext
|
|
5
|
+
(supported on SessionStart) to inject an initial system note if
|
|
6
|
+
Hindsight is available.
|
|
7
|
+
|
|
8
|
+
This is the Claude Code equivalent of Openclaw's service.start() —
|
|
9
|
+
verify the server is reachable early, before the first prompt.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
17
|
+
|
|
18
|
+
from lib.client import HindsightClient
|
|
19
|
+
from lib.config import debug_log, load_config
|
|
20
|
+
from lib.daemon import get_api_url, prestart_daemon_background
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
config = load_config()
|
|
25
|
+
|
|
26
|
+
if not config.get("autoRecall") and not config.get("autoRetain"):
|
|
27
|
+
debug_log(config, "Both autoRecall and autoRetain disabled, skipping session start")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
# Consume stdin
|
|
31
|
+
try:
|
|
32
|
+
hook_input = json.load(sys.stdin)
|
|
33
|
+
except (json.JSONDecodeError, EOFError):
|
|
34
|
+
hook_input = {}
|
|
35
|
+
|
|
36
|
+
debug_log(config, f"SessionStart hook, source: {hook_input.get('source', 'unknown')}")
|
|
37
|
+
|
|
38
|
+
# Try to resolve API URL (health check). Don't start daemon here —
|
|
39
|
+
# that's too slow for session start. Just check if server is reachable.
|
|
40
|
+
def _dbg(*a):
|
|
41
|
+
debug_log(config, *a)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
|
|
45
|
+
client = HindsightClient(api_url, config.get("hindsightApiToken"))
|
|
46
|
+
debug_log(config, f"Hindsight server reachable at {api_url}")
|
|
47
|
+
except (RuntimeError, ValueError) as e:
|
|
48
|
+
# Server not running — kick off background pre-start so it's ready
|
|
49
|
+
# by the time the first recall or retain hook fires. Skip the
|
|
50
|
+
# drain here: with no server to talk to, every retry would just
|
|
51
|
+
# bump attempt counters and burn the SessionStart budget.
|
|
52
|
+
debug_log(config, f"Hindsight not running, initiating background pre-start: {e}")
|
|
53
|
+
prestart_daemon_background(config, debug_fn=_dbg)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Drain any retains that session_end.py queued on failure (#1071).
|
|
57
|
+
# Bounded by HINDSIGHT_DRAIN_BUDGET_S so a slow upstream can't pin
|
|
58
|
+
# the SessionStart hook. The drain is best-effort — failures stay
|
|
59
|
+
# queued for the next session, dead entries surface via
|
|
60
|
+
# `switchroom doctor`.
|
|
61
|
+
try:
|
|
62
|
+
from drain_pending import drain as drain_pending_retains
|
|
63
|
+
|
|
64
|
+
drain_pending_retains(config)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
# Never let the drain break session start. Issue sink picks
|
|
67
|
+
# this up via run-hook.sh — see exit-code path in __main__.
|
|
68
|
+
debug_log(config, f"drain_pending unexpected error (ignored): {e}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
try:
|
|
73
|
+
main()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f"[Hindsight] SessionStart error: {e}", file=sys.stderr)
|
|
76
|
+
sys.exit(0)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Register hindsight-memory hooks into ~/.claude/settings.json.
|
|
3
|
+
|
|
4
|
+
Claude Code's plugin installer does not currently merge hooks.json into
|
|
5
|
+
settings.json automatically. Run this script once after installing the plugin:
|
|
6
|
+
|
|
7
|
+
python3 setup_hooks.py
|
|
8
|
+
|
|
9
|
+
Or via the /hindsight:setup skill inside a Claude Code session.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_plugin_root() -> str:
|
|
20
|
+
"""Locate the installed hindsight-memory plugin cache directory."""
|
|
21
|
+
cache_base = os.path.expanduser("~/.claude/plugins/cache/hindsight/hindsight-memory")
|
|
22
|
+
if not os.path.isdir(cache_base):
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"hindsight-memory plugin not found. "
|
|
25
|
+
"Run /plugin install hindsight-memory inside Claude Code first."
|
|
26
|
+
)
|
|
27
|
+
versions = sorted(os.listdir(cache_base), reverse=True)
|
|
28
|
+
if not versions:
|
|
29
|
+
raise RuntimeError(f"No versions found in {cache_base}")
|
|
30
|
+
return os.path.join(cache_base, versions[0])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_hooks(plugin_root: str) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
"UserPromptSubmit": [
|
|
36
|
+
{
|
|
37
|
+
"hooks": [
|
|
38
|
+
{
|
|
39
|
+
"type": "command",
|
|
40
|
+
"command": f'python3 "{plugin_root}/scripts/recall.py"',
|
|
41
|
+
"timeout": 12,
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"Stop": [
|
|
47
|
+
{
|
|
48
|
+
"hooks": [
|
|
49
|
+
{
|
|
50
|
+
"type": "command",
|
|
51
|
+
"command": f'python3 "{plugin_root}/scripts/retain.py"',
|
|
52
|
+
"timeout": 15,
|
|
53
|
+
"async": True,
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"SessionStart": [
|
|
59
|
+
{
|
|
60
|
+
"hooks": [
|
|
61
|
+
{
|
|
62
|
+
"type": "command",
|
|
63
|
+
"command": f'python3 "{plugin_root}/scripts/session_start.py"',
|
|
64
|
+
"timeout": 5,
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"SessionEnd": [
|
|
70
|
+
{
|
|
71
|
+
"hooks": [
|
|
72
|
+
{
|
|
73
|
+
"type": "command",
|
|
74
|
+
"command": f'python3 "{plugin_root}/scripts/session_end.py"',
|
|
75
|
+
"timeout": 10,
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main():
|
|
84
|
+
try:
|
|
85
|
+
plugin_root = find_plugin_root()
|
|
86
|
+
except RuntimeError as e:
|
|
87
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
if not os.path.isfile(SETTINGS_PATH):
|
|
91
|
+
print(f"Error: {SETTINGS_PATH} not found. Is Claude Code installed?", file=sys.stderr)
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
with open(SETTINGS_PATH) as f:
|
|
95
|
+
settings = json.load(f)
|
|
96
|
+
|
|
97
|
+
existing_hooks = settings.get("hooks", {})
|
|
98
|
+
if existing_hooks:
|
|
99
|
+
print("Existing hooks found — merging (hindsight-memory hooks take precedence).")
|
|
100
|
+
|
|
101
|
+
new_hooks = build_hooks(plugin_root)
|
|
102
|
+
merged = {**existing_hooks, **new_hooks}
|
|
103
|
+
settings["hooks"] = merged
|
|
104
|
+
settings.setdefault("env", {})["CLAUDE_PLUGIN_ROOT"] = plugin_root
|
|
105
|
+
|
|
106
|
+
with open(SETTINGS_PATH, "w") as f:
|
|
107
|
+
json.dump(settings, f, indent=2)
|
|
108
|
+
|
|
109
|
+
print(f"hindsight-memory hooks registered successfully.")
|
|
110
|
+
print(f"Plugin root: {plugin_root}")
|
|
111
|
+
print(f"Restart Claude Code for changes to take effect.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
File without changes
|