Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for use-cases/cursor-agent-memory helpers."""
53 changes: 53 additions & 0 deletions tests/unit/test_use_cases/test_cursor_agent_memory/test_context.py
Original file line number Diff line number Diff line change
@@ -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) == ""
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
136 changes: 136 additions & 0 deletions use-cases/cursor-agent-memory/README.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions use-cases/cursor-agent-memory/env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions use-cases/cursor-agent-memory/hooklib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Shared helpers for the Cursor + EverOS memory hooks use case."""
75 changes: 75 additions & 0 deletions use-cases/cursor-agent-memory/hooklib/config.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading