ReMe/integrations/codex/plugins/reme/reme_runtime.py
Sen Huang 749c653f23
feat(integrations): add automatic memory plugins for Claude Code and Codex (#575)
* feat(integrations): add native Claude Code and Codex memory plugins

* docs(integrations): make plugin installation the coding agent entry point

* feat(integrations): configure plugin connections from user settings

* refactor(codex): use MCP tools and native plugin settings

* fix(codex): restore plugin hooks on current runtimes

* docs(codex): expand setup guide and reserve screenshot slots

* feat(codex): add configurable memory consolidation and status

* feat(codex): merge connection checks into a tabbed status app

* docs(codex): diagnose blocked status panel sandbox loading

* fix(codex): simplify status panel layout for narrow cards

* docs(codex): focus guides on connecting and using ReMe

* docs(codex): specify validated CLI and desktop version requirements

* feat(codex): add ReMe branding to plugin icons

* docs(codex): prepare manual screenshot workflow

* fix(codex): simplify ReMe status layout and activity

* docs(codex): explain Hook loading and local project context

* fix(codex): keep status refresh clear of host close control

* fix(codex): retain status resources for active MCP connections

* docs(codex): add real desktop screenshots to usage guides

* fix(integrations): address memory capture and consolidation review

* fix(codex): preserve capture endpoint during background delivery
2026-10-12 20:12:47 +08:00

177 lines
7.3 KiB
Python

"""Lifecycle-owned daily consolidation using only the public ReMe MCP tools."""
from __future__ import annotations
import asyncio
import json
import time
from datetime import date as calendar_date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from reme_config import data_dir, load_config, lock, write_json
from reme_mcp import call_async
from reme_state import digest, endpoint_dir, log_status
def next_daily_run(cron: str, zone: str, now: datetime | None = None) -> datetime:
"""Skip nonexistent local times and run once on days with an ambiguous clock time."""
now = now or datetime.now(timezone.utc)
minute, hour = (int(value) for value in cron.split()[:2])
tz = ZoneInfo(zone)
day = now.astimezone(tz).date()
for offset in range(5):
candidate = datetime.combine(day + timedelta(days=offset), datetime.min.time()).replace(
hour=hour,
minute=minute,
)
instant = candidate.replace(tzinfo=tz, fold=0).astimezone(timezone.utc)
if instant.astimezone(tz).replace(tzinfo=None) == candidate and instant > now:
return instant
raise ValueError("No daily schedule occurrence in the next five days")
async def execute_dream(
config: dict,
*,
date: str = "",
hint: str | None = None,
origin: str = "manual",
scheduled_at: str = "",
) -> str:
"""Serialize manual and scheduled runs across MCP processes in this Codex profile."""
day = date or datetime.now(ZoneInfo(config["timezone"])).date().isoformat()
if calendar_date.fromisoformat(day).isoformat() != day:
raise ValueError("date must use YYYY-MM-DD")
state = endpoint_dir(config)
with lock(state / "dream.lock") as acquired:
if not acquired:
raise RuntimeError("Consolidation is already running for this endpoint in this Codex profile.")
receipt = state / "dream_receipt.json"
if scheduled_at:
if receipt.exists() and json.loads(receipt.read_text(encoding="utf-8")).get("scheduled_at") == scheduled_at:
return "This scheduled run was already attempted."
# Claim before the request: a lost acknowledgement must not trigger repeated daily runs.
write_json(receipt, {"scheduled_at": scheduled_at})
run = {"status": "running", "origin": origin, "date": day, "started_at": time.time()}
write_json(state / "dream.json", run)
log_status("dream_started", config=config)
try:
result = await call_async(
config,
"auto_dream",
{"date": day, "hint": config["dreamHint"] if hint is None else hint},
config["backgroundTimeoutMs"] / 1000,
)
except BaseException as exc:
run.update(status="cancelled" if isinstance(exc, asyncio.CancelledError) else "failed")
run["error"] = type(exc).__name__
log_status("dream_" + run["status"], exc, config=config)
raise
else:
run["status"] = "completed"
log_status("dream_completed", config=config)
answer = result["answer"]
return answer if isinstance(answer, str) else json.dumps(answer, ensure_ascii=False)
finally:
run["completed_at"] = time.time()
write_json(state / "dream.json", run)
class ReMeRuntime:
"""Own one bounded task; elect a single scheduler among a profile's MCP processes."""
def __init__(self):
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self._changed = asyncio.Event()
async def start(self) -> None:
"""Start only inside the FastMCP lifespan."""
if self._task is None:
self._stop.clear()
self._task = asyncio.create_task(self._serve())
def settings_changed(self) -> None:
"""Wake this process immediately; other processes observe saved settings within a second."""
self._changed.set()
async def close(self) -> None:
"""Stop the timer and bound an in-flight dream by the configured shutdown budget."""
self._stop.set()
self._changed.set()
if self._task is None:
return
try:
timeout = load_config()["shutdownTimeoutMs"] / 1000
except (OSError, ValueError):
timeout = 5
try:
await asyncio.wait_for(asyncio.shield(self._task), timeout)
except TimeoutError:
self._task.cancel()
await asyncio.gather(self._task, return_exceptions=True)
finally:
self._task = None
async def _wait(self, seconds: float = 1) -> None:
try:
await asyncio.wait_for(self._changed.wait(), max(0.01, seconds))
except TimeoutError:
pass
self._changed.clear()
async def _serve(self) -> None:
while not self._stop.is_set():
try:
with lock(data_dir() / "scheduler.lock") as acquired:
if acquired:
await self._lead()
except (OSError, ValueError, RuntimeError) as exc:
log_status("scheduler_failed", exc)
if not self._stop.is_set():
await self._wait()
async def _lead(self) -> None:
previous, due = None, None
state = data_dir() / "scheduler.json"
try:
while not self._stop.is_set():
config = load_config()
key = tuple(config[name] for name in ("mcpUrl", "autoDreamEnabled", "dreamCron", "timezone"))
if key != previous:
due = (
next_daily_run(config["dreamCron"], config["timezone"]) if config["autoDreamEnabled"] else None
)
previous = key
write_json(
state,
{
"phase": "running",
"endpoint": digest(config["endpoint"]),
"enabled": config["autoDreamEnabled"],
"cron": config["dreamCron"],
"timezone": config["timezone"],
"next_run_at": due.isoformat() if due else None,
},
)
if due and datetime.now(timezone.utc) >= due:
write_json(
state,
{
"phase": "running",
"endpoint": digest(config["endpoint"]),
"enabled": config["autoDreamEnabled"],
"cron": config["dreamCron"],
"timezone": config["timezone"],
"next_run_at": None,
},
)
try:
await execute_dream(config, origin="scheduled", scheduled_at=due.isoformat())
except Exception as exc:
log_status("scheduled_dream_failed", exc, config=config)
previous = None
continue
await self._wait(min(1, (due - datetime.now(timezone.utc)).total_seconds()) if due else 1)
finally:
write_json(state, {"phase": "stopped", "next_run_at": None})