personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add `sol remote` CLI for remote observer management

Phase 1 of observer decoupling: create, list, revoke, and status
commands for managing remote observer registrations directly on the
journal filesystem — no Convey web server dependency required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+353
+351
observe/remote_cli.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI for remote observer management. 5 + 6 + Provides commands for creating, listing, revoking, and checking status 7 + of remote observer registrations. Operates directly on the journal 8 + filesystem — no dependency on the Convey web server. 9 + 10 + Usage: 11 + sol remote create <name> Create a new remote observer 12 + sol remote list List all registered remotes 13 + sol remote revoke <name-or-prefix> Revoke a remote registration 14 + sol remote status [name-or-prefix] Show remote status details 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import argparse 20 + import base64 21 + import datetime 22 + import logging 23 + import secrets 24 + import sys 25 + 26 + from apps.remote.utils import ( 27 + find_remote_by_name, 28 + get_hist_dir, 29 + get_remotes_dir, 30 + list_remotes, 31 + load_history, 32 + save_remote, 33 + ) 34 + from apps.utils import log_app_action 35 + from think.utils import now_ms, setup_cli 36 + 37 + logger = logging.getLogger(__name__) 38 + 39 + # Key: 256 bits = 32 bytes, URL-safe base64 (same as web API) 40 + KEY_BYTES = 32 41 + 42 + # Connected threshold: last_seen within 2 minutes (matches web UI) 43 + CONNECTED_THRESHOLD_MS = 2 * 60 * 1000 44 + 45 + 46 + def _generate_key() -> str: 47 + """Generate a URL-safe key for remote authentication.""" 48 + return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).decode().rstrip("=") 49 + 50 + 51 + def _find_remote(identifier: str) -> dict | None: 52 + """Find a remote by name or key prefix.""" 53 + # Try name first 54 + remote = find_remote_by_name(identifier) 55 + if remote: 56 + return remote 57 + 58 + # Try key prefix (file is named <prefix>.json) 59 + import json 60 + 61 + remotes_dir = get_remotes_dir() 62 + remote_path = remotes_dir / f"{identifier}.json" 63 + if remote_path.exists(): 64 + try: 65 + with open(remote_path) as f: 66 + return json.load(f) 67 + except (json.JSONDecodeError, OSError): 68 + pass 69 + 70 + return None 71 + 72 + 73 + def _status_label(remote: dict) -> str: 74 + """Get human-readable connection status.""" 75 + if remote.get("revoked", False): 76 + return "revoked" 77 + last_seen = remote.get("last_seen") 78 + if last_seen is None: 79 + return "disconnected" 80 + if now_ms() - last_seen < CONNECTED_THRESHOLD_MS: 81 + return "connected" 82 + return "disconnected" 83 + 84 + 85 + def _fmt_bytes(n: int) -> str: 86 + """Format byte count for display.""" 87 + if n < 1024: 88 + return f"{n} B" 89 + elif n < 1024 * 1024: 90 + return f"{n / 1024:.1f} KB" 91 + elif n < 1024 * 1024 * 1024: 92 + return f"{n / (1024 * 1024):.1f} MB" 93 + return f"{n / (1024 * 1024 * 1024):.1f} GB" 94 + 95 + 96 + def _fmt_time(ms: int | None) -> str: 97 + """Format millisecond timestamp for display.""" 98 + if ms is None: 99 + return "never" 100 + dt = datetime.datetime.fromtimestamp(ms / 1000) 101 + return dt.strftime("%Y-%m-%d %H:%M") 102 + 103 + 104 + # === Subcommands === 105 + 106 + 107 + def cmd_create(args: argparse.Namespace) -> int: 108 + """Create a new remote observer registration.""" 109 + name = args.name 110 + 111 + if find_remote_by_name(name): 112 + print(f"Error: remote '{name}' already exists", file=sys.stderr) 113 + return 1 114 + 115 + key = _generate_key() 116 + remote_data = { 117 + "key": key, 118 + "name": name, 119 + "created_at": now_ms(), 120 + "last_seen": None, 121 + "last_segment": None, 122 + "enabled": True, 123 + "stats": { 124 + "segments_received": 0, 125 + "bytes_received": 0, 126 + }, 127 + } 128 + 129 + if not save_remote(remote_data): 130 + print("Error: failed to save remote", file=sys.stderr) 131 + return 1 132 + 133 + log_app_action( 134 + app="remote", 135 + facet=None, 136 + action="observer_create", 137 + params={"name": name, "key_prefix": key[:8]}, 138 + ) 139 + 140 + print(f"Remote observer created:") 141 + print(f" Name: {name}") 142 + print(f" Prefix: {key[:8]}") 143 + print(f" Key: {key}") 144 + print(f" Ingest URL: /app/remote/ingest/{key}") 145 + return 0 146 + 147 + 148 + def cmd_list(_args: argparse.Namespace) -> int: 149 + """List all registered remotes.""" 150 + remotes = list_remotes() 151 + 152 + if not remotes: 153 + print("No remotes registered.") 154 + return 0 155 + 156 + print( 157 + f"{'Name':<20} {'Prefix':<10} {'Status':<14} " 158 + f"{'Last Seen':<18} {'Segments':>10} {'Bytes':>12}" 159 + ) 160 + print("-" * 86) 161 + 162 + for r in remotes: 163 + name = r.get("name", "") 164 + prefix = r.get("key", "")[:8] 165 + status = _status_label(r) 166 + last_seen = _fmt_time(r.get("last_seen")) 167 + stats = r.get("stats", {}) 168 + segments = stats.get("segments_received", 0) 169 + bytes_recv = _fmt_bytes(stats.get("bytes_received", 0)) 170 + print( 171 + f"{name:<20} {prefix:<10} {status:<14} " 172 + f"{last_seen:<18} {segments:>10} {bytes_recv:>12}" 173 + ) 174 + 175 + return 0 176 + 177 + 178 + def cmd_revoke(args: argparse.Namespace) -> int: 179 + """Revoke a remote registration (soft-delete).""" 180 + identifier = args.identifier 181 + 182 + remote = _find_remote(identifier) 183 + if not remote: 184 + print(f"Error: remote '{identifier}' not found", file=sys.stderr) 185 + return 1 186 + 187 + if remote.get("revoked", False): 188 + print(f"Remote '{remote.get('name')}' is already revoked.", file=sys.stderr) 189 + return 1 190 + 191 + name = remote.get("name", "") 192 + key_prefix = remote.get("key", "")[:8] 193 + 194 + remote["revoked"] = True 195 + remote["revoked_at"] = now_ms() 196 + 197 + if not save_remote(remote): 198 + print("Error: failed to save remote", file=sys.stderr) 199 + return 1 200 + 201 + log_app_action( 202 + app="remote", 203 + facet=None, 204 + action="observer_revoke", 205 + params={"name": name, "key_prefix": key_prefix}, 206 + ) 207 + 208 + print(f"Revoked remote '{name}' ({key_prefix})") 209 + return 0 210 + 211 + 212 + def cmd_status(args: argparse.Namespace) -> int: 213 + """Show remote status details.""" 214 + if args.identifier: 215 + return _status_single(args.identifier) 216 + return _status_all() 217 + 218 + 219 + def _status_single(identifier: str) -> int: 220 + """Detailed status for a single remote.""" 221 + remote = _find_remote(identifier) 222 + if not remote: 223 + print(f"Error: remote '{identifier}' not found", file=sys.stderr) 224 + return 1 225 + 226 + name = remote.get("name", "") 227 + key_prefix = remote.get("key", "")[:8] 228 + stats = remote.get("stats", {}) 229 + 230 + print(f"Remote: {name}") 231 + print(f" Prefix: {key_prefix}") 232 + print(f" Status: {_status_label(remote)}") 233 + print(f" Created: {_fmt_time(remote.get('created_at'))}") 234 + print(f" Last seen: {_fmt_time(remote.get('last_seen'))}") 235 + if remote.get("revoked"): 236 + print(f" Revoked at: {_fmt_time(remote.get('revoked_at'))}") 237 + print(f" Segments: {stats.get('segments_received', 0)}") 238 + print(f" Bytes: {_fmt_bytes(stats.get('bytes_received', 0))}") 239 + if stats.get("duplicates_rejected"): 240 + print(f" Duplicates: {stats['duplicates_rejected']} rejected") 241 + 242 + # Today's sync history 243 + today = datetime.date.today().strftime("%Y%m%d") 244 + history = load_history(key_prefix, today) 245 + if history: 246 + uploads = [r for r in history if not r.get("type")] 247 + print(f"\n Today ({today}): {len(uploads)} segment(s) synced") 248 + for rec in uploads[-5:]: 249 + seg = rec.get("segment", "?") 250 + files = rec.get("files", []) 251 + total = sum(f.get("size", 0) for f in files) 252 + ts = _fmt_time(rec.get("ts")) 253 + print(f" {seg} {len(files)} file(s) {_fmt_bytes(total)} {ts}") 254 + 255 + # Segment count by recent days 256 + hist_dir = get_hist_dir(key_prefix, ensure_exists=False) 257 + if hist_dir.exists(): 258 + day_files = sorted(hist_dir.glob("*.jsonl"), reverse=True)[:7] 259 + if day_files: 260 + print(f"\n Recent days:") 261 + for df in day_files: 262 + day = df.stem 263 + records = load_history(key_prefix, day) 264 + day_uploads = [r for r in records if not r.get("type")] 265 + print(f" {day}: {len(day_uploads)} segment(s)") 266 + 267 + return 0 268 + 269 + 270 + def _status_all() -> int: 271 + """Health overview for all remotes.""" 272 + remotes = list_remotes() 273 + 274 + if not remotes: 275 + print("No remotes registered.") 276 + return 0 277 + 278 + connected = sum(1 for r in remotes if _status_label(r) == "connected") 279 + disconnected = sum(1 for r in remotes if _status_label(r) == "disconnected") 280 + revoked = sum(1 for r in remotes if _status_label(r) == "revoked") 281 + total_segments = sum(r.get("stats", {}).get("segments_received", 0) for r in remotes) 282 + total_bytes = sum(r.get("stats", {}).get("bytes_received", 0) for r in remotes) 283 + 284 + print(f"Remote observers: {len(remotes)} total") 285 + print(f" Connected: {connected}") 286 + print(f" Disconnected: {disconnected}") 287 + print(f" Revoked: {revoked}") 288 + print(f" Total segments: {total_segments}") 289 + print(f" Total bytes: {_fmt_bytes(total_bytes)}") 290 + 291 + print(f"\n{'Name':<20} {'Status':<14} {'Last Seen':<18}") 292 + print("-" * 54) 293 + for r in remotes: 294 + name = r.get("name", "") 295 + status = _status_label(r) 296 + last_seen = _fmt_time(r.get("last_seen")) 297 + print(f"{name:<20} {status:<14} {last_seen:<18}") 298 + 299 + return 0 300 + 301 + 302 + # === Entry point === 303 + 304 + 305 + def main() -> None: 306 + """Entry point for sol remote CLI.""" 307 + parser = argparse.ArgumentParser( 308 + prog="sol remote", 309 + description="Manage remote observer registrations", 310 + ) 311 + 312 + sub = parser.add_subparsers(dest="command") 313 + 314 + # create 315 + p_create = sub.add_parser("create", help="Create a new remote observer") 316 + p_create.add_argument("name", help="Name for the remote observer") 317 + 318 + # list 319 + sub.add_parser("list", help="List all registered remotes") 320 + 321 + # revoke 322 + p_revoke = sub.add_parser("revoke", help="Revoke a remote registration") 323 + p_revoke.add_argument("identifier", help="Remote name or key prefix") 324 + 325 + # status 326 + p_status = sub.add_parser("status", help="Show remote status details") 327 + p_status.add_argument( 328 + "identifier", nargs="?", default=None, help="Remote name or key prefix (omit for overview)" 329 + ) 330 + 331 + args = setup_cli(parser) 332 + 333 + # Bridge journal path to convey.state so apps.utils resolves correctly 334 + # (setup_cli initializes the journal, but convey.state needs it too) 335 + import convey.state 336 + from think.utils import get_journal 337 + 338 + convey.state.journal_root = get_journal() 339 + 340 + if not args.command: 341 + parser.print_help() 342 + sys.exit(1) 343 + 344 + handlers = { 345 + "create": cmd_create, 346 + "list": cmd_list, 347 + "revoke": cmd_revoke, 348 + "status": cmd_status, 349 + } 350 + 351 + sys.exit(handlers[args.command](args))
+2
sol.py
··· 60 60 "sync": "observe.sync", 61 61 "transfer": "observe.transfer", 62 62 "observer": "observe.observer", 63 + "remote": "observe.remote_cli", 63 64 "observe-linux": "observe.linux.observer", 64 65 "observe-macos": "observe.macos.observer", 65 66 # AI agents (formerly muse package) ··· 113 114 "sync", 114 115 "transfer", 115 116 "observer", 117 + "remote", 116 118 ], 117 119 "Muse (AI agents)": [ 118 120 "agents",