chore: archive mac mini automation baseline

This commit is contained in:
Adolfo Reyna
2026-08-03 11:47:09 -04:00
parent a0a26565f0
commit 6e2117188e
93 changed files with 10005 additions and 5 deletions
+46
View File
@@ -19,6 +19,7 @@ from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs
from reyna_cli.immich import ImmichClient
from reyna_cli.mcp import MCPClient
from reyna_cli.mongo_direct import MongoDirectClient
from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once
from reyna_cli.tts import TTSError, synthesize_wav
from reyna_cli.utils import infer_capabilities, resolve_tool_name
from reyna_cli.zoom_direct import ZoomClient
@@ -39,6 +40,7 @@ zoom_app = typer.Typer(help="Zoom direct REST API commands.")
email_app = typer.Typer(help="Read-only local Thunderbird email commands.")
deco_app = typer.Typer(help="TP-Link Deco direct router commands.")
macmini_app = typer.Typer(help="Mac mini MCP tools (Calendar, Contacts, Notes, Reminders, Deco).")
remarkable_app = typer.Typer(help="Paper Pro discovery, local cache, and macOS listener service.")
macmini_calendar_app = typer.Typer(help="Mac mini Calendar tools.")
macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.")
macmini_notes_app = typer.Typer(help="Mac mini Notes tools.")
@@ -69,6 +71,7 @@ macmini_app.add_typer(macmini_notes_app, name="notes")
macmini_app.add_typer(macmini_reminders_app, name="reminders")
macmini_app.add_typer(macmini_deco_app, name="deco")
app.add_typer(macmini_app, name="macmini")
app.add_typer(remarkable_app, name="remarkable")
def scrub_sensitive(value: Any) -> Any:
@@ -692,6 +695,49 @@ def computer_service_logs(lines: int = typer.Option(80, "--lines")):
subprocess.run(["journalctl", "--user", "-u", SERVICE_NAME, "-n", str(lines), "--no-pager"], check=False)
@remarkable_app.command("listen")
def remarkable_listen():
"""Run the UDP receiver in the foreground (used by the LaunchAgent)."""
listen_forever()
@remarkable_app.command("sync")
def remarkable_sync(json_output: bool = typer.Option(False, "--json")):
"""Snapshot tracked notes and cache only fresh Paper Pro page versions."""
result = sync_once()
emit({"ok": bool(result.get("online")), "result": result}, json_output)
@remarkable_app.command("service-install")
def remarkable_service_install(json_output: bool = typer.Option(False, "--json")):
"""Install and start the macOS UDP-listener LaunchAgent."""
result = listener_service_action("install")
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@remarkable_app.command("service-status")
def remarkable_service_status(json_output: bool = typer.Option(False, "--json")):
emit({"ok": True, "service": LISTENER_LABEL, "status": listener_service_status()}, json_output)
@remarkable_app.command("service-start")
def remarkable_service_start(json_output: bool = typer.Option(False, "--json")):
result = listener_service_action("start")
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@remarkable_app.command("service-stop")
def remarkable_service_stop(json_output: bool = typer.Option(False, "--json")):
result = listener_service_action("stop")
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@iphone_app.command("tools")
def iphone_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
wrapper_tools("iphone_mcp", json_output, live_only, cache_only, refresh)
+515
View File
@@ -0,0 +1,515 @@
#!/usr/bin/env python3
import datetime as dt
import io
import json
import os
import plistlib
import shlex
import socket
import subprocess
import sys
import tarfile
import time
import uuid as uuidlib
from pathlib import Path
DEFAULT_TABLET = os.environ.get("REMARKABLE_HOST", "root@192.168.68.128")
BASE = "/home/root/.local/share/remarkable/xochitl"
LISTENER_LABEL = "com.reynafamily.reyna-cli.remarkable-listener"
SYNC_LABEL = "com.reynafamily.reyna-cli.remarkable-sync"
LISTENER_PORT = 49321
def data_root() -> Path:
"""Return the Mac-local Paper Pro cache, with a testable override."""
return Path(os.environ.get("REYNA_REMARKABLE_ROOT", Path.home() / "Library" / "Application Support" / "reyna-cli" / "remarkable"))
def parse_udp_message(message: str) -> dict[str, str]:
"""Parse the sidecar's key=value prefix while preserving its freeform details."""
fields: dict[str, str] = {}
prefix, marker, details = message.partition(" details=")
for part in prefix.split():
if "=" in part:
key, value = part.split("=", 1)
fields[key] = value
if marker:
fields["details"] = details
return fields
LOGDIR = data_root()
LOGDIR.mkdir(parents=True, exist_ok=True)
KNOWN_HOSTS = LOGDIR / "known_hosts"
STATE_PATH = LOGDIR / "state.json"
EVENTS_PATH = LOGDIR / "events.jsonl"
LATEST_PATH = LOGDIR / "latest.json"
CACHE_DIR = LOGDIR / "cache"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
LATEST_CHANGED_PAGES = LOGDIR / "latest_changed_pages.json"
CACHE_STATE_PATH = LOGDIR / "cache_state.json"
TRACKED_CONFIG_PATH = LOGDIR / "tracked_documents.json"
RENDERER = LOGDIR / "tools" / "render_rm_fast.py"
RENDER_PYTHON = Path(sys.executable)
WATCH_TERMS = [t.strip().lower() for t in os.environ.get("REMARKABLE_WATCH_TERMS", "diary,dairy,journal").split(",") if t.strip()]
def resolve_tablet():
# If REMARKABLE_HOST is explicitly set, honor it. Otherwise use latest UDP discovery.
if os.environ.get("REMARKABLE_HOST"):
return DEFAULT_TABLET
latest = LOGDIR / "latest_device.json"
try:
data = json.loads(latest.read_text())
ip = (data.get("fields") or {}).get("ip") or data.get("source_ip")
if ip:
return f"root@{ip}"
except Exception:
pass
return DEFAULT_TABLET
def ssh_args():
return [
"ssh",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=no",
"-o", f"UserKnownHostsFile={KNOWN_HOSTS}",
resolve_tablet(),
]
def now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def run_ssh(remote, timeout=20):
return subprocess.run(ssh_args() + [remote], text=True, capture_output=True, timeout=timeout)
def load_state():
try:
return json.loads(STATE_PATH.read_text())
except Exception:
return {}
def load_cache_state():
try:
data = json.loads(CACHE_STATE_PATH.read_text())
if isinstance(data, dict):
return data
except Exception:
pass
return {"seen_page_versions": []}
def save_cache_state(state):
# Keep the state small; it only prevents stale page batches from being
# re-announced after the tablet sleeps or reconnects.
seen = list(dict.fromkeys(state.get("seen_page_versions", [])))[-1000:]
tmp = CACHE_STATE_PATH.with_name(f"{CACHE_STATE_PATH.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
tmp.write_text(json.dumps({"seen_page_versions": seen}, indent=2, sort_keys=True) + "\n")
tmp.replace(CACHE_STATE_PATH)
def save_state(state):
tmp = STATE_PATH.with_name(f"{STATE_PATH.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
tmp.replace(STATE_PATH)
def append_event(event):
with EVENTS_PATH.open("a") as f:
f.write(json.dumps(event, sort_keys=True, ensure_ascii=False) + "\n")
LATEST_PATH.write_text(json.dumps(event, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
def load_tracked_uuids():
try:
data = json.loads(TRACKED_CONFIG_PATH.read_text())
except Exception:
return set()
return {d.get("uuid") for d in data.get("tracked_documents", []) if d.get("uuid")}
def is_watched_doc(doc, tracked_uuids=None):
tracked_uuids = tracked_uuids or set()
name = (doc.get("visibleName") or "").lower()
return doc.get("uuid") in tracked_uuids or any(term in name for term in WATCH_TERMS)
def ssh_read_file(remote_relpath, timeout=20):
remote_path = f"{BASE}/{remote_relpath.lstrip('/')}"
proc = subprocess.run(
ssh_args() + ["cat " + shlex.quote(remote_path)],
capture_output=True,
timeout=timeout,
)
if proc.returncode != 0:
return None
return proc.stdout
def cache_remote_file(remote_relpath, local_relpath=None, timeout=25):
data = ssh_read_file(remote_relpath, timeout=timeout)
if data is None:
return None
local = CACHE_DIR / (local_relpath or remote_relpath)
local.parent.mkdir(parents=True, exist_ok=True)
tmp = local.with_name(f"{local.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
tmp.write_bytes(data)
tmp.replace(local)
return local
def recent_udp_document_changes(limit=500):
changes = []
udp_dir = LOGDIR / "udp"
files = sorted(udp_dir.glob("*.jsonl"), reverse=True)[:3]
for path in files:
try:
lines = path.read_text(errors="replace").splitlines()[-limit:]
except Exception:
continue
for line in reversed(lines):
try:
ev = json.loads(line)
except Exception:
continue
fields = ev.get("fields") or {}
if fields.get("event") == "document_change" and fields.get("file"):
changes.append(ev)
return changes
def render_cached_rm(rm_path):
if not rm_path or rm_path.suffix != ".rm" or not rm_path.exists():
return None
if not RENDERER.exists():
return None
py = RENDER_PYTHON if RENDER_PYTHON.exists() else Path("python3")
out = rm_path.with_suffix(".png")
try:
proc = subprocess.run([str(py), str(RENDERER), str(rm_path), str(out)], text=True, capture_output=True, timeout=45)
except Exception:
return None
return out if proc.returncode == 0 and out.exists() else None
def extract_page_ids(content):
"""Return only likely-current page IDs, not every page in the document.
Reprocessing every page whenever a document-level mtime changes caused the
reviewer to loop over old handwriting. Exact changed pages come from the UDP
sidecar below; this fallback should be narrow.
"""
ids = []
current = (
content.get("lastOpenedPage")
or content.get("currentPage")
or (content.get("cPages") or {}).get("lastOpened")
)
if isinstance(current, dict):
current = current.get("value")
if isinstance(current, str) and current:
ids.append(current)
seen = set()
return [p for p in ids if not (p in seen or seen.add(p))]
def cache_changed_pages(snapshot, prev):
"""Cache local copies of changed tracked pages so Hermes can read them later.
This intentionally limits itself to tracked/watched documents and recently
changed/current pages. It does not mirror the whole tablet.
"""
if not snapshot.get("online"):
return []
tracked_uuids = load_tracked_uuids()
prev_by_uuid = {d.get("uuid"): d for d in prev.get("watched", [])}
watched = [d for d in snapshot.get("watched", []) if is_watched_doc(d, tracked_uuids)]
changed_docs = []
for doc in watched:
old = prev_by_uuid.get(doc.get("uuid"))
if not old or old.get("lastModified") != doc.get("lastModified"):
changed_docs.append(doc)
if not changed_docs:
return []
udp_changes = recent_udp_document_changes()
cache_state = load_cache_state()
seen_versions = set(cache_state.get("seen_page_versions", []))
cached = []
for doc in changed_docs:
uuid = doc.get("uuid")
if not uuid:
continue
# Cache document metadata needed to map page IDs later.
for ext in ("metadata", "content", "pagedata"):
cache_remote_file(f"{uuid}.{ext}", f"{uuid}/{uuid}.{ext}", timeout=20)
content = {}
content_path = CACHE_DIR / uuid / f"{uuid}.content"
try:
content = json.loads(content_path.read_text())
except Exception:
content = {}
page_ids = set(extract_page_ids(content))
# UDP sidecar reports exact changed files; prefer those for changed pages.
for ev in udp_changes:
fields = ev.get("fields") or {}
rel = fields.get("file") or ""
if not rel.startswith(uuid + "/") and rel not in {f"{uuid}.content", f"{uuid}.metadata", f"{uuid}.pagedata"}:
continue
local = cache_remote_file(rel, rel, timeout=30)
if local and local.suffix == ".rm":
page_ids.add(local.stem)
# Also cache the last/current page from content because it is what the user most often just wrote on.
for page_id in sorted(page_ids):
version_key = f"{uuid}:{page_id}:{doc.get('lastModified') or 0}"
if version_key in seen_versions:
continue
rel = f"{uuid}/{page_id}.rm"
local = cache_remote_file(rel, rel, timeout=30)
if not local:
continue
png = render_cached_rm(local)
seen_versions.add(version_key)
cached.append({
"cached_at": now_iso(),
"document_uuid": uuid,
"document_name": doc.get("visibleName"),
"page_uuid": page_id,
"rm_path": str(local),
"png_path": str(png) if png else None,
"lastModified": doc.get("lastModified"),
"lastModifiedIso": doc.get("lastModifiedIso"),
"version_key": version_key,
})
if seen_versions != set(cache_state.get("seen_page_versions", [])):
save_cache_state({"seen_page_versions": sorted(seen_versions)})
if cached:
tmp = LATEST_CHANGED_PAGES.with_name(f"{LATEST_CHANGED_PAGES.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
tmp.write_text(json.dumps({"ts": now_iso(), "pages": cached}, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
tmp.replace(LATEST_CHANGED_PAGES)
else:
# No fresh page versions. Remove the manifest so the cron reviewer does
# not keep reprocessing an old batch.
try:
LATEST_CHANGED_PAGES.unlink()
except FileNotFoundError:
pass
return cached
def parse_json_member(tf, name):
try:
m = tf.extractfile(name)
if not m:
return None
return json.loads(m.read().decode("utf-8", "replace"))
except Exception:
return None
def collect_snapshot():
ping = run_ssh("echo online; date -u +%Y-%m-%dT%H:%M:%SZ; systemctl is-active xochitl 2>/dev/null || true", timeout=12)
if ping.returncode != 0:
return {
"ts": now_iso(),
"event": "tablet_unreachable",
"online": False,
"error": (ping.stderr or ping.stdout).strip()[-500:],
}
lines = [l.strip() for l in ping.stdout.splitlines() if l.strip()]
tablet_time = lines[1] if len(lines) > 1 else None
xochitl = lines[2] if len(lines) > 2 else "unknown"
tar_cmd = f"cd {BASE} && tar -cf - -- *.metadata *.content *.pagedata 2>/dev/null"
tar_proc = subprocess.run(ssh_args() + [tar_cmd], capture_output=True, timeout=45)
docs = []
if tar_proc.returncode == 0 and tar_proc.stdout:
tf = tarfile.open(fileobj=io.BytesIO(tar_proc.stdout), mode="r:")
infos = {i.name: i for i in tf.getmembers() if i.isfile()}
uuids = sorted({n[:-9] for n in infos if n.endswith(".metadata")})
for uuid in uuids:
meta_name = uuid + ".metadata"
content_name = uuid + ".content"
page_name = uuid + ".pagedata"
meta = parse_json_member(tf, meta_name) or {}
content = parse_json_member(tf, content_name) or {}
mt = max(
infos.get(meta_name).mtime if infos.get(meta_name) else 0,
infos.get(content_name).mtime if infos.get(content_name) else 0,
infos.get(page_name).mtime if infos.get(page_name) else 0,
)
visible = meta.get("visibleName") or meta.get("name") or ""
docs.append({
"uuid": uuid,
"visibleName": visible,
"type": meta.get("type"),
"parent": meta.get("parent"),
"lastModified": mt,
"lastModifiedIso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(mt)) if mt else None,
"currentPage": content.get("lastOpenedPage") or content.get("cPages", {}).get("lastOpened") if isinstance(content.get("cPages"), dict) else content.get("lastOpenedPage"),
"pageCount": content.get("pageCount") or len(content.get("pages", [])) if isinstance(content.get("pages"), list) else content.get("pageCount"),
})
docs.sort(key=lambda d: d.get("lastModified") or 0, reverse=True)
tracked_uuids = load_tracked_uuids()
watched = [d for d in docs if is_watched_doc(d, tracked_uuids)]
return {
"ts": now_iso(),
"event": "snapshot",
"online": True,
"tablet_time": tablet_time,
"xochitl": xochitl,
"document_count": len(docs),
"most_recent": docs[:5],
"watched_terms": WATCH_TERMS,
"watched": watched[:10],
}
def summarize_changes(snapshot, prev):
events = [snapshot]
if not snapshot.get("online"):
if prev.get("online") is not False:
events.append({"ts": snapshot["ts"], "event": "tablet_offline_transition", "online": False})
return events
if prev.get("online") is False:
events.append({"ts": snapshot["ts"], "event": "tablet_online_transition", "online": True})
if prev.get("xochitl") and prev.get("xochitl") != snapshot.get("xochitl"):
events.append({"ts": snapshot["ts"], "event": "xochitl_state_changed", "from": prev.get("xochitl"), "to": snapshot.get("xochitl")})
cur = snapshot.get("most_recent", [{}])[0] if snapshot.get("most_recent") else {}
prev_cur = prev.get("current_guess") or {}
if cur and cur.get("uuid") != prev_cur.get("uuid"):
events.append({"ts": snapshot["ts"], "event": "current_document_guess_changed", "document": cur})
watched_prev = {d.get("uuid"): d for d in prev.get("watched", [])}
for d in snapshot.get("watched", []):
old = watched_prev.get(d.get("uuid"))
if old and old.get("lastModified") != d.get("lastModified"):
events.append({"ts": snapshot["ts"], "event": "watched_document_updated", "document": d})
elif not old:
events.append({"ts": snapshot["ts"], "event": "watched_document_seen", "document": d})
return events
def sync_once() -> dict:
"""Collect one tracked-note snapshot and cache only fresh changed pages."""
prev = load_state()
snap = collect_snapshot()
cached_pages = cache_changed_pages(snap, prev)
if cached_pages:
snap["cached_changed_pages"] = cached_pages
for ev in summarize_changes(snap, prev):
append_event(ev)
state = {
"online": snap.get("online"),
"xochitl": snap.get("xochitl"),
"current_guess": (snap.get("most_recent") or [{}])[0] if snap.get("online") else prev.get("current_guess", {}),
"watched": snap.get("watched", []) if snap.get("online") else prev.get("watched", []),
"last_snapshot_ts": snap.get("ts"),
}
save_state(state)
return snap
def listen_forever(port: int = LISTENER_PORT) -> None:
"""Persist Paper Pro UDP heartbeats and document_change events locally."""
udp_dir = LOGDIR / "udp"
udp_dir.mkdir(parents=True, exist_ok=True)
latest = LOGDIR / "latest_device.json"
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", port))
print(f"listening on UDP 0.0.0.0:{port}", flush=True)
last_seen: dict[tuple[str, str], float] = {}
while True:
data, addr = sock.recvfrom(8192)
now = dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
text = data.decode("utf-8", "replace").strip()
key = (addr[0], text)
now_epoch = time.time()
if now_epoch - last_seen.get(key, 0) < 2:
continue
last_seen[key] = now_epoch
if len(last_seen) > 200:
last_seen = {item: seen_at for item, seen_at in last_seen.items() if seen_at >= now_epoch - 30}
event = {
"received_ts": now,
"source_ip": addr[0],
"source_port": addr[1],
"message": text,
"fields": parse_udp_message(text),
}
day = dt.datetime.now(dt.UTC).strftime("%Y-%m-%d")
with (udp_dir / f"{day}.jsonl").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True, ensure_ascii=False) + "\n")
latest.write_text(json.dumps(event, indent=2, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8")
print(json.dumps(event, sort_keys=True, ensure_ascii=False), flush=True)
def launch_agent_path() -> Path:
return Path.home() / "Library" / "LaunchAgents" / f"{LISTENER_LABEL}.plist"
def build_launch_agent(python_bin: Path, cli_module: str, root: Path) -> dict:
return {
"Label": LISTENER_LABEL,
"ProgramArguments": [str(python_bin), "-m", cli_module, "remarkable", "listen"],
"EnvironmentVariables": {"REYNA_REMARKABLE_ROOT": str(root)},
"RunAtLoad": True,
"KeepAlive": True,
"StandardOutPath": str(root / "listener.log"),
"StandardErrorPath": str(root / "listener.error.log"),
}
def build_sync_agent(python_bin: Path, cli_module: str, root: Path) -> dict:
return {
"Label": SYNC_LABEL,
"ProgramArguments": [str(python_bin), "-m", cli_module, "remarkable", "sync"],
"EnvironmentVariables": {"REYNA_REMARKABLE_ROOT": str(root)},
"RunAtLoad": True,
"StartInterval": 30,
"StandardOutPath": str(root / "sync.log"),
"StandardErrorPath": str(root / "sync.error.log"),
}
def sync_agent_path() -> Path:
return Path.home() / "Library" / "LaunchAgents" / f"{SYNC_LABEL}.plist"
def install_listener_service(python_bin: Path | None = None) -> Path:
root = data_root()
root.mkdir(parents=True, exist_ok=True)
path = launch_agent_path()
path.parent.mkdir(parents=True, exist_ok=True)
interpreter = python_bin or Path(sys.executable)
with path.open("wb") as handle:
plistlib.dump(build_launch_agent(interpreter, "reyna_cli.cli", root), handle)
with sync_agent_path().open("wb") as handle:
plistlib.dump(build_sync_agent(interpreter, "reyna_cli.cli", root), handle)
return path
def listener_service_status() -> dict:
uid = str(os.getuid())
services = {}
for label, path in ((LISTENER_LABEL, launch_agent_path()), (SYNC_LABEL, sync_agent_path())):
proc = subprocess.run(["launchctl", "print", f"gui/{uid}/{label}"], capture_output=True, text=True, check=False)
services[label] = {"installed": path.exists(), "path": str(path), "active": proc.returncode == 0, "detail": (proc.stdout or proc.stderr)[-1000:]}
return {"listener": services[LISTENER_LABEL], "sync": services[SYNC_LABEL]}
def listener_service_action(action: str) -> dict:
uid = str(os.getuid())
if action == "install":
install_listener_service()
action = "start"
commands: list[list[str]] = []
if action == "start":
for label, path in ((LISTENER_LABEL, launch_agent_path()), (SYNC_LABEL, sync_agent_path())):
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True, text=True, check=False)
commands.append(["launchctl", "bootstrap", f"gui/{uid}", str(path)])
elif action == "stop":
commands = [["launchctl", "bootout", f"gui/{uid}/{label}"] for label in (LISTENER_LABEL, SYNC_LABEL)]
else:
raise ValueError(f"unsupported service action: {action}")
results = [subprocess.run(command, capture_output=True, text=True, check=False) for command in commands]
return {
"ok": all(proc.returncode == 0 for proc in results),
"action": action,
"results": [{"command": " ".join(command), "stdout": proc.stdout.strip(), "stderr": proc.stderr.strip()} for command, proc in zip(commands, results)],
"status": listener_service_status(),
}
if __name__ == "__main__":
print(json.dumps(sync_once(), indent=2, ensure_ascii=False, sort_keys=True))