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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "cognee-memory",
"source": "./integrations/claude-code",
"description": "Cognee knowledge graph memory for Claude Code — session-aware storage, auto-routing recall, and persistent learning across sessions. Supports local mode and Cognee Cloud.",
"version": "1.2.6",
"version": "1.3.0",
"author": {
"name": "Cognee"
},
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ A plain `export` in the launching shell also works and overrides the file. Re-pa
the block with a new value is safe — the last value wins.

To target Cognee Cloud or a remote server instead, set `COGNEE_BASE_URL` and
`COGNEE_API_KEY` there. On startup you should see a **"Cognee Memory Connected"** message.
`COGNEE_API_KEY` there. The file may hold **both modes' variables at once** — cloud
wins by default, and `export COGNEE_BACKEND=local` (or `=cloud`) flips a single
terminal without touching the file; see
[Which mode wins, and how to switch](integrations/claude-code/README.md#which-mode-wins-and-how-to-switch).
On startup you should see a **"Cognee Memory Connected"** message.

**3. Use Claude Code as usual**

Expand Down
2 changes: 1 addition & 1 deletion integrations/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cognee-memory",
"description": "Cognee knowledge graph memory for Claude Code — session-aware storage, auto-routing recall, and persistent learning across sessions. Supports local mode and Cognee Cloud.",
"version": "1.2.6",
"version": "1.3.0",
"author": {
"name": "Cognee"
},
Expand Down
26 changes: 26 additions & 0 deletions integrations/claude-code/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ Code only offers an update when that string changes. Tag releases as
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
project adheres to [Semantic Versioning](https://semver.org/).

## [1.3.0]

### Added
- **`COGNEE_BACKEND` per-terminal mode switch.** `~/.cognee/.env` may now hold
the cloud vars (`COGNEE_BASE_URL`, `COGNEE_API_KEY`) *and* the local vars
(`LLM_API_KEY`, …) together; with nothing exported, cloud wins as before.
`export COGNEE_BACKEND=local` (or `=cloud`) flips a single terminal — the
shared name switches both the Claude Code and Codex plugins at once, while
`COGNEE_CLAUDE_BACKEND` targets this plugin only and beats the shared name.
- **Forced cloud is pinned, and misconfiguration is surfaced.** With
`COGNEE_BACKEND=cloud` but no `COGNEE_BASE_URL`, the plugin no longer
silently falls back to local (no local server boot, no venv build); the
status line shows `✕ (missing_cognee_base_url)` and `cognee doctor`'s mode
row explains what forced the decision and what is missing.

### Fixed
- **`COGNEE_CLAUDE_BACKEND=local` now holds on the HTTP hot paths.** The
switch used to clear the cloud URL only in `load_config()`'s view, while
recall/remember read `COGNEE_BASE_URL` from the environment — where the env
file had already injected the cloud URL — so those calls still went to the
cloud. A forced-local switch now scrubs `COGNEE_BASE_URL`/`COGNEE_API_KEY`
from the process environment itself (with empty strings, so re-running the
loader in child processes cannot re-inject the file's values).
- `COGNEE_CODEX_BACKEND` no longer flips this plugin: an export targeting the
Codex plugin used to switch Claude Code's backend too.

## [1.2.6]

### Changed
Expand Down
55 changes: 47 additions & 8 deletions integrations/claude-code/README.md

Large diffs are not rendered by default.

86 changes: 78 additions & 8 deletions integrations/claude-code/scripts/_env_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
cloud path, so python-dotenv is not an option. The parser accepts a leading
``export `` so users can paste their existing shell export lines verbatim.

The file may hold BOTH modes' variables at once (cloud connection + local LLM
key); with nothing exported, cloud wins because ``COGNEE_BASE_URL`` routes the
connection. One export flips a single terminal: ``COGNEE_BACKEND=local`` (or
``=cloud``), with the plugin-specific variable (``_PLUGIN_BACKEND_VAR``)
beating the shared name. A forced-local switch is applied to ``os.environ``
itself (see ``_apply_backend_switch``) so the HTTP hot paths and spawned
children — which read ``COGNEE_BASE_URL`` from the environment directly, not
via ``config.load_config`` — see the same mode.

Loading must never break a hook: any parse or IO problem results in the file
being (partially) ignored, never an exception.
"""
Expand All @@ -34,11 +43,26 @@
_DENYLIST_EXACT = {"PATH", "HOME", "PYTHONPATH", "PYTHONHOME", "SHELL", "USER"}
_DENYLIST_PREFIXES = ("LD_", "DYLD_")

# Explicit per-terminal mode switch. The shared name flips every Cognee plugin
# in the terminal; the plugin-specific name beats it when both are set. The
# plugin var is the ONE line that differs between the Claude Code and Codex
# copies of this module.
_PLUGIN_BACKEND_VAR = "COGNEE_CLAUDE_BACKEND"
_SHARED_BACKEND_VAR = "COGNEE_BACKEND"
_LOCAL_BACKEND_VALUES = ("local", "native", "sdk")
_CLOUD_BACKEND_VALUES = ("cloud", "http", "api", "server")

_TEMPLATE = """\
# Cognee plugin configuration — shared by the Claude Code and Codex plugins.
# Values here are loaded at session start and act like shell exports, except
# you only set them once. A real `export` in your shell still wins over this
# file. Lines starting with `#` are comments; a leading `export ` is allowed.
#
# You can fill in BOTH modes below. When both are configured, cloud wins.
# To pick a mode for a single terminal, export the switch before launching:
# export COGNEE_BACKEND=local # this terminal: local mode
# export COGNEE_BACKEND=cloud # this terminal: cloud mode
# (COGNEE_CLAUDE_BACKEND / COGNEE_CODEX_BACKEND target one plugin only.)

## Cloud / remote mode — point the plugins at a Cognee instance:
# COGNEE_BASE_URL="https://your-instance.cognee.ai"
Expand Down Expand Up @@ -113,7 +137,10 @@ def load_env_file() -> None:
"""Inject env-file values into os.environ (setdefault). Never raises.

Idempotent per process: repeated calls (this module is imported from
several entry-point modules) parse the file at most once.
several entry-point modules) parse the file at most once. The backend
switch is applied afterwards — and also when there is no file at all, so
``COGNEE_BACKEND=local`` beats a ``COGNEE_BASE_URL`` exported in the shell
the same way it beats one defined in the file.
"""
global _loaded
if _loaded:
Expand All @@ -122,15 +149,55 @@ def load_env_file() -> None:

try:
path = env_file_path()
if not path.is_file():
return
_tighten_permissions(path)
for key, value in parse_env_file(path).items():
if _blocked(key):
continue
os.environ.setdefault(key, value)
if path.is_file():
_tighten_permissions(path)
for key, value in parse_env_file(path).items():
if _blocked(key):
continue
os.environ.setdefault(key, value)
except Exception:
pass
_apply_backend_switch()


def forced_backend_with_source() -> tuple[str, str]:
"""The exported backend switch: ("local"|"cloud", var name), or ("", "").

The plugin-specific variable beats the shared ``COGNEE_BACKEND``; a
variable holding an unrecognized value is skipped rather than honored.
"""
for var in (_PLUGIN_BACKEND_VAR, _SHARED_BACKEND_VAR):
value = os.environ.get(var, "").strip().lower()
if value in _LOCAL_BACKEND_VALUES:
return "local", var
if value in _CLOUD_BACKEND_VALUES:
return "cloud", var
return "", ""


def forced_backend() -> str:
""""local", "cloud", or "" — the exported backend switch, if any."""
return forced_backend_with_source()[0]


def _apply_backend_switch() -> None:
"""Make a forced-local terminal actually local, everywhere.

``config.load_config`` clears base_url/api_key on the backend switch, but
the HTTP hot paths (``_plugin_common``) and every child process read
``COGNEE_BASE_URL`` from the environment directly — so the switch must land
in the environment itself. Overwrite with EMPTY strings rather than
deleting: a child re-running this loader must not re-inject the file's
cloud values (setdefault skips keys that are present, even when empty).

Forced cloud scrubs nothing: the cloud connection variables are exactly
what that mode needs, and missing ones are surfaced by the status line
rather than silently falling back to local.
"""
if forced_backend() != "local":
return
os.environ["COGNEE_BASE_URL"] = ""
os.environ["COGNEE_API_KEY"] = ""


def _tighten_permissions(path: Path) -> None:
Expand Down Expand Up @@ -171,6 +238,9 @@ def env_file_status() -> dict:
"""Diagnostics for doctor: existence, perms, and key *names* (no values)."""
path = env_file_path()
info: dict = {"path": str(path), "exists": path.is_file()}
mode, var = forced_backend_with_source()
if mode:
info["forced_backend"] = {"mode": mode, "var": var}
if not info["exists"]:
return info
try:
Expand Down
36 changes: 34 additions & 2 deletions integrations/claude-code/scripts/cognee_statusline_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
from pathlib import Path
from urllib.parse import urlparse

from _env_file import load_env_file
from _env_file import forced_backend, load_env_file

# ~/.cognee/.env is pure-local too, so loading it here keeps the renderer's
# no-network/no-_plugin_common contract while honoring one-time config.
# load_env_file also applies the COGNEE_BACKEND switch (forced local scrubs the
# cloud connection vars), so the mode shown below matches what the hooks use.
load_env_file()

_SHARED_ROOT = Path.home() / ".cognee-plugin"
Expand Down Expand Up @@ -95,6 +97,13 @@ def _active_dataset() -> str:


def _active_mode() -> str:
# 0. explicit backend switch: forced local always reads local (the env
# scrub in load_env_file guarantees it, this is just the direct answer);
# forced cloud with no URL to inspect reads cloud — the misconfig glyph
# (see _forced_cloud_unconfigured) reports what is missing.
forced = forced_backend()
if forced == "local":
return "local"
# 1. env var
url = os.environ.get("COGNEE_BASE_URL", "").strip()
# 2. config file
Expand All @@ -106,7 +115,7 @@ def _active_mode() -> str:
except Exception:
pass
if not url:
return "local"
return "cloud" if forced == "cloud" else "local"
return "local" if (urlparse(url).hostname or "") in _LOOPBACK else "cloud"


Expand Down Expand Up @@ -231,6 +240,7 @@ def _connection_marker(session_id: str) -> dict:
# records which of the two it was.
_COGNEE_KEY_REASON = "incorrect_cognee_api_key"
_LLM_KEY_REASON = "incorrect_llm_api_key"
_MISSING_URL_REASON = "missing_cognee_base_url"
_REASON_LABELS = {"auth_failed": _COGNEE_KEY_REASON}


Expand Down Expand Up @@ -687,17 +697,39 @@ def _credits_segment() -> str:
return seg


def _forced_cloud_unconfigured() -> bool:
"""Forced cloud (backend switch) with no URL anywhere: nothing to connect
to — a definitive misconfiguration this renderer can see directly from
env + config.json, without waiting for a hook to record a failed attempt.
"""
if forced_backend() != "cloud":
return False
if os.environ.get("COGNEE_BASE_URL", "").strip():
return False
try:
data = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(data, dict) and str(data.get("base_url") or "").strip():
return False
except Exception:
pass
return True


def _status_prefix(session_id: str = "") -> str:
"""The single left glyph slot shared by the server- and LLM-key signals.

One slot, by precedence — showing a green ● next to an ✕ would read as
contradictory:
0. forced cloud with no URL configured: a misconfiguration this renderer
can prove on its own — the precise reason beats any marker-derived one
1. a server-connection failure wins: if we can't reach or authenticate
against the server, its LLM key is not the actionable problem
2. otherwise an LLM-key failure, which *replaces* the green ● (the
``llm_*`` reason already says the server side itself is fine)
3. otherwise whatever the server signal is (``● `` or nothing).
"""
if _forced_cloud_unconfigured():
return _fail_glyph(_MISSING_URL_REASON)
server = _health_prefix(session_id)
# Membership, not startswith: the glyph is now preceded by its colour escape.
if "✕" in server:
Expand Down
29 changes: 24 additions & 5 deletions integrations/claude-code/scripts/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
3. Config file (~/.cognee-plugin/config.json)
4. Defaults

The env file may hold both modes' variables at once; cloud wins when both are
configured. `export COGNEE_BACKEND=local` (or `=cloud`) flips one terminal —
COGNEE_CLAUDE_BACKEND does the same for this plugin only, beating the shared
name. A forced mode is pinned: forced local scrubs the cloud connection vars
from the process environment (see _env_file), and forced cloud keeps
is_cloud_mode() true even when connection vars are missing, so the plugin
attempts the cloud connection and the status line reports what is wrong
instead of silently falling back to local.

Config file is created on first SessionStart if it doesn't exist.

Supports three modes:
Expand Down Expand Up @@ -84,8 +93,12 @@ def _config_log(event: str, detail: dict | None = None) -> None:

# Env var overrides (env var name → config key)
_ENV_MAP = {
# Backend switch: the shared name is scanned first so the plugin-specific
# one, applied later, wins when both are exported. COGNEE_CODEX_BACKEND is
# deliberately absent — an export targeting the Codex plugin must not flip
# this one.
"COGNEE_BACKEND": "backend",
"COGNEE_CLAUDE_BACKEND": "backend",
"COGNEE_CODEX_BACKEND": "backend",
"COGNEE_AGENT_NAME": "agent_name",
"COGNEE_PLUGIN_DATASET": "dataset",
"COGNEE_SESSION_STRATEGY": "session_strategy",
Expand Down Expand Up @@ -144,8 +157,14 @@ def load_config() -> dict:
if backend in ("native", "local", "sdk"):
config["base_url"] = ""
config["api_key"] = ""
config["base_url"] = ""
elif backend not in ("http", "api", "cloud", "server"):
config["_forced_backend"] = "local"
elif backend in ("http", "api", "cloud", "server"):
# Forced cloud is pinned even when connection vars are missing:
# is_cloud_mode() honors this flag, so the plugin attempts the cloud
# connection (and the status line reports the failure) instead of
# silently falling back to local.
config["_forced_backend"] = "cloud"
else:
# The service URL is the sole router: a URL alone is a complete
# instruction (connect to it, or boot it if local; auth falls back to
# the default user when no key is given). A key with no URL has nothing
Expand Down Expand Up @@ -202,8 +221,8 @@ def get_dataset(config: dict) -> str:


def is_cloud_mode(config: dict) -> bool:
"""Check if cloud/remote mode is configured."""
return bool(config.get("base_url"))
"""Check if cloud/remote mode is configured (or forced by the backend switch)."""
return bool(config.get("base_url")) or config.get("_forced_backend") == "cloud"


def is_local_mode(config: dict) -> bool:
Expand Down
27 changes: 23 additions & 4 deletions integrations/claude-code/scripts/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ def _resolve_local_cognee_version() -> str:
def _resolve_mode() -> str:
"""Return the resolved operating mode: Local, Local Managed, or Cloud.

- No base_url configured → Local
- No base_url configured → Local (or Cloud when the backend switch forces
cloud — the mode is pinned even though there is nothing to connect to)
- base_url pointing to localhost / 127.0.0.1 / ::1 → Local Managed
- Remote base_url → Cloud
"""
Expand All @@ -68,7 +69,7 @@ def _resolve_mode() -> str:
base_url = str(cfg.get("base_url") or "").strip()

if not base_url:
return "Local"
return "Cloud" if cfg.get("_forced_backend") == "cloud" else "Local"

hostname = urllib.parse.urlparse(base_url).hostname or ""
if hostname in ("localhost", "127.0.0.1", "::1"):
Expand All @@ -77,16 +78,34 @@ def _resolve_mode() -> str:
return "Cloud"


def _mode_annotation() -> str:
"""Suffix for the mode row when the backend switch forced the decision."""
from _env_file import forced_backend_with_source
from config import load_config

forced, var = forced_backend_with_source()
if not forced:
return ""
note = f" — forced by {var}={forced}"
if forced == "cloud" and not str(load_config().get("base_url") or "").strip():
note += " (missing COGNEE_BASE_URL — nothing to connect to)"
return note


def _resolve_server_url() -> tuple:
"""Return (display_url, raw_url).

In local mode the display value is "-" (no remote server), but the
raw_url is still resolved so the health-check can probe localhost.
Forced cloud with no URL configured has nothing to probe at all.
"""
from _plugin_common import _local_api_url_with_source
from config import load_config

url, _source = _local_api_url_with_source()
mode = _resolve_mode()
if mode == "Cloud" and not str(load_config().get("base_url") or "").strip():
return "-", ""
url, _source = _local_api_url_with_source()
display = "-" if mode == "Local" else url
return display, url

Expand Down Expand Up @@ -207,7 +226,7 @@ def collect_report() -> dict:
embedding_model, embedding_dimensions = _resolve_embedding()

return {
"mode": mode,
"mode": mode + _mode_annotation(),
"env_file": _resolve_env_file(),
"server_url": display_url if display_url != "-" else None,
"api_key_source": api_key_source,
Expand Down
Loading
Loading