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,334 @@
|
|
|
1
|
+
"""Hindsight-embed daemon lifecycle management.
|
|
2
|
+
|
|
3
|
+
Port of: HindsightServer in @vectorize-io/hindsight-all, adapted for Python
|
|
4
|
+
subprocess calls from ephemeral hook processes.
|
|
5
|
+
|
|
6
|
+
Manages three connection modes (same as Openclaw):
|
|
7
|
+
1. External API — user provides hindsightApiUrl (skip daemon entirely)
|
|
8
|
+
2. Existing local server — user already has hindsight running
|
|
9
|
+
3. Auto-managed daemon — plugin starts/stops hindsight-embed
|
|
10
|
+
|
|
11
|
+
In Claude Code's ephemeral model, daemon state is tracked via files in
|
|
12
|
+
$CLAUDE_PLUGIN_DATA/state/. The daemon itself is a background OS process
|
|
13
|
+
managed by hindsight-embed's built-in daemon command.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
from .client import USER_AGENT
|
|
24
|
+
from .llm import detect_llm_config, get_llm_env_vars
|
|
25
|
+
from .state import read_state, write_state
|
|
26
|
+
|
|
27
|
+
DAEMON_STATE_FILE = "daemon.json"
|
|
28
|
+
PROFILE_NAME = "claude-code"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_embed_command(config: dict) -> list:
|
|
32
|
+
"""Get the command to run hindsight-embed.
|
|
33
|
+
|
|
34
|
+
Port of: getEmbedCommand() in @vectorize-io/hindsight-all
|
|
35
|
+
"""
|
|
36
|
+
embed_path = config.get("embedPackagePath")
|
|
37
|
+
if embed_path:
|
|
38
|
+
return ["uv", "run", "--directory", embed_path, "hindsight-embed"]
|
|
39
|
+
|
|
40
|
+
version = config.get("embedVersion", "latest")
|
|
41
|
+
package = f"hindsight-embed@{version}" if version else "hindsight-embed@latest"
|
|
42
|
+
return ["uvx", package]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_embed(config: dict, args: list, env: dict = None, timeout: int = 10) -> subprocess.CompletedProcess:
|
|
46
|
+
"""Run a hindsight-embed command and return the result."""
|
|
47
|
+
cmd = _get_embed_command(config) + args
|
|
48
|
+
run_env = dict(os.environ)
|
|
49
|
+
if env:
|
|
50
|
+
run_env.update(env)
|
|
51
|
+
return subprocess.run(
|
|
52
|
+
cmd,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
env=run_env,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_embed_available(config: dict) -> bool:
|
|
61
|
+
"""Quick check if hindsight-embed is available on PATH.
|
|
62
|
+
|
|
63
|
+
Avoids the slow uvx download path when the tool isn't installed.
|
|
64
|
+
"""
|
|
65
|
+
import shutil
|
|
66
|
+
|
|
67
|
+
embed_path = config.get("embedPackagePath")
|
|
68
|
+
if embed_path:
|
|
69
|
+
return os.path.isdir(embed_path)
|
|
70
|
+
# Check for uvx or hindsight-embed on PATH
|
|
71
|
+
return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_health(base_url: str, timeout: int = 2) -> bool:
|
|
75
|
+
"""Quick health check against a Hindsight server."""
|
|
76
|
+
try:
|
|
77
|
+
url = f"{base_url.rstrip('/')}/health"
|
|
78
|
+
req = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT})
|
|
79
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
80
|
+
return resp.status == 200
|
|
81
|
+
except Exception:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_api_url(config: dict, debug_fn=None, allow_daemon_start: bool = False) -> str:
|
|
86
|
+
"""Determine the API URL, optionally starting daemon if needed.
|
|
87
|
+
|
|
88
|
+
Returns the API URL to use for recall/retain, handling all three modes.
|
|
89
|
+
|
|
90
|
+
Connection mode priority:
|
|
91
|
+
1. External API (hindsightApiUrl configured)
|
|
92
|
+
2. Existing local server (check port health)
|
|
93
|
+
3. Auto-managed daemon (only if allow_daemon_start=True)
|
|
94
|
+
|
|
95
|
+
The allow_daemon_start flag prevents the recall hook (10s timeout) from
|
|
96
|
+
blocking on a 30s daemon startup. Only the retain hook (async, 15s) should
|
|
97
|
+
attempt daemon start.
|
|
98
|
+
"""
|
|
99
|
+
# Mode 1: External API
|
|
100
|
+
external_url = config.get("hindsightApiUrl")
|
|
101
|
+
if external_url:
|
|
102
|
+
if debug_fn:
|
|
103
|
+
debug_fn(f"Using external API: {external_url}")
|
|
104
|
+
return external_url
|
|
105
|
+
|
|
106
|
+
# Mode 2 & 3: Local server
|
|
107
|
+
port = config.get("apiPort", 9077)
|
|
108
|
+
base_url = f"http://127.0.0.1:{port}"
|
|
109
|
+
|
|
110
|
+
# Check if something is already running on this port
|
|
111
|
+
if _check_health(base_url):
|
|
112
|
+
if debug_fn:
|
|
113
|
+
debug_fn(f"Existing server healthy on port {port}")
|
|
114
|
+
return base_url
|
|
115
|
+
|
|
116
|
+
# Mode 3: Auto-start daemon (only when allowed)
|
|
117
|
+
if not allow_daemon_start:
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
f"No Hindsight server on port {port}. Set hindsightApiUrl for external "
|
|
120
|
+
f"API, start hindsight-embed manually, or wait for the retain hook to "
|
|
121
|
+
f"auto-start the daemon."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if debug_fn:
|
|
125
|
+
debug_fn(f"No server on port {port}, attempting daemon start")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
_ensure_daemon_running(config, port, debug_fn)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
if debug_fn:
|
|
131
|
+
debug_fn(f"Daemon start failed: {e}")
|
|
132
|
+
raise RuntimeError(
|
|
133
|
+
"No Hindsight server available. Set hindsightApiUrl for external API, "
|
|
134
|
+
"or ensure hindsight-embed is installed for local daemon mode."
|
|
135
|
+
) from e
|
|
136
|
+
|
|
137
|
+
return base_url
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _ensure_daemon_running(config: dict, port: int, debug_fn=None):
|
|
141
|
+
"""Start the hindsight-embed daemon if not already running.
|
|
142
|
+
|
|
143
|
+
Port of: HindsightServer.start() in @vectorize-io/hindsight-all
|
|
144
|
+
"""
|
|
145
|
+
# Fast-fail if hindsight-embed toolchain is not available
|
|
146
|
+
if not _is_embed_available(config):
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
"hindsight-embed not found (uvx not on PATH). "
|
|
149
|
+
"Install with: pip install hindsight-embed, or set hindsightApiUrl."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
base_url = f"http://127.0.0.1:{port}"
|
|
153
|
+
|
|
154
|
+
# Detect LLM config (needed for daemon's fact extraction)
|
|
155
|
+
try:
|
|
156
|
+
llm_config = detect_llm_config(config)
|
|
157
|
+
except RuntimeError as e:
|
|
158
|
+
raise RuntimeError(f"Cannot start daemon: {e}") from e
|
|
159
|
+
|
|
160
|
+
llm_env = get_llm_env_vars(llm_config)
|
|
161
|
+
|
|
162
|
+
# Build daemon environment
|
|
163
|
+
daemon_env = dict(llm_env)
|
|
164
|
+
idle_timeout = config.get("daemonIdleTimeout", 300)
|
|
165
|
+
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
|
|
166
|
+
|
|
167
|
+
# On macOS, force CPU for embeddings/reranker (mirrors Openclaw)
|
|
168
|
+
if platform.system() == "Darwin":
|
|
169
|
+
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
|
|
170
|
+
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
|
|
171
|
+
|
|
172
|
+
# Step 1: Configure profile
|
|
173
|
+
if debug_fn:
|
|
174
|
+
debug_fn(f'Configuring "{PROFILE_NAME}" profile...')
|
|
175
|
+
|
|
176
|
+
profile_args = [
|
|
177
|
+
"profile",
|
|
178
|
+
"create",
|
|
179
|
+
PROFILE_NAME,
|
|
180
|
+
"--merge",
|
|
181
|
+
"--port",
|
|
182
|
+
str(port),
|
|
183
|
+
]
|
|
184
|
+
for env_name, env_val in daemon_env.items():
|
|
185
|
+
if env_val:
|
|
186
|
+
profile_args.extend(["--env", f"{env_name}={env_val}"])
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
result = _run_embed(config, profile_args, daemon_env, timeout=10)
|
|
190
|
+
if result.returncode != 0:
|
|
191
|
+
if debug_fn:
|
|
192
|
+
debug_fn(f"Profile create stderr: {result.stderr.strip()}")
|
|
193
|
+
raise RuntimeError(f"Profile create failed (exit {result.returncode}): {result.stderr}")
|
|
194
|
+
if debug_fn:
|
|
195
|
+
debug_fn("Profile configured")
|
|
196
|
+
except subprocess.TimeoutExpired:
|
|
197
|
+
raise RuntimeError("Profile create timed out")
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
raise RuntimeError(
|
|
200
|
+
"hindsight-embed not found. Install with: pip install hindsight-embed "
|
|
201
|
+
"or set hindsightApiUrl for external API mode."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Step 2: Start daemon
|
|
205
|
+
if debug_fn:
|
|
206
|
+
debug_fn("Starting daemon...")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
result = _run_embed(
|
|
210
|
+
config,
|
|
211
|
+
["daemon", "--profile", PROFILE_NAME, "start"],
|
|
212
|
+
daemon_env,
|
|
213
|
+
timeout=30,
|
|
214
|
+
)
|
|
215
|
+
if debug_fn:
|
|
216
|
+
debug_fn(f"Daemon start exit={result.returncode} stdout={result.stdout.strip()}")
|
|
217
|
+
if result.returncode != 0 and "already running" not in result.stderr.lower():
|
|
218
|
+
raise RuntimeError(f"Daemon start failed (exit {result.returncode}): {result.stderr}")
|
|
219
|
+
except subprocess.TimeoutExpired:
|
|
220
|
+
raise RuntimeError("Daemon start timed out")
|
|
221
|
+
|
|
222
|
+
# Step 3: Wait for ready (poll health endpoint)
|
|
223
|
+
if debug_fn:
|
|
224
|
+
debug_fn("Waiting for daemon to be ready...")
|
|
225
|
+
|
|
226
|
+
for attempt in range(30):
|
|
227
|
+
if _check_health(base_url):
|
|
228
|
+
if debug_fn:
|
|
229
|
+
debug_fn(f"Daemon ready after {attempt + 1} attempts")
|
|
230
|
+
# Track that we started this daemon
|
|
231
|
+
write_state(
|
|
232
|
+
DAEMON_STATE_FILE,
|
|
233
|
+
{
|
|
234
|
+
"port": port,
|
|
235
|
+
"started_by_plugin": True,
|
|
236
|
+
"started_at": time.time(),
|
|
237
|
+
"pid": os.getpid(),
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
time.sleep(1)
|
|
242
|
+
|
|
243
|
+
raise RuntimeError("Daemon failed to become ready within 30 seconds")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def prestart_daemon_background(config: dict, debug_fn=None):
|
|
247
|
+
"""Fire off daemon startup in the background — non-blocking.
|
|
248
|
+
|
|
249
|
+
Called from SessionStart hook to warm up the daemon before the first
|
|
250
|
+
recall or retain hook fires. Returns immediately; the daemon starts
|
|
251
|
+
asynchronously as a detached OS process.
|
|
252
|
+
"""
|
|
253
|
+
if config.get("hindsightApiUrl"):
|
|
254
|
+
return # External API mode — no local daemon needed
|
|
255
|
+
|
|
256
|
+
port = config.get("apiPort", 9077)
|
|
257
|
+
if _check_health(f"http://127.0.0.1:{port}"):
|
|
258
|
+
if debug_fn:
|
|
259
|
+
debug_fn(f"Daemon already running on port {port}, skipping pre-start")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
if not _is_embed_available(config):
|
|
263
|
+
if debug_fn:
|
|
264
|
+
debug_fn("hindsight-embed not available, skipping pre-start")
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
llm_config = detect_llm_config(config)
|
|
269
|
+
except RuntimeError as e:
|
|
270
|
+
if debug_fn:
|
|
271
|
+
debug_fn(f"No LLM configured, skipping daemon pre-start: {e}")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
llm_env = get_llm_env_vars(llm_config)
|
|
275
|
+
daemon_env = dict(os.environ)
|
|
276
|
+
daemon_env.update(llm_env)
|
|
277
|
+
idle_timeout = config.get("daemonIdleTimeout", 300)
|
|
278
|
+
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
|
|
279
|
+
if platform.system() == "Darwin":
|
|
280
|
+
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
|
|
281
|
+
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
|
|
282
|
+
|
|
283
|
+
embed_cmd = _get_embed_command(config)
|
|
284
|
+
|
|
285
|
+
profile_args = ["profile", "create", PROFILE_NAME, "--merge", "--port", str(port)]
|
|
286
|
+
for env_name, env_val in llm_env.items():
|
|
287
|
+
if env_val:
|
|
288
|
+
profile_args.extend(["--env", f"{env_name}={env_val}"])
|
|
289
|
+
|
|
290
|
+
import shlex
|
|
291
|
+
profile_str = shlex.join(embed_cmd + profile_args)
|
|
292
|
+
daemon_str = shlex.join(embed_cmd + ["daemon", "--profile", PROFILE_NAME, "start"])
|
|
293
|
+
|
|
294
|
+
import subprocess as _sp
|
|
295
|
+
_sp.Popen(
|
|
296
|
+
f"{profile_str} && {daemon_str}",
|
|
297
|
+
shell=True,
|
|
298
|
+
env=daemon_env,
|
|
299
|
+
stdout=_sp.DEVNULL,
|
|
300
|
+
stderr=_sp.DEVNULL,
|
|
301
|
+
start_new_session=True,
|
|
302
|
+
)
|
|
303
|
+
if debug_fn:
|
|
304
|
+
debug_fn(f"Daemon pre-start initiated in background (port {port})")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def stop_daemon(config: dict, debug_fn=None):
|
|
308
|
+
"""Stop the daemon if it was started by this plugin.
|
|
309
|
+
|
|
310
|
+
Called during cleanup. Only stops if we started it (tracked in state).
|
|
311
|
+
"""
|
|
312
|
+
state = read_state(DAEMON_STATE_FILE)
|
|
313
|
+
if not state or not state.get("started_by_plugin"):
|
|
314
|
+
if debug_fn:
|
|
315
|
+
debug_fn("Daemon not started by plugin, skipping stop")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
if debug_fn:
|
|
319
|
+
debug_fn("Stopping daemon...")
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
result = _run_embed(
|
|
323
|
+
config,
|
|
324
|
+
["daemon", "--profile", PROFILE_NAME, "stop"],
|
|
325
|
+
timeout=10,
|
|
326
|
+
)
|
|
327
|
+
if debug_fn:
|
|
328
|
+
debug_fn(f"Daemon stop: {result.stdout.strip()}")
|
|
329
|
+
except Exception as e:
|
|
330
|
+
if debug_fn:
|
|
331
|
+
debug_fn(f"Daemon stop error: {e}")
|
|
332
|
+
|
|
333
|
+
# Clear state
|
|
334
|
+
write_state(DAEMON_STATE_FILE, {})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Active directives fetching and formatting for the recall hook.
|
|
2
|
+
|
|
3
|
+
Why this lives separately from `content.py`:
|
|
4
|
+
Hindsight's `reflect` MCP tool has an upstream bug
|
|
5
|
+
(vectorize-io/hindsight#1269) where tagged directives are silently dropped
|
|
6
|
+
from synthesis. Until that ships, we surface directives client-side as a
|
|
7
|
+
structurally distinct top-of-prompt block so the agent reads them every
|
|
8
|
+
turn — independent of whatever `reflect` does with them.
|
|
9
|
+
|
|
10
|
+
`list_directives` itself works correctly upstream — only `reflect` is
|
|
11
|
+
broken — so this is a pure client-side win.
|
|
12
|
+
|
|
13
|
+
Failure mode: any error fetching directives (HTTP error, malformed
|
|
14
|
+
response, timeout) returns an empty list and logs a single warn line to
|
|
15
|
+
stderr. We never raise to the caller — directives are nice-to-have on the
|
|
16
|
+
recall path; a directive-fetch failure must not kill the recall block.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
# Sanity cap on how many directives we ever inject into the prompt. Banks
|
|
23
|
+
# with more active directives than this are pathological; truncate with a
|
|
24
|
+
# footer so the agent knows there are more.
|
|
25
|
+
MAX_DIRECTIVES = 15
|
|
26
|
+
|
|
27
|
+
# Hard timeout for the list_directives call. The recall hook is on the
|
|
28
|
+
# UserPromptSubmit critical path — we cannot block it for long.
|
|
29
|
+
DIRECTIVES_TIMEOUT_SECONDS = 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIMEOUT_SECONDS) -> list:
|
|
33
|
+
"""Fetch active directives for a bank, sorted by priority (highest first).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
client: A HindsightClient instance with a list_directives method.
|
|
37
|
+
bank_id: The bank to fetch directives from.
|
|
38
|
+
timeout: HTTP timeout in seconds.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A list of directive dicts (each with id, name, content, priority,
|
|
42
|
+
tags, ...), sorted by priority descending. On any failure returns
|
|
43
|
+
an empty list and logs a single warn line to stderr — never raises.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
response = client.list_directives(bank_id=bank_id, active_only=True, timeout=timeout)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"[Hindsight] list_directives failed for bank '{bank_id}': {e}", file=sys.stderr)
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
if not isinstance(response, dict):
|
|
52
|
+
print(
|
|
53
|
+
f"[Hindsight] list_directives returned non-dict for bank '{bank_id}': "
|
|
54
|
+
f"{type(response).__name__}",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
items = response.get("items")
|
|
60
|
+
if not isinstance(items, list):
|
|
61
|
+
# Empty / malformed response — quiet success, no warn (banks with
|
|
62
|
+
# no directives are normal).
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
# Filter to dicts only, then sort by priority descending. Treat missing
|
|
66
|
+
# priority as 0 so malformed entries sink to the bottom rather than
|
|
67
|
+
# crashing.
|
|
68
|
+
valid = [d for d in items if isinstance(d, dict)]
|
|
69
|
+
valid.sort(key=lambda d: d.get("priority", 0), reverse=True)
|
|
70
|
+
return valid
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_active_directives_block(directives: list, max_directives: int = MAX_DIRECTIVES) -> Optional[str]:
|
|
74
|
+
"""Format directives into the <active_directives> block string.
|
|
75
|
+
|
|
76
|
+
Returns None if the list is empty — callers should omit the block
|
|
77
|
+
entirely rather than emitting an empty wrapper.
|
|
78
|
+
|
|
79
|
+
Format:
|
|
80
|
+
<active_directives>
|
|
81
|
+
The following are HARD RULES the agent must follow on this turn. ...
|
|
82
|
+
|
|
83
|
+
1. [P10] <name>: <content>
|
|
84
|
+
2. [P9] <name>: <content>
|
|
85
|
+
...
|
|
86
|
+
(+N more, omitted)
|
|
87
|
+
</active_directives>
|
|
88
|
+
"""
|
|
89
|
+
if not directives:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
total = len(directives)
|
|
93
|
+
truncated = directives[:max_directives]
|
|
94
|
+
omitted = total - len(truncated)
|
|
95
|
+
|
|
96
|
+
lines = [
|
|
97
|
+
"<active_directives>",
|
|
98
|
+
(
|
|
99
|
+
"The following are HARD RULES the agent must follow on this turn. "
|
|
100
|
+
"They are the bank's currently active directives, ordered by priority. "
|
|
101
|
+
"Apply them when formulating your response."
|
|
102
|
+
),
|
|
103
|
+
"",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
for i, d in enumerate(truncated, start=1):
|
|
107
|
+
priority = d.get("priority", 0)
|
|
108
|
+
name = (d.get("name") or "").strip() or "(unnamed)"
|
|
109
|
+
content = (d.get("content") or "").strip()
|
|
110
|
+
# Content verbatim — directives are deliberately authored. Do not
|
|
111
|
+
# reformat or truncate.
|
|
112
|
+
lines.append(f"{i}. [P{priority}] {name}: {content}")
|
|
113
|
+
|
|
114
|
+
if omitted > 0:
|
|
115
|
+
lines.append("")
|
|
116
|
+
lines.append(f"(+{omitted} more, omitted)")
|
|
117
|
+
|
|
118
|
+
lines.append("</active_directives>")
|
|
119
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Minimal client for the switchroom telegram gateway's unix-socket IPC.
|
|
2
|
+
|
|
3
|
+
Used by the auto-recall hook to push status updates to the user's Telegram
|
|
4
|
+
draft during the silent gap between inbound and the agent's first tool
|
|
5
|
+
call. See `update_placeholder` in
|
|
6
|
+
`telegram-plugin/gateway/ipc-protocol.ts` for the wire format.
|
|
7
|
+
|
|
8
|
+
Why a separate, tiny client (instead of importing the existing bridge):
|
|
9
|
+
the recall hook is an ephemeral python subprocess invoked by Claude Code
|
|
10
|
+
on every UserPromptSubmit. The bridge (telegram-plugin/bridge/) is
|
|
11
|
+
TypeScript and lives inside the long-running claude process. Hooks can't
|
|
12
|
+
share the bridge connection. Each hook fire opens its own one-shot
|
|
13
|
+
unix-socket connection, sends one JSON line, closes. ~5 ms total.
|
|
14
|
+
|
|
15
|
+
Failure-tolerant by design: every error path returns silently. The
|
|
16
|
+
recall hook MUST NOT block on a Telegram UX nice-to-have.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import socket
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
# Same regex `bin/auto-recall-hook.sh` (now removed) used; mirrors the
|
|
28
|
+
# `<channel ...>` wrapper that telegram-plugin emits on inbound. Kept
|
|
29
|
+
# permissive — attribute order varies, attributes can be quoted with " or
|
|
30
|
+
# (rarely) ' depending on tooling.
|
|
31
|
+
_CHANNEL_OPEN_RE = re.compile(
|
|
32
|
+
r"<channel\b[^>]*\bchat_id=[\"']([^\"']+)[\"'][^>]*>",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extract_chat_id_from_prompt(prompt: str) -> Optional[str]:
|
|
38
|
+
"""Pull `chat_id` out of a `<channel ...>...</channel>` wrapper.
|
|
39
|
+
|
|
40
|
+
Returns None when the prompt isn't channel-wrapped (e.g. interactive
|
|
41
|
+
sessions, non-Telegram channels, or test fixtures). Caller should
|
|
42
|
+
silently skip the IPC update when None — there's no user-visible
|
|
43
|
+
draft to update.
|
|
44
|
+
"""
|
|
45
|
+
if not prompt or not isinstance(prompt, str):
|
|
46
|
+
return None
|
|
47
|
+
# Inspect only the first 1 KB — the wrapper is always at the head;
|
|
48
|
+
# anchoring there caps the regex cost regardless of prompt size.
|
|
49
|
+
head = prompt[:1024]
|
|
50
|
+
match = _CHANNEL_OPEN_RE.search(head)
|
|
51
|
+
if not match:
|
|
52
|
+
return None
|
|
53
|
+
chat_id = match.group(1).strip()
|
|
54
|
+
return chat_id or None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def gateway_socket_path() -> Optional[str]:
|
|
58
|
+
"""Resolve the gateway socket path for the current agent.
|
|
59
|
+
|
|
60
|
+
Order of resolution:
|
|
61
|
+
1. SWITCHROOM_GATEWAY_SOCKET env var (explicit override).
|
|
62
|
+
2. <agent_dir>/telegram/gateway.sock — the conventional path
|
|
63
|
+
that gateway.ts uses by default.
|
|
64
|
+
|
|
65
|
+
Returns None when neither is available; callers no-op on None.
|
|
66
|
+
"""
|
|
67
|
+
explicit = os.environ.get("SWITCHROOM_GATEWAY_SOCKET", "").strip()
|
|
68
|
+
if explicit:
|
|
69
|
+
return explicit
|
|
70
|
+
# CLAUDE_PLUGIN_DATA → <agent_dir>/.claude/plugins/data/<plugin>/.
|
|
71
|
+
# Step up four to land at <agent_dir>.
|
|
72
|
+
plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "").strip()
|
|
73
|
+
if plugin_data:
|
|
74
|
+
agent_dir = os.path.normpath(os.path.join(plugin_data, "..", "..", "..", ".."))
|
|
75
|
+
candidate = os.path.join(agent_dir, "telegram", "gateway.sock")
|
|
76
|
+
if os.path.exists(candidate):
|
|
77
|
+
return candidate
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def update_placeholder(
|
|
82
|
+
chat_id: str,
|
|
83
|
+
text: str,
|
|
84
|
+
*,
|
|
85
|
+
socket_path: Optional[str] = None,
|
|
86
|
+
timeout_secs: float = 0.25,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Send an `update_placeholder` message to the gateway. Returns True
|
|
89
|
+
on success (message written), False on any failure.
|
|
90
|
+
|
|
91
|
+
Failure cases (all silent):
|
|
92
|
+
- No socket path resolvable.
|
|
93
|
+
- Socket connect refused / timeout.
|
|
94
|
+
- Socket write fails (rare).
|
|
95
|
+
|
|
96
|
+
Caller should never branch on the return value — it's purely for
|
|
97
|
+
test introspection.
|
|
98
|
+
"""
|
|
99
|
+
if not chat_id or not isinstance(chat_id, str):
|
|
100
|
+
return False
|
|
101
|
+
if not text or not isinstance(text, str):
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
path = socket_path or gateway_socket_path()
|
|
105
|
+
if path is None:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
payload = json.dumps({
|
|
109
|
+
"type": "update_placeholder",
|
|
110
|
+
"chatId": chat_id,
|
|
111
|
+
"text": text,
|
|
112
|
+
}) + "\n"
|
|
113
|
+
|
|
114
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
115
|
+
sock.settimeout(timeout_secs)
|
|
116
|
+
try:
|
|
117
|
+
sock.connect(path)
|
|
118
|
+
sock.sendall(payload.encode("utf-8"))
|
|
119
|
+
return True
|
|
120
|
+
except (FileNotFoundError, ConnectionRefusedError, OSError, socket.timeout):
|
|
121
|
+
return False
|
|
122
|
+
finally:
|
|
123
|
+
try:
|
|
124
|
+
sock.close()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|