Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Each chat is one ongoing conversation that survives restarts. Send `/new` to sta

The Telegram bot shows a **typing indicator** while processing, plus a silent "Working… step N" message that follows the agent's tool calls (searching, saving, reminders) and disappears once the reply arrives.

Commands: `/new` starts a fresh conversation, `/model` shows the available models as buttons (tap one to switch, or send `/model <id>`), and `/effort` does the same for the reasoning effort. Model and effort changes are saved to `~/.memclaw/.env` and keep the current conversation. They're available with the Claude backend.
Commands: `/new` starts a fresh conversation, `/model` shows the available models as buttons (tap one to switch, or send `/model <id>`), and `/effort` does the same for the reasoning effort. Model and effort changes are saved to `~/.memclaw/.env` and keep the current conversation. With Cursor, `/effort` only applies to models that expose a reasoning setting.

#### Setup

Expand Down Expand Up @@ -222,6 +222,7 @@ Each chat keeps one Claude Code process running and reuses it, so replies don't
- The wizard prompts for `CURSOR_API_KEY` and an optional `CURSOR_MODEL` (defaults to `composer-2.5`).
- Memclaw tools are exposed to the Cursor agent via a long-lived local HTTP MCP server on `127.0.0.1` (default port `17373`, override with `MEMCLAW_MCP_PORT`).
- Requests bill per token against your Cursor plan (or pay-as-you-go overage rates once your plan limit is reached), and the cost is logged with each turn.
- One Cursor SDK bridge process serves all chats, and each chat keeps one local Cursor agent open across turns. The agent stores its conversation under `~/.cursor` and is resumed after a restart.


## How It Works
Expand Down Expand Up @@ -362,7 +363,8 @@ Backend choice and credentials are covered above in [Agent Backend](#agent-backe
| `AGENT_BACKEND` | Optional | Agent SDK to use (defaults to `claude`; set to `cursor` for Cursor SDK) |
| `MEMCLAW_PLATFORM` | Optional | Front-end the bare `memclaw` launches: `telegram`, `whatsapp`, `slack`, or `terminal` (defaults to `terminal`) |
| `CURSOR_API_KEY` | For Cursor backend | Cursor API key from Dashboard → Integrations |
| `CURSOR_MODEL` | For Cursor backend | Cursor model name (defaults to `composer-2.5`) |
| `CURSOR_MODEL` | For Cursor backend | Cursor model name (defaults to `composer-2.5`; also set by `/model`) |
| `CURSOR_EFFORT` | Optional | Cursor reasoning level, for models that have one (set by `/effort`) |
| `MEMCLAW_MCP_PORT` | For Cursor backend | Local MCP HTTP port (defaults to `17373`) |
| `TELEGRAM_BOT_TOKEN` | For Telegram bot | Your Telegram bot token |
| `ALLOWED_USER_IDS` | For Telegram bot | Comma-separated Telegram user IDs |
Expand Down
13 changes: 8 additions & 5 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,16 @@ docker compose up -d --build
grows with your memory vault, so back the volume up periodically.
- The Claude backend (`AGENT_BACKEND=claude`, the default) keeps one
Claude Code CLI process running per active chat (closed on `/new`
and shutdown). The Cursor backend (`AGENT_BACKEND=cursor`) is
pure-Python and slightly leaner if you don't need Claude.
and shutdown). The Cursor backend (`AGENT_BACKEND=cursor`) runs a
single Cursor SDK bridge process (Node) shared by all chats.
- Conversations resume after a restart: `sessions.json` lives in the
data volume and Claude Code's own session files in the container's
`~/.claude`. That directory isn't on the volume, so after a rebuild
(`docker compose up -d --build` recreates the container) each chat
starts a fresh conversation. Memories are unaffected.
starts a fresh conversation. Memories are unaffected. The same goes
for the Cursor backend, whose agents are stored in the container's
`~/.cursor`.
- Telegram's `/model` and `/effort` save their choice to
`~/.memclaw/.env`. A `CLAUDE_MODEL` / `CLAUDE_EFFORT` passed in from
the compose `.env` wins over that file on the next start.
`~/.memclaw/.env`. A `CLAUDE_MODEL` / `CLAUDE_EFFORT` (or
`CURSOR_MODEL` / `CURSOR_EFFORT`) passed in from the compose `.env`
wins over that file on the next start.
13 changes: 6 additions & 7 deletions docs/memory-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ Short-term memory is the conversation itself: what you and the agent said in the

### Conversation Sessions

Each chat's conversation lives in the agent backend's own session (for Claude, a Claude Code session), not in a buffer Memclaw re-sends with every message.
Each chat's conversation lives in the agent backend's own session (for Claude, a Claude Code session; for Cursor, a local Cursor SDK agent), not in a buffer Memclaw re-sends with every message.

**How it works:**

- Every chat gets a session key: `<platform>:<chat id>` for the bots (e.g. `telegram:12345`, `slack:C01ABC`), `cli` for the interactive terminal.
- The Claude backend keeps one connected Claude CLI process per chat and reuses it for every turn, so a message doesn't pay the CLI start-up cost. Turns in the same chat are serialized.
- The Cursor backend runs one Cursor SDK bridge process for all chats and keeps one open agent per chat. The agent stores its conversation on disk (under `~/.cursor`, in a store tied to the bridge's working directory, which is pinned to the memory directory). Cursor agents have no system-prompt field, so the system prompt goes at the top of a new agent's first message; later messages carry only the context block and the message.
- The session id is saved in `~/.memclaw/sessions.json` (written atomically). After a restart, the next message in that chat resumes the same session, so the conversation carries on.
- If a session can't be resumed (deleted, created on another machine, ...), a warning is logged and a fresh session starts.
- Each saved session records a **fingerprint** of the code-level instructions (the system-prompt template and the backend). Claude Code keeps a session's original system prompt when it resumes, so a session created under different instructions (e.g. after an upgrade that changed the template) is not resumed; the chat starts fresh.
- Each saved session records a **fingerprint** of the code-level instructions (the system-prompt template and the backend). A resumed session keeps its original system prompt, so a session created under different instructions (e.g. after an upgrade that changed the template) is not resumed; the chat starts fresh.
- `/new` (Telegram command; a message of exactly `/new` in Slack, WhatsApp and the terminal) closes that chat's session and forgets its id. Your memories are untouched.
- `/model` and `/effort` (Telegram) switch the model or effort level. Live sessions reconnect on their next message, resuming the same session with the new settings, so the conversation is kept.
- `/model` and `/effort` (Telegram) switch the model or effort level, and the conversation is kept. Claude sessions reconnect on their next message, resuming the same session with the new settings; Cursor passes the model with every message, so its agents just use it. For Cursor, `/effort` is available only on models that expose a reasoning/effort parameter.

**What this means in practice:**

Expand All @@ -31,9 +32,7 @@ You: What's my dog called?
Assistant: Your dog's name is Max.
```

The conversation is bounded only by the backend's own context management (Claude Code compacts long sessions on its own). Use `/new` to start over.

The Cursor backend has no native sessions yet: as an interim measure it keeps the last 10 messages per chat in memory and folds them into each prompt. That transcript does not survive a restart.
The conversation is bounded only by the backend's own context management (e.g. Claude Code compacts long sessions on its own). Use `/new` to start over.

### System Prompt and Per-Message Context

Expand All @@ -56,7 +55,7 @@ The block stays in the session transcript, which is why it is kept small.

The session stores a hash of the AGENTS.md and MEMORY.md content it has seen (persisted in `sessions.json`). Before each message the files are hashed again; if either changed (an `update_instructions` call, a consolidation, a manual edit), its new content is included **once** in that message's context block, labelled as superseding the earlier version, and the stored hash is updated. The conversation continues without a reset. Changes the session makes itself during a turn (`memory_save` with `permanent=true`, `update_instructions`) count as seen and aren't sent back; a consolidation that rewrites MEMORY.md during a turn is.

Because Claude Code keeps a resumed session's original system prompt, the stored hashes always describe what the session has actually seen, including across restarts.
Because a resumed session keeps its original system prompt, the stored hashes always describe what the session has actually seen, including across restarts.

## Long-Term Memory

Expand Down
80 changes: 18 additions & 62 deletions memclaw/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .base import ProgressCallback, TurnResult, report_tool_step
from .claude_models import SDK_EFFORT_LEVELS, ModelInfo, fetch_models
from .mcp_tools import MCP_SERVER_NAME
from .model_selection import ModelSelectionMixin

if TYPE_CHECKING:
from rich.console import Console
Expand Down Expand Up @@ -253,9 +254,11 @@ class _Session:
options_key: tuple[str, str | None] | None = None


class ClaudeAgentBackend:
class ClaudeAgentBackend(ModelSelectionMixin):
"""Claude Agent SDK implementation of the AgentBackend protocol."""

_label = "Claude"
_env_prefix = "CLAUDE"
name: ClassVar[str] = "claude"
display_name: ClassVar[str] = "Claude Agent SDK"
supports_model_selection: ClassVar[bool] = True
Expand Down Expand Up @@ -561,55 +564,26 @@ async def reset_session(self, session_key: str) -> None:

# -- Model selection ---------------------------------------------------

@property
def model(self) -> str:
return self._model

@property
def effort(self) -> str | None:
return self._effort

async def list_models(self) -> list[ModelInfo]:
self._models = await fetch_models(_claude_auth_mode(self.config), _credential(self.config))
return self._models

async def _model_info(self, model_id: str) -> ModelInfo | None:
"""*model_id*'s entry in the model list, or None if it isn't listed
or the list can't be fetched."""
if not self._models:
try:
await self.list_models()
except Exception as exc:
logger.warning("Could not fetch the Claude model list: {exc}", exc=exc)
return None
return next((m for m in self._models if m.id == model_id), None)

async def effort_levels(self) -> list[str] | None:
info = await self._model_info(self._model)
return list(info.effort_levels) if info else None

async def set_model(self, model_id: str) -> str:
model_id = model_id.strip()
info = await self._model_info(model_id)
if info is None and not self._models:
# Can't check the id, and a typo would break every turn.
raise ValueError("Couldn't load the model list; try again later.")
if info is None:
raise ValueError(f"Unknown model '{model_id}'.")
effort = self._effort
"""Switch from the next turn on. Live chats then reconnect, resuming
their session (Claude Code resumes fine under a different model)."""
info = await self._known_model(model_id.strip())
# Keep the effort if the new model takes it, otherwise the nearest
# level it does take (or none at all).
effort = _fit_effort(info.effort_levels, self._effort or _DEFAULT_EFFORT)
note = ""
if info is not None:
# Keep the effort if the new model takes it, otherwise the nearest
# level it does take (or none at all).
effort = _fit_effort(info.effort_levels, self._effort or _DEFAULT_EFFORT)
if self._effort and effort is None:
note = f"{info.display_name} doesn't support an effort setting, so none is used."
elif self._effort and effort != self._effort:
note = (
f"{info.display_name} doesn't support '{self._effort}' effort; "
f"using '{effort}' instead."
)
self._apply_model(model_id, effort)
if self._effort and effort is None:
note = f"{info.display_name} doesn't support an effort setting, so none is used."
elif self._effort and effort != self._effort:
note = (
f"{info.display_name} doesn't support '{self._effort}' effort; "
f"using '{effort}' instead."
)
self._apply_model(info.id, effort)
return note

async def set_effort(self, level: str) -> None:
Expand All @@ -628,24 +602,6 @@ async def set_effort(self, level: str) -> None:
)
self._apply_model(self._model, level)

def _apply_model(self, model: str, effort: str | None) -> None:
"""Use *model* / *effort* from the next turn on and save them to
~/.memclaw/.env. Live chats reconnect on their next turn, resuming
their session (Claude Code resumes fine under a different model)."""
from ..setup import update_env_file # local import — setup imports backends

logger.info(
"Claude model: {m} effort: {e} -> {m2} effort: {e2}",
m=self._model, e=self._effort, m2=model, e2=effort,
)
self._model, self._effort = model, effort
self.config.claude_model = model
self.config.claude_effort = effort or ""
if effort:
update_env_file({"CLAUDE_MODEL": model, "CLAUDE_EFFORT": effort})
else:
update_env_file({"CLAUDE_MODEL": model}, drop_keys=["CLAUDE_EFFORT"])

# -- Runtime ---------------------------------------------------------

async def run_one_shot(
Expand Down
Loading
Loading