From 04d71cf1253acf8d6394e572664821867146d9d1 Mon Sep 17 00:00:00 2001 From: Andrej Milicevic Date: Thu, 13 Aug 2026 16:43:22 +0200 Subject: [PATCH 1/2] feat: clarify and simplify switching between cloud and local --- .claude-plugin/marketplace.json | 2 +- README.md | 6 +- .../claude-code/.claude-plugin/plugin.json | 2 +- integrations/claude-code/CHANGELOG.md | 26 ++ integrations/claude-code/README.md | 55 +++- integrations/claude-code/scripts/_env_file.py | 86 +++++- .../scripts/cognee_statusline_render.py | 36 ++- integrations/claude-code/scripts/config.py | 29 +- integrations/claude-code/scripts/doctor.py | 27 +- .../claude-code/scripts/session-start.py | 15 +- integrations/codex/README.md | 54 +++- .../plugins/cognee/.codex-plugin/plugin.json | 2 +- .../codex/plugins/cognee/CHANGELOG.md | 24 ++ .../codex/plugins/cognee/scripts/_env_file.py | 86 +++++- .../scripts/cognee_statusline_render.py | 36 ++- .../codex/plugins/cognee/scripts/config.py | 28 +- .../codex/plugins/cognee/scripts/doctor.py | 29 +- .../plugins/cognee/scripts/session-start.py | 15 +- .../plugins/cognee/skills/setup/SKILL.md | 6 + .../tests/tests/unit/test_backend_switch.py | 248 ++++++++++++++++++ 20 files changed, 746 insertions(+), 66 deletions(-) create mode 100644 integrations/tests/tests/unit/test_backend_switch.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6942ea34..11a477f7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" }, diff --git a/README.md b/README.md index a94a59f0..230db6d1 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index 98195970..be5c159b 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -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" }, diff --git a/integrations/claude-code/CHANGELOG.md b/integrations/claude-code/CHANGELOG.md index 831f1197..77e57cb2 100644 --- a/integrations/claude-code/CHANGELOG.md +++ b/integrations/claude-code/CHANGELOG.md @@ -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 diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md index 9fa9e832..129fd5e3 100644 --- a/integrations/claude-code/README.md +++ b/integrations/claude-code/README.md @@ -18,7 +18,7 @@ claude plugin install cognee-memory@cognee These CLI subcommands use the exact same plugin manager as the in-chat `/plugin` commands — same marketplace clone, same install cache, same global state — they just run before you enter the terminal. Default install scope is `user` (global); pass `--scope project` or `--scope local` to confine it. -Then configure your runtime mode — **once** — in `~/.cognee/.env`. The file is created with a commented template on the first session start; values in it act exactly like shell exports (a real `export` in your shell still overrides the file, per terminal). It is shared with the Codex plugin, so both read the same configuration. Lines may optionally start with `export `, so existing export lines can be pasted verbatim. +Then configure your runtime mode — **once** — in `~/.cognee/.env`. The file is created with a commented template on the first session start; values in it act exactly like shell exports (a real `export` in your shell still overrides the file, per terminal). It is shared with the Codex plugin, so both read the same configuration. Lines may optionally start with `export `, so existing export lines can be pasted verbatim. Pick one of the two modes below — or configure **both** and flip a terminal with a single export (see [Which mode wins, and how to switch](#which-mode-wins-and-how-to-switch)). **Cognee Cloud or a remote server** — set both (one paste, no editor needed): @@ -53,7 +53,32 @@ COGNEE_API_KEY="ck_..." '@ | Add-Content "$env:USERPROFILE\.cognee\.env" ``` -Re-running any of these blocks is safe: when a key appears more than once, the **last value wins**, so pasting again with a new value updates the configuration. Editing the file directly (`nano ~/.cognee/.env`) works too — e.g. to remove a variable such as `COGNEE_BASE_URL` when switching from cloud back to local mode. Changes apply on the next session launch. Plain shell `export`s in the launching terminal still take precedence over `~/.cognee/.env` — useful to override the shared config for one terminal. See [Managing the env file](#managing-the-env-file) for the file format and how to add, change, or remove variables later. +Re-running any of these blocks is safe: when a key appears more than once, the **last value wins**, so pasting again with a new value updates the configuration. Editing the file directly (`nano ~/.cognee/.env`) works too. Changes apply on the next session launch. Plain shell `export`s in the launching terminal still take precedence over `~/.cognee/.env` — useful to override the shared config for one terminal. See [Managing the env file](#managing-the-env-file) for the file format and how to add, change, or remove variables later. + +### Which mode wins, and how to switch + +You can configure **both modes at once** — keep `COGNEE_BASE_URL` + `COGNEE_API_KEY` *and* `LLM_API_KEY` in the file together. The mode is then decided per terminal, by three rules in order: + +1. **A `COGNEE_BACKEND` export wins.** `export COGNEE_BACKEND=local` (or `=cloud`) pins that terminal to that mode. Nothing else needs to change. +2. **Otherwise, cloud wins when configured.** If `COGNEE_BASE_URL` is set (in the file or the shell), the plugin connects to it. +3. **Otherwise, local.** With no URL anywhere, the plugin boots the local server. + +So with both modes in `~/.cognee/.env`: + +```bash +claude # → cloud (the configured URL routes) +COGNEE_BACKEND=local claude # → local, this launch only +export COGNEE_BACKEND=local # → local for every launch from this shell +``` + +Details worth knowing: + +- **The switch is pinned.** `COGNEE_BACKEND=cloud` with no `COGNEE_BASE_URL` configured still counts as cloud: the plugin does **not** silently fall back to local, and the status line shows `✕ (missing_cognee_base_url)` so you know exactly what to fix. +- **To go local, use the switch — not `unset COGNEE_BASE_URL`.** Unsetting doesn't work: the env file re-injects the URL at the next launch. (Deleting the line from the file works, but that changes the default for *every* terminal.) +- The shared `COGNEE_BACKEND` flips both the Claude Code **and** Codex plugins in that terminal. To flip only one, use `COGNEE_CLAUDE_BACKEND` / `COGNEE_CODEX_BACKEND` — the plugin-specific name beats the shared one. +- Accepted values: `local` (aliases: `native`, `sdk`) and `cloud` (aliases: `http`, `api`, `server`). Anything else is ignored. +- `COGNEE_BACKEND` can also live in `~/.cognee/.env` to make a mode the durable default; a shell export still overrides it per terminal. +- Not sure what a terminal resolved? The status line's mode field shows it live, and `doctor.py` prints the decision with its cause, e.g. `Mode: Local — forced by COGNEE_BACKEND=local`. You can also set config in `~/.cognee-plugin/claude-code/config.json`: @@ -82,9 +107,12 @@ Key resolution order: ## Mode selection rules At startup (`SessionStart`): -- `COGNEE_BASE_URL` set → `managed_endpoint`, either local, or on Cognee Cloud (API key needed in cloud case) +- `COGNEE_BACKEND` (or `COGNEE_CLAUDE_BACKEND`) exported → that mode, pinned — see [Which mode wins](#which-mode-wins-and-how-to-switch) +- otherwise `COGNEE_BASE_URL` set → `managed_endpoint`, either local, or on Cognee Cloud (API key needed in cloud case) - otherwise → `integration_local` (local API bootstrap) +A forced-local switch also scrubs `COGNEE_BASE_URL`/`COGNEE_API_KEY` from the process environment, so the per-prompt recall/remember calls and every spawned worker resolve the same local endpoint — not just `SessionStart`. A forced-cloud switch with no URL configured never boots the local server; the connection attempt fails visibly instead (status line + doctor). + At hook runtime: - hooks resolve mode through runtime endpoint auth (env + `api_key.json`), not only config intent - `http` mode skips local SDK initialization @@ -215,7 +243,7 @@ cognee: agent_sessions · local cognee: my-project · cloud ``` -`` is the active Cognee dataset. `` is `local` when no `COGNEE_BASE_URL` is set or when it points to localhost, and `cloud` when it points to a remote host. The mode is rendered **bold and coloured** — cyan for `local`, magenta for `cloud` — because it is the one field worth a double-take: it tells you which memory you are about to write to. (Red/green/amber are left to the health glyph and the warnings; bold and colour are set together so a terminal that ignores one still shows the other.) +`` is the active Cognee dataset. `` is `local` when no `COGNEE_BASE_URL` is set or when it points to localhost, and `cloud` when it points to a remote host; an exported `COGNEE_BACKEND` / `COGNEE_CLAUDE_BACKEND` switch overrides that, so the bar always shows the mode the terminal actually resolved. The mode is rendered **bold and coloured** — cyan for `local`, magenta for `cloud` — because it is the one field worth a double-take: it tells you which memory you are about to write to. (Red/green/amber are left to the health glyph and the warnings; bold and colour are set together so a terminal that ignores one still shows the other.) A connection glyph precedes the line: @@ -225,9 +253,10 @@ A connection glyph precedes the line: ✕ (unreachable) cognee: … · cloud # server positively absent (connection refused / DNS) ✕ (server_error) cognee: … · cloud # server returned a 5xx ✕ (not_responding) cognee: … · cloud # server up, but N consecutive recalls timed out +✕ (missing_cognee_base_url) cognee: … · cloud # COGNEE_BACKEND=cloud is exported, but no COGNEE_BASE_URL is configured ``` -`●` shows once the server is confirmed up **and** authenticated. On a failure the glyph flips to `✕ ()` — `incorrect_cognee_api_key` (a missing, wrong, or expired `COGNEE_API_KEY`), `unreachable` (server positively absent: connection refused or DNS failure, including a server that dies mid-session), `server_error` (5xx), or `not_responding` (the server accepts connections but hasn't answered for several consecutive prompts — a single slow response never triggers it, so a busy server is not misreported as unreachable). The state is recorded by the hooks that already talk to the server (SessionStart, and the per-prompt recall), so the line stays green until a failure is actually observed, and clears back to `●` on the next success. The glyph is read from local state only — no network on refresh. It is **colour-coded**: a bold green `●` when the connection is confirmed good, and a bold red `✕ ()` — reason included, so the whole verdict reads as one unit — when it is confirmed bad. The LLM-key failure is red as well — the two are told apart by the reason itself (`incorrect_cognee_api_key` for the key this plugin uses to reach the server, `incorrect_llm_api_key` for the key the local server uses to reach the LLM) rather than by colour. +`●` shows once the server is confirmed up **and** authenticated. On a failure the glyph flips to `✕ ()` — `incorrect_cognee_api_key` (a missing, wrong, or expired `COGNEE_API_KEY`), `unreachable` (server positively absent: connection refused or DNS failure, including a server that dies mid-session), `server_error` (5xx), or `not_responding` (the server accepts connections but hasn't answered for several consecutive prompts — a single slow response never triggers it, so a busy server is not misreported as unreachable). One reason is special: `missing_cognee_base_url` means the terminal was pinned to cloud with the `COGNEE_BACKEND` switch but no `COGNEE_BASE_URL` is configured anywhere — a misconfiguration the renderer proves directly from the environment, shown immediately rather than after a failed connection attempt. The state is recorded by the hooks that already talk to the server (SessionStart, and the per-prompt recall), so the line stays green until a failure is actually observed, and clears back to `●` on the next success. The glyph is read from local state only — no network on refresh. It is **colour-coded**: a bold green `●` when the connection is confirmed good, and a bold red `✕ ()` — reason included, so the whole verdict reads as one unit — when it is confirmed bad. The LLM-key failure is red as well — the two are told apart by the reason itself (`incorrect_cognee_api_key` for the key this plugin uses to reach the server, `incorrect_llm_api_key` for the key the local server uses to reach the LLM) rather than by colour. | Env var | Default | Effect | |---|---|---| @@ -287,7 +316,7 @@ The entry sets `refreshInterval: 2`, so Claude re-runs the (network-free, local- The status line reads only local state — no network calls on every refresh: 1. Dataset: `COGNEE_PLUGIN_DATASET` env var, otherwise `agent_sessions` -2. Mode: `COGNEE_BASE_URL` env var, then `~/.cognee-plugin/claude-code/config.json` (`base_url`) +2. Mode: `COGNEE_BACKEND` / `COGNEE_CLAUDE_BACKEND` switch, then `COGNEE_BASE_URL` env var, then `~/.cognee-plugin/claude-code/config.json` (`base_url`) 3. Default mode: `local` 4. Connection glyph: `conn-state/.json`, then `server-ready.json` + `recall-breaker.json` 5. LLM key: `llm-state/.json`, then `llm-state.json` @@ -404,6 +433,12 @@ skips local-path (dev) installs. Turn it off with `COGNEE_UPDATE_CHECK=false`. ## Troubleshooting +**Terminal connects to cloud when you wanted local (or the reverse)** +- The mode is routed by `COGNEE_BASE_URL`: configured anywhere (env file or shell) → cloud; otherwise → local. An exported `COGNEE_BACKEND=local` / `=cloud` overrides that for the terminal — see [Which mode wins](#which-mode-wins-and-how-to-switch). +- `unset COGNEE_BASE_URL` does **not** go local: the env file re-injects the URL at the next launch. Export `COGNEE_BACKEND=local` instead. +- Check what the terminal actually resolved: the status line's mode field shows it live, and `doctor.py` prints the decision with its cause (`Mode: Local — forced by COGNEE_BACKEND=local`). +- If a mode seems stuck, check for a forgotten `COGNEE_BACKEND` / `COGNEE_CLAUDE_BACKEND` export in the shell or in `~/.cognee/.env` — the plugin-specific name silently beats the shared one. + **Recall returns empty but data was ingested** - Recall is scoped to the active dataset (`COGNEE_PLUGIN_DATASET` / `agent_sessions`). - Data written via the Python SDK or `client.py` goes to `default_dataset` by default, if dataset not otherwise specified. @@ -433,7 +468,9 @@ Config precedence: 3. `~/.cognee-plugin/claude-code/config.json` 4. defaults -`~/.cognee/.env` is created with a commented template on first session start (permissions `0600`; the path can be overridden with `COGNEE_ENV_FILE`). Run `doctor.py` to see which keys the file defines and which are overridden by shell exports. +One exception sits above all four layers: the `COGNEE_BACKEND` / `COGNEE_CLAUDE_BACKEND` mode switch. When exported, it pins the mode regardless of where the connection variables are defined — forced local ignores a configured `COGNEE_BASE_URL` entirely (it is scrubbed from the process environment), and forced cloud stays cloud even when the URL is missing. See [Which mode wins](#which-mode-wins-and-how-to-switch). + +`~/.cognee/.env` is created with a commented template on first session start (permissions `0600`; the path can be overridden with `COGNEE_ENV_FILE`). Run `doctor.py` to see which keys the file defines, which are overridden by shell exports, and — in the mode row — whether a backend switch forced the mode decision. ### Managing the env file @@ -473,7 +510,7 @@ export LLM_API_KEY="sk-..." # a leading 'export ' is tolerated, so Keys are letters, digits, and underscores. Values are taken literally — no `$VAR` interpolation, no multi-line values. Malformed lines are skipped silently, never fatal, and process-critical variables (`PATH` and friends) are ignored by design. -**Remove a variable** — delete (or comment out) its line in the editor. The common case is switching from cloud back to local mode: remove the `COGNEE_BASE_URL` line and make sure `LLM_API_KEY` is set. +**Remove a variable** — delete (or comment out) its line in the editor. To switch modes you usually don't need to remove anything: keep both modes' variables in the file and export the switch instead — `export COGNEE_BACKEND=local` (see [Which mode wins](#which-mode-wins-and-how-to-switch)). Remove the `COGNEE_BASE_URL` line only when you want local to become the permanent default for every terminal. **Apply and verify** — the file is read at session start, so changes take effect on the next `claude` launch. If a value seems to be ignored, check whether the same variable is `export`ed in your shell: real exports always win over the file. The doctor's **Env File** row lists which keys the file defines and flags any that a shell export is overriding. @@ -485,6 +522,8 @@ Keys are letters, digits, and underscores. Values are taken literally — no `$V | `session_prefix` | `COGNEE_SESSION_PREFIX` | `cc` | Prefix for auto-generated session IDs | | `base_url` | `COGNEE_BASE_URL` | unset | Set to enable managed endpoint mode | | `api_key` | `COGNEE_API_KEY` | unset | API key; auto-minted if absent in local mode | +| mode switch | `COGNEE_BACKEND` | unset | `local` or `cloud` — pins the terminal's mode, overriding the URL rule; flips the Claude Code **and** Codex plugins | +| plugin-only mode switch | `COGNEE_CLAUDE_BACKEND` | unset | Same, for this plugin only; beats `COGNEE_BACKEND` | | local URL override | `COGNEE_LOCAL_API_URL` | `http://localhost:8011` | Local API base URL | | local LLM | `LLM_API_KEY`, `LLM_MODEL` | unset | Required for local mode runtime | | demo auto-clear | `COGNEE_CLAUDE_CLEAR_AFTER_MESSAGE` | disabled | Clear transcript on Stop after capture | diff --git a/integrations/claude-code/scripts/_env_file.py b/integrations/claude-code/scripts/_env_file.py index 47724e19..eec787e7 100644 --- a/integrations/claude-code/scripts/_env_file.py +++ b/integrations/claude-code/scripts/_env_file.py @@ -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. """ @@ -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" @@ -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: @@ -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: @@ -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: diff --git a/integrations/claude-code/scripts/cognee_statusline_render.py b/integrations/claude-code/scripts/cognee_statusline_render.py index 0071b883..f5763d89 100644 --- a/integrations/claude-code/scripts/cognee_statusline_render.py +++ b/integrations/claude-code/scripts/cognee_statusline_render.py @@ -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" @@ -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 @@ -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" @@ -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} @@ -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: diff --git a/integrations/claude-code/scripts/config.py b/integrations/claude-code/scripts/config.py index 2f8e0833..05f926a5 100644 --- a/integrations/claude-code/scripts/config.py +++ b/integrations/claude-code/scripts/config.py @@ -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: @@ -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", @@ -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 @@ -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: diff --git a/integrations/claude-code/scripts/doctor.py b/integrations/claude-code/scripts/doctor.py index 393258cf..acdaf405 100644 --- a/integrations/claude-code/scripts/doctor.py +++ b/integrations/claude-code/scripts/doctor.py @@ -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 """ @@ -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"): @@ -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 @@ -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, diff --git a/integrations/claude-code/scripts/session-start.py b/integrations/claude-code/scripts/session-start.py index 8860ed32..91ad0b91 100644 --- a/integrations/claude-code/scripts/session-start.py +++ b/integrations/claude-code/scripts/session-start.py @@ -1342,7 +1342,12 @@ async def _start(payload: dict | None = None) -> dict: # never claims ownership of its teardown. configured_url = _with_scheme(str(config.get("base_url", "") or "").strip()) api_key = str(config.get("api_key", "") or "").strip() - target_url = configured_url or _LOCAL_SERVICE_URL + # Forced cloud (backend switch) with no URL configured: there is nothing to + # boot and nothing to fall back to — keep the target empty so no local + # server is spawned, let the connection attempt fail, and let the status + # line report the missing URL. Everything else keeps the localhost default. + forced_cloud_unconfigured = not configured_url and config.get("_forced_backend") == "cloud" + target_url = configured_url or ("" if forced_cloud_unconfigured else _LOCAL_SERVICE_URL) config["base_url"] = target_url os.environ["COGNEE_BASE_URL"] = target_url if api_key: @@ -1401,8 +1406,10 @@ async def _start(payload: dict | None = None) -> dict: # * down + remote URL -> can't boot a remote host; connect and degrade user_id = "" agent_api_key = "" - server_live = _health_ok(_health_url(target_url)) - will_boot = (not server_live) and _is_local_url(target_url) + # An empty target (forced cloud, no URL) must never boot: _is_local_url("") + # parses to localhost, so gate on the URL being present at all. + server_live = bool(target_url) and _health_ok(_health_url(target_url)) + will_boot = (not server_live) and bool(target_url) and _is_local_url(target_url) hook_log( "endpoint_mode_selected", {"base_url": target_url, "server_live": server_live, "will_boot": will_boot}, @@ -1422,7 +1429,7 @@ async def _start(payload: dict | None = None) -> dict: boot_timeout=_HEALTH_TIMEOUT_SECONDS, ) if not ok: - if _LAZY_BOOTSTRAP and _is_local_url(target_url): + if _LAZY_BOOTSTRAP and target_url and _is_local_url(target_url): # Inline attempt failed; retry the heavy path out of band. _spawn_bootstrap(config, cwd, session_id, agent_session_name, session_key, dataset) else: diff --git a/integrations/codex/README.md b/integrations/codex/README.md index 08c1a3ee..1e64a2bc 100644 --- a/integrations/codex/README.md +++ b/integrations/codex/README.md @@ -30,7 +30,7 @@ codex plugin marketplace add topoteretes/cognee-integrations --ref main codex plugin add cognee@cognee ``` -Then configure your runtime mode — **once** — in `~/.cognee/.env`. The file is created with a commented template on the first session start; values in it act exactly like shell exports (a real `export` in your shell still overrides the file, per terminal). It is shared with the Claude Code plugin, so both read the same configuration. Lines may optionally start with `export `, so existing export lines can be pasted verbatim. +Then configure your runtime mode — **once** — in `~/.cognee/.env`. The file is created with a commented template on the first session start; values in it act exactly like shell exports (a real `export` in your shell still overrides the file, per terminal). It is shared with the Claude Code plugin, so both read the same configuration. Lines may optionally start with `export `, so existing export lines can be pasted verbatim. Pick one of the two modes below — or configure **both** and flip a terminal with a single export (see [Which mode wins, and how to switch](#which-mode-wins-and-how-to-switch)). **Cognee Cloud or a remote server** — set both (one paste, no editor needed): @@ -65,7 +65,32 @@ COGNEE_API_KEY="ck_..." '@ | Add-Content "$env:USERPROFILE\.cognee\.env" ``` -Re-running any of these blocks is safe: when a key appears more than once, the **last value wins**, so pasting again with a new value updates the configuration. Editing the file directly (`nano ~/.cognee/.env`) works too — e.g. to remove a variable such as `COGNEE_BASE_URL` when switching from cloud back to local mode. Changes apply on the next session launch. Plain shell `export`s in the launching terminal still take precedence over `~/.cognee/.env` — useful to override the shared config for one terminal. See [Managing the env file](#managing-the-env-file) for the file format and how to add, change, or remove variables later. +Re-running any of these blocks is safe: when a key appears more than once, the **last value wins**, so pasting again with a new value updates the configuration. Editing the file directly (`nano ~/.cognee/.env`) works too. Changes apply on the next session launch. Plain shell `export`s in the launching terminal still take precedence over `~/.cognee/.env` — useful to override the shared config for one terminal. See [Managing the env file](#managing-the-env-file) for the file format and how to add, change, or remove variables later. + +### Which mode wins, and how to switch + +You can configure **both modes at once** — keep `COGNEE_BASE_URL` + `COGNEE_API_KEY` *and* `LLM_API_KEY` in the file together. The mode is then decided per terminal, by three rules in order: + +1. **A `COGNEE_BACKEND` export wins.** `export COGNEE_BACKEND=local` (or `=cloud`) pins that terminal to that mode. Nothing else needs to change. +2. **Otherwise, cloud wins when configured.** If `COGNEE_BASE_URL` is set (in the file or the shell), the plugin connects to it. +3. **Otherwise, local.** With no URL anywhere, the plugin boots the local server. + +So with both modes in `~/.cognee/.env`: + +```bash +codex # → cloud (the configured URL routes) +COGNEE_BACKEND=local codex # → local, this launch only +export COGNEE_BACKEND=local # → local for every launch from this shell +``` + +Details worth knowing: + +- **The switch is pinned.** `COGNEE_BACKEND=cloud` with no `COGNEE_BASE_URL` configured still counts as cloud: the plugin does **not** silently fall back to local, and the status line shows `✕ (missing_cognee_base_url)` so you know exactly what to fix. +- **To go local, use the switch — not `unset COGNEE_BASE_URL`.** Unsetting doesn't work: the env file re-injects the URL at the next launch. (Deleting the line from the file works, but that changes the default for *every* terminal.) +- The shared `COGNEE_BACKEND` flips both the Codex **and** Claude Code plugins in that terminal. To flip only one, use `COGNEE_CODEX_BACKEND` / `COGNEE_CLAUDE_BACKEND` — the plugin-specific name beats the shared one. +- Accepted values: `local` (aliases: `native`, `sdk`) and `cloud` (aliases: `http`, `api`, `server`). Anything else is ignored. +- `COGNEE_BACKEND` can also live in `~/.cognee/.env` to make a mode the durable default; a shell export still overrides it per terminal. +- Not sure what a terminal resolved? The status line's mode field shows it live, and `doctor.py` prints the decision with its cause, e.g. `Mode: Local — forced by COGNEE_BACKEND=local`. You can also set config in `~/.cognee-plugin/config.json`: @@ -90,9 +115,12 @@ Key resolution order: ## Mode selection rules At startup (`SessionStart`): -- `COGNEE_BASE_URL` set → `managed_endpoint` +- `COGNEE_BACKEND` (or `COGNEE_CODEX_BACKEND`) exported → that mode, pinned — see [Which mode wins](#which-mode-wins-and-how-to-switch) +- otherwise `COGNEE_BASE_URL` set → `managed_endpoint` - otherwise → `integration_local` (local API bootstrap) +A forced-local switch also scrubs `COGNEE_BASE_URL`/`COGNEE_API_KEY` from the process environment, so the per-prompt recall/remember calls and every spawned worker resolve the same local endpoint — not just `SessionStart`. A forced-cloud switch with no URL configured never boots the local server; the connection attempt fails visibly instead (status + doctor). + At hook runtime: - hooks resolve the endpoint from env, then `config.json`, with localhost as the default - hooks resolve auth from env, then the URL-scoped `api_key.json` cache @@ -177,9 +205,9 @@ cognee: agent_sessions · local cognee: my-project · cloud ``` -`` is the active Cognee dataset. `` is `local` when no `COGNEE_BASE_URL` is set or when it points to localhost, and `cloud` when it points to a remote host. +`` is the active Cognee dataset. `` is `local` when no `COGNEE_BASE_URL` is set or when it points to localhost, and `cloud` when it points to a remote host; an exported `COGNEE_BACKEND` / `COGNEE_CODEX_BACKEND` switch overrides that, so the status always shows the mode the terminal actually resolved. -A connection glyph precedes the line: `●` once the server is confirmed up **and** authenticated, or `✕ ()` on failure — `incorrect_cognee_api_key` (a missing, wrong, or expired `COGNEE_API_KEY`), `unreachable` (server positively absent: connection refused or DNS failure, including a server that dies mid-session), `server_error` (5xx), or `not_responding` (the server accepts connections but hasn't answered for several consecutive prompts — a single slow response never triggers it, so a busy server is not misreported as unreachable). The state is recorded by the hooks that already talk to the server (SessionStart, and the per-prompt recall), so it stays green until a failure is actually observed and clears back to `●` on the next success. Read from local state only — no network on refresh. +A connection glyph precedes the line: `●` once the server is confirmed up **and** authenticated, or `✕ ()` on failure — `incorrect_cognee_api_key` (a missing, wrong, or expired `COGNEE_API_KEY`), `unreachable` (server positively absent: connection refused or DNS failure, including a server that dies mid-session), `server_error` (5xx), `not_responding` (the server accepts connections but hasn't answered for several consecutive prompts — a single slow response never triggers it, so a busy server is not misreported as unreachable), or `missing_cognee_base_url` (the terminal was pinned to cloud with the `COGNEE_BACKEND` switch but no `COGNEE_BASE_URL` is configured anywhere — a misconfiguration proven directly from the environment and shown immediately, not after a failed connection attempt). The state is recorded by the hooks that already talk to the server (SessionStart, and the per-prompt recall), so it stays green until a failure is actually observed and clears back to `●` on the next success. Read from local state only — no network on refresh. | Env var | Default | Effect | |---|---|---| @@ -203,7 +231,7 @@ Use `COGNEE_SESSION_ID` to pin a session and `COGNEE_PLUGIN_DATASET` to pin a da The renderer reads only local state — no network calls on every refresh: 1. Dataset: `COGNEE_PLUGIN_DATASET` env var, otherwise `agent_sessions` -2. Mode: `COGNEE_BASE_URL` env var, then `~/.cognee-plugin/config.json` (`base_url`) +2. Mode: `COGNEE_BACKEND` / `COGNEE_CODEX_BACKEND` switch, then `COGNEE_BASE_URL` env var, then `~/.cognee-plugin/config.json` (`base_url`) 3. Default mode: `local` ## Logs and state @@ -301,7 +329,9 @@ Config precedence: 3. `~/.cognee-plugin/config.json` 4. defaults -`~/.cognee/.env` is created with a commented template on first session start (permissions `0600`; the path can be overridden with `COGNEE_ENV_FILE`). Run `doctor.py` to see which keys the file defines and which are overridden by shell exports. +One exception sits above all four layers: the `COGNEE_BACKEND` / `COGNEE_CODEX_BACKEND` mode switch. When exported, it pins the mode regardless of where the connection variables are defined — forced local ignores a configured `COGNEE_BASE_URL` entirely (it is scrubbed from the process environment), and forced cloud stays cloud even when the URL is missing. See [Which mode wins](#which-mode-wins-and-how-to-switch). + +`~/.cognee/.env` is created with a commented template on first session start (permissions `0600`; the path can be overridden with `COGNEE_ENV_FILE`). Run `doctor.py` to see which keys the file defines, which are overridden by shell exports, and — in the mode row — whether a backend switch forced the mode decision. ### Managing the env file @@ -341,7 +371,7 @@ export LLM_API_KEY="sk-..." # a leading 'export ' is tolerated, so Keys are letters, digits, and underscores. Values are taken literally — no `$VAR` interpolation, no multi-line values. Malformed lines are skipped silently, never fatal, and process-critical variables (`PATH` and friends) are ignored by design. -**Remove a variable** — delete (or comment out) its line in the editor. The common case is switching from cloud back to local mode: remove the `COGNEE_BASE_URL` line and make sure `LLM_API_KEY` is set. +**Remove a variable** — delete (or comment out) its line in the editor. To switch modes you usually don't need to remove anything: keep both modes' variables in the file and export the switch instead — `export COGNEE_BACKEND=local` (see [Which mode wins](#which-mode-wins-and-how-to-switch)). Remove the `COGNEE_BASE_URL` line only when you want local to become the permanent default for every terminal. **Apply and verify** — the file is read at session start, so changes take effect on the next `codex` launch. If a value seems to be ignored, check whether the same variable is `export`ed in your shell: real exports always win over the file. The doctor's **Env File** row lists which keys the file defines and flags any that a shell export is overriding. @@ -353,6 +383,8 @@ Keys are letters, digits, and underscores. Values are taken literally — no `$V | `session_prefix` | `COGNEE_SESSION_PREFIX` | `codex` | Prefix for auto-generated session IDs | | `base_url` | `COGNEE_BASE_URL` | unset | Set to enable managed endpoint mode | | `api_key` | `COGNEE_API_KEY` | unset | API key; auto-minted if absent in local mode | +| mode switch | `COGNEE_BACKEND` | unset | `local` or `cloud` — pins the terminal's mode, overriding the URL rule; flips the Codex **and** Claude Code plugins | +| plugin-only mode switch | `COGNEE_CODEX_BACKEND` | unset | Same, for this plugin only; beats `COGNEE_BACKEND` | | local URL override | `COGNEE_LOCAL_API_URL` | `http://localhost:8011` | Local API base URL | | local LLM | `LLM_API_KEY`, `LLM_MODEL` | unset | Required for local mode runtime | | idle watcher poll | `COGNEE_IDLE_POLL` | `10` | Idle watcher poll interval in seconds | @@ -363,6 +395,12 @@ Keys are letters, digits, and underscores. Values are taken literally — no `$V ## Troubleshooting +**Terminal connects to cloud when you wanted local (or the reverse)** +- The mode is routed by `COGNEE_BASE_URL`: configured anywhere (env file or shell) → cloud; otherwise → local. An exported `COGNEE_BACKEND=local` / `=cloud` overrides that for the terminal — see [Which mode wins](#which-mode-wins-and-how-to-switch). +- `unset COGNEE_BASE_URL` does **not** go local: the env file re-injects the URL at the next launch. Export `COGNEE_BACKEND=local` instead. +- Check what the terminal actually resolved: the status's mode field shows it live, and `doctor.py` prints the decision with its cause (`Mode: Local — forced by COGNEE_BACKEND=local`). +- If a mode seems stuck, check for a forgotten `COGNEE_BACKEND` / `COGNEE_CODEX_BACKEND` export in the shell or in `~/.cognee/.env` — the plugin-specific name silently beats the shared one. + **Recall returns empty but data was ingested** - Recall is scoped to the active dataset (`COGNEE_PLUGIN_DATASET` / `agent_sessions`). - Data written via the Python SDK or `client.py` goes to `default_dataset` by default, if dataset not otherwise specified. diff --git a/integrations/codex/plugins/cognee/.codex-plugin/plugin.json b/integrations/codex/plugins/cognee/.codex-plugin/plugin.json index 18c6b436..3210d49e 100644 --- a/integrations/codex/plugins/cognee/.codex-plugin/plugin.json +++ b/integrations/codex/plugins/cognee/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cognee", - "version": "1.3.5", + "version": "1.4.0", "description": "CLI-first Cognee workflows for memory, knowledge graphs, codebase ingestion, and local UI launch.", "author": { "name": "Topoteretes", diff --git a/integrations/codex/plugins/cognee/CHANGELOG.md b/integrations/codex/plugins/cognee/CHANGELOG.md index 3f853424..f25fbcf6 100644 --- a/integrations/codex/plugins/cognee/CHANGELOG.md +++ b/integrations/codex/plugins/cognee/CHANGELOG.md @@ -10,6 +10,30 @@ is the cache key and semver record, bumped on each release, not the update trigg The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [1.4.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_CODEX_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 `doctor`'s mode row + explains what forced the decision and what is missing. + +### Fixed +- **`COGNEE_CODEX_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). + ## [1.3.5] ### Changed diff --git a/integrations/codex/plugins/cognee/scripts/_env_file.py b/integrations/codex/plugins/cognee/scripts/_env_file.py index 47724e19..b44e9701 100644 --- a/integrations/codex/plugins/cognee/scripts/_env_file.py +++ b/integrations/codex/plugins/cognee/scripts/_env_file.py @@ -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. """ @@ -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_CODEX_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" @@ -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: @@ -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: @@ -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: diff --git a/integrations/codex/plugins/cognee/scripts/cognee_statusline_render.py b/integrations/codex/plugins/cognee/scripts/cognee_statusline_render.py index 9b1db98c..12f95917 100644 --- a/integrations/codex/plugins/cognee/scripts/cognee_statusline_render.py +++ b/integrations/codex/plugins/cognee/scripts/cognee_statusline_render.py @@ -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" @@ -77,6 +79,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 @@ -88,7 +97,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" @@ -193,6 +202,7 @@ def _connection_marker(session_id: str) -> dict: # `llm-state.json` still 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} @@ -415,17 +425,39 @@ def _llm_prefix(session_id: str = "") -> str: return "" +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 leading glyph slot shared by the server- and LLM-key signals. One slot, by precedence — showing a ● 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 ● (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 f"✕ ({_MISSING_URL_REASON}) " server = _health_prefix(session_id) if server.startswith("✕"): return server diff --git a/integrations/codex/plugins/cognee/scripts/config.py b/integrations/codex/plugins/cognee/scripts/config.py index cd791c7e..383deb6b 100644 --- a/integrations/codex/plugins/cognee/scripts/config.py +++ b/integrations/codex/plugins/cognee/scripts/config.py @@ -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_CODEX_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: @@ -73,6 +82,11 @@ 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_CLAUDE_BACKEND is + # deliberately absent — an export targeting the Claude Code plugin must not + # flip this one. + "COGNEE_BACKEND": "backend", "COGNEE_CODEX_BACKEND": "backend", "COGNEE_AGENT_NAME": "agent_name", "COGNEE_PLUGIN_DATASET": "dataset", @@ -131,8 +145,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 @@ -189,8 +209,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: diff --git a/integrations/codex/plugins/cognee/scripts/doctor.py b/integrations/codex/plugins/cognee/scripts/doctor.py index a167af6a..a5862bd5 100644 --- a/integrations/codex/plugins/cognee/scripts/doctor.py +++ b/integrations/codex/plugins/cognee/scripts/doctor.py @@ -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 """ @@ -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"): @@ -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 "-", while the raw URL remains - available for the health probe. + available for the health probe. 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 - raw_url, _source = _local_api_url_with_source() mode = _resolve_mode() + if mode == "Cloud" and not str(load_config().get("base_url") or "").strip(): + return "-", "" + raw_url, _source = _local_api_url_with_source() display = "-" if mode == "Local" else raw_url return display, raw_url @@ -217,7 +236,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, diff --git a/integrations/codex/plugins/cognee/scripts/session-start.py b/integrations/codex/plugins/cognee/scripts/session-start.py index 1f790a84..00388f8f 100644 --- a/integrations/codex/plugins/cognee/scripts/session-start.py +++ b/integrations/codex/plugins/cognee/scripts/session-start.py @@ -1153,7 +1153,12 @@ async def _start(payload: dict | None = None) -> dict: # never claims ownership of its teardown. configured_url = _with_scheme(str(config.get("base_url", "") or "").strip()) api_key = str(config.get("api_key", "") or "").strip() - target_url = configured_url or _LOCAL_SERVICE_URL + # Forced cloud (backend switch) with no URL configured: there is nothing to + # boot and nothing to fall back to — keep the target empty so no local + # server is spawned, let the connection attempt fail, and let the status + # line report the missing URL. Everything else keeps the localhost default. + forced_cloud_unconfigured = not configured_url and config.get("_forced_backend") == "cloud" + target_url = configured_url or ("" if forced_cloud_unconfigured else _LOCAL_SERVICE_URL) config["base_url"] = target_url os.environ["COGNEE_BASE_URL"] = target_url if api_key: @@ -1209,8 +1214,10 @@ async def _start(payload: dict | None = None) -> dict: # * down + remote URL -> can't boot a remote host; connect and degrade user_id = "" agent_api_key = "" - server_live = _health_ok(_health_url(target_url)) - will_boot = (not server_live) and _is_local_url(target_url) + # An empty target (forced cloud, no URL) must never boot: _is_local_url("") + # parses to localhost, so gate on the URL being present at all. + server_live = bool(target_url) and _health_ok(_health_url(target_url)) + will_boot = (not server_live) and bool(target_url) and _is_local_url(target_url) hook_log( "endpoint_mode_selected", {"base_url": target_url, "server_live": server_live, "will_boot": will_boot}, @@ -1230,7 +1237,7 @@ async def _start(payload: dict | None = None) -> dict: boot_timeout=_HEALTH_TIMEOUT_SECONDS, ) if not ok: - if _LAZY_BOOTSTRAP and _is_local_url(target_url): + if _LAZY_BOOTSTRAP and target_url and _is_local_url(target_url): # Inline attempt failed; retry the heavy path out of band. _spawn_bootstrap(config, cwd, session_id, agent_session_name, session_key, dataset) else: diff --git a/integrations/codex/plugins/cognee/skills/setup/SKILL.md b/integrations/codex/plugins/cognee/skills/setup/SKILL.md index c34453e3..96c9aca0 100644 --- a/integrations/codex/plugins/cognee/skills/setup/SKILL.md +++ b/integrations/codex/plugins/cognee/skills/setup/SKILL.md @@ -57,6 +57,12 @@ setup file shared with the Claude Code plugin, loaded at session start. Shell exports still override it per terminal. Guide the user to edit that file rather than exporting in every shell; never echo its secret values. +The file may hold both modes' variables at once: with nothing exported, cloud +wins (`COGNEE_BASE_URL` routes the connection). To pin one terminal to a mode, +the user exports `COGNEE_BACKEND=local` or `COGNEE_BACKEND=cloud` before +launching — that beats the URL rule, and `unset COGNEE_BASE_URL` is NOT a way +to go local (the file re-injects the URL on the next launch). + To disconnect: ```bash diff --git a/integrations/tests/tests/unit/test_backend_switch.py b/integrations/tests/tests/unit/test_backend_switch.py new file mode 100644 index 00000000..a4f9f0ff --- /dev/null +++ b/integrations/tests/tests/unit/test_backend_switch.py @@ -0,0 +1,248 @@ +"""The COGNEE_BACKEND terminal switch (plus the per-plugin variants). + +Users keep BOTH modes' variables in ~/.cognee/.env; with nothing exported the +cloud vars win (COGNEE_BASE_URL routes the connection). One export flips a +single terminal: + + export COGNEE_BACKEND=local # or cloud + +Forced local must hold everywhere — not just in load_config()'s view, but in +os.environ itself, because the HTTP hot paths (_plugin_common) and spawned +children read COGNEE_BASE_URL directly, and the scrub must survive a child +re-running the loader. Forced cloud is pinned even when the connection vars +are missing: is_cloud_mode() stays true, the plugin attempts the cloud +connection, and the status line reports ✕ (missing_cognee_base_url) instead +of silently falling back to local. +""" + +from __future__ import annotations + +import os + +import pytest + +#: Each suite's own switch, and the OTHER plugin's switch — which it must ignore. +OWN_BACKEND_VAR = {"claude-code": "COGNEE_CLAUDE_BACKEND", "codex": "COGNEE_CODEX_BACKEND"} +OTHER_BACKEND_VAR = {"claude-code": "COGNEE_CODEX_BACKEND", "codex": "COGNEE_CLAUDE_BACKEND"} + +CLOUD_URL = "https://tenant.cognee.ai" +CLOUD_ENV_FILE = "\n".join( + [ + f'COGNEE_BASE_URL="{CLOUD_URL}"', + 'COGNEE_API_KEY="ck_from_file"', + 'LLM_API_KEY="sk_from_file"', + "", + ] +) + + +@pytest.fixture +def env_file(suite, isolated_modules, tmp_path, monkeypatch): + """The isolated _env_file module + a loader that runs a fresh env file.""" + ef = isolated_modules(suite, "_env_file") + + def _load(content: str): + path = tmp_path / ".env" + path.write_text(content, encoding="utf-8") + monkeypatch.setenv("COGNEE_ENV_FILE", str(path)) + monkeypatch.setattr(ef, "_loaded", False) + ef.load_env_file() + + return ef, _load + + +# ── environment layer (_env_file) ────────────────────────────────────────── + + +def test_no_switch_cloud_vars_win(env_file): + ef, load = env_file + load(CLOUD_ENV_FILE) + assert os.environ["COGNEE_BASE_URL"] == CLOUD_URL + assert os.environ["COGNEE_API_KEY"] == "ck_from_file" + + +def test_forced_local_scrubs_cloud_vars_from_environ(env_file, monkeypatch): + ef, load = env_file + monkeypatch.setenv("COGNEE_BACKEND", "local") + load(CLOUD_ENV_FILE) + assert os.environ["COGNEE_BASE_URL"] == "" + assert os.environ["COGNEE_API_KEY"] == "" + # Local-mode vars from the same file are untouched. + assert os.environ["LLM_API_KEY"] == "sk_from_file" + + +def test_forced_local_beats_a_real_shell_export(env_file, monkeypatch): + """The switch wins even over an exported URL, with no env file at all.""" + ef, _load = env_file + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + monkeypatch.setenv("COGNEE_BACKEND", "local") + monkeypatch.setattr(ef, "_loaded", False) + ef.load_env_file() + assert os.environ["COGNEE_BASE_URL"] == "" + + +def test_scrub_survives_a_child_rerunning_the_loader(env_file, monkeypatch): + """The scrub writes EMPTY strings, not deletions: a child re-running the + loader must not re-inject the file's cloud URL (setdefault skips keys that + are present, even when empty).""" + ef, load = env_file + monkeypatch.setenv("COGNEE_BACKEND", "local") + load(CLOUD_ENV_FILE) + monkeypatch.delenv("COGNEE_BACKEND") # even if the child lost the switch + monkeypatch.setattr(ef, "_loaded", False) + ef.load_env_file() + assert os.environ["COGNEE_BASE_URL"] == "" + assert os.environ["COGNEE_API_KEY"] == "" + + +def test_forced_cloud_scrubs_nothing(env_file, monkeypatch): + ef, load = env_file + monkeypatch.setenv("COGNEE_BACKEND", "cloud") + load(CLOUD_ENV_FILE) + assert os.environ["COGNEE_BASE_URL"] == CLOUD_URL + assert os.environ["COGNEE_API_KEY"] == "ck_from_file" + + +def test_plugin_var_beats_the_shared_var(env_file, suite, monkeypatch): + ef, _load = env_file + monkeypatch.setenv("COGNEE_BACKEND", "local") + monkeypatch.setenv(OWN_BACKEND_VAR[suite.name], "cloud") + assert ef.forced_backend() == "cloud" + + +def test_the_other_plugins_var_is_ignored(env_file, suite, monkeypatch): + ef, _load = env_file + monkeypatch.setenv(OTHER_BACKEND_VAR[suite.name], "local") + assert ef.forced_backend() == "" + + +def test_unrecognized_value_is_ignored(env_file, monkeypatch): + ef, _load = env_file + monkeypatch.setenv("COGNEE_BACKEND", "bananas") + assert ef.forced_backend() == "" + + +@pytest.mark.parametrize("value", ["local", "native", "sdk", "LOCAL", " local "]) +def test_local_synonyms_and_normalization(env_file, monkeypatch, value): + ef, _load = env_file + monkeypatch.setenv("COGNEE_BACKEND", value) + assert ef.forced_backend() == "local" + + +@pytest.mark.parametrize("value", ["cloud", "http", "api", "server"]) +def test_cloud_synonyms(env_file, monkeypatch, value): + ef, _load = env_file + monkeypatch.setenv("COGNEE_BACKEND", value) + assert ef.forced_backend() == "cloud" + + +def test_template_documents_the_switch(env_file): + ef, _load = env_file + assert "COGNEE_BACKEND" in ef._TEMPLATE + + +# ── config layer (load_config / is_cloud_mode) ───────────────────────────── + + +@pytest.fixture +def config_mod(suite, isolated_modules): + return isolated_modules(suite, "config") + + +def test_config_no_switch_cloud_wins(config_mod, monkeypatch): + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + monkeypatch.setenv("LLM_API_KEY", "sk") + cfg = config_mod.load_config() + assert config_mod.is_cloud_mode(cfg) + assert not config_mod.is_local_mode(cfg) + + +def test_config_forced_local_despite_cloud_vars(config_mod, monkeypatch): + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + monkeypatch.setenv("COGNEE_API_KEY", "ck") + monkeypatch.setenv("LLM_API_KEY", "sk") + monkeypatch.setenv("COGNEE_BACKEND", "local") + cfg = config_mod.load_config() + assert not config_mod.is_cloud_mode(cfg) + assert config_mod.is_local_mode(cfg) + assert cfg["base_url"] == "" + assert cfg["api_key"] == "" + assert cfg["llm_api_key"] == "sk" + + +def test_config_forced_cloud_is_pinned_without_a_url(config_mod, monkeypatch): + """Missing connection vars must NOT silently fall back to local.""" + monkeypatch.setenv("LLM_API_KEY", "sk") + monkeypatch.setenv("COGNEE_BACKEND", "cloud") + cfg = config_mod.load_config() + assert config_mod.is_cloud_mode(cfg) + assert not config_mod.is_local_mode(cfg) + assert cfg["base_url"] == "" + + +def test_config_plugin_var_beats_shared(config_mod, suite, monkeypatch): + monkeypatch.setenv("COGNEE_BACKEND", "cloud") + monkeypatch.setenv(OWN_BACKEND_VAR[suite.name], "local") + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + cfg = config_mod.load_config() + assert not config_mod.is_cloud_mode(cfg) + + +def test_config_ignores_the_other_plugins_var(config_mod, suite, monkeypatch): + monkeypatch.setenv(OTHER_BACKEND_VAR[suite.name], "local") + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + cfg = config_mod.load_config() + assert config_mod.is_cloud_mode(cfg) + + +# ── status line ───────────────────────────────────────────────────────────── + + +def test_statusline_forced_local_shows_local(statusline, monkeypatch): + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + monkeypatch.setenv("COGNEE_BACKEND", "local") + assert statusline._active_mode() == "local" + + +def test_statusline_forced_cloud_without_url_warns(statusline, monkeypatch): + monkeypatch.setenv("COGNEE_BACKEND", "cloud") + assert statusline._active_mode() == "cloud" + prefix = statusline._status_prefix("sess-1") + assert "missing_cognee_base_url" in prefix + assert "✕" in prefix + + +def test_statusline_forced_cloud_with_url_does_not_warn(statusline, monkeypatch): + monkeypatch.setenv("COGNEE_BACKEND", "cloud") + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + assert statusline._active_mode() == "cloud" + assert "missing_cognee_base_url" not in statusline._status_prefix("sess-1") + + +def test_statusline_no_switch_keeps_url_heuristic(statusline, monkeypatch): + monkeypatch.setenv("COGNEE_BASE_URL", "http://localhost:8011") + assert statusline._active_mode() == "local" + + +# ── doctor ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def doctor(suite, isolated_modules): + return isolated_modules(suite, "doctor") + + +def test_doctor_reports_forced_cloud_without_url(doctor, monkeypatch): + monkeypatch.setenv("COGNEE_BACKEND", "cloud") + assert doctor._resolve_mode() == "Cloud" + assert "missing COGNEE_BASE_URL" in doctor._mode_annotation() + display, raw = doctor._resolve_server_url() + assert display == "-" + assert raw == "" + + +def test_doctor_reports_forced_local(doctor, monkeypatch): + monkeypatch.setenv("COGNEE_BASE_URL", CLOUD_URL) + monkeypatch.setenv("COGNEE_BACKEND", "local") + assert doctor._resolve_mode() == "Local" + assert "COGNEE_BACKEND=local" in doctor._mode_annotation() From 736e6333c25655427583a77219f6f1c7074e6218 Mon Sep 17 00:00:00 2001 From: Andrej Milicevic Date: Thu, 13 Aug 2026 17:24:03 +0200 Subject: [PATCH 2/2] test: fix failing tests --- integrations/tests/utils/isolation.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/integrations/tests/utils/isolation.py b/integrations/tests/utils/isolation.py index fc0491d3..c9c35db2 100644 --- a/integrations/tests/utils/isolation.py +++ b/integrations/tests/utils/isolation.py @@ -52,11 +52,17 @@ #: raw.githubusercontent.com; #: - COGNEE_LAZY_BOOTSTRAP=0 makes SessionStart bootstrap synchronously #: instead of via a detached worker that can outlive the test. +#: - PYTHONIOENCODING=utf-8 pins the child's stdio encoding: on Windows the +#: default is the locale code page (cp1252), and while the renderers +#: reconfigure their own stdout to UTF-8, other hook scripts don't — this +#: keeps every child's output UTF-8 so run_hook can decode it as such. +#: Tests that probe encoding behavior override it via extra_env/build_env. DETERMINISTIC_ENV = { "COGNEE_PLUGIN_IN_VENV": "1", "COGNEE_IDLE_DISABLED": "1", "COGNEE_UPDATE_CHECK": "off", "COGNEE_LAZY_BOOTSTRAP": "0", + "PYTHONIOENCODING": "utf-8", } #: Env-var prefixes scrubbed from the inherited environment so a developer's @@ -149,7 +155,14 @@ def run_hook( env=env, cwd=str(cwd if cwd is not None else home), capture_output=True, - text=True, + # Explicit UTF-8, not text=True: text mode decodes with the parent's + # locale encoding — cp1252 on Windows — which mangles the UTF-8 the + # hooks write (the bar's ·/✕ glyphs came back as mojibake and full-line + # assertions failed). PYTHONIOENCODING (DETERMINISTIC_ENV) pins the + # child's write side to match; errors="replace" keeps a stray + # non-UTF-8 byte from crashing the harness instead of the assertion. + encoding="utf-8", + errors="replace", timeout=timeout, )