skills/memstate/scripts/_client.py (16675 bytes)
1 """Shared HTTP client for the memstate CLI scripts. 2 3 Two modes, mirroring the TS proxy: 4 5 * attach — MEMSTATE_ADDR is set: talk to a daemon someone else started. 6 We never spawn, never kill. 7 * child — MEMSTATE_ADDR is unset: spawn `memstated --owner-pid=<us>`, 8 read the "MEMSTATE_READY addr=..." banner from its stderr, 9 SIGTERM on script exit. 1:1 lifetime with this Python process. 10 11 The child is cached on the module, so a single script that imports this 12 module and makes several requests reuses one daemon. 13 14 Env: 15 MEMSTATE_ADDR attach to this host:port (attach mode) 16 MEMSTATE_BIN override the daemon path (default: sibling build / PATH) 17 MEMSTATE_LOCAL_URL full base URL override (for both modes) 18 MEMSTATE_DB the child's DB; the daemon log goes in the same directory 19 """ 20 import atexit 21 import json 22 import os 23 import re 24 import signal 25 import socket 26 import subprocess 27 import sys 28 import threading 29 import time 30 import urllib.error 31 import urllib.request 32 from pathlib import Path 33 from typing import Optional 34 35 READY_RE = re.compile(r"MEMSTATE_READY addr=(\S+)") 36 _READY_TIMEOUT = 5.0 37 38 _child: Optional[subprocess.Popen] = None 39 _base_url: Optional[str] = None 40 _started_lock = threading.Lock() 41 42 43 def _resolve_bin() -> str: 44 explicit = os.environ.get("MEMSTATE_BIN") 45 if explicit and Path(explicit).exists(): 46 return explicit 47 # scripts/ → client/skill/scripts/ → ../../../server/memstated 48 binary = "memstated.exe" if os.name == "nt" else "memstated" 49 sibling = (Path(__file__).resolve().parent / ".." / ".." / ".." / "server" / binary).resolve() 50 if sibling.exists(): 51 return str(sibling) 52 return "memstated" # fall through to PATH 53 54 55 def _memstate_dir() -> Path: 56 """The directory of the DB. Mirrors memstateDir in the TS proxy.""" 57 db = os.environ.get("MEMSTATE_DB") 58 if db: 59 if db.startswith("~/"): 60 db = str(Path.home() / db[2:]) 61 return Path(db).resolve().parent 62 return Path.home() / ".memstate" 63 64 65 def _spawn_child() -> str: 66 """Spawn memstated, read banner, wire atexit cleanup. Returns addr.""" 67 global _child 68 bin_path = _resolve_bin() 69 # The daemon log is next to the DB, so a daemon on a test DB does not 70 # write to the log of the user's daemon. 71 log_path = _memstate_dir() / "memstated.log" 72 log_path.parent.mkdir(parents=True, exist_ok=True) 73 log_fd = open(log_path, "a") 74 75 # NOT detached — keep the child in our process group so SIGINT on the 76 # terminal propagates, and .terminate() is authoritative. --owner-pid is 77 # the safety net if we get SIGKILLed. 78 child = subprocess.Popen( 79 [bin_path, "--owner-pid", str(os.getpid())], 80 stdin=subprocess.DEVNULL, 81 stdout=log_fd, 82 stderr=subprocess.PIPE, 83 start_new_session=False, 84 ) 85 _child = child 86 87 addr: Optional[str] = None 88 deadline = time.monotonic() + _READY_TIMEOUT 89 assert child.stderr is not None 90 while time.monotonic() < deadline: 91 line = child.stderr.readline() 92 if not line: 93 break 94 try: 95 text = line.decode("utf-8", errors="replace") 96 except Exception: 97 text = "" 98 # Tee to log so we don't lose banner or subsequent lines. 99 log_fd.write(text) 100 log_fd.flush() 101 m = READY_RE.search(text) 102 if m: 103 addr = m.group(1).strip() 104 break 105 106 if addr is None: 107 child.kill() 108 child.wait(timeout=2) 109 raise RuntimeError( 110 f"memstated never printed READY banner within {_READY_TIMEOUT}s; see {log_path}" 111 ) 112 113 # Drain the rest of stderr in background so the child never blocks on a 114 # full pipe buffer. Each line goes to the log. 115 def _drain() -> None: 116 try: 117 assert child.stderr is not None 118 for chunk in child.stderr: 119 log_fd.write(chunk.decode("utf-8", errors="replace")) 120 log_fd.flush() 121 except Exception: 122 pass 123 124 threading.Thread(target=_drain, daemon=True).start() 125 126 atexit.register(_cleanup_child) 127 # SIGINT / SIGTERM → run atexit (Python default) then let the signal 128 # actually terminate us. We intercept to make sure we reap the child. 129 def _on_signal(signum: int, _frame) -> None: 130 _cleanup_child() 131 # Restore default and re-raise so exit code reflects the signal. 132 signal.signal(signum, signal.SIG_DFL) 133 os.kill(os.getpid(), signum) 134 for s in (signal.SIGINT, signal.SIGTERM): 135 try: 136 signal.signal(s, _on_signal) 137 except (ValueError, OSError): 138 # Not main thread / not supported: rely on atexit. 139 pass 140 141 return addr 142 143 144 def _cleanup_child() -> None: 145 global _child 146 c = _child 147 if c is None or c.poll() is not None: 148 return 149 try: 150 c.terminate() 151 try: 152 c.wait(timeout=2) 153 except subprocess.TimeoutExpired: 154 c.kill() 155 c.wait(timeout=1) 156 except Exception: 157 pass 158 _child = None 159 160 161 def _base() -> str: 162 global _base_url 163 if _base_url is not None: 164 return _base_url 165 explicit_url = os.environ.get("MEMSTATE_LOCAL_URL") 166 if explicit_url: 167 _base_url = explicit_url.rstrip("/") 168 return _base_url 169 attach_addr = os.environ.get("MEMSTATE_ADDR") 170 if attach_addr: 171 _base_url = f"http://{attach_addr}/api/v1" 172 return _base_url 173 # Child mode: spawn exactly once (thread-safe via the lock). 174 with _started_lock: 175 if _child is None: 176 addr = _spawn_child() 177 else: 178 addr = "" # unreachable 179 _base_url = f"http://{addr}/api/v1" 180 return _base_url 181 182 183 _HEADERS = {"Content-Type": "application/json"} 184 185 186 def fetch(method: str, path: str, body: Optional[dict] = None): 187 """One daemon call. Returns the parsed JSON body (or the raw text when 188 it is not JSON). Raises urllib errors; use emit() to turn them into an 189 exit code.""" 190 url = f"{_base()}{path}" 191 data = None if body is None else json.dumps(body).encode("utf-8") 192 req = urllib.request.Request(url, data=data, headers=_HEADERS, method=method) 193 with urllib.request.urlopen(req) as resp: 194 payload = resp.read().decode("utf-8") 195 try: 196 return json.loads(payload) 197 except json.JSONDecodeError: 198 return payload 199 200 201 def emit(call) -> int: 202 """Run call(), print its result as JSON, and map daemon errors to exit 203 codes: 1 for an HTTP error, 2 for an unreachable daemon.""" 204 try: 205 result = call() 206 if isinstance(result, str): 207 print(result) 208 else: 209 print(json.dumps(result, indent=2)) 210 return 0 211 except urllib.error.HTTPError as e: 212 detail = e.read().decode("utf-8", errors="replace") 213 print(f"Error: HTTP {e.code} {detail}", file=sys.stderr) 214 return 1 215 except urllib.error.URLError as e: 216 print( 217 f"Error: could not reach memstated at {_base_url}: {e.reason}", 218 file=sys.stderr, 219 ) 220 return 2 221 222 223 def _cwd_base(): 224 """(name, in_repo): the git top-level directory name when the cwd is 225 inside a repository, else the cwd name.""" 226 try: 227 top = subprocess.run( 228 ["git", "rev-parse", "--show-toplevel"], 229 capture_output=True, text=True, timeout=5, 230 ) 231 if top.returncode == 0 and top.stdout.strip(): 232 return Path(top.stdout.strip()).name, True 233 except Exception: 234 pass 235 return Path.cwd().name, False 236 237 238 def default_project() -> str: 239 """Project id derived from the git repo name (or cwd basename outside a 240 repo), slugged to lowercase snake_case — same rule as the TS proxy, so 241 scripts and MCP sessions land in the same project.""" 242 return slug_name(_cwd_base()[0]) 243 244 245 def in_git_repo() -> bool: 246 """True when the cwd is inside a git repository: its name is intent.""" 247 return _cwd_base()[1] 248 249 250 def is_home_dir() -> bool: 251 """True when the cwd is the home directory, which names the user, not a 252 project.""" 253 try: 254 return Path.cwd().resolve() == Path.home().resolve() 255 except Exception: 256 return False 257 258 259 def slug_name(name: str) -> str: 260 """Shared id rule: lowercase, runs of other characters become "_", 261 edge underscores trimmed. Same rule as the TS proxy and the Go daemon.""" 262 slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") 263 return slug or "default" 264 265 266 # USER_PROJECT is the daemon's one reserved project for facts about the 267 # user and the host. The daemon rejects writes there outside a short 268 # allowlist of keypath shapes (see SKILL.md, "User scope"). 269 USER_PROJECT = "_user" 270 271 272 def host_slug() -> str: 273 """This machine's segment under host.<slug> in USER_PROJECT: the first 274 hostname label, slugged. Same rule as the TS proxy and the Go daemon.""" 275 return slug_name(socket.gethostname().split(".")[0]) 276 277 278 def add_scope_args(ap) -> None: 279 """Add the --project / --scope pair every script accepts.""" 280 ap.add_argument("--project", default=None, 281 help="project id (default: the cwd project, derived from the git " 282 "repository or directory name; ids that start with _ are reserved)") 283 ap.add_argument("--scope", choices=("project", "user"), default="project", 284 help="'user' targets the reserved user scope (facts about " 285 "the user or this machine, not about this repo)") 286 287 288 def resolve_project(args) -> str: 289 """Project id for a call: the reserved user project for --scope user, 290 else --project, else this repo's default.""" 291 if args.scope == "user": 292 if args.project: 293 raise SystemExit("Error: pass --scope user or --project, not both") 294 return USER_PROJECT 295 if args.project and is_reserved_id(args.project): 296 raise SystemExit( 297 f'Error: project ids that start with "_" are reserved ("{args.project}"); ' 298 "use --scope user for the user scope") 299 return args.project or default_project() 300 301 302 def is_reserved_id(pid: str) -> bool: 303 """Reserved ids start with "_". The daemon lists _user among the 304 projects, but for scripts it is --scope user, never --project.""" 305 return pid.startswith("_") 306 307 308 def home_slug_name() -> str: 309 """The project id the home directory would derive. It names the user, 310 not a project, so writes never accept it.""" 311 return slug_name(Path.home().name) 312 313 314 def list_projects_visible() -> dict: 315 """The daemon's project list without reserved ids.""" 316 out = fetch("GET", "/projects") 317 if isinstance(out, dict): 318 out = dict(out) 319 out["projects"] = [p for p in (out.get("projects") or []) if not is_reserved_id(p["id"])] 320 return out 321 322 323 def add_write_args(ap) -> None: 324 """Add --new-project, which the two write scripts accept.""" 325 ap.add_argument("--new-project", action="store_true", 326 help="allow this write to create a project that does not exist " 327 "yet (the git repository you are in never needs it)") 328 329 330 # The near-duplicate rule folds the spellings that split one project into 331 # several: underscores, digits, a trailing dev/test/tmp/old/new, one id 332 # containing the other, or a small edit distance. Same rule as the TS proxy. 333 def normalize_id(pid: str) -> str: 334 s = pid.replace("_", "") 335 s = re.sub(r"[0-9]", "", s) 336 return re.sub(r"(dev|test|tests|tmp|old|new)$", "", s) 337 338 339 def levenshtein(a: str, b: str) -> int: 340 prev = list(range(len(b) + 1)) 341 for i in range(1, len(a) + 1): 342 cur = [i] + [0] * len(b) 343 for j in range(1, len(b) + 1): 344 cost = 0 if a[i - 1] == b[j - 1] else 1 345 cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost) 346 prev = cur 347 return prev[len(b)] 348 349 350 def near_duplicates(name: str, ids: list) -> list: 351 """Existing ids that name looks like.""" 352 n = normalize_id(name) 353 out = [] 354 for pid in ids: 355 if pid == name: 356 continue 357 m = normalize_id(pid) 358 if n == m or (len(n) >= 4 and len(m) >= 4 and (n in m or m in n)): 359 out.append(pid) 360 continue 361 limit = 1 if min(len(n), len(m)) < 6 else 2 362 if levenshtein(n, m) <= limit: 363 out.append(pid) 364 return out 365 366 367 def list_project_ids() -> list: 368 """Live project ids from the daemon. Exits 2 when it is unreachable.""" 369 try: 370 out = fetch("GET", "/projects") 371 except urllib.error.URLError as e: 372 print(f"Error: could not reach memstated at {_base_url}: {e.reason}", file=sys.stderr) 373 raise SystemExit(2) 374 projects = out.get("projects") if isinstance(out, dict) else None 375 return [p["id"] for p in (projects or [])] 376 377 378 def check_write_target(args, project: str) -> None: 379 """One rule for every write: it never creates a project unless it 380 targets the git repository the script runs in, or --new-project is set 381 and the id resembles no existing project. The home directory has no 382 default project for writes. Same rule as the TS proxy; the memstate CLI 383 is the human escape for a deliberate near-duplicate. Exits 1 with the 384 way out on stderr; a refusal creates nothing.""" 385 if args.new_project and args.scope == "user": 386 raise SystemExit( 387 "Error: --new-project has no effect with --scope user: the user scope always exists") 388 if args.scope == "user": 389 return 390 explicit = bool(args.project) 391 if not explicit: 392 if os.environ.get("MEMSTATE_REQUIRE_PROJECT") == "1": 393 raise SystemExit( 394 "Error: MEMSTATE_REQUIRE_PROJECT=1: a write needs a project. Pass " 395 f'--project ID (for this directory "{project}", an id from ' 396 "memstate_get.py --list-projects, or a new id with --new-project), " 397 "or --scope user for facts about this machine") 398 if in_git_repo(): 399 if args.new_project: 400 raise SystemExit( 401 "Error: --new-project has no effect here: the project of the git " 402 f'repository you are in ("{project}") is created without it') 403 return 404 if is_home_dir(): 405 raise SystemExit( 406 "Error: the working directory is your home directory, which has no " 407 "default project for writes. Pass --project ID (an id from " 408 "memstate_get.py --list-projects, or a new id with --new-project), " 409 "or --scope user for facts about this machine") 410 if explicit and project == home_slug_name(): 411 raise SystemExit( 412 f'Error: "{project}" is the name of your home directory, which names the user, ' 413 "not a project; pass another --project, or --scope user for facts about this machine") 414 ids = list_project_ids() 415 if project in ids: 416 return 417 if explicit: 418 where = f'project "{project}" does not exist' 419 else: 420 where = ("the working directory is not a git repository and its project " 421 f'"{project}" does not exist') 422 near = near_duplicates(project, ids) 423 if near: 424 listed = ", ".join(f'"{n}"' for n in near) 425 raise SystemExit( 426 f"Error: {where} but looks like existing project {listed}; use " 427 f"--project {near[0]}. To create \"{project}\" as a separate project, " 428 "use the memstate CLI") 429 if not args.new_project: 430 raise SystemExit( 431 f"Error: {where}. Pass --project ID with an id from " 432 "memstate_get.py --list-projects, or add --new-project to create " 433 f'"{project}" (this also revives a soft-deleted project)') 434 435 436 def is_other_host(keypath: str) -> bool: 437 """True when a user-scope keypath describes another machine.""" 438 seg = keypath.split(".") 439 return seg[0] == "host" and len(seg) >= 2 and seg[1] != host_slug() 440 441 442 def prune_other_hosts(domains: list) -> list: 443 """Keep only this machine's subtree under `host`.""" 444 out = [] 445 for d in domains: 446 if d.get("name") == "host": 447 d = dict(d) 448 d["children"] = [c for c in d.get("children", []) if c.get("name") == host_slug()] 449 out.append(d) 450 return out 451 452 453 def count_values(nodes: list) -> int: 454 n = 0 455 for node in nodes: 456 if node.get("has_value"): 457 n += 1 458 n += count_values(node.get("children", [])) 459 return n 460 461 462 def post(path: str, body: dict) -> int: 463 return emit(lambda: fetch("POST", path, body)) 464 465 466 def get(path: str) -> int: 467 return emit(lambda: fetch("GET", path, None))