switchroom 0.12.26 → 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 (57) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/skill-validate-pretool.mjs +72 -72
  5. package/dist/cli/switchroom.js +359 -357
  6. package/dist/host-control/main.js +99 -99
  7. package/dist/vault/approvals/kernel-server.js +82 -82
  8. package/dist/vault/broker/server.js +83 -83
  9. package/package.json +2 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  11. package/telegram-plugin/dist/gateway/gateway.js +368 -209
  12. package/telegram-plugin/dist/server.js +160 -160
  13. package/telegram-plugin/gateway/gateway.ts +55 -40
  14. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
  15. package/telegram-plugin/stderr-timestamps.ts +106 -0
  16. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
  17. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  18. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  19. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  20. package/vendor/hindsight-memory/LICENSE +21 -0
  21. package/vendor/hindsight-memory/README.md +329 -0
  22. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  23. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  24. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  25. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  26. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  27. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  28. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  29. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  30. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  31. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  32. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  33. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  34. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  36. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  37. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  38. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  39. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  40. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  44. package/vendor/hindsight-memory/settings.json +37 -0
  45. package/vendor/hindsight-memory/skills/setup.md +24 -0
  46. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  47. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  48. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  49. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  50. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  51. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  52. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  53. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  54. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  55. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  56. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  57. 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