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.
- package/dist/cli/switchroom.js +4 -2
- package/package.json +2 -1
- package/telegram-plugin/dist/gateway/gateway.js +49 -5
- package/telegram-plugin/gateway/gateway.ts +5 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -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,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)
|