` in the system temp directory, with the skill's slug as a subfolder), which keeps the source directory fixed per dataset and skill name, so re-ingesting the same skill name (the `skill_name` field on the inline path; the uploaded `SKILL.md`'s parent-folder name on the upload path) into the same dataset upserts the same node with refreshed content and embedding. The same name in a *different* dataset still resolves to a distinct id, so attaching one skill to several datasets keeps working, and path-based (folder) ingestion is unchanged. The new `DELETE /api/v1/skills/{skill_id}` takes a required `dataset_id` query parameter and requires `delete` permission on that dataset — a separate grant from the `write` that ingestion needs and the `read` the list/fetch routes need — returning `200` with `{"status": "deleted", ...}`, `403` for a dataset you cannot delete in, `404` for an unknown skill id, and `409` when the deletion fails. It is a hard delete of the graph node, its edges and its `Skill_search_text` embedding (the embedding cleanup is best-effort — a vector-store failure is logged without failing the delete) rather than an `is_active=False` soft delete, because a hidden leftover node would be silently resurrected by a later re-ingest now that ids are stable. **Impact:** no migration runs, so Skill nodes duplicated by the old behavior stay in the graph until you remove them — delete the extra copies with the new endpoint (a subsequent re-ingest will then keep updating one node). Deletion is not recoverable; re-ingest the `SKILL.md` to restore a skill (PR #4290).
* Applies the session cache's sliding TTL lazily on the SQL backends (`sqlite` and `postgres`), removing a per-write rewrite of the whole session. The sliding TTL — Redis `EXPIRE`-on-write parity, which pushes a session's expiry forward on every write — was translated to SQL as an `UPDATE` over *all* of that session's rows at every write path, so the cost of a write grew with the length of the session and total write cost grew quadratically; on the default SQLite backend this produced extreme WAL write amplification (a reported 64.2 GB `cache.db-wal` against a 200 MB `cache.db`, growing 5–39 GB/hour under steady agent traffic). `log_usage` had the same shape one scope wider, re-stamping every usage-log row for the user on each logged call across the 12 decorated API routes (including `POST /api/v1/recall` and `POST /api/v1/search`) and the MCP tools that share the decorator. The `UPDATE` now skips rows whose recorded expiry lags the new target by less than 5% of the TTL, so each row is rewritten at most once per slack window and a write costs roughly its own bytes. **Behavior change:** session entries and usage logs now expire between 0.95 × `SESSION_TTL_SECONDS` and 1.0 × `SESSION_TTL_SECONDS` after the last write instead of exactly at the TTL — with the 7-day default, up to \~8.4 hours earlier — so treat the TTL as a lower bound with a small slack window; rows written while the TTL was disabled are still stamped on the next write, and Redis keeps exact `EXPIRE` semantics. Setting `SESSION_TTL_SECONDS=0` disables expiry entirely and skips the sliding-TTL writes as well, which is the lightest-I/O setting for long-lived sessions on SQLite. No public API signature, configuration option, or environment variable changed, and no migration is required (COG-6106, PR #4405).
* **Breaking: removes multiprocess and distributed (Modal) execution support.** The `COGNEE_DISTRIBUTED` environment variable, the `cognee[distributed]` install extra, and the Modal execution path are no longer supported, and running multiple Cognee processes against the same stores is not a supported configuration — the embedded defaults (Ladybug/Kuzu graph, SQLite, LanceDB) are file-based with process-local locks, so a second process opening the same files can see stale or empty data. Cognee runs as a single process; when more than one process or agent needs the same memory, route all access through a single Cognee service backed by external stores (Neo4j for the graph, Postgres for the relational store, PGVector for vectors). The distributed-execution guide and Modal deployment page have been removed, and the deployment, caching, and configuration docs now consistently describe single-process operation (COG-6050).
* Fixes Graphiti temporal-awareness indexing embedding the wrong text into the `GraphitiNode_name` and `GraphitiNode_summary` vector collections, so similarity search against those fields matched on the node's `content` instead of on the field the collection is named for. `index_and_transform_graphiti_nodes_and_edges()` builds one indexable point per entry in `GraphitiNode.metadata["index_fields"]` (`name`, `summary`, `content`) by calling `model_copy()` and then narrowing the copy's `metadata["index_fields"]` to the single field being indexed. Pydantic v2's `model_copy()` is a shallow copy and `DataPoint` does not override it, so every copy taken from a given node shared that node's one `metadata` dict: each field's assignment overwrote the previous ones, and by the time the points were flushed all of that node's copies carried the *last* indexed field — `content` where set, otherwise `summary`. The vector adapters then resolve the text to embed from `metadata["index_fields"][0]` rather than from the `index_property_name` they were called with — directly in `LanceDBAdapter.index_data_points`, and via `DataPoint.get_embeddable_data` on the PGVector and Turso adapters — so that overwritten field name decided what was actually embedded. Each copy now gets its own `metadata` dict before the assignment. The contamination was bounded to copies of a single node within one indexing pass: Pydantic v2 gives each instance its own copy of a mutable field default, so the class-level `GraphitiNode.metadata` default was never corrupted and later nodes and other pipelines were unaffected. `EdgeType` declares exactly one index field, so edge indexing made a single copy per edge type and was never affected. **Operator impact:** if you indexed a Graphiti graph before this fix, re-run `index_and_transform_graphiti_nodes_and_edges()` to correct the affected collections — nodes whose only non-`None` indexable field was the one being indexed were already correct. On the default LanceDB backend the re-run is enough, because its `merge_insert` upsert rewrites the stored vector along with the payload; on PGVector and Turso the `ON CONFLICT (id)` clause updates only the row's `payload` and leaves the existing vector untouched, so delete the stale rows from `GraphitiNode_name` and `GraphitiNode_summary` first (`delete_data_points`) or the wrong embeddings will survive the re-run. No schema change, no migration, and no public API signature, configuration option, or environment variable changed (fixes #3292, PR #3580).
* Bumps the pinned `enola` release used by code-graph extraction from `0.1.34` to `0.3.13` and ingests the explainer findings that 0.3.x writes to `insights.json` as a new fact kind. Each finding becomes a synthetic fact of kind `insight`, mapped to a new `CodeInsight` DataPoint whose `name` is the finding's title, whose `description` is the explainer's own prose (used verbatim instead of the generic `kind: k=v` property summary other fact kinds get), and whose `fact_properties` carry `source` (the explainer that produced it), `confidence`, `description`, and `suggested_actions`, each when the finding provides it. Each piece of evidence the finding cites — a symbol, fact, or file — becomes an `evidences` edge from the insight to that node when the target resolves to a fact in the snapshot, so a finding is linked to the code it is about. Findings come from enola's deterministic, LLM-free explainers — hotspots, god-class, dependency-depth, cycles, layers, exported-surface, complexity-outliers, and others. `SearchType.CODE` picks these up with no API change: `insight` is now a valid `kind`/`kinds` value, `CodeInsight` a valid `node_types` value, and `evidences` a usable `relation_types` value. Installation also gains a `darwin-amd64` (Intel macOS) build alongside the existing `darwin-arm64`, `linux-amd64`, `linux-arm64`, and `windows-amd64` ones, and archive extraction handles the 0.3.x tarball layout, which ships `LICENSE` and `NOTICE` next to the binary — the extractor still refuses any archive that does not contain exactly one top-level `enola*` file, or whose members contain a path separator or start with a dot. **Upgrade impact:** the `facts.jsonl` relation shape is unchanged, so existing consumers of the code graph keep working, and a snapshot without `insights.json` is skipped silently while one that cannot be parsed is logged and ignored, rather than failing extraction, so 0.1.x snapshots still extract; re-extracting a repo grows its graph by the number of findings enola reports (129 on the cognee repo itself). The auto-installed binary is version-scoped by filename, so an existing `enola-0.1.34-*` install is not reused — the first extraction after upgrading downloads `0.3.13` unless `ENOLA_PATH` points at your own binary, which still wins over the auto-install (COG-6113, PR #4404).
* Fixes the Amazon Neptune graph adapter (`NeptuneGraphDB`, used by `GRAPH_DATABASE_PROVIDER="neptune"`) never releasing its AWS client when the graph engine is dropped from Cognee's engine cache. Graph engines are created through `_create_graph_engine`, which is wrapped in `closing_lru_cache`; that cache closes each entry once it has left the cache and the last caller handle has been released. Its close step starts by checking whether the cached value has a `close` attribute and returns immediately when it does not — and `NeptuneGraphDB` implemented no `close()`, nor does `GraphDBInterface` declare one — so for Neptune the close was silently skipped and the `langchain_aws` `NeptuneAnalyticsGraph` together with its underlying boto3 client was discarded without being closed, leaving the client's pooled connections to be reclaimed non-deterministically by the interpreter instead of released at eviction. The adapter now implements `async close()`, which closes the wrapped boto3 client (`self._client.client`) when it exposes a `close()` method and then clears `self._client`; the client and attribute checks are defensive, so the call is a safe no-op when the client was never initialized and when close runs more than once, as the cache's idempotency requirement expects. The evictions this affects are the ones any provider sees — capacity eviction once more distinct graph configurations are in play than `DATABASE_MAX_LRU_CACHE_SIZE` (default `6`), explicit eviction when a dataset is deleted, and the `cache_clear` behind prune — so the benefit is to long-running services that cycle graph configurations, not to a short script that creates one engine and exits; Neptune is not the per-dataset handler (`GRAPH_DATASET_DATABASE_HANDLER` defaults to `ladybug`), so this is not about per-dataset database isolation. Eviction-time close remains deferred until the last engine handle drops and a failing close is still logged and swallowed rather than raised. The separate `neptune_analytics` provider is served by a different hybrid adapter and is unchanged. No public API signature, configuration option, or environment variable changed and no migration is required; callers continue to obtain engines through `get_graph_engine()` and are not expected to construct or close adapters themselves (PR #3244).
* Adds an extraction-oriented image transcription prompt and an optional local OCR pass to `ImageLoader` (`cognee/infrastructure/loaders/core/image_loader.py`). **Default behavior changes:** images are now transcribed with a new prompt template (`transcribe_image_prompt.txt`) that asks for the entities shown and their attributes, the relationships between them, all visible text/numbers/dates/labels transcribed verbatim, and structured content (tables as rows, charts as series and data points, diagrams as element connections), under a 1024-token completion cap — where previously every image got the hardcoded `"What's in this image?"` caption prompt and a 300-token cap. Images therefore produce longer, denser text and cost more tokens per image than before; set `IMAGE_EXTRACTION_ENABLED="false"` to restore the previous caption prompt and 300-token cap. Five environment variables are new: `IMAGE_EXTRACTION_ENABLED` (default `"true"`), `IMAGE_TRANSCRIPTION_PROMPT_PATH` (default `"transcribe_image_prompt.txt"`; a file name resolves inside `cognee/infrastructure/llm/prompts`, an absolute path is loaded from its own directory), `IMAGE_TRANSCRIPTION_MAX_COMPLETION_TOKENS` (default `1024`), `IMAGE_TRANSCRIPTION_REASONING_EFFORT` (default `"low"`; `minimal`/`low`/`medium`/`high`, dropped for models without reasoning support), and `IMAGE_OCR_ENABLED` (default `"false"`). With `IMAGE_OCR_ENABLED="true"` and the new `cognee[rapidocr]` extra installed (`rapidocr-onnxruntime`, pip-only — no system binary), a local OCR pass runs off the event loop and its recognized text is appended to the transcription under an `[OCR extracted text]` heading, truncated at 8000 characters; an OCR failure is logged and the vision transcription is kept rather than failing ingestion. Because `IMAGE_TRANSCRIPTION_MAX_COMPLETION_TOKENS` also caps reasoning tokens, a small value on a reasoning model (including the default `openai/gpt-5-mini`) can return empty content — the loader logs a warning suggesting a higher cap and continues with empty text for that image. `transcribe_image` gained optional `prompt`, `max_completion_tokens`, and `reasoning_effort` keyword parameters on `LLMGateway` and the LLM interface; existing calls keep working unchanged, and images still reduce to `chunk.text` and feed the existing graph extractor unchanged (partially addresses #3637, PR #3956).
* Fixes the two connected-components metrics being transposed in `get_graph_metrics()` on the Neptune graph backends (`GRAPH_DATABASE_PROVIDER=neptune` and Neptune Analytics). `NeptuneGraphDB.get_graph_metrics` unpacked its internal `_get_connected_components_stat()` helper as `num_cluster, list_clsuter_size`, but that helper returns `(sizes, count)` — so `num_connected_components` came back as the descending list of per-component sizes and `sizes_of_connected_components` as the integer number of components, the exact inverse of what the keys name. The unpack is corrected, so `num_connected_components` is now an `int` and `sizes_of_connected_components` a `list[int]` of per-component sizes in descending order, matching the `int`/`list[int]` shape the Ladybug, Neo4j, Postgres, and Turso adapters already returned — Neptune was the only backend that disagreed. **Impact is limited to Neptune deployments:** anything reading these two keys off a Neptune graph received the wrong type for each, so dashboards, alerts, and scripts written against the old shape — for example treating `num_connected_components` as a list, or `sizes_of_connected_components` as a scalar — must be switched back to the documented types, and a consumer that assumed the correct types was getting a type error or nonsense value rather than a wrong-but-plausible number. The same values feed the `graph_metrics` row written by `get_pipeline_run_metrics`, whose columns are `Integer` for `num_connected_components` and `JSON` for `sizes_of_connected_components`, so rows recorded from a Neptune graph before this fix cannot be trusted for these two fields; re-run metrics collection if you rely on their history. No public API signature, configuration option, environment variable, or migration changed — deploy the fix to pick it up (PR #3171).
* Separates ontology-aware from ontology-free graph construction in the Cognify extraction path, and bases persisted entity identity on entity names instead of the graph-local ids the LLM assigns. `cognee.modules.graph.utils` no longer exports `expand_with_nodes_and_edges` or `retrieve_existing_edges`: the first is replaced by `construct_data_points_and_edges` plus `attach_new_edges_to_data_points`, and the second by `find_existing_edge_identities`, which takes a collection of `EdgeIdentity` values and returns the subset already in graph storage rather than the previous `{edge_key: True}` mapping. Ontology enrichment moves out to `cognee/modules/ontology/construct_data_points_and_edges_with_ontology.py` and now runs as a canonicalize-first pre-pass that rewrites the extracted graph before any nodes are constructed from it, rather than validating nodes one by one as they are built: nodes matching the same ontology individual collapse into a single node with the collapsed nodes' edges rewired onto the survivor, and an edge from a matched ontology subgraph is attached only when **both** of its endpoints are part of that subgraph instead of minting a node for the missing endpoint. A run with no ontology configured no longer passes through the ontology code path at all — the new `get_configured_ontology_resolver(config)`, which `cognify()` and `get_default_tasks()` both now call in place of their duplicated branching, returns `None` when neither an explicit `config["ontology_config"]["ontology_resolver"]` nor an `ONTOLOGY_FILE_PATH` environment setting is present, where the previous code fell back to instantiating an empty `RDFLibOntologyResolver`. **Entity ids change:** they are now derived from `Entity.id_for(node.name)` rather than `Entity.id_for(node.id)`, so two chunks mentioning the same entity name converge on one node, while several distinct nodes sharing a name inside a single extracted graph keep deterministic chunk-scoped ids instead of collapsing together; an extracted edge whose endpoint is not among that chunk's extracted nodes is now dropped rather than pointing at an id no node was created for. Entity nodes written by earlier runs keep their old id-derived ids, so re-cognifying data that is already in the graph can create a second node for an entity that is already there — re-run the affected datasets from scratch if you need ids to line up. **Edge properties change:** persisted edges no longer carry an `ontology_valid` property — grounding was never applied to relationship names, so the flag is now node-only; filter on the endpoints instead. The `cognify()` signature, configuration options, environment variables, and migrations are unchanged (SDK-160, PR #4262).
* Makes enola code-graph ingestion incremental, so re-running it against an unchanged repository is near-free and a changed repository no longer accumulates facts that were deleted upstream. Previously every run re-loaded every fact and deleted nothing, so nodes for removed classes, files, and routes and edges for removed dependencies stayed in the graph indefinitely; the pipeline's generic incremental mode could not help, because it keys on the data item's content hash and the code-graph data item is a repository *path*, which does not change when the repository does. `extract_code_graph` now derives a snapshot identity for each run through the new `snapshot_identity()` helper — enola's `receipt.json` `snapshot_id` when present, otherwise a `sha256:` digest of `facts.jsonl`, and no identity at all when neither is readable, in which case the run always loads fully. That identity is compared against `last_snapshot_id`, a new field on the `CodeRepository` node; the marker is stored on the node in the graph rather than in the relational metastore because this pipeline persists no `Data` row to key relational state on, which also means it cannot outlive the graph it describes. On a match, `extract_code_graph` returns an empty list and both `add_code_graph_data_points` and `add_code_graph_edges` short-circuit, so an unchanged repository costs only the enola scan. When the identity differs, the load becomes a delta write followed by a sweep: `CodeGraphEntity` gained a `fact_hash` field fingerprinting each fact's derived fields, only facts whose hash is new or changed are written, only edges not already present are added, and then nodes and edges that earlier ingestions derived but the current snapshot no longer does are removed via `delete_nodes` and `delete_edge_triples` (chunked, so no single statement outruns the engine's per-call deadline). The sweep is deliberately narrow: it considers only code-graph node types belonging to repositories the current snapshot covers, and removes edges only between surviving code nodes, so other datasets, other repositories in the same graph, and edges to non-code nodes such as `belongs_to_set` → `NodeSet` are never touched. `last_snapshot_id` and a new `last_delta` record — added/updated/unchanged/removed counts, capped name samples, the snapshot id, and a load timestamp — are stamped on the repository node only after the load and the sweep both succeed, so a crashed run cannot later be mistaken for an up-to-date one. `SearchType.CODE`'s `code_query` gains a matching `delta` operation alongside `query_facts`, `explore`, `traverse`, `find_path`, and `impact_analysis`; it reads those records back and reports what the last ingestion changed per repository, returning `delta: null` for repositories loaded before this change. Same-named facts of the same kind now collapse to the first occurrence rather than the last, so a node's stored content and `fact_hash` stay stable across ingestions instead of flip-flopping and reading as "updated" on every run. **Operator impact:** repeated code-graph runs over an unchanged repository drop from minutes to roughly the \~3s enola scan, and orphans left behind by earlier ingestions are cleared on the first changed run after upgrading, so expect a one-off drop in code-graph node and edge counts. Because the marker is a property of the `CodeRepository` node, anything that drops the graph itself — prune, or deleting the graph database files — drops the marker with it, and the next run rebuilds from scratch. `forget(memory_only=True)` does not: it deletes by provenance, and the code-graph pipeline's payload is a repository path with no `Data` row to record provenance against, so its nodes and their marker survive. One known gap: on `index_vectors=True` runs, vector index entries for swept nodes are not yet removed; the default graph-only path (`index_vectors=False`) is fully handled. No environment variable, configuration option, or public function signature changed, and no migration is required (COG-6115, PR #4407).
* Fixes `POST /api/v1/cognify` and `POST /api/v1/memify` hiding the real failure inside their `500` response body. Both routers built the body's `detail` as `getattr(run, "error", None) or str(run)`, but `PipelineRunErrored` has no `error` attribute — the failing task's error is recorded on `payload`, which the pipeline runner sets to `repr(error)` — so the `getattr` was always `None` and `detail` fell back to the model's repr, e.g. `status='PipelineRunErrored' pipeline_run_id=UUID('...') dataset_name='ds' payload="ValueError('LLM_API_KEY is missing')" ...` instead of the actionable message it wraps. Both routers now read `payload` when it is a string — the pattern `POST /api/v1/add` already used — so `detail` is the task's own error, e.g. `ValueError('LLM_API_KEY is missing')`, and falls back to the run repr only when the run carries no error string. The `500` status code, the `{"error": "Pipeline run errored", "detail": ...}` body shape, and the separate `{"error": "Internal server error", "detail": ...}` body those endpoints return for unexpected exceptions are all unchanged. **Client impact:** only the text inside `detail` changed, so log parsing or alerting rules that pattern-matched the old `status='PipelineRunErrored' ...` repr no longer match and should read the plain message instead. No public API signature, configuration option, environment variable, or migration changed (PR #3265).
* Replaces the non-standard `418 I'm a teapot` status code with `500 Internal Server Error` in the four places it was used. `GET /api/v1/datasets` and `POST /api/v1/datasets` wrap any unexpected failure in an `HTTPException` — retrieving datasets and creating a dataset respectively — and both now raise `500`; their endpoint docstrings and the generated HTTP API reference list `500 Internal Server Error` accordingly. The global `CogneeApiError` handler in `cognee/api/client.py` also returns `500` for its fallback branch, which fires when a raised Cognee exception is missing a message, name, or status code and the handler substitutes `{"detail": "An unexpected error occurred."}`. The `CogneeApiError` base class default `status_code` moves from `418` to `500` as well; every direct subclass sets its own status code (for example `422` for `CogneeValidationError`, `503` for `CogneeTransientError`), so this default applies only to a direct `CogneeApiError(...)` raise that omits `status_code`. **Client impact:** integrations that branch on `418` for these responses must switch to `500` — response bodies, status codes for every other error, endpoint paths, and request shapes are unchanged. No configuration option or environment variable changed (issue #3742, PR #3860).
* Removes the stale `tree-sitter` and `tree-sitter-python` dependencies from the `cognee[codegraph]` extra, which now installs only `fastembed` and `transformers`. Neither package was imported anywhere in Cognee: they were left behind by the removed Python AST-based code-graph parser, and the current code-graph implementation delegates extraction to the external `enola` binary (`cognee/tasks/code_graph/extract_code_graph.py`). Because `codegraph` pinned `tree-sitter>=0.24.0,<0.25` while `docling-full` needs `tree-sitter>=0.25` through `docling-core[chunking]`, `pyproject.toml` also declared the two extras mutually exclusive under `[tool.uv] conflicts`, so the contributor setup command documented in `AGENTS.md` — `uv sync --dev --all-extras --reinstall` — failed to resolve at all with `error: Extras codegraph and docling-full are incompatible with the declared conflicts`. With the unused pins gone the conflict declaration is dropped too, so all extras install together and both `codegraph` and `docling-full` can be used in the same environment; `uv.lock` was regenerated to drop the obsolete conflict markers. **Impact:** an `ImportError` mentioning `tree_sitter` is no longer resolved by installing `cognee[codegraph]` — no Cognee code path imports it, so the error comes from something else in your environment. Code-graph extraction behavior is unchanged (it needs the `enola` binary, not a Python parser), and no public API signature, configuration option, or environment variable changed; no migration is required (PR #4432).
* **Security: restricts `POST /api/v1/settings` to superusers.** Writing the system LLM configuration (provider, model, API key) and vector-database configuration (provider, URL, API key) over HTTP only required an authenticated user, so any account that could log in — an ordinary tenant member, an integration or agent account, a signed-in UI session — could repoint the deployment's LLM or vector store and read back a masked preview of the stored keys (first ten characters) through `GET /api/v1/settings`. The `save_settings` handler now checks `user.is_superuser` before touching `save_llm_config` / `save_vector_db_config` and answers a non-superuser with `403 Forbidden` and the body `{"error": "Superuser privileges required to modify settings"}`; the request payload, the empty `200` on success, and the `400` / `500` error codes are unchanged. This re-adds a guard that shipped in #3115 and was later reverted, and it fixes **CVE-2026-58473 / GHSA-49f7-whx5-4256**; a regression test (`cognee/tests/api/test_settings_authorization.py`) now pins both the allowed and the forbidden case. **`GET /api/v1/settings` is unchanged** — reading the settings still requires only an authenticated user, so this is not a lockdown of the read path. **Who is affected:** only callers that are not superusers. `create_user(...)` defaults to `is_superuser=False`, so integrations, automation scripts, and UI flows that let non-admin accounts adjust LLM or vector settings now break with `403` and must either authenticate as a superuser or drop the write. The auto-created default user (`default_user@example.com`) *is* a superuser, and with authentication disabled every request falls back to that user, so single-user deployments, local development, and the `cognee.start_ui()` / `cognee-cli -ui` "Add your API key" modal keep working exactly as before. No configuration option, environment variable, or migration ships with the fix — deploy it to pick it up (PR #4434, mirroring contributor PR #4252).
* Fixes the eval-framework HTML dashboards interpolating arbitrary benchmark text into their markup without escaping it. Both dashboard modules — `cognee/eval_framework/metrics_dashboard.py`, the one the eval runner uses, and its analysis twin `cognee/eval_framework/analysis/dashboard_generator.py` behind `create_dashboard()` — built the report with f-strings: `generate_details_html` emitted each per-item field straight into a ``, and `get_dashboard_html_template` dropped the `benchmark` name into `` and ``. Any `<`, `&`, or tag-like content in that text corrupted the rendered page (a ` | ` or `