diff --git a/tests/unit/test_use_cases/__init__.py b/tests/unit/test_use_cases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_use_cases/test_cursor_agent_memory/__init__.py b/tests/unit/test_use_cases/test_cursor_agent_memory/__init__.py new file mode 100644 index 000000000..f904411a1 --- /dev/null +++ b/tests/unit/test_use_cases/test_cursor_agent_memory/__init__.py @@ -0,0 +1 @@ +"""Tests for use-cases/cursor-agent-memory helpers.""" diff --git a/tests/unit/test_use_cases/test_cursor_agent_memory/test_context.py b/tests/unit/test_use_cases/test_cursor_agent_memory/test_context.py new file mode 100644 index 000000000..5e5d6e512 --- /dev/null +++ b/tests/unit/test_use_cases/test_cursor_agent_memory/test_context.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +USE_CASE_ROOT = ( + Path(__file__).resolve().parents[4] / "use-cases" / "cursor-agent-memory" +) +if str(USE_CASE_ROOT) not in sys.path: + sys.path.insert(0, str(USE_CASE_ROOT)) + +from hooklib.context import ( # noqa: E402 + count_words, + format_search_context, + workspace_recall_query, +) + + +def test_workspace_recall_query_uses_folder_name() -> None: + query = workspace_recall_query(["/Users/dev/Projects/EverOS"]) + assert "EverOS" in query + + +def test_workspace_recall_query_fallback_without_roots() -> None: + query = workspace_recall_query([]) + assert "project context" in query + + +def test_count_words() -> None: + assert count_words("hello world") == 2 + assert count_words(" ") == 0 + + +def test_format_search_context_renders_episodes() -> None: + data = { + "episodes": [ + { + "subject": "Testing preference", + "episode": "Prefers pytest over unittest.", + "score": 0.9, + "atomic_facts": [{"content": "Uses pytest."}], + } + ], + "profiles": [], + } + text = format_search_context(data, min_score=0.1) + assert "EverOS recalled memory" in text + assert "pytest" in text + assert "Uses pytest." in text + + +def test_format_search_context_empty_when_no_hits() -> None: + assert format_search_context({"episodes": [], "profiles": []}, min_score=0.1) == "" diff --git a/tests/unit/test_use_cases/test_cursor_agent_memory/test_everos_client.py b/tests/unit/test_use_cases/test_cursor_agent_memory/test_everos_client.py new file mode 100644 index 000000000..d08ebbd28 --- /dev/null +++ b/tests/unit/test_use_cases/test_cursor_agent_memory/test_everos_client.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +USE_CASE_ROOT = ( + Path(__file__).resolve().parents[4] / "use-cases" / "cursor-agent-memory" +) +if str(USE_CASE_ROOT) not in sys.path: + sys.path.insert(0, str(USE_CASE_ROOT)) + +from hooklib.everos_client import message_item # noqa: E402 + + +def test_message_item_shape() -> None: + msg = message_item( + sender_id="cursor-user", + role="user", + content="hello", + timestamp_ms=1_700_000_000_000, + ) + assert msg["sender_id"] == "cursor-user" + assert msg["role"] == "user" + assert msg["content"] == "hello" + assert msg["timestamp"] == 1_700_000_000_000 diff --git a/tests/unit/test_use_cases/test_cursor_agent_memory/test_transcript.py b/tests/unit/test_use_cases/test_cursor_agent_memory/test_transcript.py new file mode 100644 index 000000000..846bd060a --- /dev/null +++ b/tests/unit/test_use_cases/test_cursor_agent_memory/test_transcript.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +USE_CASE_ROOT = ( + Path(__file__).resolve().parents[4] / "use-cases" / "cursor-agent-memory" +) +if str(USE_CASE_ROOT) not in sys.path: + sys.path.insert(0, str(USE_CASE_ROOT)) + +from hooklib.transcript import TurnContent, extract_last_turn # noqa: E402 + + +def _line(entry: dict) -> str: + return json.dumps(entry) + + +def test_extract_last_turn_single_exchange() -> None: + lines = [ + _line( + { + "type": "user", + "message": {"content": "Remember I like dark mode."}, + } + ), + _line( + { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "Got it — dark mode."}] + }, + } + ), + ] + turn = extract_last_turn(lines) + assert turn.user == "Remember I like dark mode." + assert turn.assistant == "Got it — dark mode." + assert turn.has_content + + +def test_extract_last_turn_ignores_prior_turn_after_marker() -> None: + lines = [ + _line({"type": "user", "message": {"content": "old question"}}), + _line({"type": "system", "subtype": "turn_duration"}), + _line({"type": "user", "message": {"content": "new question"}}), + _line( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "new answer"}]}, + } + ), + ] + turn = extract_last_turn(lines) + assert turn.user == "new question" + assert turn.assistant == "new answer" + + +def test_turn_fingerprint_stable() -> None: + turn = TurnContent(user="a", assistant="b") + assert turn.fingerprint == "a\n---\nb" diff --git a/use-cases/cursor-agent-memory/README.md b/use-cases/cursor-agent-memory/README.md new file mode 100644 index 000000000..47dfd810f --- /dev/null +++ b/use-cases/cursor-agent-memory/README.md @@ -0,0 +1,136 @@ +# Cursor Agent Memory (EverOS) + +Persistent memory for **Cursor Agent** using the local [EverOS 1.0 HTTP API](https://github.com/EverMind-AI/EverOS/blob/main/docs/api.md). This use case wires [Cursor hooks](https://cursor.com/docs/agent/hooks) so your agent **recalls** past context at session start and **saves** each turn back to EverOS when a session ends. + +Unlike the legacy Claude Code plugin in `use-cases/claude-code-plugin/`, this example targets the **current OSS API**: + +```text +POST /api/v1/memory/add +POST /api/v1/memory/flush +POST /api/v1/memory/search +``` + +## What it does + +| Cursor hook | EverOS action | +|---|---| +| `sessionStart` | `GET /health` → `POST /search` with a workspace-based query → inject `additional_context` | +| `stop` | Read composer transcript → `POST /add` with the latest user + assistant turn | +| `sessionEnd` | `POST /flush` to extract the session into Markdown-backed memory | + +```mermaid +sequenceDiagram + participant C as Cursor Agent + participant H as EverOS hooks + participant E as EverOS server + + C->>H: sessionStart + H->>E: search (workspace query) + E-->>H: episodes / profile + H-->>C: additional_context + + C->>H: stop (after each turn) + H->>E: add messages + + C->>H: sessionEnd + H->>E: flush session +``` + +## Prerequisites + +- Python 3.12+ (stdlib only — no extra pip packages for the hooks) +- [EverOS](https://github.com/EverMind-AI/EverOS) server running locally (`everos server start`) +- LLM + embedding keys configured in `~/.everos/everos.toml` +- Cursor with **Agent hooks** enabled (see Cursor Settings → Hooks) +- Composer **transcripts** enabled (hooks receive `transcript_path`; without it, save-on-stop is skipped) + +## Install into your project + +From this directory: + +```bash +chmod +x install.sh +./install.sh +``` + +This copies hook scripts to `.cursor/hooks/everos-memory/` and creates `.cursor/hooks.json` if missing. + +If you already have a `.cursor/hooks.json`, merge the entries from `hooks/hooks.json.example`. + +Copy and edit environment variables: + +```bash +cp env.example .env +# EVEROS_BASE_URL, EVEROS_USER_ID, etc. +``` + +The hooks load `.env` from the project root (or from this use-case folder during development). + +## Verify EverOS is reachable + +```bash +curl http://127.0.0.1:8000/health +# {"status":"ok"} +``` + +Run a manual memory loop (optional): + +```bash +TS=$(($(date +%s)*1000)) +curl -X POST http://127.0.0.1:8000/api/v1/memory/add \ + -H 'Content-Type: application/json' \ + -d "{\"session_id\":\"cursor-test\",\"app_id\":\"default\",\"project_id\":\"default\",\"messages\":[{\"sender_id\":\"cursor-user\",\"role\":\"user\",\"timestamp\":$TS,\"content\":\"I prefer pytest over unittest.\"}]}" + +curl -X POST http://127.0.0.1:8000/api/v1/memory/flush \ + -H 'Content-Type: application/json' \ + -d '{"session_id":"cursor-test","app_id":"default","project_id":"default"}' +``` + +Open a **new** Cursor Agent chat in the project. Check the **Hooks** output channel for errors. + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `EVEROS_BASE_URL` | `http://127.0.0.1:8000` | EverOS server | +| `EVEROS_USER_ID` | `cursor-user` | `user_id` for search + `sender_id` on saved messages | +| `EVEROS_APP_ID` | `default` | EverOS scope | +| `EVEROS_PROJECT_ID` | `default` | EverOS scope | +| `EVEROS_SESSION_PREFIX` | `cursor-` | Prepended to Cursor `conversation_id` for `session_id` | +| `EVEROS_TOP_K` | `5` | Search result limit | +| `EVEROS_MIN_SCORE` | `0.1` | Relevance floor | +| `EVEROS_DEBUG` | `0` | Log hook diagnostics to stderr | + +## Limitations + +- **Local desktop Cursor only** — cloud agents do not run `sessionStart` / `stop` / `sessionEnd` hooks the same way ([Cursor docs](https://cursor.com/docs/agent/hooks)). +- **Per-prompt recall** — Cursor's `beforeSubmitPrompt` hook cannot inject context today; recall happens at **session start** (bootstrap query from the workspace folder name). +- **Eventual search consistency** — after `flush`, wait a moment before expecting `/search` to return new episodes. +- **Transcript format** — save logic expects JSONL composer transcripts compatible with Claude Code-style entries. + +## Development + +Shared logic lives in `hooklib/`. Unit tests run with the main EverOS suite: + +```bash +make test # includes tests/unit/test_use_cases/test_cursor_agent_memory/ +``` + +## Files + +```text +use-cases/cursor-agent-memory/ +├── README.md +├── env.example +├── install.sh +├── hooklib/ # stdlib EverOS client + transcript parser +└── hooks/ # Cursor hook entrypoints + ├── hooks.json.example + ├── recall_on_session_start.py + ├── save_on_stop.py + └── flush_on_session_end.py +``` + +## License + +Apache-2.0 — same as EverOS. diff --git a/use-cases/cursor-agent-memory/env.example b/use-cases/cursor-agent-memory/env.example new file mode 100644 index 000000000..25449c4d2 --- /dev/null +++ b/use-cases/cursor-agent-memory/env.example @@ -0,0 +1,20 @@ +# EverOS local server (see docs/api.md) +EVEROS_BASE_URL=http://127.0.0.1:8000 + +# Owner identity for /search and message sender_id on /add +EVEROS_USER_ID=cursor-user + +# EverOS scope (defaults match everos init) +EVEROS_APP_ID=default +EVEROS_PROJECT_ID=default + +# session_id = EVEROS_SESSION_PREFIX + Cursor conversation_id +EVEROS_SESSION_PREFIX=cursor- + +# Recall tuning +EVEROS_TOP_K=5 +EVEROS_MIN_SCORE=0.1 +EVEROS_MIN_QUERY_WORDS=3 + +# Set to 1 to log hook diagnostics on stderr (visible in Cursor Hooks output) +EVEROS_DEBUG=0 diff --git a/use-cases/cursor-agent-memory/hooklib/__init__.py b/use-cases/cursor-agent-memory/hooklib/__init__.py new file mode 100644 index 000000000..06181755e --- /dev/null +++ b/use-cases/cursor-agent-memory/hooklib/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for the Cursor + EverOS memory hooks use case.""" diff --git a/use-cases/cursor-agent-memory/hooklib/config.py b/use-cases/cursor-agent-memory/hooklib/config.py new file mode 100644 index 000000000..7e8eb75fc --- /dev/null +++ b/use-cases/cursor-agent-memory/hooklib/config.py @@ -0,0 +1,75 @@ +"""Environment-backed configuration for Cursor hooks.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def _load_dotenv(path: Path) -> None: + """Load KEY=VALUE pairs from a dotenv file into os.environ (no override).""" + if not path.is_file(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'\"") + os.environ.setdefault(key, value) + + +def _find_dotenv() -> Path | None: + """Search upward from cwd and hook install dir for .env.""" + candidates: list[Path] = [] + hook_root = Path(__file__).resolve().parent.parent + candidates.append(hook_root / ".env") + cwd = Path.cwd() + for parent in [cwd, *cwd.parents]: + candidates.append(parent / ".env") + candidates.append(parent / "use-cases" / "cursor-agent-memory" / ".env") + for path in candidates: + if path.is_file(): + return path + return None + + +@dataclass(frozen=True) +class EverOSHookConfig: + base_url: str + user_id: str + app_id: str + project_id: str + session_prefix: str + top_k: int + min_score: float + min_query_words: int + debug: bool + + @classmethod + def load(cls) -> EverOSHookConfig: + dotenv = _find_dotenv() + if dotenv is not None: + _load_dotenv(dotenv) + + return cls( + base_url=os.environ.get("EVEROS_BASE_URL", "http://127.0.0.1:8000").rstrip( + "/" + ), + user_id=os.environ.get("EVEROS_USER_ID", "cursor-user"), + app_id=os.environ.get("EVEROS_APP_ID", "default"), + project_id=os.environ.get("EVEROS_PROJECT_ID", "default"), + session_prefix=os.environ.get("EVEROS_SESSION_PREFIX", "cursor-"), + top_k=int(os.environ.get("EVEROS_TOP_K", "5")), + min_score=float(os.environ.get("EVEROS_MIN_SCORE", "0.1")), + min_query_words=int(os.environ.get("EVEROS_MIN_QUERY_WORDS", "3")), + debug=os.environ.get("EVEROS_DEBUG", "0") in {"1", "true", "yes"}, + ) + + def is_configured(self) -> bool: + return bool(self.base_url and self.user_id) + + def session_id_for(self, conversation_id: str) -> str: + return f"{self.session_prefix}{conversation_id}" diff --git a/use-cases/cursor-agent-memory/hooklib/context.py b/use-cases/cursor-agent-memory/hooklib/context.py new file mode 100644 index 000000000..a673f70fb --- /dev/null +++ b/use-cases/cursor-agent-memory/hooklib/context.py @@ -0,0 +1,67 @@ +"""Format EverOS search hits for Cursor hook additional_context.""" + +from __future__ import annotations + +from typing import Any + + +def count_words(text: str) -> int: + """Rough word count for gating short recall queries.""" + if not text or not text.strip(): + return 0 + return len(text.split()) + + +def workspace_recall_query(workspace_roots: list[str]) -> str: + """Build a bootstrap recall query from the workspace folder name.""" + if not workspace_roots: + return "project context and recent decisions" + name = workspace_roots[0].rstrip("/").split("/")[-1] + if not name: + return "project context and recent decisions" + return f"{name} project context preferences and recent decisions" + + +def format_search_context(data: dict[str, Any], *, min_score: float) -> str: + """Turn SearchData into a compact markdown block for the agent.""" + episodes = data.get("episodes") or [] + profiles = data.get("profiles") or [] + if not episodes and not profiles: + return "" + + lines = [ + "## EverOS recalled memory", + "", + "The following memories were retrieved from your local EverOS server.", + "Treat them as prior context from past Cursor sessions.", + "", + ] + + for idx, episode in enumerate(episodes[:5], start=1): + if not isinstance(episode, dict): + continue + score = episode.get("score") + if isinstance(score, (int, float)) and score < min_score: + continue + subject = episode.get("subject") or episode.get("summary") or "Episode" + body = episode.get("episode") or episode.get("summary") or "" + lines.append(f"### {idx}. {subject}") + if body: + lines.append(str(body).strip()) + facts = episode.get("atomic_facts") or [] + for fact in facts[:3]: + if isinstance(fact, dict) and fact.get("content"): + lines.append(f"- {fact['content']}") + lines.append("") + + for profile in profiles[:1]: + if not isinstance(profile, dict): + continue + pdata = profile.get("profile_data") + if isinstance(pdata, dict) and pdata: + lines.append("### User profile") + for key, value in list(pdata.items())[:8]: + lines.append(f"- **{key}**: {value}") + lines.append("") + + return "\n".join(lines).strip() diff --git a/use-cases/cursor-agent-memory/hooklib/everos_client.py b/use-cases/cursor-agent-memory/hooklib/everos_client.py new file mode 100644 index 000000000..2d9a0a9c4 --- /dev/null +++ b/use-cases/cursor-agent-memory/hooklib/everos_client.py @@ -0,0 +1,145 @@ +"""Minimal EverOS 1.0 HTTP client (stdlib only).""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from typing import Any + + +class EverOSError(Exception): + """EverOS HTTP or contract error.""" + + +def _request( + *, + base_url: str, + method: str, + path: str, + body: dict[str, Any] | None = None, + timeout: float = 10.0, +) -> dict[str, Any]: + url = f"{base_url}{path}" + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise EverOSError(f"HTTP {exc.code} {path}: {detail}") from exc + except urllib.error.URLError as exc: + raise EverOSError(f"unreachable {base_url}: {exc.reason}") from exc + if not isinstance(payload, dict): + raise EverOSError(f"unexpected response type from {path}") + return payload + + +def health(base_url: str) -> bool: + try: + payload = _request(base_url=base_url, method="GET", path="/health") + except EverOSError: + return False + return payload.get("status") == "ok" + + +def search( + *, + base_url: str, + user_id: str, + query: str, + app_id: str, + project_id: str, + top_k: int, + min_score: float, +) -> dict[str, Any]: + body = { + "user_id": user_id, + "app_id": app_id, + "project_id": project_id, + "query": query, + "top_k": top_k, + "min_score": min_score, + } + payload = _request( + base_url=base_url, + method="POST", + path="/api/v1/memory/search", + body=body, + ) + data = payload.get("data") + if not isinstance(data, dict): + raise EverOSError("search response missing data envelope") + return data + + +def add_messages( + *, + base_url: str, + session_id: str, + app_id: str, + project_id: str, + messages: list[dict[str, Any]], +) -> dict[str, Any]: + body = { + "session_id": session_id, + "app_id": app_id, + "project_id": project_id, + "messages": messages, + } + payload = _request( + base_url=base_url, + method="POST", + path="/api/v1/memory/add", + body=body, + ) + data = payload.get("data") + if not isinstance(data, dict): + raise EverOSError("add response missing data envelope") + return data + + +def flush( + *, + base_url: str, + session_id: str, + app_id: str, + project_id: str, +) -> dict[str, Any]: + body = { + "session_id": session_id, + "app_id": app_id, + "project_id": project_id, + } + payload = _request( + base_url=base_url, + method="POST", + path="/api/v1/memory/flush", + body=body, + ) + data = payload.get("data") + if not isinstance(data, dict): + raise EverOSError("flush response missing data envelope") + return data + + +def message_item( + *, + sender_id: str, + role: str, + content: str, + timestamp_ms: int | None = None, +) -> dict[str, Any]: + ts = timestamp_ms if timestamp_ms is not None else int(time.time() * 1000) + return { + "sender_id": sender_id, + "role": role, + "timestamp": ts, + "content": content, + } diff --git a/use-cases/cursor-agent-memory/hooklib/transcript.py b/use-cases/cursor-agent-memory/hooklib/transcript.py new file mode 100644 index 000000000..a9517c050 --- /dev/null +++ b/use-cases/cursor-agent-memory/hooklib/transcript.py @@ -0,0 +1,96 @@ +"""Parse Cursor / Claude-style JSONL composer transcripts.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TurnContent: + user: str + assistant: str + + @property + def has_content(self) -> bool: + return bool(self.user.strip() or self.assistant.strip()) + + @property + def fingerprint(self) -> str: + return f"{self.user}\n---\n{self.assistant}" + + +def _text_from_user_content(content: object) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text" and block.get("text"): + parts.append(str(block["text"])) + return "\n\n".join(parts) + return "" + + +def _text_from_assistant_content(content: object) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text" and block.get("text"): + parts.append(str(block["text"])) + return "\n\n".join(parts) + return "" + + +def extract_last_turn(lines: list[str]) -> TurnContent: + """Extract the latest user/assistant text from a JSONL transcript. + + Compatible with Claude Code / Cursor composer transcripts that mark + turn boundaries with ``{"type":"system","subtype":"turn_duration"}``. + When the stop hook runs, the current turn's duration marker may not + exist yet, so the active turn spans from the last marker to EOF. + """ + turn_start = 0 + for index in range(len(lines) - 1, -1, -1): + try: + entry = json.loads(lines[index]) + except json.JSONDecodeError: + continue + if entry.get("type") == "system" and entry.get("subtype") == "turn_duration": + turn_start = index + 1 + break + + user_parts: list[str] = [] + assistant_parts: list[str] = [] + + for line in lines[turn_start:]: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + message = entry.get("message") or {} + content = message.get("content") + if entry.get("type") == "user": + text = _text_from_user_content(content) + if text.strip(): + user_parts.append(text.strip()) + elif entry.get("type") == "assistant": + text = _text_from_assistant_content(content) + if text.strip(): + assistant_parts.append(text.strip()) + + return TurnContent( + user="\n\n".join(user_parts), + assistant="\n\n".join(assistant_parts), + ) + + +def read_transcript_lines(path: str) -> list[str]: + with open(path, encoding="utf-8") as handle: + return [line for line in handle.read().splitlines() if line.strip()] diff --git a/use-cases/cursor-agent-memory/hooks/_bootstrap.py b/use-cases/cursor-agent-memory/hooks/_bootstrap.py new file mode 100755 index 000000000..d2b705ad9 --- /dev/null +++ b/use-cases/cursor-agent-memory/hooks/_bootstrap.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Add hooklib/ to sys.path for hook scripts.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_HOOKS_DIR = Path(__file__).resolve().parent +_ROOT = _HOOKS_DIR +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) diff --git a/use-cases/cursor-agent-memory/hooks/flush_on_session_end.py b/use-cases/cursor-agent-memory/hooks/flush_on_session_end.py new file mode 100755 index 000000000..616b20f36 --- /dev/null +++ b/use-cases/cursor-agent-memory/hooks/flush_on_session_end.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Cursor sessionEnd hook — flush buffered messages into EverOS memory.""" + +from __future__ import annotations + +import json +import sys + +import _bootstrap # noqa: F401 # pyright: ignore[reportMissingImports] +from hooklib.config import EverOSHookConfig +from hooklib.everos_client import EverOSError, flush, health + + +def main() -> None: + raw = sys.stdin.read() + try: + hook_input = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + hook_input = {} + + cfg = EverOSHookConfig.load() + if not cfg.is_configured(): + sys.exit(0) + + conversation_id = hook_input.get("conversation_id") + if not conversation_id: + sys.exit(0) + + if not health(cfg.base_url): + if cfg.debug: + print("EverOS health check failed", file=sys.stderr) + sys.exit(0) + + session_id = cfg.session_id_for(str(conversation_id)) + try: + flush( + base_url=cfg.base_url, + session_id=session_id, + app_id=cfg.app_id, + project_id=cfg.project_id, + ) + except EverOSError as exc: + if cfg.debug: + print(f"EverOS flush failed: {exc}", file=sys.stderr) + sys.exit(0) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/use-cases/cursor-agent-memory/hooks/hooks.json.example b/use-cases/cursor-agent-memory/hooks/hooks.json.example new file mode 100644 index 000000000..68782617c --- /dev/null +++ b/use-cases/cursor-agent-memory/hooks/hooks.json.example @@ -0,0 +1,23 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "python3 .cursor/hooks/everos-memory/recall_on_session_start.py", + "timeout": 15 + } + ], + "stop": [ + { + "command": "python3 .cursor/hooks/everos-memory/save_on_stop.py", + "timeout": 30 + } + ], + "sessionEnd": [ + { + "command": "python3 .cursor/hooks/everos-memory/flush_on_session_end.py", + "timeout": 15 + } + ] + } +} diff --git a/use-cases/cursor-agent-memory/hooks/recall_on_session_start.py b/use-cases/cursor-agent-memory/hooks/recall_on_session_start.py new file mode 100755 index 000000000..b1f3e4464 --- /dev/null +++ b/use-cases/cursor-agent-memory/hooks/recall_on_session_start.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Cursor sessionStart hook — recall EverOS memories into additional_context.""" + +from __future__ import annotations + +import json +import sys + +import _bootstrap # noqa: F401 # pyright: ignore[reportMissingImports] +from hooklib.config import EverOSHookConfig +from hooklib.context import format_search_context, workspace_recall_query +from hooklib.everos_client import EverOSError, health, search + + +def main() -> None: + raw = sys.stdin.read() + try: + hook_input = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + hook_input = {} + + cfg = EverOSHookConfig.load() + if not cfg.is_configured(): + sys.exit(0) + + if not health(cfg.base_url): + if cfg.debug: + print("EverOS health check failed", file=sys.stderr) + sys.exit(0) + + roots = hook_input.get("workspace_roots") or [] + query = workspace_recall_query(roots if isinstance(roots, list) else []) + + try: + data = search( + base_url=cfg.base_url, + user_id=cfg.user_id, + query=query, + app_id=cfg.app_id, + project_id=cfg.project_id, + top_k=cfg.top_k, + min_score=cfg.min_score, + ) + except EverOSError as exc: + if cfg.debug: + print(f"EverOS search failed: {exc}", file=sys.stderr) + sys.exit(0) + + context = format_search_context(data, min_score=cfg.min_score) + if not context: + sys.exit(0) + + print(json.dumps({"additional_context": context})) + + +if __name__ == "__main__": + main() diff --git a/use-cases/cursor-agent-memory/hooks/save_on_stop.py b/use-cases/cursor-agent-memory/hooks/save_on_stop.py new file mode 100755 index 000000000..5ed3ed14e --- /dev/null +++ b/use-cases/cursor-agent-memory/hooks/save_on_stop.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Cursor stop hook — append the latest turn to EverOS via /add.""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +import _bootstrap # noqa: F401 # pyright: ignore[reportMissingImports] +from hooklib.config import EverOSHookConfig +from hooklib.everos_client import EverOSError, add_messages, health, message_item +from hooklib.transcript import extract_last_turn, read_transcript_lines + + +def _state_path(root: Path, conversation_id: str) -> Path: + state_dir = root / ".state" + state_dir.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(conversation_id.encode()).hexdigest()[:16] + return state_dir / f"{digest}.json" + + +def _already_saved(state_file: Path, fingerprint: str) -> bool: + if not state_file.is_file(): + return False + try: + state = json.loads(state_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return False + return state.get("fingerprint") == fingerprint + + +def _mark_saved(state_file: Path, fingerprint: str) -> None: + state_file.write_text( + json.dumps({"fingerprint": fingerprint}, indent=2), + encoding="utf-8", + ) + + +def main() -> None: + raw = sys.stdin.read() + try: + hook_input = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + hook_input = {} + + cfg = EverOSHookConfig.load() + if not cfg.is_configured(): + sys.exit(0) + + transcript_path = hook_input.get("transcript_path") + conversation_id = hook_input.get("conversation_id") + if not transcript_path or not conversation_id: + sys.exit(0) + + if not health(cfg.base_url): + if cfg.debug: + print("EverOS health check failed", file=sys.stderr) + sys.exit(0) + + try: + lines = read_transcript_lines(str(transcript_path)) + except OSError as exc: + if cfg.debug: + print(f"transcript read failed: {exc}", file=sys.stderr) + sys.exit(0) + + turn = extract_last_turn(lines) + if not turn.has_content: + sys.exit(0) + + hook_root = Path(__file__).resolve().parent + state_file = _state_path(hook_root, str(conversation_id)) + fingerprint = turn.fingerprint + if _already_saved(state_file, fingerprint): + sys.exit(0) + + session_id = cfg.session_id_for(str(conversation_id)) + messages: list[dict] = [] + if turn.user.strip(): + messages.append( + message_item( + sender_id=cfg.user_id, + role="user", + content=turn.user.strip(), + ) + ) + if turn.assistant.strip(): + messages.append( + message_item( + sender_id=cfg.user_id, + role="assistant", + content=turn.assistant.strip(), + ) + ) + if not messages: + sys.exit(0) + + try: + add_messages( + base_url=cfg.base_url, + session_id=session_id, + app_id=cfg.app_id, + project_id=cfg.project_id, + messages=messages, + ) + except EverOSError as exc: + if cfg.debug: + print(f"EverOS add failed: {exc}", file=sys.stderr) + sys.exit(0) + + _mark_saved(state_file, fingerprint) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/use-cases/cursor-agent-memory/install.sh b/use-cases/cursor-agent-memory/install.sh new file mode 100755 index 000000000..b908fd11e --- /dev/null +++ b/use-cases/cursor-agent-memory/install.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Install EverOS memory hooks into the current project (.cursor/hooks/everos-memory). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="${1:-.cursor/hooks/everos-memory}" + +mkdir -p "$TARGET" +cp -R "$ROOT/hooklib" "$TARGET/" +cp "$ROOT/hooks/"*.py "$TARGET/" +chmod +x "$TARGET"/*.py + +ENV_TARGET="${2:-.env}" +if [[ ! -f "$ENV_TARGET" ]] && [[ -f "$ROOT/env.example" ]]; then + cp "$ROOT/env.example" "$ENV_TARGET" + echo "Created $ENV_TARGET from env.example — fill in EverOS settings if needed." +fi + +HOOKS_JSON=".cursor/hooks.json" +if [[ ! -f "$HOOKS_JSON" ]]; then + cp "$ROOT/hooks/hooks.json.example" "$HOOKS_JSON" + echo "Created $HOOKS_JSON" +else + echo "" + echo "NOTE: $HOOKS_JSON already exists." + echo "Merge the entries from:" + echo " $ROOT/hooks/hooks.json.example" + echo "Paths assume hooks live at: $TARGET" +fi + +echo "" +echo "Installed EverOS Cursor hooks to: $TARGET" +echo "Next: start EverOS (everos server start), enable hooks in Cursor, open a new Agent chat."