diff --git a/cortex/cli.py b/cortex/cli.py index 5d6e3f5..1cad4f5 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -2,6 +2,8 @@ """Cortex CLI — unified entry point for all intelligence modules. Usage: + cortex init [--enable-all] [--non-interactive] + cortex schedule list|status|enable|disable|logs cortex triage score "task description" cortex health [--json] cortex feedback --since 6h [--dry-run] @@ -25,7 +27,15 @@ def main(): # Strip the command from argv so sub-modules see clean args sys.argv = [f"cortex {cmd}"] + sys.argv[2:] - if cmd == "version": + if cmd == "init": + from cortex.init import main as init_main + init_main() + + elif cmd == "schedule": + from cortex.scheduler import main as schedule_main + schedule_main() + + elif cmd == "version": from cortex import __version__ print(f"cortex {__version__}") diff --git a/cortex/init.py b/cortex/init.py new file mode 100644 index 0000000..82f1f73 --- /dev/null +++ b/cortex/init.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Cortex Init — first-time setup. + +Usage: + cortex init [--home ~/.cortex] [--enable-all] [--non-interactive] +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +from cortex.config import cortex_home, memory_dir, config_path, logs_dir +from cortex.scheduler import JOBS, enable_job + + +def _prompt(msg: str, default: str = "y") -> bool: + """Simple y/n prompt.""" + suffix = " [Y/n] " if default == "y" else " [y/N] " + try: + answer = input(msg + suffix).strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return default == "y" + if not answer: + return default == "y" + return answer in ("y", "yes", "ja", "j") + + +def init(home: Path | None = None, enable_all: bool = False, + non_interactive: bool = False) -> bool: + """Initialize cortex workspace.""" + base = home or cortex_home() + mem = memory_dir() + logs = logs_dir() + cfg = config_path() + + print(f"🧠 Cortex Init") + print(f" Home: {base}") + print(f" Memory: {mem}") + print(f" Config: {cfg}") + print() + + # Create directories + for d in [base, mem, mem / "archive", logs]: + d.mkdir(parents=True, exist_ok=True) + print(f" 📁 {d}") + + # Create default config if not exists + if not cfg.exists(): + default_config = { + "version": "0.1.0", + "permanent_files": ["README.md"], + "sessions_dir": "~/.openclaw/agents/main/sessions", + } + cfg.write_text(json.dumps(default_config, indent=2) + "\n") + print(f" 📄 {cfg} (created)") + else: + print(f" 📄 {cfg} (exists)") + + # Create default memory README + readme = mem / "README.md" + if not readme.exists(): + readme.write_text("# Memory\n\nCortex memory directory. Files here are managed by `cortex hygiene`.\n") + print(f" 📄 {readme} (created)") + + print() + + # Smoke test + print(" 🔍 Smoke test...") + try: + from cortex.triage import score_task + r = score_task("test task") + print(f" ✅ Triage: priority={r.priority:.2f}") + except Exception as e: + print(f" ❌ Triage failed: {e}") + + try: + from cortex.memory_hygiene import gather_stats + s = gather_stats() + print(f" ✅ Hygiene: {s['total_files']} files, {s['total_size_human']}") + except Exception as e: + print(f" ❌ Hygiene failed: {e}") + + print() + + # Schedule jobs + if enable_all: + jobs_to_enable = list(JOBS.keys()) + elif non_interactive: + jobs_to_enable = [] + else: + print(" 📅 Schedule periodic jobs?") + jobs_to_enable = [] + for name, jdef in JOBS.items(): + interval = jdef["default_interval_min"] + if _prompt(f" Enable {name} (every {interval}min)?"): + jobs_to_enable.append(name) + + for job in jobs_to_enable: + ok = enable_job(job) + if ok: + print(f" ✅ {job} scheduled") + else: + print(f" ❌ {job} failed to schedule") + + print() + print(" 🧠 Cortex ready!") + if jobs_to_enable: + print(f" Run 'cortex schedule status' to verify jobs.") + print(f" Run 'cortex --help' for all commands.") + return True + + +def main(): + parser = argparse.ArgumentParser(description="Initialize Cortex") + parser.add_argument("--home", type=Path, help="Cortex home directory") + parser.add_argument("--enable-all", action="store_true", + help="Enable all scheduled jobs") + parser.add_argument("--non-interactive", action="store_true", + help="Skip interactive prompts") + args = parser.parse_args() + init(home=args.home, enable_all=args.enable_all, + non_interactive=args.non_interactive) + + +if __name__ == "__main__": + main() diff --git a/cortex/scheduler.py b/cortex/scheduler.py new file mode 100644 index 0000000..b678dea --- /dev/null +++ b/cortex/scheduler.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Cortex Scheduler — manage periodic jobs via systemd (Linux) or launchd (macOS). + +Usage: + cortex schedule list + cortex schedule enable [--interval ] + cortex schedule disable + cortex schedule status + cortex schedule logs [--lines 50] +""" + +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Optional + +from cortex.config import cortex_home + + +# --- Job definitions --- + +JOBS = { + "feedback": { + "description": "Extract lessons from session transcripts", + "command": "cortex feedback --since {interval}", + "default_interval_min": 360, # 6 hours + "calendar": "*-*-* 0/6:00:00", # systemd OnCalendar + }, + "hygiene": { + "description": "Clean stale/duplicate/orphan memory files", + "command": "cortex hygiene duplicates && cortex hygiene stale && cortex hygiene archive", + "default_interval_min": 1440, # daily + "calendar": "*-*-* 04:00:00", + }, + "health": { + "description": "Proactive system health scan", + "command": "cortex health --json", + "default_interval_min": 30, + "calendar": "*-*-* *:0/30:00", + }, +} + + +def _is_linux() -> bool: + return platform.system() == "Linux" + + +def _is_macos() -> bool: + return platform.system() == "Darwin" + + +def _cortex_bin() -> str: + """Find the cortex binary path.""" + which = shutil.which("cortex") + if which: + return which + # Fallback: python -m cortex.cli + return f"{sys.executable} -m cortex.cli" + + +def _env_vars() -> dict[str, str]: + """Collect CORTEX_* env vars to pass to scheduled jobs.""" + env = {} + for key in ("CORTEX_HOME", "CORTEX_MEMORY_DIR", "CORTEX_CONFIG", + "CORTEX_GROWTH_LOG", "CORTEX_ROADMAP"): + val = os.environ.get(key) + if val: + env[key] = val + return env + + +# --- systemd (Linux) --- + +def _systemd_dir() -> Path: + return Path.home() / ".config" / "systemd" / "user" + + +def _systemd_unit_name(job: str) -> str: + return f"cortex-{job}" + + +def _write_systemd_units(job: str, interval_min: int) -> tuple[Path, Path]: + """Write .service and .timer files for a job.""" + job_def = JOBS[job] + unit = _systemd_unit_name(job) + sdir = _systemd_dir() + sdir.mkdir(parents=True, exist_ok=True) + + cortex_bin = _cortex_bin() + env_vars = _env_vars() + env_lines = "\n".join(f"Environment={k}={v}" for k, v in env_vars.items()) + + # For feedback, convert interval to --since format + command = job_def["command"] + if "{interval}" in command: + hours = max(1, interval_min // 60) + command = command.replace("{interval}", f"{hours}h") + + service_path = sdir / f"{unit}.service" + service_path.write_text(textwrap.dedent(f"""\ + [Unit] + Description=Cortex {job.title()} — {job_def['description']} + + [Service] + Type=oneshot + ExecStart=/bin/bash -c '{command}' + {env_lines} + Environment=PATH={os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin')} + + [Install] + WantedBy=default.target + """)) + + timer_path = sdir / f"{unit}.timer" + timer_path.write_text(textwrap.dedent(f"""\ + [Unit] + Description=Cortex {job.title()} Timer — every {interval_min}min + + [Timer] + OnBootSec=5min + OnUnitActiveSec={interval_min}min + Persistent=true + + [Install] + WantedBy=timers.target + """)) + + return service_path, timer_path + + +def _enable_systemd(job: str, interval_min: int) -> bool: + service_path, timer_path = _write_systemd_units(job, interval_min) + unit = _systemd_unit_name(job) + try: + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True, capture_output=True) + subprocess.run(["systemctl", "--user", "enable", "--now", f"{unit}.timer"], + check=True, capture_output=True) + return True + except subprocess.CalledProcessError as e: + print(f"Error enabling systemd timer: {e.stderr.decode()}", file=sys.stderr) + return False + + +def _disable_systemd(job: str) -> bool: + unit = _systemd_unit_name(job) + try: + subprocess.run(["systemctl", "--user", "disable", "--now", f"{unit}.timer"], + check=True, capture_output=True) + # Clean up unit files + sdir = _systemd_dir() + for ext in (".service", ".timer"): + f = sdir / f"{unit}{ext}" + if f.exists(): + f.unlink() + subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True) + return True + except subprocess.CalledProcessError as e: + print(f"Error disabling systemd timer: {e.stderr.decode()}", file=sys.stderr) + return False + + +def _status_systemd() -> list[dict]: + results = [] + for job in JOBS: + unit = _systemd_unit_name(job) + try: + r = subprocess.run( + ["systemctl", "--user", "is-active", f"{unit}.timer"], + capture_output=True, text=True + ) + active = r.stdout.strip() == "active" + except FileNotFoundError: + active = False + + # Get next trigger time + next_run = "" + if active: + try: + r = subprocess.run( + ["systemctl", "--user", "show", f"{unit}.timer", + "--property=NextElapseUSecRealtime"], + capture_output=True, text=True + ) + next_run = r.stdout.strip().split("=", 1)[-1] if "=" in r.stdout else "" + except Exception: + pass + + results.append({ + "job": job, + "description": JOBS[job]["description"], + "active": active, + "next_run": next_run, + }) + return results + + +def _logs_systemd(job: str, lines: int = 50) -> str: + unit = _systemd_unit_name(job) + try: + r = subprocess.run( + ["journalctl", "--user", "-u", f"{unit}.service", + f"--lines={lines}", "--no-pager"], + capture_output=True, text=True + ) + return r.stdout + except FileNotFoundError: + return "journalctl not available" + + +# --- launchd (macOS) --- + +def _launchd_dir() -> Path: + return Path.home() / "Library" / "LaunchAgents" + + +def _launchd_label(job: str) -> str: + return f"dev.cortex.{job}" + + +def _write_launchd_plist(job: str, interval_min: int) -> Path: + """Write a launchd plist for a job.""" + job_def = JOBS[job] + label = _launchd_label(job) + pdir = _launchd_dir() + pdir.mkdir(parents=True, exist_ok=True) + + cortex_bin = _cortex_bin() + env_vars = _env_vars() + + command = job_def["command"] + if "{interval}" in command: + hours = max(1, interval_min // 60) + command = command.replace("{interval}", f"{hours}h") + + log_dir = cortex_home() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + env_xml = "" + all_env = {**env_vars, "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")} + if all_env: + env_xml = " EnvironmentVariables\n \n" + for k, v in all_env.items(): + env_xml += f" {k}\n {v}\n" + env_xml += " " + + plist_path = pdir / f"{label}.plist" + plist_path.write_text(textwrap.dedent(f"""\ + + + + + Label + {label} + ProgramArguments + + /bin/bash + -c + {command} + + StartInterval + {interval_min * 60} + StandardOutPath + {log_dir / f'{job}.log'} + StandardErrorPath + {log_dir / f'{job}.err'} + {env_xml} + + + """)) + return plist_path + + +def _enable_launchd(job: str, interval_min: int) -> bool: + plist_path = _write_launchd_plist(job, interval_min) + label = _launchd_label(job) + try: + # Unload first if already loaded + subprocess.run(["launchctl", "unload", str(plist_path)], + capture_output=True) + subprocess.run(["launchctl", "load", str(plist_path)], + check=True, capture_output=True) + return True + except subprocess.CalledProcessError as e: + print(f"Error loading launchd job: {e.stderr.decode()}", file=sys.stderr) + return False + + +def _disable_launchd(job: str) -> bool: + label = _launchd_label(job) + plist_path = _launchd_dir() / f"{label}.plist" + try: + subprocess.run(["launchctl", "unload", str(plist_path)], + check=True, capture_output=True) + if plist_path.exists(): + plist_path.unlink() + return True + except subprocess.CalledProcessError as e: + print(f"Error unloading launchd job: {e.stderr.decode()}", file=sys.stderr) + return False + + +def _status_launchd() -> list[dict]: + results = [] + for job in JOBS: + label = _launchd_label(job) + try: + r = subprocess.run( + ["launchctl", "list", label], + capture_output=True, text=True + ) + active = r.returncode == 0 + except FileNotFoundError: + active = False + + results.append({ + "job": job, + "description": JOBS[job]["description"], + "active": active, + "next_run": "", + }) + return results + + +def _logs_launchd(job: str, lines: int = 50) -> str: + log_path = cortex_home() / "logs" / f"{job}.log" + if not log_path.exists(): + return f"No log file at {log_path}" + all_lines = log_path.read_text().splitlines() + return "\n".join(all_lines[-lines:]) + + +# --- Unified interface --- + +def enable_job(job: str, interval_min: Optional[int] = None) -> bool: + if job not in JOBS: + print(f"Unknown job: {job}. Available: {', '.join(JOBS.keys())}") + return False + interval = interval_min or JOBS[job]["default_interval_min"] + if _is_linux(): + return _enable_systemd(job, interval) + elif _is_macos(): + return _enable_launchd(job, interval) + else: + print(f"Unsupported platform: {platform.system()}") + return False + + +def disable_job(job: str) -> bool: + if job not in JOBS: + print(f"Unknown job: {job}. Available: {', '.join(JOBS.keys())}") + return False + if _is_linux(): + return _disable_systemd(job) + elif _is_macos(): + return _disable_launchd(job) + else: + print(f"Unsupported platform: {platform.system()}") + return False + + +def status() -> list[dict]: + if _is_linux(): + return _status_systemd() + elif _is_macos(): + return _status_launchd() + return [] + + +def logs(job: str, lines: int = 50) -> str: + if _is_linux(): + return _logs_systemd(job, lines) + elif _is_macos(): + return _logs_launchd(job, lines) + return "Unsupported platform" + + +# --- CLI --- + +def main(): + parser = argparse.ArgumentParser(description="Cortex Scheduler") + sub = parser.add_subparsers(dest="action") + + sub.add_parser("list", help="List available jobs") + sub.add_parser("status", help="Show active job status") + + enable_p = sub.add_parser("enable", help="Enable a scheduled job") + enable_p.add_argument("job", choices=JOBS.keys()) + enable_p.add_argument("--interval", type=int, help="Interval in minutes") + + disable_p = sub.add_parser("disable", help="Disable a scheduled job") + disable_p.add_argument("job", choices=JOBS.keys()) + + logs_p = sub.add_parser("logs", help="Show job logs") + logs_p.add_argument("job", choices=JOBS.keys()) + logs_p.add_argument("--lines", type=int, default=50) + + args = parser.parse_args() + + if args.action == "list": + print(f"{'Job':<12} {'Interval':<12} {'Description'}") + print("-" * 60) + for name, jdef in JOBS.items(): + interval = f"{jdef['default_interval_min']}min" + print(f"{name:<12} {interval:<12} {jdef['description']}") + + elif args.action == "status": + results = status() + if not results: + print(f"No scheduler support on {platform.system()}") + return + print(f"{'Job':<12} {'Status':<10} {'Next Run'}") + print("-" * 60) + for r in results: + st = "✅ active" if r["active"] else "⬚ inactive" + print(f"{r['job']:<12} {st:<10} {r['next_run']}") + + elif args.action == "enable": + ok = enable_job(args.job, args.interval) + if ok: + interval = args.interval or JOBS[args.job]["default_interval_min"] + print(f"✅ {args.job} enabled (every {interval}min)") + else: + print(f"❌ Failed to enable {args.job}") + sys.exit(1) + + elif args.action == "disable": + ok = disable_job(args.job) + if ok: + print(f"⬚ {args.job} disabled") + else: + print(f"❌ Failed to disable {args.job}") + sys.exit(1) + + elif args.action == "logs": + print(logs(args.job, args.lines)) + + else: + parser.print_help() + + +if __name__ == "__main__": + main()