switchroom 0.12.27 → 0.12.29

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 (48) hide show
  1. package/dist/cli/switchroom.js +4 -2
  2. package/package.json +2 -1
  3. package/telegram-plugin/dist/gateway/gateway.js +113 -7
  4. package/telegram-plugin/gateway/gateway.ts +52 -9
  5. package/telegram-plugin/gateway/prefix-warmup.ts +123 -0
  6. package/telegram-plugin/stderr-timestamps.ts +106 -0
  7. package/telegram-plugin/tests/prefix-warmup.test.ts +175 -0
  8. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  9. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  10. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  11. package/vendor/hindsight-memory/LICENSE +21 -0
  12. package/vendor/hindsight-memory/README.md +329 -0
  13. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  14. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  15. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  16. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  17. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  18. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  19. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  20. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  21. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  22. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  23. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  24. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  25. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  26. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  27. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  28. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  29. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  30. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  31. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  35. package/vendor/hindsight-memory/settings.json +37 -0
  36. package/vendor/hindsight-memory/skills/setup.md +24 -0
  37. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  38. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  39. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  40. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  41. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  42. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  43. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  44. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  45. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  46. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  47. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  48. 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()