SLOPSHOPPER

Cognee Memory

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

newpanebandspinnerrowsguard
★ 100v1.7.1Apache-2.0updated 2026-10-16topoteretes/cognee-integrations/integrations/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cognee-memory
│ ┃ cognee-fix ✕ › fix the failing auth test and add an audit log call │ ┃ ✕ Memory reported a problem │ ┃ reason: unknown ⏺ Read(src/auth.ts) │ ┃ The row shows a reason the plugin recorded; ⎿ Read 6 lines │ ┃ the doctor report below has the details. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 1. Run doctor below. ⏺ Bash(bun test) │ ┃ 2. A new session re-checks everything. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ run doctor ] close ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cognee-dataset │ ⎿ cognee-memory: Cognee: no datasets listed (is memory connected?) │ │ ✻ Thinking · saving…… ⠋ cognee memory · … · saving… The dataset name is a picker ↑: pick another dataset, or "+ new dataset…" to create one [ next ] skip ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⠋ cognee memory · … · saving… The dataset name is a picker ↑: pick another dataset, or "+ new dataset…" to create one [ next ] sk ⟨Claude Code's own drawing⟩
Pane · cognee-fix
✕ Memory reported a problem reason: unknown The row shows a reason the plugin recorded; the doctor report below has the details. 1. Run doctor below. 2. A new session re-checks everything. [ run doctor ] close
README

<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>

Cognee Memory Plugin for Claude Code

Adds persistent memory to Claude Code through Cognee.

The integration:

  • captures prompts, tool traces, and assistant responses into session memory
  • injects relevant context on prompt submit
  • syncs session memory into graph memory on session end/final exit

What this plugin runs, stores and sends

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.

  • Local mode (default): a cognee server the plugin starts on this machine, bound to 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.
  • Cloud mode: your Cognee Cloud instance at 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.

Install

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.

Which mode wins, and how to switch

You can configure both modes at once — keep COGNEE_BASE_URL + COGNEE_API_KEY and LLM_API_KEY in the file together. The mode is then decided per terminal, by three rules in order:

  1. A COGNEE_BACKEND export wins. export COGNEE_BACKEND=local (or =cloud) pins that terminal to that mode. Nothing else needs to change.
  2. Otherwise, cloud wins when configured. If COGNEE_BASE_URL is set (in the file or the shell), the plugin connects to it.
  3. Otherwise, local. With no URL anywhere, the plugin boots the local server.

So with both modes in ~/.cognee/.env:

claude                          # → cloud (the configured URL routes)
COGNEE_BACKEND=local claude     # → local, this launch only
export COGNEE_BACKEND=local     # → local for every launch from this shell

Details worth knowing:

  • The switch is pinned. COGNEE_BACKEND=cloud with no COGNEE_BASE_URL configured still counts as cloud: the plugin does not silently fall back to local, and the status line shows ✕ (missing_cognee_base_url) so you know exactly what to fix.
  • To go local, use the switch — not unset COGNEE_BASE_URL. Unsetting doesn't work: the env file re-injects the URL at the next launch. (Deleting the line from the file works, but that changes the default for every terminal.)
  • The shared COGNEE_BACKEND flips both the Claude Code and Codex plugins in that terminal. To flip only one, use COGNEE_CLAUDE_BACKEND / COGNEE_CODEX_BACKEND — the plugin-specific name beats the shared one.
  • Accepted values: local (aliases: native, sdk) and cloud (aliases: http, api, server). Anything else is ignored.
  • COGNEE_BACKEND can also live in ~/.cognee/.env to make a mode the durable default; a shell export still overrides it per terminal.
  • Not sure what a terminal resolved? The status line's mode field shows it live, and doctor.py prints the decision with its cause, e.g. Mode: Local — forced by COGNEE_BACKEND=local.

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.

Auth

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:

  1. ~/.cognee-plugin/claude-code/agent_key.json (the provisioned plugin identity)
  2. COGNEE_API_KEY env var
  3. ~/.cognee-plugin/api_key.json (cached from a previous mint)
  4. Auto-mint from the default local user (local mode only), then cache to api_key.json

Provisioning 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.
  • A cached credential is bound to its server and principal. Under 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.
  • Use dataset UUIDs for shared write targets. Dataset switching checks effective write permissions. Set 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.

Shared agent memory

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:

  • Session start ensures your user owns a tenant (one is created for a fresh, tenant-less install) and a 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.
  • The launch's dataset is a canonical, user-owned dataset addressed by UUID (the launch record's 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.
  • All tenant/role/grant calls run as your user, never as the agent — the server only lets the tenant owner manage roles, so an agent key cannot widen its own access.

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.

How the two settings combine

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_MEMORYtrue (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.
trueShared 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.
falsePrincipal 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.

Mode selection rules

At startup (SessionStart):

  • COGNEE_BACKEND (or COGNEE_CLAUDE_BACKEND) exported → that mode, pinned — see Which mode wins
  • otherwise COGNEE_BASE_URL set → managed_endpoint, either local, or on Cognee Cloud (API key needed in cloud case)
  • otherwise → integration_local (local API bootstrap)

A forced-local switch also scrubs COGNEE_BASE_URL/COGNEE_API_KEY from the process environment, so the per-prompt recall/remember calls and every spawned worker resolve the same local endpoint — not just SessionStart. A forced-cloud switch with no URL configured never boots the local server; the connection attempt fails visibly instead (status line + doctor).

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:

  • hooks resolve the endpoint through runtime endpoint auth (env + api_key.json), not only config intent
  • every hook talks to that endpoint over HTTP; there is no in-process (SDK) fallback

The hooks emit mode_decision logs with mode (always http), service_url, url_source, key_source, api_key_present.

Sessions

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

Source 1 files
hooks/register.ts 1409 lines
1// 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) {