switchroom 0.12.27 → 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.
Files changed (46) hide show
  1. package/dist/cli/switchroom.js +4 -2
  2. package/package.json +2 -1
  3. package/telegram-plugin/dist/gateway/gateway.js +49 -5
  4. package/telegram-plugin/gateway/gateway.ts +5 -0
  5. package/telegram-plugin/stderr-timestamps.ts +106 -0
  6. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  7. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  8. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  9. package/vendor/hindsight-memory/LICENSE +21 -0
  10. package/vendor/hindsight-memory/README.md +329 -0
  11. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  12. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  13. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  14. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  15. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  16. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  17. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  18. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  19. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  20. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  21. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  22. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  23. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  24. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  25. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  26. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  27. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  28. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  29. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  33. package/vendor/hindsight-memory/settings.json +37 -0
  34. package/vendor/hindsight-memory/skills/setup.md +24 -0
  35. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  36. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  37. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  38. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  39. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  40. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  41. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  42. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  43. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  44. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  45. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  46. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -0,0 +1,122 @@
1
+ """Bank ID derivation and mission management.
2
+
3
+ Port of Openclaw's deriveBankId() and banksWithMissionSet logic, adapted
4
+ for Claude Code's context model.
5
+
6
+ Openclaw derives bank IDs from: agent, channel, user, provider.
7
+ Claude Code equivalent dimensions:
8
+ - agent → configured name or "claude-code" (HINDSIGHT_AGENT_NAME)
9
+ - project → derived from cwd (working directory basename)
10
+ - session → session_id from hook input
11
+ - channel → from env var HINDSIGHT_CHANNEL_ID (for Telegram/Discord agents)
12
+ - user → from env var HINDSIGHT_USER_ID (for multi-user agents)
13
+
14
+ The channel/user dimensions enable the same per-user/per-channel isolation
15
+ that Openclaw provides via its messageProvider/channelId/senderId context.
16
+ Telegram/Discord agents set HINDSIGHT_CHANNEL_ID and HINDSIGHT_USER_ID in
17
+ their environment to achieve equivalent behavior.
18
+ """
19
+
20
+ import os
21
+ import sys
22
+
23
+ from .state import read_state, write_state
24
+
25
+ DEFAULT_BANK_NAME = "claude-code"
26
+
27
+ # Valid granularity fields for Claude Code
28
+ VALID_FIELDS = {"agent", "project", "session", "channel", "user"}
29
+
30
+
31
+ def derive_bank_id(hook_input: dict, config: dict) -> str:
32
+ """Derive a bank ID from hook context and config.
33
+
34
+ Port of: deriveBankId() in index.js
35
+
36
+ When dynamicBankId is false, returns the static bank.
37
+ When true, composes from granularity fields joined by '::'.
38
+
39
+ Args:
40
+ hook_input: The hook's stdin JSON (has session_id, cwd).
41
+ config: Plugin configuration dict.
42
+ """
43
+ prefix = config.get("bankIdPrefix", "")
44
+
45
+ if not config.get("dynamicBankId", False):
46
+ # Static mode — single bank for everything
47
+ base = config.get("bankId") or DEFAULT_BANK_NAME
48
+ return f"{prefix}-{base}" if prefix else base
49
+
50
+ # Dynamic mode — compose from granularity fields
51
+ fields = config.get("dynamicBankGranularity")
52
+ if not fields or not isinstance(fields, list):
53
+ fields = ["agent", "project"]
54
+
55
+ # Warn on unknown fields (mirrors Openclaw's runtime check)
56
+ for f in fields:
57
+ if f not in VALID_FIELDS:
58
+ print(
59
+ f'[Hindsight] Unknown dynamicBankGranularity field "{f}" — '
60
+ f"valid for Claude Code: {', '.join(sorted(VALID_FIELDS))}",
61
+ file=sys.stderr,
62
+ )
63
+
64
+ # Build field values from hook context + env vars
65
+ cwd = hook_input.get("cwd", "")
66
+ session_id = hook_input.get("session_id", "")
67
+ agent_name = config.get("agentName", "claude-code")
68
+
69
+ # Channel and user come from environment variables, set by the host agent
70
+ # (e.g. Telegram bot sets HINDSIGHT_CHANNEL_ID=telegram-group-12345)
71
+ channel_id = os.environ.get("HINDSIGHT_CHANNEL_ID", "")
72
+ user_id = os.environ.get("HINDSIGHT_USER_ID", "")
73
+
74
+ field_map = {
75
+ "agent": agent_name,
76
+ "project": os.path.basename(cwd) if cwd else "unknown",
77
+ "session": session_id or "unknown",
78
+ "channel": channel_id or "default",
79
+ "user": user_id or "anonymous",
80
+ }
81
+
82
+ # bank_id is stored as-is server-side; HTTP path encoding is the client layer's job.
83
+ segments = [field_map.get(f, "unknown") for f in fields]
84
+ base_bank_id = "::".join(segments)
85
+
86
+ return f"{prefix}-{base_bank_id}" if prefix else base_bank_id
87
+
88
+
89
+ def ensure_bank_mission(client, bank_id: str, config: dict, debug_fn=None):
90
+ """Set bank mission on first use, skip if already set.
91
+
92
+ Port of: banksWithMissionSet Set tracking in index.js
93
+
94
+ Uses a state file to persist which banks have had their mission set
95
+ across ephemeral hook invocations.
96
+ """
97
+ mission = config.get("bankMission", "")
98
+ if not mission or not mission.strip():
99
+ return
100
+
101
+ # Check if we've already set mission for this bank
102
+ missions_set = read_state("bank_missions.json", {})
103
+ if bank_id in missions_set:
104
+ return
105
+
106
+ try:
107
+ retain_mission = config.get("retainMission")
108
+ client.set_bank_mission(bank_id, mission, retain_mission=retain_mission, timeout=10)
109
+ missions_set[bank_id] = True
110
+ # Cap tracked banks (mirrors Openclaw's MAX_TRACKED_BANK_CLIENTS)
111
+ if len(missions_set) > 10000:
112
+ keys = sorted(missions_set.keys())
113
+ for k in keys[: len(keys) // 2]:
114
+ del missions_set[k]
115
+ write_state("bank_missions.json", missions_set)
116
+ if debug_fn:
117
+ debug_fn(f"Set mission for bank: {bank_id}")
118
+ except Exception as e:
119
+ # Don't fail if mission set fails — bank might not exist yet,
120
+ # will be created on first retain (mirrors Openclaw behavior)
121
+ if debug_fn:
122
+ debug_fn(f"Could not set bank mission for {bank_id}: {e}")
@@ -0,0 +1,204 @@
1
+ """Hindsight REST API client.
2
+
3
+ Communicates with a Hindsight server via HTTP. Mirrors the HTTP mode of the
4
+ Openclaw HindsightClient (client.js), adapted for Python stdlib.
5
+ """
6
+
7
+ import json
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ DEFAULT_TIMEOUT = 15 # seconds
15
+ HEALTH_CHECK_RETRIES = 3
16
+ HEALTH_CHECK_DELAY = 2 # seconds
17
+
18
+
19
+ def _plugin_version() -> str:
20
+ """Read the plugin version from plugin.json (single source of truth)."""
21
+ manifest = Path(__file__).resolve().parents[2] / ".claude-plugin" / "plugin.json"
22
+ try:
23
+ return json.loads(manifest.read_text()).get("version", "0.0.0")
24
+ except (OSError, ValueError):
25
+ return "0.0.0"
26
+
27
+
28
+ # Sent on every request so self-hosted deployments behind Cloudflare (or any
29
+ # reverse proxy with UA-based bot filtering) don't block the stdlib default
30
+ # "Python-urllib/X.Y", which trips Cloudflare error 1010.
31
+ USER_AGENT = f"hindsight-claude-code/{_plugin_version()}"
32
+
33
+
34
+ def _validate_api_url(url: str) -> str:
35
+ """Validate and normalize the API URL. Reject non-HTTP schemes."""
36
+ parsed = urllib.parse.urlparse(url)
37
+ if parsed.scheme not in ("http", "https"):
38
+ raise ValueError(f"Hindsight API URL must use http or https, got: {parsed.scheme!r}")
39
+ if not parsed.hostname:
40
+ raise ValueError(f"Hindsight API URL has no hostname: {url!r}")
41
+ return url.rstrip("/")
42
+
43
+
44
+ class HindsightClient:
45
+ """HTTP client for the Hindsight API."""
46
+
47
+ def __init__(self, api_url: str, api_token: Optional[str] = None):
48
+ self.api_url = _validate_api_url(api_url)
49
+ self.api_token = api_token
50
+
51
+ def _headers(self) -> dict:
52
+ headers = {
53
+ "Content-Type": "application/json",
54
+ "User-Agent": USER_AGENT,
55
+ }
56
+ if self.api_token:
57
+ headers["Authorization"] = f"Bearer {self.api_token}"
58
+ return headers
59
+
60
+ def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
61
+ url = f"{self.api_url}{path}"
62
+ data = json.dumps(body).encode() if body else None
63
+ req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
64
+ try:
65
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
66
+ return json.loads(resp.read().decode())
67
+ except urllib.error.HTTPError as e:
68
+ body_text = ""
69
+ try:
70
+ body_text = e.read().decode()
71
+ except Exception:
72
+ pass
73
+ raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e
74
+
75
+ def health_check(self, timeout: int = 5) -> bool:
76
+ """Check if the Hindsight server is reachable.
77
+
78
+ Mirrors Openclaw's checkExternalApiHealth: retries up to 3 times
79
+ with 2s delay between attempts.
80
+ """
81
+ import time
82
+
83
+ for attempt in range(1, HEALTH_CHECK_RETRIES + 1):
84
+ try:
85
+ url = f"{self.api_url}/health"
86
+ req = urllib.request.Request(url, headers=self._headers(), method="GET")
87
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
88
+ if resp.status == 200:
89
+ return True
90
+ except Exception:
91
+ pass
92
+ if attempt < HEALTH_CHECK_RETRIES:
93
+ time.sleep(HEALTH_CHECK_DELAY)
94
+ return False
95
+
96
+ def recall(
97
+ self,
98
+ bank_id: str,
99
+ query: str,
100
+ max_tokens: int = 1024,
101
+ budget: str = "mid",
102
+ types: Optional[list] = None,
103
+ timeout: int = 10,
104
+ ) -> dict:
105
+ """Recall memories from a bank.
106
+
107
+ Returns the raw API response dict with 'results' list.
108
+ """
109
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
110
+ body = {
111
+ "query": query,
112
+ "max_tokens": max_tokens,
113
+ }
114
+ if budget:
115
+ body["budget"] = budget
116
+ if types:
117
+ body["types"] = types
118
+ return self._request("POST", path, body, timeout=timeout)
119
+
120
+ def retain(
121
+ self,
122
+ bank_id: str,
123
+ content: str,
124
+ document_id: str = "conversation",
125
+ context: Optional[str] = None,
126
+ metadata: Optional[dict] = None,
127
+ tags: Optional[list] = None,
128
+ timeout: int = 15,
129
+ ) -> dict:
130
+ """Retain content into a bank's memory.
131
+
132
+ Posts with async=true so the server processes in the background.
133
+ The context field helps Hindsight cluster memories by provenance
134
+ (e.g. "claude-code" vs manual retains).
135
+ """
136
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
137
+ item = {
138
+ "content": content,
139
+ "document_id": document_id,
140
+ "metadata": metadata or {},
141
+ }
142
+ if context:
143
+ item["context"] = context
144
+ if tags:
145
+ item["tags"] = tags
146
+ body = {
147
+ "items": [item],
148
+ "async": True,
149
+ }
150
+ return self._request("POST", path, body, timeout=timeout)
151
+
152
+ def list_directives(
153
+ self,
154
+ bank_id: str,
155
+ active_only: bool = True,
156
+ tags: Optional[list] = None,
157
+ timeout: int = 5,
158
+ ) -> dict:
159
+ """List active directives for a bank.
160
+
161
+ Switchroom-local: this method was missing in the vendored
162
+ HindsightClient even though `lib/directives.py` (also
163
+ switchroom-local, the workaround for upstream
164
+ vectorize-io/hindsight#1269) calls it on every recall hook.
165
+ Without this method the directives fetch always raised
166
+ AttributeError → silently caught at directives.py:47-49 → no
167
+ directives ever surfaced in the recall block.
168
+
169
+ The upstream REST endpoint is `GET
170
+ /v1/default/banks/{bank_id}/directives` with `active_only` and
171
+ optional `tags` query params (see upstream
172
+ `hindsight-clients/python/hindsight_client_api/api/directives_api.py`).
173
+
174
+ Returns the raw response dict, expected to have an `items` list
175
+ of directives where each item has at least `id`, `name`,
176
+ `content`, `priority`, `is_active`, `tags`. Caller (directives.py)
177
+ already defends against missing/malformed entries.
178
+ """
179
+ params = {}
180
+ if active_only:
181
+ # Server expects lowercase string per the OpenAPI spec.
182
+ params["active_only"] = "true"
183
+ if tags:
184
+ # Hindsight accepts repeated `tags=` query params for
185
+ # multi-tag filtering. urlencode with doseq=True handles it.
186
+ params["tags"] = tags
187
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/directives"
188
+ if params:
189
+ path = f"{path}?{urllib.parse.urlencode(params, doseq=True)}"
190
+ return self._request("GET", path, timeout=timeout)
191
+
192
+ def set_bank_mission(
193
+ self, bank_id: str, mission: str, retain_mission: Optional[str] = None, timeout: int = 15
194
+ ) -> dict:
195
+ """Set the mission/persona for a bank.
196
+
197
+ Uses PATCH /banks/{id}/config with reflect_mission and retain_mission.
198
+ The old PUT /banks/{id} with 'mission' field is deprecated in v0.4.19.
199
+ """
200
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/config"
201
+ updates = {"reflect_mission": mission}
202
+ if retain_mission:
203
+ updates["retain_mission"] = retain_mission
204
+ return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
@@ -0,0 +1,180 @@
1
+ """Configuration management for Hindsight plugin.
2
+
3
+ Loads settings from settings.json (plugin defaults) merged with environment
4
+ variable overrides. Full config schema matching Openclaw's 30+ options.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ DEFAULTS = {
12
+ # Recall
13
+ "autoRecall": True,
14
+ # Switchroom default: "low" — vector search only, no LLM reranking.
15
+ # Cuts the recall hook latency from ~5s (mid budget) to ~1-2s (low).
16
+ # Operators who want richer recall can set HINDSIGHT_RECALL_BUDGET=mid
17
+ # via per-agent env or write `recallBudget: "mid"` into the user
18
+ # config file. Forensics on real klanker turns showed mid-budget
19
+ # recall was ~5s of wall-clock latency dominated by the LLM filter
20
+ # pass; for chat-pattern agents the vector hits alone are fine and
21
+ # the 5s is the second-largest contributor to perceived dead air
22
+ # (after the model TTFT).
23
+ "recallBudget": "low",
24
+ "recallMaxTokens": 1024,
25
+ # Switchroom-local: cap on the number of memories injected into the
26
+ # `<hindsight_memories>` block, regardless of token budget. Plugin v0.4.0
27
+ # exposes `recallTopK` only in the Openclaw integration, not the
28
+ # Claude Code integration, so we slice client-side in recall.py before
29
+ # formatting. Set to 0 (or any non-positive value) to disable the cap
30
+ # and inject everything Hindsight returns.
31
+ "recallMaxMemories": 12,
32
+ # Switchroom-local: minimum lexical (Jaccard) overlap between the
33
+ # user's query terms and a memory's text terms. Memories below this
34
+ # threshold are dropped before formatting. 0.0 disables the gate
35
+ # (current behaviour: inject everything Hindsight returns up to the
36
+ # count cap). Hindsight's HTTP API does not expose similarity
37
+ # scores, so this is the switchroom-side quality filter — see #475.
38
+ "recallMinOverlap": 0.0,
39
+ "recallTypes": ["world", "experience"],
40
+ "recallContextTurns": 1,
41
+ "recallMaxQueryChars": 800,
42
+ "recallRoles": ["user", "assistant"],
43
+ "recallPromptPreamble": (
44
+ "Relevant memories from past conversations (prioritize recent when "
45
+ "conflicting). Only use memories that are directly useful to continue "
46
+ "this conversation; ignore the rest:"
47
+ ),
48
+ # Retain
49
+ "autoRetain": True,
50
+ "retainMode": "full-session",
51
+ "retainRoles": ["user", "assistant"],
52
+ "retainEveryNTurns": 10,
53
+ "retainOverlapTurns": 2,
54
+ "retainToolCalls": True,
55
+ "retainContext": "claude-code",
56
+ "retainTags": [],
57
+ "retainMetadata": {},
58
+ "recallAdditionalBanks": [],
59
+ # Connection
60
+ "hindsightApiUrl": None,
61
+ "hindsightApiToken": None,
62
+ "apiPort": 9077,
63
+ "daemonIdleTimeout": 0,
64
+ "embedVersion": "latest",
65
+ "embedPackagePath": None,
66
+ # Bank
67
+ "bankId": None,
68
+ "bankIdPrefix": "",
69
+ "dynamicBankId": False,
70
+ "dynamicBankGranularity": ["agent", "project"],
71
+ "bankMission": "",
72
+ "retainMission": None,
73
+ "agentName": "claude-code",
74
+ # LLM (for daemon mode)
75
+ "llmProvider": None,
76
+ "llmModel": None,
77
+ "llmApiKeyEnv": None,
78
+ # Misc
79
+ "debug": False,
80
+ }
81
+
82
+ # Map env var names to config keys and their types
83
+ ENV_OVERRIDES = {
84
+ "HINDSIGHT_API_URL": ("hindsightApiUrl", str),
85
+ "HINDSIGHT_API_TOKEN": ("hindsightApiToken", str),
86
+ "HINDSIGHT_BANK_ID": ("bankId", str),
87
+ "HINDSIGHT_AGENT_NAME": ("agentName", str),
88
+ "HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
89
+ "HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
90
+ "HINDSIGHT_RETAIN_MODE": ("retainMode", str),
91
+ "HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
92
+ "HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int),
93
+ # Switchroom-local: count cap. Set by start.sh from
94
+ # agents.<name>.memory.recall.max_memories (cascading through
95
+ # defaults.memory.recall.max_memories) when present in switchroom.yaml.
96
+ "HINDSIGHT_RECALL_MAX_MEMORIES": ("recallMaxMemories", int),
97
+ # Switchroom-local: lexical-overlap threshold (#475). Float in
98
+ # [0.0, 1.0]. Set by start.sh from agents.<name>.memory.recall.min_overlap
99
+ # (cascading through defaults). 0.0 = off (current behaviour).
100
+ "HINDSIGHT_RECALL_MIN_OVERLAP": ("recallMinOverlap", float),
101
+ "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
102
+ "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
103
+ "HINDSIGHT_API_PORT": ("apiPort", int),
104
+ "HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
105
+ "HINDSIGHT_EMBED_VERSION": ("embedVersion", str),
106
+ "HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str),
107
+ "HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool),
108
+ "HINDSIGHT_BANK_MISSION": ("bankMission", str),
109
+ "HINDSIGHT_LLM_PROVIDER": ("llmProvider", str),
110
+ "HINDSIGHT_LLM_MODEL": ("llmModel", str),
111
+ "HINDSIGHT_DEBUG": ("debug", bool),
112
+ }
113
+
114
+
115
+ def _cast_env(value: str, typ):
116
+ """Cast environment variable string to target type. Returns None on failure."""
117
+ try:
118
+ if typ is bool:
119
+ return value.lower() in ("true", "1", "yes")
120
+ if typ is int:
121
+ return int(value)
122
+ if typ is float:
123
+ return float(value)
124
+ return value
125
+ except (ValueError, AttributeError):
126
+ return None
127
+
128
+
129
+ def _load_settings_file(path: str, config: dict) -> None:
130
+ """Merge a settings.json file into config in-place. Silently skips if missing."""
131
+ if not os.path.exists(path):
132
+ return
133
+ try:
134
+ with open(path) as f:
135
+ file_config = json.load(f)
136
+ config.update({k: v for k, v in file_config.items() if v is not None})
137
+ except (json.JSONDecodeError, OSError) as e:
138
+ debug_log(config, f"Failed to load {path}: {e}")
139
+
140
+
141
+ def load_config() -> dict:
142
+ """Load plugin configuration from settings.json + env overrides.
143
+
144
+ Loading order (later entries win):
145
+ 1. Built-in defaults
146
+ 2. Plugin default settings.json (CLAUDE_PLUGIN_ROOT/settings.json)
147
+ 3. User config (~/.hindsight/claude-code.json)
148
+ 4. Environment variable overrides
149
+
150
+ ~/.hindsight/claude-code.json is the recommended place to configure the
151
+ plugin — same convention as ~/.openclaw/openclaw.json. It is stable across
152
+ plugin updates and marketplace changes.
153
+ """
154
+ config = dict(DEFAULTS)
155
+
156
+ # 1. Plugin default settings.json (ships with the plugin, version-specific path)
157
+ plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
158
+ if not plugin_root:
159
+ plugin_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
160
+ _load_settings_file(os.path.join(plugin_root, "settings.json"), config)
161
+
162
+ # 2. User config — stable, version-independent, matches openclaw convention
163
+ user_config_path = os.path.join(os.path.expanduser("~"), ".hindsight", "claude-code.json")
164
+ _load_settings_file(user_config_path, config)
165
+
166
+ # Apply environment variable overrides
167
+ for env_name, (key, typ) in ENV_OVERRIDES.items():
168
+ val = os.environ.get(env_name)
169
+ if val is not None:
170
+ cast_val = _cast_env(val, typ)
171
+ if cast_val is not None:
172
+ config[key] = cast_val
173
+
174
+ return config
175
+
176
+
177
+ def debug_log(config: dict, *args):
178
+ """Log to stderr if debug mode is enabled."""
179
+ if config.get("debug"):
180
+ print("[Hindsight]", *args, file=sys.stderr)