Cognee knowledge graph memory for Claude Code — session-aware storage, auto-routing recall, and persistent learning across sessions. Supports local mode and…

<a href="https://www.cognee.ai"> <img src="https://raw.githubusercontent.com/topoteretes/cognee-integrations/main/assets/cognee-logo.svg" alt="Cognee" width="260"> </a> <a href="https://docs.cognee.ai">Docs</a> · <a href="https://discord.gg/NQPKmU5CCg">Discord</a> · <a href="https://github.com/topoteretes/cognee">Cognee core</a>
Adds persistent memory to Claude Code through Cognee.
The integration:
Everything below is the plugin's own readable Python and shell under this folder; it runs no code from anywhere else.
Captured. Your prompts, the tool calls Claude makes (name, arguments and output, filtered by the capture controls), and Claude's answers, plus a short memory anchor before compaction. Credentials, private keys and database URLs are redacted before anything is stored or uploaded, and .env, credential and key files are never captured. COGNEE_CAPTURE=false turns automatic capture off; recall and explicit remember keep working.
Where it goes. To the Cognee server you configure, and nowhere else.
localhost:8011. Memory is stored on this machine. To build the graph, that server sends captured text to the LLM and embedding provider you configured (LLM_API_KEY, LLM_PROVIDER), or to Claude Code through the observer below.COGNEE_BASE_URL, over HTTPS, authenticated with your COGNEE_API_KEY. The status line's credit balance comes from the Cognee platform API (api.aws.cognee.ai).Memory is kept until you delete it (the cognee-forget skill, or deleting the dataset). The plugin never expires server-side memory on its own.
Other network calls. Local mode installs cognee==1.6.3 from PyPI into ~/.cognee-plugin/venv once. The background idle watcher fetches this repository's marketplace.json from raw.githubusercontent.com at most hourly to offer updates (COGNEE_UPDATE_CHECK=off). No telemetry, no analytics: nothing is sent to Anthropic, Cognee or any third party beyond the calls above.
Claude settings. In cloud mode the plugin disables native auto-memory unless you explicitly enabled it, keeping an ownership marker and previous value for restoration. Set COGNEE_DISABLE_AUTO_MEMORY=false to opt out. Existing memory files are retained. See Memory preference.
Stored locally. ~/.cognee-plugin/claude-code/ (logs, status markers, buffered turns, swept automatically; see Logs and state), ~/.cognee-plugin/venv/, ~/.cognee-plugin/api_key.json, and your settings in ~/.cognee/.env.
Settings it writes. One entry, and only where it is needed: in a session whose status row the hooks module draws above the prompt, nothing is written; in a session where the module does not load (an older Claude Code, --safe-mode, an organisation that allows only its own mods), session start installs the statusLine entry in ~/.claude/settings.json that draws the line below the prompt, and refreshes it when the plugin moved. Only an entry whose command runs the plugin's own cognee-statusline.sh is ever written or replaced; a status line you set yourself is never touched. Nothing else in your Claude Code settings is touched.
Claude's own files. The plugin reads the hook payloads Claude Code sends it, not your transcript or Claude Code's memory files, and writes to neither.
Code that runs inside Claude Code. hooks/register.ts is a hooks module (a Claude Code mod) that draws the memory status row above the prompt (in a terminal and in the Desktop app), shows what memory is doing while it works, makes the dataset name a picker and serves two tools Claude can call directly. On its own it reads one file, the plugin's own activity marker under ~/.cognee-plugin/claude-code/phase/, writes one, band/<session>.json, which tells the settings-file status line to stay quiet for that session, and runs one program, the plugin's own status-line renderer (scripts/cognee_statusline_render.py --json, local, no network), on the events the hooks fire. On your explicit action in the fix dialog it also runs cognee-doctor.sh (read-only diagnostics) and appends a value you typed to ~/.cognee/.env. Everything else it runs is one of this folder's scripts, on your action or Claude's: switch-dataset.py --list once when the band is first drawn and switch-dataset.py <name> when you pick a dataset or run /cognee-dataset <name>; the search tool runs cognee-search.sh (and recall-trim.py on its output); the remember tool runs cognee-remember.sh. Those are the same programs the skills run, with the same server traffic. The module itself makes no network requests, writes no settings, reads only HOME / USERPROFILE from the environment, and answers only its own two tools. claude plugin validate lists exactly that.
Background processes. Detached helpers that outlive a hook: the idle watcher and exit watcher (session sync and status), and in local mode the cognee server and, when enabled, the Claude observer shim. All are scripts from this folder.
Credentials it reads. COGNEE_API_KEY, COGNEE_BASE_URL and LLM_API_KEY from ~/.cognee/.env or the environment. The API key goes only to the Cognee server you configured; the LLM key only to the local server's process.
Your Claude subscription. In local mode with no LLM_API_KEY set and the claude CLI on PATH, the server's own LLM calls (cognify, improve) run as claude -p --safe-mode under your Claude Code login and count against your subscription usage. Your login is passed only to that child process, never to any server. Session start says so every time it is in use; COGNEE_LLM_OBSERVER=false turns it off. Details under Claude observer.
Requirements. Any Python 3.9 or newer available as python3 (or python) on PATH runs the hooks — they are stdlib-only HTTP clients and never import cognee, so the Python 3.9.6 that ships with macOS's Xcode Command Line Tools is enough for them. Local mode additionally needs a Python 3.10–3.14 installed on the machine (python.org, Homebrew, pyenv, or uv python install 3.12): the plugin builds its own virtualenv for the Cognee server under ~/.cognee-plugin/venv from an interpreter it finds, and installs exactly cognee==<pinned version> into it. The plugin never downloads installers or interpreters — it uses uv if you already have it (with interpreter downloads disabled), otherwise the stdlib venv + pip with the newest python3.x it finds on PATH. If no 3.10+ interpreter exists it refuses, logs host_python_too_old_for_venv to hook.log, and every session start says so until one is installed. Cloud mode never builds a runtime. Claude Code: the memory pipeline (hooks, skills, recall, sync) runs on any Claude Code that reads plugin userConfig; the live status row, spinner text and the two memory tools come from the hooks module and need Claude Code 2.1.287 or newer (2.1.286 in the Desktop app), see Status line. (SDK-based integrations such as LangGraph or CrewAI import cognee in-process and need Python 3.10+; see CONFIGURATION.md.)
If local mode refuses to start. A session that opens with "Cognee requires Python 3.10 or newer (up to 3.14), and no such interpreter is installed on this machine" means exactly that: the hooks ran (they only need 3.9), but the Cognee server has nothing to run on, and the plugin will not download an interpreter. The status line shows ✕ (cognee_needs_python_3_10) and doctor.py reports Runtime Python: MISSING until you install one (brew install python@3.12, python.org, pyenv, or uv python install 3.12) and start a new session — nothing else to configure. Cloud mode has no such requirement.
Install from the Claude Code marketplace. The recommended way is from your shell, before launching Claude Code, so the first claude launch is a clean session that runs the plugin bootstrap automatically — no in-app restart needed:
claude plugin marketplace add topoteretes/cognee-integrations
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. 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).
Cognee Cloud or a remote server — set both (one paste, no editor needed):
mkdir -p ~/.cognee
cat >> ~/.cognee/.env <<'EOF'
COGNEE_BASE_URL="https://your-instance.cognee.ai"
COGNEE_API_KEY="ck_..."
EOF
chmod 600 ~/.cognee/.env
The plugin is an HTTP client in both modes; the hooks never import cognee in-process. Cloud mode does not install a local Cognee runtime. The bundled virtualenv (
~/.cognee-plugin/venv) is built only in local mode, where it runs the local Cognee server the hooks talk to.
Local mode (default when COGNEE_BASE_URL is not set) — the plugin bootstraps a local Cognee API at http://localhost:8011. COGNEE_API_KEY is auto-minted if absent. The server needs an LLM for cognify: either give it a provider key of your own —
mkdir -p ~/.cognee
cat >> ~/.cognee/.env <<'EOF'
LLM_API_KEY="sk-..."
EOF
chmod 600 ~/.cognee/.env
— or set nothing at all and let it run on your Claude subscription: with no LLM_API_KEY configured and the claude CLI on PATH, session start switches the local server's LLM calls to the Claude observer (claude -p behind a loopback OpenAI-compatible shim) and embeddings to a local model. No key, no extra account.
Default user and its password. The local server is started with DEFAULT_USER_EMAIL=default_user@example.com and DEFAULT_USER_PASSWORD=default_password, which is how cognee 1.6.0 and later create the default user at all (a server started without DEFAULT_USER_PASSWORD creates no default account, and the password is set once and never rewritten). The plugin logs in as that user to mint its owner API key, so a fresh install needs no manual step and an existing install keeps working. Exporting DEFAULT_USER_EMAIL/DEFAULT_USER_PASSWORD yourself overrides what the plugin passes; COGNEE_USER_EMAIL/COGNEE_USER_PASSWORD pick the user the plugin logs in as, and a non-default user must already exist on the server. When pointing at a server you run yourself (COGNEE_BASE_URL), either start it with DEFAULT_USER_PASSWORD set to the same value as COGNEE_USER_PASSWORD, or set COGNEE_API_KEY so no login is needed; a server without either answers the login with an error that says so.
Windows (PowerShell) — same idea, same file:
New-Item -ItemType Directory -Force "$env:USERPROFILE\.cognee" | Out-Null
@'
COGNEE_BASE_URL="https://your-instance.cognee.ai"
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. Changes apply on the next session launch. Plain shell exports in the launching terminal still take precedence over ~/.cognee/.env — useful to override the shared config for one terminal. See Managing the env file for the file format and how to add, change, or remove variables later.
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:
COGNEE_BACKEND export wins. export COGNEE_BACKEND=local (or =cloud) pins that terminal to that mode. Nothing else needs to change.COGNEE_BASE_URL is set (in the file or the shell), the plugin connects to it.So with both modes in ~/.cognee/.env:
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:
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.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.)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.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.doctor.py prints the decision with its cause, e.g. Mode: Local — forced by COGNEE_BACKEND=local.Then launch claude. All setup happens in the SessionStart hook, which fires once per fresh launch — so with the shell install above, the first launch connects memory with no extra steps.
If you instead installed from inside the chat with the /plugin slash commands, you must restart Claude Code (start a new session) before memory connects: /reload-plugins makes the skills and agents available in the current session but does not run SessionStart. On a first-run marketplace install the marketplace is also fetched asynchronously, so SessionStart may not fire that session even with a reload. Either way, make sure your configuration is in ~/.cognee/.env (or exported in the shell you launch from).
On startup you should see a "Cognee Memory Connected" system message.
The integration authenticates with one principal (one API key, one user), and — on servers that support it — a plugin identity derived from that principal: a dedicated agent sub-user with its own labeled API key, provisioned via POST /api/v1/integrations/plugins/claude-code/provision. Running under a plugin identity lets the cognee dashboard attribute sessions, traces and datasets to this plugin; datasets the plugin creates are automatically shared with your user.
Key resolution order for data-plane traffic:
~/.cognee-plugin/claude-code/agent_key.json (the provisioned plugin identity)COGNEE_API_KEY env var~/.cognee-plugin/api_key.json (cached from a previous mint)api_key.jsonProvisioning policy:
COGNEE_PLUGIN_IDENTITY selects the identity policy:auto (default) — an identity in service of shared agent memory (below). When shared memory is on, session start provisions one (create-only, never rotating a key another machine holds), wires the shared role, and pins the canonical dataset; if that wiring cannot be done (older server, not the tenant owner, tenant-less user who already owns data) the plugin stays on the principal key and says why in cognee-doctor. With shared memory off it stays on the principal.true — explicit identity: provisioning is required and never falls back to the owner. Servers without the SDK's create_only capability, a cached credential bound to another principal, or rejected credentials stop with an error instead of rotating keys or silently using the owner's authority.false — principal mode; a cached identity is ignored.auto, a credential the server rejected or that belongs to another account is not used and the plugin runs as the principal (logged); under true that is an error, and reconnecting a revoked identity requires explicit reconnection.COGNEE_PLUGIN_READ_DATASET_IDS to a JSON array of allowed UUIDs for graph recall across datasets you granted yourself; it takes precedence over the datasets shared memory resolved, and session history stays scoped to its own dataset.A plugin identity is its own cognee user, and grants only flow child→parent: your user sees what the agent writes, but the agent sees nothing your user (or another plugin's agent) owns. Left alone, that would silo memory per plugin — Claude Code could not recall what Codex stored. Shared agent memory, on by default, makes every plugin agent of your user share one memory:
cognee-agent role in it, adds the agent to both, and grants the role read+write on your datasets. Grants are backfilled on every session start and every ~60 s by the idle watcher, so a dataset another plugin creates becomes visible here without a restart.dataset_id for writes, dataset_ids for recall — including any same-named per-agent copies from before). Every hook and skill addresses it that way; a plain name would only resolve among datasets the agent itself owns.Turn it off with "shared_agent_memory": false in config.json or COGNEE_SHARED_AGENT_MEMORY=false for separated, per-plugin memory (name-addressed, agent-owned datasets). Opting out removes the agent from the shared role — it can no longer read or write your datasets — and starts it on a private dataset; it does not move data, so what it shared before stays in your user's dataset. Re-enabling puts the agent back into the same role and dataset. cognee-doctor shows the current state under Memory Sharing.
COGNEE_PLUGIN_IDENTITY decides who the plugin authenticates as, and how strictly; COGNEE_SHARED_AGENT_MEMORY decides what an agent identity can see. Sharing is a property of agent identities — your own user sees everything regardless — so the second setting only matters once an identity exists.
COGNEE_PLUGIN_IDENTITY \ COGNEE_SHARED_AGENT_MEMORY | true (default) | false |
|---|---|---|
auto (default) | Shared memory, graceful. Provisions an identity when the server allows it, wires the shared role, and falls back to your principal key whenever that cannot be done. | Principal, unless already provisioned. A fresh install never provisions (auto provisions only in service of sharing). An identity provisioned earlier is kept, leaves the shared role, and writes to its own private dataset. |
true | Shared memory, strict. Same wiring; any obstacle (no create_only support, a credential bound to another principal, a rejected key) is an error — never a silent fall back to the owner's key. | Separated identities, strict. Each plugin is its own agent with its own private memory, blind to your other datasets. This is the isolation mode: a leaked or revoked plugin key affects only that plugin. |
false | Principal only. The sharing setting has no effect. | Principal only. Identical to the cell above. |
Practical reading: leave both at their defaults for one memory across all of your plugins; set COGNEE_PLUGIN_IDENTITY=true when you want the strict guarantees; add COGNEE_SHARED_AGENT_MEMORY=false to that for fully separated per-plugin memory. Setting COGNEE_PLUGIN_IDENTITY=false makes the sharing setting irrelevant. cognee-doctor reports the resulting state under API Key Source and Memory Sharing.
At startup (SessionStart):
COGNEE_BACKEND (or COGNEE_CLAUDE_BACKEND) exported → that mode, pinned — see Which mode winsCOGNEE_BASE_URL set → managed_endpoint, either local, or on Cognee Cloud (API key needed in cloud case)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).
Self-hosted deployments on loopback: when the configured URL is a loopback address (127.0.0.1, localhost) and the server is down at session start, the plugin normally boots its embedded local server on that port. If the URL actually belongs to an externally managed deployment (docker compose stack, systemd service), set COGNEE_MANAGED_ENDPOINT=true: an outage then makes SessionStart report Cognee Memory OFFLINE loudly instead of silently booting an unrelated fallback instance over the deployment's port.
At hook runtime:
api_key.json), not only config intentThe hooks emit mode_decision logs with mode (always http), service_url, url_source, key_source, api_key_present.
Each terminal launch maintains a small map file:
~/.cognee-plugin/claude-code/sessions/<host_session_id>.json
→ { "conn_uuid": "...", "session_id": "...", "host_key": "..." }
session_id — which Cognee session this terminal writes to and recalls from. Fixed at launch.conn_uuid — per-launch liveness handle used for agent registration and server shutdown counting.By default a new session_id is generated each launch. Set COGNEE_SESSION_ID to resume a specific session:
export COGNEE_SESSION_ID="my-project"
Two terminals can deliberately share a session by sett
hooks/register.ts 1409 lines1// Cognee memory: the in-process layer of the plugin.
2//
3// The settings hooks in hooks.json run the memory pipeline (recall, capture,
4// learning from the session, sync) exactly as before. This module adds what
5// only an in-process module can:
6//
7// * the status row, above the prompt on every surface: it runs the plugin's
8// own status-line renderer in `--json` mode and lays the result out, so
9// the band shows the row the settings-file status line shows (the badge,
10// the counters and the tokens-saved estimate included). In a
11// terminal the module marks its session (`band/<session>.json`) and the
12// renderer then prints nothing for it, so the row is never drawn twice;
13// with the `band` option off the settings status line is back below;
14// * live activity: it reads the per-session marker the hooks write
15// (`~/.cognee-plugin/claude-code/phase/<session>.json`) and shows it on
16// the row and after the spinner (the answer's own credit line is the
17// agent's, from the recall hook's instruction);
18// * the dataset name on the row is a picker: choosing another writable
19// dataset, or typing the name of a new one, switches the session there
20// without a model turn (`/cognee-dataset <name>` does the same from the
21// prompt);
22// * a `see fix` button beside a red ✕ on the row: a dialog pane that says what
23// is wrong in plain words and how to fix it, runs the plugin's doctor, and
24// for a key or URL problem takes the value and writes it to ~/.cognee/.env;
25// * a first-run tour: three dim hint lines under the row, each about the
26// thing above it, shown once per machine (the plugin's store remembers)
27// and again on `/cognee-tour`;
28// * two tools the model calls directly, `mcp__cognee-memory__search` and
29// `mcp__cognee-memory__remember`, served by the plugin's own shell
30// wrappers: no skill file to read, no Bash row, structured arguments.
31//
32// Everything the module runs is one of the plugin's own scripts, as a local
33// process; the only network traffic is theirs, on the person's or the model's
34// explicit action, plus one listing of the writable datasets for the picker. Nothing here runs on a fixed timer while the session is
35// idle: the marker is read on the events the hooks fire, and polled once a
36// second only while an operation is running or for a short window after one
37// was started.
38//
39// Only native events are hooked (prompt.submit, tool.call, turn.complete,
40// session.compact, session.start, session.end), never the classic.* mirrors
41// of the settings hooks: the built-in guard bypasses a user-installed mod's
42// classic.* hooks for Team and Enterprise sign-ins and wherever managed
43// settings exist, so nothing here would run for those users.
44import type { Register, RenderElement } from 'claude-code'
45
46type Activity = { kind: 'active' | 'result'; phase: string; text: string; age: number; ok: boolean; count?: number }
47
48type Counts = {
49 tokens_saved: string
50 tokens_saved_short: string
51}
52
53type Snapshot = {
54 enabled: boolean
55 state: string
56 reason: string
57 dataset: string
58 mode: string
59 credits: string
60 credits_exhausted?: boolean
61 billing_url?: string
62 update: string
63 text: string
64 counts?: Counts
65}
66
67// The brand lavender (#BC9BFF), a deeper violet on a light theme so it keeps
68// its contrast on white.
69const PURPLE_DARK = '#bc9bff'
70const PURPLE_LIGHT = '#7c4dff'
71
72type PhaseResult = { count?: unknown; at?: unknown; ok?: unknown; buffered?: unknown }
73type PhaseMarker = {
74 active?: { phase?: unknown; detail?: unknown; updated_at?: unknown } | null
75 results?: Record<string, PhaseResult>
76}
77
78const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
79const FRAME_MS = 120
80const POLL_MS = 1000
81// How long after a hook fired the marker keeps being polled for the async
82// work (saving, learning from the session) that hook started.
83const POLL_WINDOW_MS = 30_000
84// After a session start the connection marker lands when the SessionStart
85// hook finishes, which can be well after the module's first snapshot. While
86// the row still says "unknown", the snapshot is retaken every few seconds for
87// this long, then left to the next prompt.
88const WARMUP_MS = 120_000
89const WARMUP_RETRY_TICKS = 5
90// Mirrors the renderer: an active phase older than this is a hook that died.
91const STALE_MS = 90_000
92const DIM_AFTER_MS = 60_000
93// A result older than this is no longer news: it leaves the row.
94const HIDE_AFTER_MS = 300_000
95const RENDERER_TIMEOUT_MS = 5000
96const RENDERER_RETRY_MS = 60_000
97// Phases whose settings hook runs async, so the terminal spinner shows no
98// statusMessage for them; the mod adds its own suffix there. The sync hooks
99// (recall, anchor, session start) already announce themselves in a terminal.
100const ASYNC_PHASES = ['saving', 'consolidating', 'indexing', 'syncing']
101
102const ACTIVE_LABELS = {
103 connecting: 'connecting…',
104 setting_up: 'setting up memory',
105 recalling: 'recalling…',
106 saving: 'saving…',
107 consolidating: 'learning from this session…',
108 indexing: 'indexing repository…',
109 anchoring: 'preserving context…',
110 syncing: 'saving session…',
111 // The module's own work, never written to the marker.
112 searching: 'searching memory…',
113 remembering: 'remembering…',
114 switching: 'switching dataset…',
115} as const
116type Phase = keyof typeof ACTIVE_LABELS
117// What the module runs itself shows after a terminal spinner too: the tool row
118// there says only which tool runs.
119const OWN_PHASES = ['searching', 'remembering', 'switching']
120// How a call to each of the plugin's tools is labelled in the transcript.
121const TOOL_ROW_LABELS: Record<string, string> = {
122 'mcp__cognee-memory__search': 'Cognee search',
123 'mcp__cognee-memory__remember': 'Cognee remember',
124}
125
126const SEARCH_TIMEOUT_MS = 60_000
127// Above the wrapper's own worst case (a 120 s POST, an 8 s wait for the
128// cognify to become queryable, the interpreter's start), so the module never
129// cuts off a write that is still in flight and then calls it not stored.
130const REMEMBER_TIMEOUT_MS = 150_000
131// What the remember tool takes in one call. Each call runs a cognify on the
132// tenant's key (an LLM pass per chunk plus embeddings), and a typed tool
133// whose description invites a whole file lowers the bar for a large one.
134// Past these the call is refused with a pointer to the cognee-remember
135// skill, which has no cap.
136const REMEMBER_MAX_CHARS = 16_000
137const REMEMBER_MAX_FILE_BYTES = 64_000
138// And per session: each call is a paid cognify, and nothing else bounds how
139// many the model makes. Past either the call is refused with a pointer to the
140// skill, which has no allowance, so a person who wants more is never stopped.
141const REMEMBER_SESSION_CALLS = 5
142const REMEMBER_SESSION_BYTES = 64_000
143let rememberCalls = 0
144let rememberBytes = 0
145const LIST_TIMEOUT_MS = 20_000
146// A switch syncs the session it leaves first; that can take minutes.
147const SWITCH_TIMEOUT_MS = 600_000
148const NODE_SETS: Record<string, string> = {
149 user: 'user_context',
150 project: 'project_docs',
151 agent: 'agent_actions',
152}
153
154// ── module state (reset on a reload, which is fine: it is all redrawn) ──────
155let sessionId = ''
156let phasePath = ''
157let bandPath = ''
158let rendererPython: string | null = null
159let rendererFailures = 0
160// When the renderer last failed for the third time: the cutoff below expires
161// after RENDERER_RETRY_MS, so a slow start does not freeze the row for good.
162let rendererFailedAt = 0
163let snapshotInFlight = false
164let snapshot: Snapshot | null = null
165let active: Activity | null = null
166let result: Activity | null = null
167// When the shown activity was set, and whether the marker itself says one is
168// running: a 'saving' the Stop hook announced but nothing ever reported (a
169// cloud capture the worker could not deliver, no marker at all) expires
170// after the marker's own stale window instead of spinning for the session.
171let activeSince = 0
172let markerActive = false
173let frame = 0
174let spin: { cancel: () => void } | null = null
175let poll: { cancel: () => void } | null = null
176let pollUntil = 0
177let pollTicks = 0
178let warmupUntil = 0
179// The mark: cognee's waveform icon, drawn only where it can be drawn as
180// itself, the official SVG on the desktop row, read from the plugin's assets
181// at session start. Text gets no stand-in: no glyph in a terminal, nothing
182// after the spinner or in toasts (a character never looks like the mark).
183let markOn = true
184let waveSvg = ''
185let purple = PURPLE_DARK
186let bandOn = true
187let spinnerOn = true
188let toolsOn = true
189
190// The dataset picker on the band.
191type DatasetRow = { name: string; id: string; current: boolean }
192let switchTargets: DatasetRow[] = []
193let switching = false
194// The picker's last entry: picking it turns the picker into a text field for
195// a dataset that does not exist yet (the switch script creates it). Spaces
196// make the value one no dataset can be named, so it never collides.
197const NEW_DATASET = '+ new dataset…'
198let namingDataset = false
199// In a terminal the picker stays closed until asked for: a click anywhere on
200// the band gives the band the keys, and a focused picker shows its list.
201let pickerOpen = false
202
203// The first-run tour: one hint line under the row per step, about what is
204// right above it. Shown once per machine (the store key survives sessions);
205// bump TOUR_VERSION to show a rewritten tour once more. -1 is "not showing".
206const TOUR_VERSION = 1
207const TOUR_STEPS = [
208 'The dataset name is a picker ↑: pick another dataset, or "+ new dataset…" to create one',
209 '↑ ● means memory is connected · local/cloud shows you where your memory lives · the end of the row says what memory is doing',
210 'Claude uses memories recalled by Cognee on every prompt · /cognee-dataset <name> switches by name · /cognee-tour replays this tutorial',
211]
212let tourOn = true
213let tourStep = -1
214let tourChecked = false
215
216// The fix dialog: what the row's reason means and what to do about it. Each
217// guide is plain words first, then steps; `field` names the env-file variable
218// the pane takes a value for, when one value is the fix.
219const FIX_PANE = 'cognee-fix'
220const DOCTOR_TIMEOUT_MS = 30_000
221type FixGuide = { title: string; what: string; steps: string[]; field?: { name: string; placeholder: string } }
222const FIX_GUIDES: Record<string, FixGuide> = {
223 incorrect_cognee_api_key: {
224 title: 'The memory server rejected the plugin\'s API key',
225 what: 'The key this plugin sends to the Cognee server (`COGNEE_API_KEY`) is missing, wrong or expired, so nothing can be recalled or saved.',
226 steps: [
227 '**Cloud:** paste the API key from your Cognee Cloud account below, or put `COGNEE_API_KEY=ck_…` in `~/.cognee/.env`. A key exported in your shell wins over the file.',
228 '**Local:** delete `~/.cognee-plugin/api_key.json` and start a new session; the plugin mints a fresh key from the local server.',
229 'With a plugin identity (`COGNEE_PLUGIN_IDENTITY`), **run doctor** below: it says whether the cached identity credential was the one rejected.',
230 'A new prompt tries the new key; a new session applies it everywhere.',
231 ],
232 field: { name: 'COGNEE_API_KEY', placeholder: 'ck_… (saved to ~/.cognee/.env)' },
233 },
234 incorrect_llm_api_key: {
235 title: 'The local server cannot reach its LLM',
236 what: 'The Cognee server on this machine builds memory with an LLM, and the key it uses (`LLM_API_KEY`) is missing or the provider rejected it. The server itself is fine.',
237 steps: [
238 'Paste a working provider key below, or put `LLM_API_KEY=…` (and `LLM_PROVIDER` if not OpenAI) in `~/.cognee/.env`.',
239 'Or use your Claude subscription instead: remove `LLM_API_KEY` from `~/.cognee/.env` and keep the `claude` CLI on PATH; the next session routes the server\'s LLM calls through it.',
240 'Start a new session: the server reads its key at start.',
241 ],
242 field: { name: 'LLM_API_KEY', placeholder: 'sk-… (saved to ~/.cognee/.env)' },
243 },
244 missing_cognee_base_url: {
245 title: 'Cloud mode is forced, but no server URL is configured',
246 what: '`COGNEE_BACKEND=cloud` is exported in this shell, and no `COGNEE_BASE_URL` is set anywhere, so there is no server to talk to.',
247 steps: [
248 'Paste your Cognee Cloud URL below (it is saved as `COGNEE_BASE_URL` in `~/.cognee/.env`), and make sure `COGNEE_API_KEY` is set too.',
249 'Or go local for this terminal: `unset COGNEE_BACKEND`, then start a new session.',
250 ],
251 field: { name: 'COGNEE_BASE_URL', placeholder: 'https://your-instance.cognee.ai' },
252 },
253 cognee_needs_python_3_10: {
254 title: 'Local mode needs a Python the server can run on',
255 what: 'The hooks run on any Python 3.9+, but the Cognee server itself needs Python 3.10 to 3.14, and none was found on this machine. The plugin never downloads one.',
256 steps: [
257 'Install one: `brew install python@3.12`, `uv python install 3.12`, pyenv, or python.org.',
258 'Start a new session: the plugin builds its runtime under `~/.cognee-plugin/venv` and starts the server.',
259 'Or use Cognee Cloud instead, which needs no local Python: set `COGNEE_BASE_URL` and `COGNEE_API_KEY` in `~/.cognee/.env`.',
260 ],
261 },
262 unreachable: {
263 title: 'The memory server cannot be reached',
264 what: 'Connections are refused or the host does not resolve. In local mode the server process is not running (it may have died); in cloud mode the URL is wrong or the network is in the way.',
265 steps: [
266 '**Local:** start a new session; the plugin starts the server again. If it keeps failing, `~/.cognee-plugin/claude-code/hook.log` says why.',
267 '**Cloud:** check `COGNEE_BASE_URL` in `~/.cognee/.env` (or your shell), then your network or VPN.',
268 '**Run doctor** below: it shows the URL in use, the mode and the health check.',
269 'The dot turns green again on the next successful recall.',
270 ],
271 },
272 server_error: {
273 title: 'The memory server is answering with errors',
274 what: 'The server is up but replied with a server error (HTTP 5xx) to the last requests. Memory is not read or written until it recovers.',
275 steps: [
276 '**Run doctor** below for the health check and the server version.',
277 '**Local:** start a new session to restart the server; `~/.cognee-plugin/claude-code/hook.log` holds the error.',
278 '**Cloud:** retry in a moment; if it persists, the error belongs to the Cognee team.',
279 ],
280 },
281 not_responding: {
282 title: 'The memory server is not answering in time',
283 what: 'The server accepts connections but several recalls in a row timed out. It is usually busy (a large memory build) rather than down.',
284 steps: [
285 'Wait a little and send another prompt; a single answer in time clears the mark.',
286 'On a slow or loaded server, raise the per-prompt budget: `COGNEE_READY_PROBE_TIMEOUT=6` in `~/.cognee/.env`.',
287 '**Run doctor** below to see whether the health check answers at all.',
288 ],
289 },
290}
291const FIX_FALLBACK: FixGuide = {
292 title: 'Memory reported a problem',
293 what: 'The row shows a reason the plugin recorded; the doctor report below has the details.',
294 steps: ['**Run doctor** below.', 'A new session re-checks everything.'],
295}
296let fixReason = ''
297let fixOpen = false
298let doctorOutput = ''
299let doctorRunning = false
300let fixNote: { text: string; isError: boolean } | null = null
301// One listing per session, refreshed after a switch or by the pane's refresh.
302let datasetsListed = false
303let datasetsListing = false
304
305// ── pure helpers ────────────────────────────────────────────────────────────
306function plural(n: number, word: string): string {
307 if (n === 1) return `${n} ${word}`
308 return `${n} ${word.endsWith('y') ? word.slice(0, -1) + 'ies' : word + 's'}`
309}
310
311function toNumber(value: unknown): number | null {
312 const n = typeof value === 'number' ? value : Number(value)
313 return Number.isFinite(n) ? n : null
314}
315
316function activeLabel(phase: string, detail: string): string {
317 const label = (ACTIVE_LABELS as Record<string, string>)[phase] ?? ''
318 if (!label) return ''
319 if (phase === 'setting_up') return detail ? `${label} · ${detail}…` : `${label}…`
320 return label
321}
322
323// At rest: a number only for recall (what helped this turn), a ticked verb
324// for everything that keeps work. Mirrors the renderer's _result_label.
325function resultLabel(kind: string, entry: PhaseResult): string {
326 const ok = entry.ok !== false
327 const count = toNumber(entry.count)
328 switch (kind) {
329 case 'recalled':
330 if (!ok) return 'recall failed'
331 if (!count) return 'nothing relevant'
332 return `${plural(count, 'memory')} used`
333 case 'saved':
334 if (ok || count) return '✓ memory saved'
335 // Nothing landed this turn: spooled for replay, or dropped for good.
336 return entry.buffered === true ? 'save queued' : 'save failed'
337 case 'ready':
338 return ok ? 'memory ready' : 'memory unavailable'
339 case 'consolidated':
340 return ok ? '✓ learned' : 'learning failed'
341 case 'indexed':
342 return ok ? '✓ repository indexed' : 'indexing failed'
343 case 'anchored':
344 return ok ? '✓ context kept' : 'context not kept'
345 case 'synced':
346 return ok ? '✓ session saved' : 'session sync failed'
347 default:
348 return ''
349 }
350}
351
352// The same choice the renderer makes: an active phase wins, else the most
353// recent finished result; a stale active phase is ignored.
354function pickActivity(marker: PhaseMarker, nowMs: number): Activity | null {
355 const current = marker.active
356 if (current && typeof current === 'object' && typeof current.phase === 'string') {
357 const updated = toNumber(current.updated_at)
358 const ageMs = updated === null ? STALE_MS : nowMs - updated * 1000
359 if (ageMs < STALE_MS) {
360 const text = activeLabel(current.phase, typeof current.detail === 'string' ? current.detail : '')
361 if (text) return { kind: 'active', phase: current.phase, text, age: ageMs / 1000, ok: true }
362 }
363 }
364 const results = marker.results
365 if (!results || typeof results !== 'object') return null
366 let latestKind = ''
367 let latest: PhaseResult = {}
368 let latestAt = -1
369 for (const [kind, entry] of Object.entries(results)) {
370 if (!entry || typeof entry !== 'object') continue
371 const at = toNumber(entry.at)
372 if (at !== null && at > latestAt) {
373 latestKind = kind
374 latest = entry
375 latestAt = at
376 }
377 }
378 const text = latestKind ? resultLabel(latestKind, latest) : ''
379 if (!text) return null
380 const count = toNumber(latest.count)
381 return {
382 kind: 'result',
383 phase: latestKind,
384 text,
385 age: Math.max(0, (nowMs - latestAt * 1000) / 1000),
386 ok: latest.ok !== false,
387 ...(count === null ? {} : { count }),
388 }
389}
390
391function activity(phase: Phase): Activity {
392 return { kind: 'active', phase, text: ACTIVE_LABELS[phase], age: 0, ok: true }
393}
394
395// Whether a tree draws anything: text that is not blank, or a control. The
396// engine's own band is often an empty element, which still takes a row in a
397// terminal when stacked under ours, so an empty one is left out.
398// The credit line at the end of an answer, as the recall hook's instruction
399// has the model write it: "> 🧠 Cognee memory [m4, m5]: what it remembered".
400// Returns the text without it, the tags and the note; null when the text
401// does not end with one.
402const CREDIT_LINE = /^(?:>\s*)?🧠\s*Cognee memory \[([^\]]+)\]:\s*(.*)$/u
403function splitCredit(text: string): { body: string; tags: string; note: string } | null {
404 const lines = text.replace(/\s+$/u, '').split('\n')
405 const last = lines[lines.length - 1] ?? ''
406 const m = CREDIT_LINE.exec(last.trim())
407 if (!m) return null
408 return { body: lines.slice(0, -1).join('\n').replace(/\s+$/u, ''), tags: (m[1] ?? '').trim(), note: (m[2] ?? '').trim() }
409}
410
411// Whether the row should use the light-background purple: the env override
412// first, then Claude Code's theme row ("light", "light-daltonized", ...).
413async function lightTheme($: any): Promise<boolean> {
414 const forced = ((await $.env.get('COGNEE_STATUSLINE_THEME')) ?? '').toLowerCase()
415 if (forced === 'light') return true
416 if (forced === 'dark') return false
417 try {
418 const rows = (await $.config.list()) as { key?: string; value?: unknown }[]
419 const theme = String(rows.find(r => r.key === 'theme')?.value ?? '').toLowerCase()
420 return theme.startsWith('light')
421 } catch {
422 return false
423 }
424}
425
426function drawsSomething(tree: unknown): boolean {
427 if (tree === null || tree === undefined || tree === false) return false
428 if (typeof tree === 'string') return tree.trim().length > 0
429 if (typeof tree === 'number') return true
430 if (Array.isArray(tree)) return tree.some(drawsSomething)
431 if (typeof tree !== 'object') return false
432 const el = tree as { type?: unknown; props?: { children?: unknown } }
433 if (el.type !== 'Box' && el.type !== 'Text') return true
434 return drawsSomething(el.props?.children)
435}
436
437function asText(value: unknown): string {
438 if (typeof value === 'string') return value
439 if (value === null || value === undefined) return ''
440 try {
441 return JSON.stringify(value)
442 } catch {
443 return String(value)
444 }
445}
446
447// One recall entry (the wrapper's /api/v1/recall item) as text.
448function hitText(entry: Record<string, unknown>): string {
449 const source = asText(entry.source)
450 if (source === 'trace') {
451 const parts = [`${asText(entry.origin_function) || '?'} — ${asText(entry.status)}`]
452 if (entry.session_feedback) parts.push(`feedback: ${asText(entry.session_feedback)}`)
453 if (entry.method_return_value) parts.push(`output: ${asText(entry.method_return_value).slice(0, 600)}`)
454 return parts.join('\n')
455 }
456 const text = asText(entry.text) || asText(entry.content)
457 if (text) return text
458 const lines: string[] = []
459 if (entry.question) lines.push(`Q: ${asText(entry.question)}`)
460 if (entry.answer) lines.push(`A: ${asText(entry.answer)}`)
461 return lines.join('\n')
462}
463
464// What a search wrapper printed, as the model and the pane read it.
465function formatSearch(stdout: string, exitCode: number): { text: string; count: number; isError: boolean } {
466 const raw = stdout.trim()
467 if (!raw || raw === 'UNREACHABLE' || (exitCode !== 0 && !raw.startsWith('[') && !raw.startsWith('{'))) {
468 return { text: 'Cognee server unreachable: the search did not run. Retry once memory is ready.', count: 0, isError: true }
469 }
470 let parsed: unknown
471 try {
472 parsed = JSON.parse(raw)
473 } catch {
474 return { text: raw, count: raw ? 1 : 0, isError: exitCode !== 0 }
475 }
476 if (Array.isArray(parsed)) {
477 const entries = parsed.filter(x => x && typeof x === 'object') as Record<string, unknown>[]
478 if (entries.length === 0) return { text: 'No memories matched.', count: 0, isError: false }
479 const blocks = entries.map((entry, i) => {
480 const source = asText(entry.source) || 'memory'
481 return `### ${i + 1}. ${source}\n${hitText(entry).trim()}`
482 })
483 return { text: blocks.join('\n\n'), count: entries.length, isError: false }
484 }
485 if (parsed && typeof parsed === 'object' && 'error' in parsed) {
486 return { text: `Cognee search failed: ${asText((parsed as { error: unknown }).error)}`, count: 0, isError: true }
487 }
488 return { text: raw, count: 1, isError: false }
489}
490
491// What the remember wrapper printed, as one line for the model.
492function formatRemember(stdout: string, exitCode: number, dataset: string): { text: string; isError: boolean } {
493 const raw = stdout.trim()
494 if (!raw || raw === 'UNREACHABLE') {
495 return { text: 'Cognee server unreachable: nothing was stored. Retry once memory is ready.', isError: true }
496 }
497 let parsed: Record<string, unknown> = {}
498 try {
499 const value = JSON.parse(raw)
500 if (value && typeof value === 'object') parsed = value as Record<string, unknown>
501 } catch {
502 return { text: raw, isError: exitCode !== 0 }
503 }
504 if (parsed.error) return { text: `Cognee remember failed: ${asText(parsed.error)}`, isError: true }
505 if (parsed.ok === false) return { text: `Cognee rejected the write: ${raw}`, isError: true }
506 const where = dataset || asText(parsed.dataset) || 'the active dataset'
507 if (parsed.queryable === true) return { text: `Stored in ${where}; it is in the graph and recall will find it.`, isError: false }
508 const outcome = asText(parsed.wait_outcome)
509 if (outcome === 'errored') return { text: `Stored in ${where}, but building the graph from it failed; check the server log.`, isError: true }
510 return { text: `Stored in ${where}; the graph is still being built from it, so recall finds it in a moment.`, isError: false }
511}
512
513function datasetRows(value: unknown): DatasetRow[] {
514 if (!value || typeof value !== 'object') return []
515 const rows = (value as { datasets?: unknown }).datasets
516 if (!Array.isArray(rows)) return []
517 return rows
518 .filter(row => row && typeof row === 'object' && typeof (row as { name?: unknown }).name === 'string')
519 .map(row => {
520 const r = row as { name: string; id?: unknown; current?: unknown }
521 return { name: r.name, id: asText(r.id), current: r.current === true }
522 })
523}
524
525// ── helpers that reach the engine ───────────────────────────────────────────
526async function stateDir($: any): Promise<string> {
527 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
528 return `${home}/.cognee-plugin/claude-code`
529}
530
531async function bindSession($: any): Promise<void> {
532 sessionId = await $.session.id()
533 const dir = await stateDir($)
534 phasePath = `${dir}/phase/${sessionId}.json`
535 bandPath = `${dir}/band/${sessionId}.json`
536 await markBand($)
537}
538
539// Tell the settings-file status line that this session's row is drawn above
540// the prompt: the renderer prints nothing for a session with this marker, so
541// the row is not shown twice. Written only while the band is on.
542async function markBand($: any): Promise<void> {
543 if (!bandOn || !bandPath) return
544 try {
545 await $.fs.write(bandPath, JSON.stringify({ session_id: sessionId, at: (await $.clock.now()) / 1000, plugin: 'cognee-memory' }))
546 } catch {
547 // the status line then stays; nothing else depends on the marker
548 }
549}
550
551function redraw($: any): void {
552 $.ui.invalidate('ui.render')
553}
554
555function setActive($: any, next: Activity): void {
556 active = next
557 if (!spin) {
558 spin = $.clock.every(FRAME_MS, () => {
559 frame = (frame + 1) % FRAMES.length
560 $.ui.invalidate('ui.render')
561 })
562 }
563 redraw($)
564}
565
566function clearActive($: any): void {
567 active = null
568 if (spin) {
569 spin.cancel()
570 spin = null
571 }
572 redraw($)
573}
574
575async function readPhase($: any): Promise<void> {
576 if (!phasePath) return
577 markerActive = false
578 let marker: PhaseMarker
579 try {
580 marker = JSON.parse(await $.fs.read(phasePath)) as PhaseMarker
581 } catch {
582 return
583 }
584 const now = await $.clock.now()
585 const shown = pickActivity(marker, now)
586 if (shown?.kind === 'active') {
587 markerActive = true
588 if (!active || active.phase !== shown.phase) {
589 setActive($, shown)
590 activeSince = now
591 }
592 return
593 }
594 result = shown
595 if (active) clearActive($)
596 else redraw($)
597}
598
599// Poll the marker once a second while something runs, and for a short window
600// after a hook fired (its async work lands within seconds). Stops by itself.
601async function startPoll($: any, windowMs: number): Promise<void> {
602 const now = await $.clock.now()
603 pollUntil = Math.max(pollUntil, now + windowMs)
604 if (poll) return
605 poll = $.clock.every(POLL_MS, async () => {
606 await readPhase($)
607 const at = await $.clock.now()
608 pollTicks += 1
609 if (active?.phase === 'saving' && !markerActive && at - activeSince > STALE_MS) clearActive($)
610 // Warming up: the connection is unconfirmed until the SessionStart hook
611 // writes its marker; retake the snapshot every few ticks until it is.
612 const settled = snapshot?.state === 'ok' || snapshot?.state === 'fail'
613 if (at < warmupUntil && !settled && pollTicks % WARMUP_RETRY_TICKS === 0) {
614 await refreshSnapshot($)
615 }
616 if (!active && at > pollUntil && poll) {
617 poll.cancel()
618 poll = null
619 await refreshSnapshot($)
620 }
621 })
622}
623
624type Ran = { exitCode: number; stdout: string; stderr: string }
625
626// One of the plugin's Python scripts, under whichever interpreter answers
627// first (python3 on macOS and Linux, python or py on Windows). Null when none
628// could start it.
629// 'timeout' when the script ran and was killed at its time: the next
630// interpreter is NOT tried then, since the script did run (a switch killed
631// mid-sync must not be started over; an append must not land twice). Only a
632// spawn that failed (no such interpreter) moves on to the next candidate.
633async function runPython($: any, args: string[], init: { stdin?: string; timeoutMs: number }): Promise<Ran | 'timeout' | null> {
634 // The interpreter that answered last goes first; the others stay as the
635 // fallback for when it can no longer be spawned (a venv gone, a PATH change).
636 const defaults = ['python3', 'python', 'py']
637 const candidates = rendererPython ? [rendererPython, ...defaults.filter(c => c !== rendererPython)] : defaults
638 for (const python of candidates) {
639 try {
640 const ran = (await $.process.run([python, ...args], init)) as Ran
641 rendererPython = python
642 return ran
643 } catch (err) {
644 const why = String(err)
645 if (/time|outlast|kill/i.test(why)) return 'timeout'
646 if (!/ENOENT|not found|no such|spawn|cannot (find|run|start)/i.test(why)) return null
647 // the next candidate
648 }
649 }
650 return null
651}
652
653// One of the plugin's shell wrappers (the same ones the skills run).
654// Null when the wrapper could not run at all; 'timeout' when it ran past its
655// time and was killed, which for a write means the outcome is unknown.
656async function runWrapper($: any, script: string, args: string[], timeoutMs: number): Promise<Ran | 'timeout' | null> {
657 try {
658 return (await $.process.run(['bash', `${$.plugin.root}/scripts/${script}`, ...args], { timeoutMs })) as Ran
659 } catch (err) {
660 return /time|outlast|kill/i.test(String(err)) ? 'timeout' : null
661 }
662}
663
664// A search, trimmed the way the prompt hook trims what it injects: a graph
665// item on cognee >= 1.6.0 carries the whole prompt the server's LLM would
666// have read, and only its retrieved context is worth showing. The trim is the
667// plugin's own Python (recall-trim.py); when it cannot run, the raw answer is
668// shown rather than nothing.
669async function runSearch($: any, query: string, args: string[]): Promise<Ran | null> {
670 const ran = await runWrapper($, 'cognee-search.sh', [query, ...args], SEARCH_TIMEOUT_MS)
671 if (ran === 'timeout') return null
672 if (!ran || ran.exitCode !== 0 || !ran.stdout.trimStart().startsWith('[')) return ran
673 const trimmedRun = await runPython($, [`${$.plugin.root}/scripts/recall-trim.py`, '--query', query], {
674 stdin: ran.stdout,
675 timeoutMs: RENDERER_TIMEOUT_MS,
676 })
677 const trimmed = trimmedRun === 'timeout' ? null : trimmedRun
678 if (!trimmed || trimmed.exitCode !== 0 || !trimmed.stdout.trim()) return ran
679 return { ...ran, stdout: trimmed.stdout }
680}
681
682// One run of the plugin's own renderer in --json mode: the dataset, mode,
683// connection state, credits and update nudge, as the terminal bar shows them.
684// Local process, no network. Runs only on events, never on a timer.
685async function refreshSnapshot($: any): Promise<void> {
686 if (!sessionId || snapshotInFlight) return
687 if (rendererFailures >= 3) {
688 if ((await $.clock.now()) - rendererFailedAt < RENDERER_RETRY_MS) return
689 rendererFailures = 0
690 }
691 snapshotInFlight = true
692 try {
693 const script = `${$.plugin.root}/scripts/cognee_statusline_render.py`
694 const stdin = JSON.stringify({ session_id: sessionId })
695 const ran = await runPython($, [script, '--json'], { stdin, timeoutMs: RENDERER_TIMEOUT_MS })
696 if (ran && ran !== 'timeout' && ran.exitCode === 0 && ran.stdout.trim()) {
697 try {
698 snapshot = JSON.parse(ran.stdout) as Snapshot
699 rendererFailures = 0
700 redraw($)
701 return
702 } catch {
703 // fall through
704 }
705 }
706 rendererFailures += 1
707 if (rendererFailures >= 3) rendererFailedAt = await $.clock.now()
708 } finally {
709 snapshotInFlight = false
710 }
711}
712
713// ── the fix dialog ──────────────────────────────────────────────────────────
714function fixGuide(reason: string): FixGuide {
715 return FIX_GUIDES[reason] ?? FIX_FALLBACK
716}
717
718async function openFix($: any, reason: string): Promise<void> {
719 fixReason = reason
720 fixOpen = true
721 fixNote = null
722 doctorOutput = ''
723 const opened = await $.ui.open({ id: FIX_PANE, title: 'Fix memory', focus: true, closeOnEscape: true, holdToasts: true, rows: 18 })
724 if (!opened.isPlaced) $.ui.toast(`fix: ${opened.reason}`)
725}
726
727async function runDoctor($: any): Promise<void> {
728 if (doctorRunning) return
729 doctorRunning = true
730 redraw($)
731 try {
732 const ran = await runWrapper($, 'cognee-doctor.sh', [], DOCTOR_TIMEOUT_MS)
733 if (ran === 'timeout') doctorOutput = `the doctor gave no answer within ${DOCTOR_TIMEOUT_MS / 1000} s`
734 else doctorOutput = ran ? (ran.stdout.trim() || ran.stderr.trim() || '(no output)') : 'could not run the doctor: bash not found'
735 } finally {
736 doctorRunning = false
737 redraw($)
738 }
739}
740
741// Append one variable to ~/.cognee/.env (the last value of a key wins there,
742// so appending is also how a value is changed). Written by the module, so the
743// secret never goes through a shell history.
744async function saveEnvValue($: any, name: string, value: string): Promise<void> {
745 const trimmed = value.trim()
746 if (!trimmed) return
747 if (/[\r\n]/.test(trimmed)) {
748 fixNote = { text: 'one line only', isError: true }
749 redraw($)
750 return
751 }
752 // The plugin's own env-file module does the write: it resolves the file the
753 // hooks read (COGNEE_ENV_FILE, else ~/.cognee/.env) and creates it 0600,
754 // which the fs API cannot. The value travels on stdin, never on argv.
755 const ran = await runPython($, [`${$.plugin.root}/scripts/_env_file.py`, '--append', name], { stdin: trimmed, timeoutMs: RENDERER_TIMEOUT_MS })
756 if (ran === 'timeout') {
757 fixNote = { text: `saving ${name} took too long; check the env file before trying again`, isError: true }
758 redraw($)
759 return
760 }
761 const written = ran && ran.exitCode === 0 ? ran.stdout.trim() : ''
762 if (written) fixNote = { text: `${name} saved to ${written}; a new session applies it`, isError: false }
763 else fixNote = { text: `could not save ${name}${ran?.stderr.trim() ? `: ${ran.stderr.trim()}` : ''}`, isError: true }
764 redraw($)
765}
766
767// ── the tour ────────────────────────────────────────────────────────────────
768// Once, at the first draw of the band (the person is looking at the row by
769// then; at session start the Desktop app has no surface yet).
770async function startTourIfFirstRun($: any): Promise<void> {
771 if (tourChecked) return
772 tourChecked = true
773 if (!tourOn) return
774 try {
775 const seen = (await $.store.get('tour')) as { version?: unknown } | undefined
776 if (seen && toNumber(seen.version) === TOUR_VERSION) return
777 } catch {
778 return
779 }
780 tourStep = 0
781 redraw($)
782}
783
784async function endTour($: any, finished: boolean): Promise<void> {
785 tourStep = -1
786 redraw($)
787 try {
788 await $.store.set('tour', { version: TOUR_VERSION, at: (await $.clock.now()) / 1000, finished })
789 } catch {
790 // shown again next session, which is the worst case
791 }
792 if (finished) $.ui.toast(`that is the tour · /cognee-tour replays it`)
793}
794
795// ── the dataset picker ──────────────────────────────────────────────────────
796function sessionKeyArgs(): string[] {
797 return sessionId ? ['--session-key', sessionId] : []
798}
799
800// The datasets the picker offers: the writable ones, from the plugin's own
801// switch script, once when the band is first drawn and after each switch.
802async function loadDatasets($: any): Promise<void> {
803 if (datasetsListing) return
804 datasetsListing = true
805 const switchable = await runPython($, [`${$.plugin.root}/scripts/switch-dataset.py`, '--list', '--json', ...sessionKeyArgs()], {
806 timeoutMs: LIST_TIMEOUT_MS,
807 })
808 try {
809 const parsed = switchable && switchable !== 'timeout' && switchable.stdout.trim() ? JSON.parse(switchable.stdout) : {}
810 if (!parsed.error) switchTargets = datasetRows(parsed)
811 } catch {
812 // the name stays plain text
813 }
814 datasetsListed = true
815 datasetsListing = false
816 redraw($)
817}
818
819// Move the session to another dataset: the plugin's switch script, which
820// syncs the session it leaves and starts a new one. No model turn.
821async function switchDataset($: any, target: string): Promise<string> {
822 const name = target.trim()
823 if (!name) return 'name a dataset'
824 if (switching) return 'a switch is already running'
825 switching = true
826 setActive($, activity('switching'))
827 let line = ''
828 try {
829 const ran = await runPython($, [`${$.plugin.root}/scripts/switch-dataset.py`, name, '--json', ...sessionKeyArgs()], {
830 timeoutMs: SWITCH_TIMEOUT_MS,
831 })
832 let parsed: Record<string, unknown> = {}
833 try {
834 parsed = ran && ran !== 'timeout' && ran.stdout.trim() ? (JSON.parse(ran.stdout) as Record<string, unknown>) : {}
835 } catch {
836 parsed = {}
837 }
838 if (ran === 'timeout') line = `the switch ran past ${SWITCH_TIMEOUT_MS / 60_000} min and was stopped; /cognee-dataset shows where the session is`
839 else if (!ran) line = 'could not start the switch: no python interpreter found'
840 else if (parsed.error) line = asText(parsed.error)
841 else if (parsed.switched === false) line = `already on ${asText(parsed.dataset) || name}`
842 else if (parsed.switched === true) {
843 const previous = (parsed.previous ?? {}) as { dataset?: unknown; synced?: unknown }
844 const synced = previous.synced === false ? ' (previous session NOT synced)' : ''
845 line = `switched to ${asText(parsed.dataset) || name}${synced}`
846 } else line = `switch failed${ran.stderr.trim() ? `: ${ran.stderr.trim().split('\n').pop()}` : ''}`
847 const failed = !parsed.switched && parsed.switched !== false
848 $.ui.toast(failed ? `✕ ${line}` : `${line}`)
849 } finally {
850 switching = false
851 clearActive($)
852 }
853 await refreshSnapshot($)
854 void loadDatasets($)
855 return line
856}
857
858export const register: Register = (on, options) => {
859 markOn = options.icon !== 'none'
860 bandOn = options.band !== false
861 spinnerOn = options.spinner !== false
862 toolsOn = options.tools !== false
863 tourOn = options.tour !== false
864
865 on('session.start', async ($, e, next) => {
866 // A fresh start (or a reload): timers of an earlier life of the module
867 // hold an engine interface that is gone; start over.
868 if (poll) {
869 poll.cancel()
870 poll = null
871 }
872 if (spin) {
873 spin.cancel()
874 spin = null
875 }
876 pollTicks = 0
877 // The brand lavender reads on a dark background; a light one gets the
878 // darker purple. COGNEE_STATUSLINE_THEME says which; without it, Claude
879 // Code's own theme does (a light theme, or auto on a light system, is
880 // only known to Claude Code when the theme is set explicitly).
881 purple = (await lightTheme($)) ? PURPLE_LIGHT : PURPLE_DARK
882 if (markOn && !waveSvg) {
883 try {
884 const source = await $.fs.read(`${$.plugin.root}/assets/icon-lavender.svg`)
885 if (typeof source === 'string' && source.trimStart().startsWith('<svg')) waveSvg = source
886 } catch {
887 // the text mark then stands in on the desktop too
888 }
889 }
890 await bindSession($)
891 await readPhase($)
892 await refreshSnapshot($)
893 // The band's dataset picker needs the writable datasets: one listing, not
894 // awaited (the session must not wait on a slow server). The Desktop app
895 // reports no surface at start (it attaches after), so the band's first
896 // draw starts the listing too; this covers a session that does say desktop.
897 if (bandOn && e.surface === 'desktop') void loadDatasets($)
898 await $.command.register({
899 name: 'cognee-dataset',
900 description: 'Switch this session to another Cognee dataset; without a name, list them.',
901 argumentHint: '[name]',
902 })
903 await $.command.register({
904 name: 'cognee-tour',
905 description: 'Replay the short tour of the Cognee status row.',
906 })
907 if (toolsOn) {
908 await $.tool.register({
909 name: 'search',
910 description:
911 'Search Cognee memory: the permanent knowledge graph of this session\'s dataset (scope "graph", the default), ' +
912 'or the indexed repository\'s code graph (scope "code"). Memory is already recalled automatically on every prompt; ' +
913 'call this for a deeper or differently worded search, for more results, or to read another dataset by its id. ' +
914 'Returns the matching memories as text; an empty result from a reachable server is authoritative.',
915 inputSchema: {
916 type: 'object',
917 properties: {
918 query: { type: 'string', description: 'What to look for, as a question or a few keywords.' },
919 top_k: { type: 'integer', minimum: 1, maximum: 50, default: 10, description: 'How many results to return.' },
920 scope: { type: 'string', enum: ['graph', 'code'], default: 'graph', description: 'graph: memory; code: the indexed repository.' },
921 dataset_id: {
922 type: 'string',
923 description: 'Read another dataset by its UUID (from the "Other Cognee datasets" hint). Leaves the session on the active dataset.',
924 },
925 },
926 required: ['query'],
927 },
928 })
929 await $.tool.register({
930 name: 'remember',
931 description:
932 'Store what the person asked to remember in Cognee memory (the active dataset\'s knowledge graph): ' +
933 'a preference, a correction, a fact about them, a decision, a note. Use it when they say "remember this", ' +
934 '"save this to memory" or "store this file", not on your own initiative: the session is captured automatically, ' +
935 'routine tool calls included, so do not store those or your working notes. ' +
936 'Category "user" for their preferences and facts about them; "project" for facts about the codebase, docs and decisions; ' +
937 '"agent" only when they ask you to keep a finding of yours. Pass "file" instead of "content" to store a file they named ' +
938 '(code then rides the code path). Each call runs a cognify on their key: a session allows ' +
939 `${REMEMBER_SESSION_CALLS} calls and ${REMEMBER_SESSION_BYTES} bytes in all; past that the cognee-remember skill is the way.`,
940 inputSchema: {
941 type: 'object',
942 properties: {
943 content: { type: 'string', description: 'The text to remember.' },
944 category: { type: 'string', enum: ['user', 'project', 'agent'], default: 'user' },
945 file: { type: 'string', description: 'A file path to store instead of inline content.' },
946 },
947 },
948 })
949 }
950 // A session start still connecting or bootstrapping: watch it land, and
951 // keep retaking the snapshot until the connection is confirmed.
952 warmupUntil = (await $.clock.now()) + WARMUP_MS
953 await startPoll($, snapshot?.state === 'ok' && !active ? POLL_WINDOW_MS : WARMUP_MS)
954 return next(e)
955 })
956
957 on('command.run', { command: 'cognee-tour' }, $ => {
958 tourChecked = true
959 tourStep = 0
960 redraw($)
961 return { text: 'Cognee: the tour is on the status row above the prompt.' }
962 })
963
964 // /cognee-dataset <name> switches from the prompt, on every surface; alone
965 // it lists the datasets the picker offers and opens the terminal's picker.
966 on('command.run', { command: 'cognee-dataset' }, async ($, e) => {
967 const name = e.args.trim()
968 if (!name) {
969 datasetsListed = false
970 await loadDatasets($)
971 if (switchTargets.length === 0) return { text: 'Cognee: no datasets listed (is memory connected?).' }
972 const rows = switchTargets.map(r => (r.current ? ` * ${r.name} (current)` : ` - ${r.name}`)).join('\n')
973 pickerOpen = true
974 redraw($)
975 return {
976 text: `Cognee datasets you can write to:\n${rows}\n\n/cognee-dataset <name> switches this session; the picker on the status row is open too (ctrl+x tab reaches it, arrows move, Enter picks).`,
977 }
978 }
979 const line = await switchDataset($, name)
980 return { text: `Cognee: ${line}.` }
981 })
982
983 // The model's direct line to memory: the same wrappers the skills run,
984 // without the skill file and the Bash row. A registered tool answers with
985 // the text the model reads (the engine takes a string or an array, never an
986 // object); a failure is a deny, which the model reads as an error result.
987 on('tool.call', { tool: 'mcp__cognee-memory__search' }, async ($, e) => {
988 const query = asText(e.query).trim()
989 if (!query) return { deny: 'query is required' }
990 const topK = Math.max(1, Math.min(50, toNumber(e.top_k) ?? 10))
991 const scope = e.scope === 'code' ? '--code' : '--graph'
992 const datasetId = asText(e.dataset_id).trim()
993 setActive($, activity('searching'))
994 let shown
995 try {
996 const ran = await runSearch($, query, [String(topK), scope, ...(datasetId ? ['--dataset-id', datasetId] : [])])
997 shown = ran ? formatSearch(ran.stdout, ran.exitCode) : formatSearch('', 1)
998 } finally {
999 clearActive($)
1000 }
1001 const where = datasetId ? `dataset ${datasetId}` : scope === '--code' ? 'the code graph' : 'the active dataset'
1002 if (shown.isError) return { deny: shown.text }
1003 return { result: `${plural(shown.count, 'result')} from ${where}.\n\n${shown.text}` }
1004 }).catch($ => {
1005 clearActive($)
1006 return { deny: 'Cognee search failed inside the plugin; try the cognee-search skill.' }
1007 })
1008
1009 on('tool.call', { tool: 'mcp__cognee-memory__remember' }, async ($, e) => {
1010 const content = asText(e.content).trim()
1011 const file = asText(e.file).trim()
1012 if (!content && !file) return { deny: 'content or file is required' }
1013 if (content.length > REMEMBER_MAX_CHARS) {
1014 return {
1015 deny: `content is ${content.length} characters; the remember tool takes up to ${REMEMBER_MAX_CHARS}. Store a longer text with the cognee-remember skill.`,
1016 }
1017 }
1018 if (file) {
1019 const stat = await $.fs.stat(file)
1020 if (stat.kind !== 'file') return { deny: `${file} is not a file` }
1021 if (stat.size > REMEMBER_MAX_FILE_BYTES) {
1022 return {
1023 deny: `${file} is ${stat.size} bytes; the remember tool takes files up to ${REMEMBER_MAX_FILE_BYTES}. Store a larger file with the cognee-remember skill.`,
1024 }
1025 }
1026 }
1027 const size = file ? (await $.fs.stat(file)).size : content.length
1028 if (rememberCalls >= REMEMBER_SESSION_CALLS || rememberBytes + size > REMEMBER_SESSION_BYTES) {
1029 return {
1030 deny:
1031 `this session has used its remember allowance (${rememberCalls} of ${REMEMBER_SESSION_CALLS} calls, ` +
1032 `${rememberBytes} of ${REMEMBER_SESSION_BYTES} bytes). Store it with the cognee-remember skill if the person asked for it.`,
1033 }
1034 }
1035 const nodeSet = NODE_SETS[asText(e.category)] ?? 'user_context'
1036 // Counted before the call: a call that was cut off may have cost as much
1037 // as one that answered.
1038 rememberCalls += 1
1039 rememberBytes += size
1040 setActive($, activity('remembering'))
1041 let shown
1042 try {
1043 const args = file ? ['--file', file, '--node-set', nodeSet] : [content, '--node-set', nodeSet]
1044 const ran = await runWrapper($, 'cognee-remember.sh', args, REMEMBER_TIMEOUT_MS)
1045 if (ran === 'timeout') {
1046 // The POST may have landed; a resubmit would pay for a second cognify.
1047 shown = {
1048 text: `Cognee remember gave no answer within ${REMEMBER_TIMEOUT_MS / 1000} s; the write may have landed. Do not resubmit the same text: search for it first.`,
1049 isError: true,
1050 }
1051 } else shown = ran ? formatRemember(ran.stdout, ran.exitCode, snapshot?.dataset ?? '') : formatRemember('', 1, '')
1052 } finally {
1053 clearActive($)
1054 }
1055 if (shown.isError) return { deny: shown.text }
1056 return { result: shown.text }
1057 }).catch($ => {
1058 clearActive($)
1059 return { deny: 'Cognee remember failed inside the plugin; nothing was stored. Try the cognee-remember skill.' }
1060 })
1061
1062 // Fires at each prompt, before the turn starts; next(e) runs the plugin's
1063 // recall hook (UserPromptSubmit), whose header "Cognee memory: recall N
1064 // memory ..." may come back in the context. The marker the hook wrote is
1065 // the fallback. A /clear or /resume changes the session id, so rebind here.
1066 on('prompt.submit', async ($, e, next) => {
1067 const id = await $.session.id()
1068 if (id !== sessionId) await bindSession($)
1069 setActive($, activity('recalling'))
1070 let ran
1071 try {
1072 ran = await next(e)
1073 } finally {
1074 clearActive($)
1075 }
1076 if (ran && 'drop' in ran) return ran
1077 await readPhase($)
1078 // Not awaited: a process spawn on the path from prompt to answer only
1079 // updates the row, and the poll below refreshes it a second later anyway.
1080 void refreshSnapshot($)
1081 // The turn's tool calls are saved as they happen; watch for them.
1082 await startPoll($, POLL_WINDOW_MS)
1083 return ran
1084 }).catch(($, e, next) => next(e))
1085
1086 // Each tool call's PostToolUse hook saves a trace in the background once the
1087 // tool ran; next(e) is that run. Watch the marker for the save landing.
1088 on('tool.call', async ($, e, next) => {
1089 const ran = await next(e)
1090 await startPoll($, POLL_WINDOW_MS)
1091 return ran
1092 }).catch(($, e, next) => next(e))
1093
1094 // Compaction runs the plugin's PreCompact hook (the memory anchor) inside next(e).
1095 on('session.compact', async ($, e, next) => {
1096 setActive($, activity('anchoring'))
1097 try {
1098 return await next(e)
1099 } finally {
1100 clearActive($)
1101 await readPhase($)
1102 }
1103 }).catch(($, e, next) => next(e))
1104
1105 // /clear and /resume end the conversation and start another under a new id,
1106 // with no session.start: forget what was shown, and let the next prompt rebind.
1107 on('session.end', async ($, e, next) => {
1108 if (e.reason === 'clear' || e.reason === 'resume') {
1109 if (active) clearActive($)
1110 result = null
1111 snapshot = null
1112 namingDataset = false
1113 pickerOpen = false
1114 sessionId = ''
1115 rememberCalls = 0
1116 rememberBytes = 0
1117 }
1118 return next(e)
1119 })
1120
1121 // The Stop hook has started saving the answer by now; show it as it lands.
1122 // Never for a subagent's turn. The credit line inside an answer that used
1123 // memory is the agent's own, asked for by the recall hook.
1124 on('turn.complete', async ($, e, next) => {
1125 const ran = await next(e)
1126 if (e.agentId) return ran
1127 setActive($, activity('saving'))
1128 activeSince = await $.clock.now()
1129 await startPoll($, POLL_WINDOW_MS)
1130 return ran
1131 })
1132
1133 on('ui.close', { id: FIX_PANE }, ($, e, next) => {
1134 fixOpen = false
1135 return next(e)
1136 }).catch(($, e, next) => next(e))
1137
1138 // The fix dialog: the reason in plain words, the steps, the doctor's report
1139 // on request, and a field for the one value that fixes a key or URL problem.
1140 on('ui.render', { component: 'Pane', requestId: FIX_PANE }, async ($, e) => {
1141 const { Box, Text, Markdown, Button, Input, Code } = $.ui.resolve(e) as any
1142 const guide = fixGuide(fixReason)
1143 const rows: RenderElement[] = []
1144 rows.push(Text({ bold: true, color: 'red', children: [`✕ ${guide.title}`] }))
1145 rows.push(Text({ dimColor: true, children: [`reason: ${fixReason || 'unknown'}`] }))
1146 rows.push(Markdown({ text: `${guide.what}\n\n${guide.steps.map((step, i) => `${i + 1}. ${step}`).join('\n')}` }))
1147 if (guide.field && Input) {
1148 rows.push(Text({ children: [''] }))
1149 rows.push(
1150 Input({
1151 key: 'secret',
1152 label: `${guide.field.name} `,
1153 placeholder: guide.field.placeholder,
1154 submitLabel: 'save',
1155 onSubmit: (value: string) => void saveEnvValue($, guide.field!.name, value),
1156 }),
1157 )
1158 }
1159 if (fixNote) rows.push(Text({ color: fixNote.isError ? 'red' : 'green', children: [fixNote.text] }))
1160 rows.push(Text({ children: [''] }))
1161 rows.push(
1162 Box({
1163 flexDirection: 'row',
1164 children: [
1165 Button({ key: 'doctor', label: doctorRunning ? 'running doctor…' : 'run doctor', variant: 'primary', onPress: () => void runDoctor($) }),
1166 Text({ children: [' '] }),
1167 Button({ key: 'closeFix', label: 'close', plain: true, dimColor: true, onPress: () => void $.ui.close({ id: FIX_PANE }) }),
1168 ],
1169 }),
1170 )
1171 if (doctorOutput) rows.push(Code({ source: doctorOutput }))
1172 return Box({ flexDirection: 'column', children: rows })
1173 })
1174
1175 // The status row, above the prompt on the desktop and in a terminal (where
1176 // the settings-file status line is told to stay quiet for this session), the
1177 // dataset name a picker: open on the desktop; in a terminal a button that
1178 // opens it (or /cognee-dataset alone). Once open, ctrl+x tab or a click
1179 // reaches it; arrows move, Enter picks, Escape returns to the prompt.
1180 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1181 const theirs = await next(e)
1182 if (!bandOn || e.props.hasSurvey) return theirs
1183 if (e.surface !== 'desktop' && e.surface !== 'terminal') return theirs
1184 const els = $.ui.resolve(e) as any
1185 const { Box, Text, Select, Input, Button, Link } = els
1186 const snap = snapshot
1187 const parts: RenderElement[] = []
1188 if (!datasetsListed && !datasetsListing) void loadDatasets($)
1189 void startTourIfFirstRun($)
1190 const current = switchTargets.find(r => r.current)?.name ?? snap?.dataset ?? ''
1191 const canPick = switchTargets.length > 0 && switchTargets.some(r => r.name === current)
1192 const picker = () =>
1193 Select({
1194 key: 'dataset',
1195 options: [...switchTargets.map(r => ({ value: r.name, label: r.name })), { value: NEW_DATASET, label: NEW_DATASET }],
1196 value: current,
1197 ...(e.surface === 'terminal' ? { autoFocus: true as const } : {}),
1198 onSelect: (value: string) => {
1199 pickerOpen = false
1200 if (value === NEW_DATASET) {