mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
* feat: add DSH memory integration and organize extensions * fix: support newer DSH release candidates * fix: address DSH integration review feedback * fix: handle DSH cross-day retry edge cases
173 lines
6 KiB
Python
Executable file
173 lines
6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""ReMe Stop hook: fire-and-forget auto-memory for the current session.
|
|
|
|
Claude Code 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
|
|
|
|
# auto_memory drives an inner agent; give it room. The foreground process has
|
|
# already returned by the time this matters (we are detached), so a long ceiling
|
|
# is harmless.
|
|
_CALL_TIMEOUT = 600
|
|
|
|
|
|
def _plugin_root() -> str:
|
|
return os.environ.get("CLAUDE_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"}
|
|
|
|
# 1. initialize (captures the session id header)
|
|
init = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-06-18",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "reme-stop-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
|
|
|
|
# 2. notifications/initialized (no id; 202 with empty body)
|
|
try:
|
|
with _post(url, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, headers) as resp:
|
|
resp.read()
|
|
except urllib.error.HTTPError:
|
|
pass
|
|
|
|
# 3. tools/call
|
|
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) # original process returns -> hook completes, Claude stops
|
|
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 # nothing to anchor a recording on
|
|
|
|
# Detach before the slow agent run. Without fork() (e.g. Windows) we fall
|
|
# through and run inline — correct, just not async.
|
|
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:
|
|
# Server not running / unreachable — expected when ReMe isn't started.
|
|
_log(session_id, "unreachable", str(exc.reason))
|
|
except Exception as exc: # noqa: BLE001 - best-effort, never surface
|
|
_log(session_id, "exception", repr(exc)[:500])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|