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.
- package/dist/cli/switchroom.js +4 -2
- package/package.json +2 -1
- package/telegram-plugin/dist/gateway/gateway.js +113 -7
- package/telegram-plugin/gateway/gateway.ts +52 -9
- package/telegram-plugin/gateway/prefix-warmup.ts +123 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/prefix-warmup.test.ts +175 -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,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.py\"",
|
|
9
|
+
"timeout": 5
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"UserPromptSubmit": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/recall.py\"",
|
|
20
|
+
"timeout": 12
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"Stop": [
|
|
26
|
+
{
|
|
27
|
+
"hooks": [
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/retain.py\"",
|
|
31
|
+
"timeout": 15,
|
|
32
|
+
"async": true
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"SessionEnd": [
|
|
38
|
+
{
|
|
39
|
+
"hooks": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/session_end.py\"",
|
|
43
|
+
"timeout": 10
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Drain ``~/.hindsight/pending-retains/``.
|
|
3
|
+
|
|
4
|
+
SessionStart calls into ``drain()`` to retry any retain payloads that
|
|
5
|
+
``session_end.py`` queued on failure (#1071). Each entry is retried up
|
|
6
|
+
to ``MAX_ATTEMPTS`` (5) times; after that it's renamed to ``.dead`` so
|
|
7
|
+
the queue no longer drains it but the operator can still inspect via
|
|
8
|
+
``switchroom doctor``.
|
|
9
|
+
|
|
10
|
+
Boundaries
|
|
11
|
+
----------
|
|
12
|
+
* Per-entry HTTP timeout: ``HINDSIGHT_DRAIN_TIMEOUT`` (default 5s).
|
|
13
|
+
* Stall guard: if ``STALL_THRESHOLD`` (3) consecutive entries fail with
|
|
14
|
+
the same error class, we stop draining for this session — that's a
|
|
15
|
+
systemic outage, not a transient flake, and continuing would only
|
|
16
|
+
burn the SessionStart timeout budget. The remaining entries stay
|
|
17
|
+
queued for the next session.
|
|
18
|
+
* Total wall-clock cap: ``HINDSIGHT_DRAIN_BUDGET_S`` (default 4s) so
|
|
19
|
+
drain never blocks SessionStart longer than the upstream
|
|
20
|
+
hook timeout permits.
|
|
21
|
+
|
|
22
|
+
Standalone usage::
|
|
23
|
+
|
|
24
|
+
python3 drain_pending.py # one-shot drain, prints summary
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
|
|
35
|
+
from lib.client import HindsightClient
|
|
36
|
+
from lib.config import debug_log, load_config
|
|
37
|
+
from lib.pending import (
|
|
38
|
+
MAX_ATTEMPTS,
|
|
39
|
+
delete_entry,
|
|
40
|
+
iter_entries,
|
|
41
|
+
mark_dead,
|
|
42
|
+
update_attempt,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
STALL_THRESHOLD = 3
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _per_entry_timeout() -> int:
|
|
50
|
+
raw = os.environ.get("HINDSIGHT_DRAIN_TIMEOUT", "5")
|
|
51
|
+
try:
|
|
52
|
+
v = int(raw)
|
|
53
|
+
return max(1, v)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return 5
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _budget_seconds() -> float:
|
|
59
|
+
raw = os.environ.get("HINDSIGHT_DRAIN_BUDGET_S", "4")
|
|
60
|
+
try:
|
|
61
|
+
v = float(raw)
|
|
62
|
+
return max(0.5, v)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return 4.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _retry_one(entry: dict, timeout: int) -> None:
|
|
68
|
+
"""POST a single queued retain. Raises on failure."""
|
|
69
|
+
client = HindsightClient(entry["api_url"], entry.get("api_token"))
|
|
70
|
+
client.retain(
|
|
71
|
+
bank_id=entry["bank_id"],
|
|
72
|
+
content=entry["content"],
|
|
73
|
+
document_id=entry.get("document_id", "conversation"),
|
|
74
|
+
context=entry.get("context"),
|
|
75
|
+
metadata=entry.get("metadata") or {},
|
|
76
|
+
tags=entry.get("tags"),
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def drain(config: dict | None = None) -> dict:
|
|
82
|
+
"""Walk the pending-retains directory and retry each entry.
|
|
83
|
+
|
|
84
|
+
Returns a summary dict::
|
|
85
|
+
|
|
86
|
+
{"drained": int, # successful retries (entries deleted)
|
|
87
|
+
"retried": int, # failures kept for next session
|
|
88
|
+
"dead": int, # entries promoted to .dead this run
|
|
89
|
+
"stalled": bool, # stall guard tripped
|
|
90
|
+
"budget_exceeded": bool}
|
|
91
|
+
"""
|
|
92
|
+
config = config or load_config()
|
|
93
|
+
timeout = _per_entry_timeout()
|
|
94
|
+
budget = _budget_seconds()
|
|
95
|
+
started = time.monotonic()
|
|
96
|
+
|
|
97
|
+
summary = {
|
|
98
|
+
"drained": 0,
|
|
99
|
+
"retried": 0,
|
|
100
|
+
"dead": 0,
|
|
101
|
+
"stalled": False,
|
|
102
|
+
"budget_exceeded": False,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
entries = iter_entries()
|
|
106
|
+
if not entries:
|
|
107
|
+
debug_log(config, "drain_pending: queue empty")
|
|
108
|
+
return summary
|
|
109
|
+
|
|
110
|
+
debug_log(config, f"drain_pending: {len(entries)} entries to retry")
|
|
111
|
+
|
|
112
|
+
consecutive_failures = 0
|
|
113
|
+
last_error_class: str | None = None
|
|
114
|
+
|
|
115
|
+
for path, entry in entries:
|
|
116
|
+
if time.monotonic() - started > budget:
|
|
117
|
+
summary["budget_exceeded"] = True
|
|
118
|
+
debug_log(config, "drain_pending: total budget exceeded, stopping")
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
_retry_one(entry, timeout=timeout)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
err_class = type(e).__name__
|
|
125
|
+
if err_class == last_error_class:
|
|
126
|
+
consecutive_failures += 1
|
|
127
|
+
else:
|
|
128
|
+
consecutive_failures = 1
|
|
129
|
+
last_error_class = err_class
|
|
130
|
+
|
|
131
|
+
attempts = int(entry.get("attempt_count", 1))
|
|
132
|
+
if attempts >= MAX_ATTEMPTS:
|
|
133
|
+
marker = mark_dead(path, entry)
|
|
134
|
+
summary["dead"] += 1
|
|
135
|
+
print(
|
|
136
|
+
f"[Hindsight] drain_pending: entry exceeded {MAX_ATTEMPTS} "
|
|
137
|
+
f"attempts, marking dead at {marker} (last error: {err_class}: {e})",
|
|
138
|
+
file=sys.stderr,
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
update_attempt(path, entry, e)
|
|
142
|
+
summary["retried"] += 1
|
|
143
|
+
debug_log(
|
|
144
|
+
config,
|
|
145
|
+
f"drain_pending: retry {attempts}/{MAX_ATTEMPTS} failed for {path} ({err_class}: {e})",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if consecutive_failures >= STALL_THRESHOLD:
|
|
149
|
+
summary["stalled"] = True
|
|
150
|
+
print(
|
|
151
|
+
f"[Hindsight] drain_pending: {consecutive_failures} consecutive "
|
|
152
|
+
f"failures with {err_class}, stalling drain. Remaining entries "
|
|
153
|
+
f"stay queued.",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
break
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Success — delete the entry.
|
|
160
|
+
delete_entry(path)
|
|
161
|
+
summary["drained"] += 1
|
|
162
|
+
consecutive_failures = 0
|
|
163
|
+
last_error_class = None
|
|
164
|
+
|
|
165
|
+
return summary
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> int:
|
|
169
|
+
config = load_config()
|
|
170
|
+
summary = drain(config)
|
|
171
|
+
if summary["drained"] or summary["retried"] or summary["dead"]:
|
|
172
|
+
print(
|
|
173
|
+
f"[Hindsight] drain_pending: "
|
|
174
|
+
f"drained={summary['drained']} retried={summary['retried']} "
|
|
175
|
+
f"dead={summary['dead']} "
|
|
176
|
+
f"stalled={summary['stalled']} budget_exceeded={summary['budget_exceeded']}",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
# Non-zero only when we promoted entries to .dead — that's the
|
|
180
|
+
# operator-visible signal. Plain retry-still-pending isn't an error,
|
|
181
|
+
# the next SessionStart picks them up.
|
|
182
|
+
return 1 if summary["dead"] else 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
try:
|
|
187
|
+
sys.exit(main())
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(f"[Hindsight] drain_pending unexpected error: {e}", file=sys.stderr)
|
|
190
|
+
sys.exit(2)
|
|
File without changes
|
|
@@ -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)
|