diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json new file mode 100644 index 00000000..3631e0b1 --- /dev/null +++ b/plugins/codex/.codex-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "reme", + "version": "0.1.0", + "description": "File-native long-term memory for Codex, backed by a running ReMe MCP server. A memory skill recalls long-term knowledge on demand; recording is automatic via a Stop hook that records each session in the background.", + "author": { + "name": "EconML team of Alibaba Tongyi Lab", + "email": "jinli.yl@alibaba-inc.com" + }, + "homepage": "https://reme.agentscope.io/", + "repository": "https://github.com/agentscope-ai/ReMe", + "license": "Apache-2.0", + "keywords": ["memory", "reme", "mcp", "long-term-memory", "agent"] +} diff --git a/plugins/codex/README.md b/plugins/codex/README.md new file mode 100644 index 00000000..23bc0a43 --- /dev/null +++ b/plugins/codex/README.md @@ -0,0 +1,100 @@ +# ReMe Plugin for Codex + +Connect Codex to [ReMe](https://github.com/agentscope-ai/ReMe) — file-native long-term memory for AI agents. The plugin gives Codex **recall** (read long-term memory) and **records every session automatically** via a Stop hook. + +## What you get + +- **MCP tools** from the `reme` server: `search`, `traverse`, `daily_list`, `frontmatter_read`, `read`, `auto_memory_cc`, and more. +- **Stop hook** (`hooks/auto_memory.py`) — when a session ends it calls ReMe's server-side `auto_memory_cc` tool in a detached background process. Recording is fully automatic and asynchronous. +- **Skill** `reme-memory` — recall long-term memory before answering (semantic `search`, topological `traverse`, state `daily_list`/`frontmatter_read`, then `read` with citations), plus a server status check. + +## Deployment Model + +The plugin **connects to a shared HTTP MCP server you start once** — it does not spawn ReMe. One server means one set of background watchers / dream cron across all your Codex sessions. + +## Prerequisites + +1. Install ReMe (Python 3.11+): + + ```bash + pip install "reme-ai[core]" + ``` + +2. Configure model credentials in a `.env` (see `example.env`): + + ```bash + EMBEDDING_API_KEY=sk-xxx + EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 + LLM_API_KEY=sk-xxx + LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 + ``` + +3. Start the ReMe MCP server (one time, leave it running): + + ```bash + reme start service.backend=mcp service.transport=streamable-http + ``` + + It serves `http://127.0.0.1:2333/mcp`. To use a different port, start with + `service.port=` and update the `url` in `.mcp.json` to match. + +## Install the Plugin + +Copy the `plugins/codex/` directory to your Codex plugins location and install: + +```bash +# Clone or copy the plugin directory +cp -r plugins/codex/ ~/.codex/plugins/reme/ + +# Configure MCP connection +# The .mcp.json is already bundled with the plugin +``` + +Restart Codex, then verify the `reme` server and its tools are connected. The `reme-memory` skill can then recall memory and report server health. + +## Plugin Structure + +``` +plugins/codex/ +├── .codex-plugin/ +│ └── plugin.json # Codex plugin manifest +├── hooks/ +│ ├── hooks.json # Stop hook configuration +│ └── auto_memory.py # Session recording hook +├── skills/ +│ └── reme-memory/ +│ └── SKILL.md # Memory recall skill +├── .mcp.json # MCP server connection +└── README.md # This file +``` + +## Notes + +- The plugin's MCP server URL lives in `plugins/codex/reme/.mcp.json`. Keep it in sync with how you start ReMe (host/port). Override with `REME_HOST` / `REME_PORT` env vars. +- The Stop hook needs `python3` on `PATH`. It logs to `plugins/codex/hooks/auto_memory_hook.log`. +- The MCP tool-name prefix may include the server segment depending on your Codex version; the skill uses the wildcard pattern so it works either way. +- Recording is best-effort: if the server is down, the hook logs and gives up silently without blocking the session. + +## Troubleshooting + +### Tools not showing up + +1. Verify ReMe is running: `curl -s http://127.0.0.1:2333/health_check` +2. Check the MCP server URL in `.mcp.json` matches your ReMe port +3. Restart Codex after installing the plugin + +### Stop hook not recording + +1. Check the log: `cat plugins/codex/hooks/auto_memory_hook.log` +2. Verify `python3` is on PATH +3. Ensure the ReMe server is running when you end a session + +### Connection refused + +The MCP server may not be running. Start it with: + +```bash +reme start service.backend=mcp service.transport=streamable-http +``` + +Then retry. diff --git a/plugins/codex/reme/.mcp.json b/plugins/codex/reme/.mcp.json new file mode 100644 index 00000000..b5d271ee --- /dev/null +++ b/plugins/codex/reme/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "reme": { + "type": "http", + "url": "http://127.0.0.1:2333/mcp" + } + } +} diff --git a/plugins/codex/reme/hooks/auto_memory.py b/plugins/codex/reme/hooks/auto_memory.py new file mode 100644 index 00000000..af7a9aba --- /dev/null +++ b/plugins/codex/reme/hooks/auto_memory.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""ReMe Stop hook: fire-and-forget auto-memory for the current Codex session. + +Codex runs this on the ``Stop`` event and feeds the hook payload as JSON +on stdin. We read only ``session_id`` from it and hand that to ReMe's +server-side ``auto_memory_cc`` tool over the (already-running) MCP server — +the server resolves *this* session's transcript on disk and records the +durable facts. No messages are sent from here; the agent never has to +record by hand. + +The actual run spins up an inner agent and can take a while, so we detach +(double-fork) and return immediately: stopping is never blocked. Any failure +is logged, never surfaced — recording is best-effort. +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from datetime import datetime + +_CALL_TIMEOUT = 600 + + +def _plugin_root() -> str: + return os.environ.get("CODEX_PLUGIN_ROOT") or os.path.dirname( + os.path.dirname(os.path.abspath(__file__)), + ) + + +def _server_url() -> str: + """ReMe MCP endpoint. Prefer the bundled .mcp.json so it stays in sync.""" + mcp_json = os.path.join(_plugin_root(), ".mcp.json") + try: + with open(mcp_json, encoding="utf-8") as f: + url = json.load(f)["mcpServers"]["reme"]["url"] + if url: + return url + except Exception: + pass + host = os.environ.get("REME_HOST", "127.0.0.1") + port = os.environ.get("REME_PORT", "2333") + return f"http://{host}:{port}/mcp" + + +def _log(session_id: str, status: str, detail: str = "") -> None: + try: + log_dir = os.path.join(_plugin_root(), "logs") + os.makedirs(log_dir, exist_ok=True) + stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"{stamp} session={session_id} {status}" + if detail: + line += f" {detail}" + with open(os.path.join(log_dir, "auto_memory_hook.log"), "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + + +def _post(url: str, body: dict, headers: dict) -> "urllib.request.addinfourl": + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + return urllib.request.urlopen(req, timeout=_CALL_TIMEOUT) + + +def _read_jsonrpc(resp) -> dict | None: + """Return the JSON-RPC envelope from a JSON or text/event-stream response.""" + ctype = resp.headers.get("content-type", "") + body = resp.read().decode("utf-8", "replace") + if "text/event-stream" in ctype: + result = None + for line in body.splitlines(): + line = line.strip() + if not line.startswith("data:"): + continue + try: + obj = json.loads(line[len("data:") :].strip()) + except json.JSONDecodeError: + continue + if isinstance(obj, dict) and ("result" in obj or "error" in obj): + result = obj + return result + try: + return json.loads(body) + except json.JSONDecodeError: + return None + + +def _mcp_call(url: str, tool: str, arguments: dict) -> dict | None: + """Minimal MCP streamable-http client: initialize -> initialized -> tools/call.""" + base = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + + init = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "reme-codex-hook", "version": "1.0"}, + }, + } + with _post(url, init, base) as resp: + mcp_session = resp.headers.get("mcp-session-id") + _read_jsonrpc(resp) + + headers = dict(base) + if mcp_session: + headers["mcp-session-id"] = mcp_session + + try: + with _post(url, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, headers) as resp: + resp.read() + except urllib.error.HTTPError: + pass + + call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": tool, "arguments": arguments}} + with _post(url, call, headers) as resp: + return _read_jsonrpc(resp) + + +def _daemonize() -> None: + """Double-fork + setsid so the (slow) call outlives the hook and is reaped by init.""" + if os.fork() > 0: + os._exit(0) + os.setsid() + if os.fork() > 0: + os._exit(0) + devnull = os.open(os.devnull, os.O_RDWR) + for fd in (0, 1, 2): + os.dup2(devnull, fd) + + +def main() -> None: + """Entry point: read the hook payload from stdin and record the session.""" + try: + payload = json.loads(sys.stdin.read() or "{}") + except Exception: + payload = {} + + session_id = payload.get("session_id") or "" + if not session_id: + return + + if hasattr(os, "fork"): + _daemonize() + + url = _server_url() + try: + result = _mcp_call(url, "auto_memory_cc", {"session_id": session_id}) + if result is None: + _log(session_id, "no-response") + elif "error" in result: + _log(session_id, "error", json.dumps(result["error"], ensure_ascii=False)[:500]) + else: + _log(session_id, "ok") + except urllib.error.URLError as exc: + _log(session_id, "unreachable", str(exc.reason)) + except Exception as exc: + _log(session_id, "exception", repr(exc)[:500]) + + +if __name__ == "__main__": + main() diff --git a/plugins/codex/reme/hooks/hooks.json b/plugins/codex/reme/hooks/hooks.json new file mode 100644 index 00000000..f83238fb --- /dev/null +++ b/plugins/codex/reme/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "description": "On stop, record this Codex session into ReMe long-term memory (background, async).", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CODEX_PLUGIN_ROOT}/hooks/auto_memory.py\"", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/codex/reme/skills/reme-memory/SKILL.md b/plugins/codex/reme/skills/reme-memory/SKILL.md new file mode 100644 index 00000000..f43f4f98 --- /dev/null +++ b/plugins/codex/reme/skills/reme-memory/SKILL.md @@ -0,0 +1,58 @@ +--- +name: reme-memory +description: Use ReMe as file-native long-term memory in Codex. RECALL — search ReMe before answering questions about past conversations, preferences, project history, or decisions. +--- + +# ReMe Memory + +ReMe is the persistent, file-native memory layer for this agent. It stores conversations and +resources as Markdown files with frontmatter and `[[wikilinks]]`, and consolidates them into +long-term **digest** knowledge. Your job with this plugin is **recall**. + +The recall tools come from the `reme` MCP server (surfaced as tool calls): `search`, `traverse`, +`daily_list`, `frontmatter_read`, `read`. They are only available when the user has the server +running: + +``` +reme start service.backend=mcp service.transport=streamable-http +``` + +If the tools are missing, that server is not running — tell the user the command above instead of +guessing answers. + +## Recall (read long-term memory) + +Before answering questions about previous conversations, user preferences, project history, +decisions, or long-term context, recall from ReMe first. ReMe answers **three independent kinds of +question** — pick the mode the request needs; don't merge them into one call. Durable knowledge +lives under `digest/`, daily notes under `daily/`, external materials under `resource/`. + +1. **Semantic** (default — "what do we know about X?"): `search` with `query=""`, + `limit=5` (optional `min_score`). Hybrid vector + BM25 with one-hop wikilink expansion. +2. **Topological** ("what links to this node?"): `traverse` with `path=""`, `depth=1` + (raise to 2 only when needed), `direction=both` to walk the `[[wikilink]]` graph. +3. **State** ("what exists / what was recorded on ?"): `daily_list` with `date="YYYY-MM-DD"` + (empty = today) to list a day's notes, or `frontmatter_read` with a `path` to inspect one file's + frontmatter — structural lookup, no semantic matching. + +Then `read` the relevant hits by `path` (optionally `start_line`/`end_line`; prefer `digest/` paths +for durable knowledge) to pull the content behind a hit. Cite the workspace-relative paths you used. +If nothing useful comes back, say so plainly rather than guessing. + +## Server status + +To check ReMe is up: call `version` and `health_check`, then summarize the version and the health +snapshot (components, workspace). If the tools are not available at all, the server +is not running — tell the user to start it with the command above. The plugin connects at +`http://127.0.0.1:2333/mcp`; a different host/port must match the `url` in `plugins/codex/reme/.mcp.json`. + +## Workspace model + +``` +daily/ lightly-processed memory: daily facts, conversation summaries +digest/ long-term consolidated knowledge (what recall mainly surfaces) +resource/ external raw materials +``` + +Consolidation of `daily/` into `digest/` and proactive interest extraction run **server-side** in +the ReMe process (background watchers + dream cron). The plugin does not drive them.