ReMe/integrations/codex/plugins/reme/reme_config.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

181 lines
6.6 KiB
Python

"""Persistent Codex plugin settings and owner-only, atomic local storage."""
from __future__ import annotations
import contextlib
import json
import math
import os
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo
HOME_ENV = "CODEX_HOME"
HOME_DEFAULT = "~/.codex"
DEFAULTS = {
"mcpUrl": "http://127.0.0.1:2333/mcp",
"autoRecall": True,
"autoMemoryEnabled": True,
"searchLimit": 5,
"recallMinScore": 0.0,
"requestTimeoutMs": 10000,
"backgroundTimeoutMs": 3600000,
"autoMemoryInterval": 5,
"shutdownTimeoutMs": 5000,
"timezone": "Asia/Shanghai",
"language": "en",
"dreamHint": "",
"autoDreamEnabled": True,
"dreamCron": "0 23 * * *",
"rootAgentsOnly": True,
}
def data_dir() -> Path:
"""Keep configuration and retry state outside the versioned plugin cache."""
return Path(os.environ.get(HOME_ENV) or HOME_DEFAULT).expanduser() / "reme"
def load_config() -> dict:
"""Read the persistent settings shared by the MCP server and lifecycle hooks."""
path = data_dir() / "config.json"
values = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
return validate_config(values)
def validate_config(values: dict) -> dict:
"""Validate user overrides before the MCP server or hooks can use them."""
if not isinstance(values, dict) or values.keys() - DEFAULTS.keys():
raise ValueError("Unknown ReMe configuration fields")
config = {**DEFAULTS, **values}
for key, default in DEFAULTS.items():
value = config[key]
if isinstance(default, bool):
if not isinstance(value, bool):
raise ValueError(f"{key} must be a boolean")
elif isinstance(default, str):
if not isinstance(value, str):
raise ValueError(f"{key} must be a string")
elif isinstance(default, (int, float)):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError(f"{key} must be a finite number")
if value < 0 or (value == 0 and key != "recallMinScore"):
raise ValueError(f"{key} must be positive")
if isinstance(default, int) and not isinstance(value, int):
raise ValueError(f"{key} must be an integer")
for key, minimum, maximum in (
("requestTimeoutMs", 1000, 120000),
("backgroundTimeoutMs", 1000, 3600000),
("shutdownTimeoutMs", 100, 60000),
):
if not minimum <= config[key] <= maximum:
raise ValueError(f"{key} must be between {minimum} and {maximum}")
if config["searchLimit"] > 50 or config["autoMemoryInterval"] > 1000:
raise ValueError("searchLimit must be at most 50 and autoMemoryInterval at most 1000")
if config["language"] not in {"en", "zh"}:
raise ValueError("language must be en or zh")
parts = config["dreamCron"].split()
if (
len(parts) != 5
or parts[2:] != ["*", "*", "*"]
or not all(part.isascii() and part.isdigit() for part in parts[:2])
or not 0 <= int(parts[0]) <= 59
or not 0 <= int(parts[1]) <= 23
):
raise ValueError("dreamCron must use a daily expression: minute hour * * *")
try:
ZoneInfo(config["timezone"])
except (KeyError, ValueError) as exc:
raise ValueError("timezone must be an IANA timezone") from exc
config["mcpUrl"] = validate_url(config["mcpUrl"])
# Queue identities are durable: changing the address never retargets pending conversations.
config["endpoint"] = config["mcpUrl"][:-4] if config["mcpUrl"].endswith("/mcp") else "mcp:" + config["mcpUrl"]
return config
def validate_url(url: str) -> str:
"""Accept HTTP endpoints without embedded credentials or ambiguous URL suffixes."""
url = url.rstrip("/")
parsed = urlsplit(url)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or any((parsed.username is not None, parsed.password is not None, parsed.query, parsed.fragment))
or any(character.isspace() for character in url)
):
raise ValueError("ReMe URLs must be absolute HTTP(S) URLs without credentials, query, or fragment")
_ = parsed.port
return url
@contextlib.contextmanager
def lock(path: Path):
"""Use a non-blocking OS lock, released automatically if a hook is killed."""
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with path.open("a+b") as handle:
if os.name == "nt":
import msvcrt
handle.write(b"\0")
handle.flush()
handle.seek(0)
def acquire():
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
def release():
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
def acquire():
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
def release():
fcntl.flock(handle, fcntl.LOCK_UN)
try:
acquire()
except OSError:
yield False
return
try:
yield True
finally:
release()
def write_json(path: Path, value: dict) -> None:
"""Atomically persist retry metadata with owner-only file permissions."""
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".reme-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(value, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def settings_values(config: dict) -> dict:
"""Expose only editable settings, excluding the derived retry-directory identity."""
return {key: config[key] for key in DEFAULTS}
def update_settings(changes: dict) -> dict:
"""Validate and merge a UI patch under the same cross-process lock as other editors."""
if not isinstance(changes, dict) or not changes or changes.keys() - DEFAULTS.keys():
raise ValueError("Provide at least one supported ReMe setting")
with lock(data_dir() / "config.lock") as acquired:
if not acquired:
raise RuntimeError("Another settings update is in progress; please retry")
current = settings_values(load_config())
config = validate_config({**current, **changes})
values = settings_values(config)
write_json(data_dir() / "config.json", values)
return values