Recently Written · git

ai-config

One canonical AI agent config (rules, skills, MCP servers) rendered into Claude Code, Codex, Copilot CLI and Antigravity by acsync

git clone https://github.com/equwal/ai-config

Log | Files | Refs


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))