Skip to main content
Glama
Adityaladi

OpenCode Brain

by Adityaladi
README.md
# OpenCode Brain — Tier 3 Persistent Memory System

A local A-MEM–style agentic memory system for OpenCode with noise filtering,
selective forgetting, and iterative retrieval. Built on your existing stack.

**Stack:**
- **Qdrant** — vector store (you already have this)
- **sentence-transformers/all-MiniLM-L6-v2** — 384-dim embeddings (you already have this)
- **NetworkX** — knowledge graph, persisted to JSON on disk
- **Qwen3-4B / Qwen3-30B** — routed note enrichment, link decisions, memory evolution
- **Obsidian Local REST API** — human-readable vault mirror (optional)
- **FastMCP / MCP** — exposes everything as an MCP server to OpenCode

> Qdrant client compatibility: OpenCode Brain supports both legacy `search(...)`
> and modern `query_points(...)` APIs via an internal adapter.

---

## Architecture

```
OpenCode ──MCP──► mcp_server.py (18 tools)
                        │
        ┌───────────────┼──────────────────┐
        ▼               ▼                  ▼
    Qdrant          NetworkX           Obsidian
 (vectors +       (link graph,        (markdown
  payloads,        typed edges,        mirror,
  access_count,    JSON on disk)       optional)
  last_accessed)
        │               │
        └───────┬────────┘
                ▼
      Qwen3 router
    (note construction,
     link decisions,
     memory evolution,
     context distillation)
```

### Three Core Improvements over Basic A-MEM

| Improvement | Module | Research Basis |
|---|---|---|
| Noise filtering via distillation | `brain/distiller.py` | MEM1 (Zhou et al., 2025): agents that discard irrelevant info outperform by 3.5x |
| Selective forgetting / decay | `brain/decay.py` | SimpleMem (Liu et al., 2026): selective retention achieves 26.4% F1 improvement |
| Iterative retrieval | `brain/deep_search.py` | Structural Memory (Zeng et al., 2024): iterative retrieval outperforms single-step across all benchmarks |

---

## Memory Pipeline

### `add_memory` (permanent note)
1. **Note Construction** — routed LLM generates title, context, keywords, tags
2. **Embedding** — `all-MiniLM-L6-v2` encodes `content + context` → 384-dim vector
3. **Upsert** — stored in Qdrant with `access_count=0`, `last_accessed=""`
4. **Link Generation** — top-K semantic candidates fetched; routed LLM decides which to link
5. **Memory Evolution** — for high-similarity links, hard model re-generates old notes' context; old vectors updated. Runs in a **background thread** so `add_memory` returns immediately.
6. **Graph Update** — NetworkX edges added with typed relations; mutations are **batched** and flushed once, not per-edge
7. **Obsidian Sync** — markdown written to vault (best-effort, non-blocking)

### `add_memory` (fleeting note)
Steps 1–3 only. **Link generation and memory evolution are skipped** — fleeting notes
are cheap quick captures. Hard LLM calls happen at distillation time, not capture time.
Fleeting notes are **excluded from all search results by default**.

### `distill_to_permanent`
Runs the full permanent pipeline on a fleeting note: re-enrichment → re-embedding → link
generation → memory evolution → Obsidian sync. Promotes `memory_type` to `"permanent"`.

### `deep_search` (iterative retrieval)
```
Hop 0: embed(query) → search Qdrant → [A, B, C]
       stamp_access(A, B, C)  ← decay tracking
Hop 1: embed(query + titles of A,B,C) → search Qdrant → [D, E, F]
+ graph.get_links(A,B,C) → pre-fetch neighbours
stamp_access(neighbours) ← graph-expanded notes are also tracked for decay
Hop 2: embed(query + titles of D,E,F) → search Qdrant → [G, H]
Final: union of all hops, ranked by best score seen across hops
```

### Decay (selective forgetting)
Every `search_notes` call stamps `last_accessed` and increments `access_count` on
returned notes. Graph-expanded neighbors surfaced by `deep_search` are **also stamped**
(BUG-4 fix — previously only direct vector-search hits were tracked).

`archive_stale_notes` soft-archives notes that have:
- Not been accessed in > 45 days, AND
- Fewer than 2 lifetime accesses, AND
- `decay_score` < 0.3 (recency 60% + access importance 40%)

`archive_stale_notes` paginates through **all notes** regardless of vault size
(pages through the full collection — no hidden cap).

Archived notes (`memory_type="archived"`) vanish from all searches but are never deleted.
`restore_note(zk_id)` reverses archival instantly.

---

## Payload Schema (Qdrant)

Every point stored in Qdrant has these fields:

| Field | Type | Description |
|---|---|---|
| `zk_id` | str | `"ZK-<8-char-hex>"` — unique note ID |
| `title` | str | Single atomic claim (LLM-generated) |
| `content` | str | Raw knowledge content |
| `context` | str | Why this matters — LLM-generated, evolves over time |
| `keywords` | list[str] | 5–10 searchable terms (LLM-generated) |
| `tags` | list[str] | 2–5 category tags |
| `links` | list[str] | zk_ids of linked notes (bidirectional) |
| `memory_type` | str | `fleeting` \| `permanent` \| `archived` |
| `note_kind` | str | `mistake` \| `fix` \| `pattern` \| `research` \| `decision` \| `general` \| `open_thread` \| `session_summary` \| `project_context` |
| `pinned` | bool | Protect high-value notes from automatic archival |
| `project` | str | `rag-chatbot` \| `resume-screener` \| `nano-r1` \| `general` |
| `created` | str | ISO-8601 timestamp |
| `updated` | str | ISO-8601 timestamp |
| `access_count` | int | Lifetime search retrieval count (decay tracking) |
| `last_accessed` | str | ISO-8601 timestamp of last search hit (decay tracking) |

---

## MCP Tools Reference (18 tools)

| Tool | When to Use |
|---|---|
| `open_session(project)` | Preferred one-call session bootstrap; wraps `get_session_context` plus `get_inbox` |
| `get_session_context(project)` | Canonical session-start fallback; loads full project state |
| `get_inbox(project)` | Canonical second fallback call; surfaces fleeting notes for distillation |
| `distill_to_permanent(zk_id)` | Promote a fleeting note to permanent knowledge |
| `deep_search(query, project, hops, include_contextual=false)` | **Complex questions** — multi-hop iterative retrieval |
| `search_memory(query, top_k, project, include_contextual=false)` | Simple direct lookups only |
| `get_related(zk_id, depth)` | Follow graph links from a specific note |
| `add_memory(content, project, memory_type, note_kind, ...)` | Store new knowledge |
| `update_memory(zk_id, new_content)` | Correct or extend an existing note |
| `archive_stale_notes(dry_run)` | Soft-archive unused notes (weekly maintenance) |
| `close_session(project, objective, completed, blockers, next_steps, key_files=None)` | Preferred one-call handoff capture; writes `session_summary` plus active `open_thread` |
| `brain_stats()` | Health check — counts by type, graph size, Qdrant status |
| `record_failure(content, project, failure_signature)` | Capture a fresh failure as a fleeting mistake note |
| `record_resolution(failure_zk_id, fix_content, project)` | Capture a validated fix linked to prior failure |
| `preflight_check(task, project, top_k)` | Retrieve similar past mistakes/fixes before coding |
| `restore_note(zk_id)` | Restore one archived note to permanent |
| `decay_report(limit)` | Inspect lowest-decay notes likely to go stale next |
| `consolidate_clusters(project, dry_run, selected_cluster_ids, ...)` | Manual cluster consolidation with dry-run and selected apply |

### Session Ergonomics

Use `open_session(project)` at the start of coding-agent work when available. It preserves the canonical `get_session_context(project)` and `get_inbox(project)` payloads while reducing session startup to one call.

Use `close_session(...)` at handoff. It writes one `session_summary`, writes one active `open_thread`, and archives older `open_thread` notes only after the new handoff is durable.

### When to use `deep_search` vs `search_memory`

```
Simple direct lookup    →  search_memory  ("what port does Qdrant use?")
Complex / causal        →  deep_search    ("why does the CrossEncoder slow things down?")
Session start context   →  open_session   (preferred; canonical fallback is get_session_context + get_inbox)
"How did we solve X?"   →  deep_search    (may span multiple sessions and notes)
After finding a note    →  get_related    (follow the knowledge graph)
```

---

## File Structure

```
opencode-brain/
├── brain/
│   ├── embedder.py       # sentence-transformers wrapper (singleton, normalised)
│   ├── vector_store.py   # all Qdrant ops: upsert, search, scroll, update, decay tracking
│   ├── graph.py          # NetworkX DiGraph: typed edges, JSON persistence
│   ├── note_builder.py   # routed LLM prompts: construct_note, decide_link, evolve_context
│   ├── model_router.py   # centralised LLM routing: fast/hard model selection
│   ├── memory_evolution.py  # A-MEM core: link generation + retroactive context updates
│   ├── obsidian_sync.py  # vault write-back via Local REST API (best-effort)
│   ├── distiller.py      # noise filtering: inbox management + fleeting→permanent promotion
│   ├── decay.py          # selective forgetting: decay scores + stale note archival
│   ├── deep_search.py    # iterative retrieval: search → graph expand → search again
│   ├── consolidation.py  # cluster detection and merge (manual-only, dry-run first)
│   ├── session.py        # session context, startup wrapper support, close handoff helpers
│   └── mistake_memory.py # mistake-aware helpers: build_failure_note, rank_preflight_results
├── mcp_server.py     # FastMCP entry point, all 18 tools defined here
├── config.py         # all configuration, all overridable via .env
├── requirements.txt
├── .env.example      # copy to .env and fill in HUGGINGFACE_API_KEY at minimum
└── AGENTS.md         # brain protocol — paste into your OpenCode AGENTS.md
```

---

## Setup

### 1. Prerequisites

Qdrant must be running locally:
```bash
docker run -p 6333:6333 qdrant/qdrant
```

### 2. Install dependencies

```bash
cd opencode-brain
pip install -r requirements.txt --break-system-packages
```

Key dependencies:
- `qdrant-client>=1.13,<2.0` — constrained for predictable compatibility; supports both `search()` and `query_points()` APIs
- `huggingface-hub>=0.23.0` — HuggingFace Inference API for Qwen routing
- `sentence-transformers>=3.0.0` — local embedding model

### 3. Configure

```bash
cp .env.example .env
# Edit .env — set HUGGINGFACE_API_KEY at minimum
# Everything else has working defaults
```

### 4. Test the server runs

```bash
python mcp_server.py
# Should print:
#   [embedder] Loading sentence-transformers/all-MiniLM-L6-v2 …
#   [vector_store] Created collection 'opencode_brain'   (first run)
#   [graph] Loaded 0 nodes, 0 edges
# Then waits for stdio MCP input — Ctrl+C to exit
```

### 5. Add to OpenCode config

Edit `~/.config/opencode/config.json`:
```json
{
  "mcp": {
    "opencode-brain": {
      "command": [
"python",
            "C:\\Projects\\Brainn\\mcp_server.py"
      ],
      "type": "local"
    }
  }
}
```

### 6. (Optional) Enable Obsidian vault sync

1. Obsidian → Settings → Community Plugins → search **"Local REST API"** → Install → Enable
2. Copy the API key from the plugin settings page
3. Add to `.env`:
   ```
   OBSIDIAN_API_KEY=your-key-here
   OBSIDIAN_VAULT_SUBFOLDER=brain
   ```
Notes will be written to `<your-vault>/brain/zk/ZK-XXXXXXXX.md` with full YAML frontmatter.

### 7. Add the brain protocol to your AGENTS.md

Copy the contents of `AGENTS.md` (in this repo) into your existing OpenCode AGENTS.md.
The protocol defines exactly when each tool should be called during a session.

---

## Seed Your Brain (Recommended First Step)

Run once to pre-load your existing project knowledge:

```python
import sys
sys.path.insert(0, ".")
from brain import vector_store
from mcp_server import add_memory

vector_store.ensure_collection()

seeds = [
    {
        "content": (
            "RAG Chatbot stack: Qdrant hybrid search (dense + BM25) + CrossEncoder reranking "
            "+ LangGraph with SqliteSaver for persistent memory + Qwen3 routed models + "
            "all-mpnet-base-v2 embeddings + Chainlit UI. "
            "Location: C:/Projects/agentic-rag/. "
            "Known fix: QdrantClient shutdown ResourceWarning → atexit.register(client.close)."
        ),
        "project": "rag-chatbot",
        "memory_type": "permanent",
    },
    {
        "content": (
            "AI Resume Screener deployed to HuggingFace Spaces (Adityaladi/Ai_resume_screener). "
            "Stack: TF-IDF + Naive Bayes/KNN/SVC, 88-92% accuracy across 25 categories. "
            "Flask REST API + Streamlit frontend + Docker + GitHub Actions CI/CD. "
            "Known issue: IT category bias — fix with class_weight='balanced' or SMOTE."
        ),
        "project": "resume-screener",
        "memory_type": "permanent",
    },
    {
        "content": (
            "Nano-R1: QLoRA/GRPO fine-tune of Qwen2.5-3B-Instruct replicating DeepSeek-R1 "
            "chain-of-thought reasoning. Trained via Unsloth/TRL targeting GSM8K math. "
            "Published at HuggingFace: Adityaladi/Nano-R1. "
            "Remaining work: GSM8K evaluation via lm-evaluation-harness to close the metric gap."
        ),
        "project": "nano-r1",
        "memory_type": "permanent",
    },
    {
        "content": (
            "OpenCode MCP config pattern: 'type: remote' silently fails for remote MCP servers. "
            "Correct pattern: type='local', command='npx', args=['mcp-remote', '<url>']. "
            "Applies to Consensus, Exa, Context7, HuggingFace servers."
        ),
        "project": "general",
        "memory_type": "permanent",
    },
]

for seed in seeds:
    result = add_memory(
        seed["content"],
        project=seed["project"],
        memory_type=seed["memory_type"],
    )
    print(result)
```

---

## Weekly Maintenance

```python
# Check what is going stale (dry run — safe)
result = archive_stale_notes(dry_run=True)

# Apply if the list looks reasonable
result = archive_stale_notes(dry_run=False)

# Health check
print(brain_stats())
```

---

## Schema Conformance and Migrations

Run these in order when upgrading or validating data integrity:

```bash
# 1) Lifecycle/semantics split migration (strict scope)
python tools/migrate_memory_schema.py
python tools/migrate_memory_schema.py --apply

# 2) note_kind enum conformance migration
python tools/migrate_note_kind_conformance.py
python tools/migrate_note_kind_conformance.py --apply

# 3) memory_type enum conformance for semantic-type drift (for example memory_type="research")
python tools/migrate_memory_type_conformance.py
python tools/migrate_memory_type_conformance.py --apply

# 4) CI-safe schema drift check (non-zero exit if invalid payloads exist)
python tools/check_schema_health.py
```

Backups are written before `--apply` runs:
- `backups/memory-pre-migration-<timestamp>.json`
- `backups/notekind-pre-migration-<timestamp>.json`
- `backups/memorytype-pre-migration-<timestamp>.json`

---

## Tunable Config Values (`.env`)

| Variable | Default | Description |
|---|---|---|
| `HUGGINGFACE_API_KEY` | — | **Required** |
| `FAST_MODEL_ID` | `Qwen/Qwen3-4B-Instruct-2507` | Fast default LLM for note construction and extraction |
| `FAST_MODEL_PROVIDER` | `featherless-ai` | Inference provider for the fast model (overridden to `nscale` in `.env`) |
| `HARD_MODEL_ID` | `Qwen/Qwen3-30B-A3B-Instruct-2507` | Hard fallback model for synthesis and memory evolution |
| `HARD_MODEL_PROVIDER` | `featherless-ai` | Inference provider for the hard model |
| `QDRANT_HOST` | `localhost` | Qdrant server host |
| `QDRANT_PORT` | `6333` | Qdrant server port |
| `QDRANT_COLLECTION` | `opencode_brain` | Collection name |
| `EMBEDDING_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | Embedding model |
| `OBSIDIAN_API_KEY` | _(empty — sync disabled)_ | Obsidian Local REST API key |
| `OBSIDIAN_HOST` | `http://localhost:27123` | Obsidian REST API URL |
| `OBSIDIAN_VAULT_SUBFOLDER` | `brain` | Subfolder in vault for notes |
| `STALE_DAYS_THRESHOLD` | `45` | Days before a note is stale |
| `STALE_ACCESS_THRESHOLD` | `2` | Min accesses to be immune from archival |
| `GRAPH_PATH` | `~/.opencode-brain/graph.json` | Knowledge graph persistence path |

---

## Research Basis

**Core Architecture**
- [A-MEM: Agentic Memory for LLM Agents](https://arxiv.org/abs/2502.12110) (Xu et al., 2025) — Zettelkasten-inspired atomic notes + semantic linking + memory evolution
- [Zettelkasten Method](https://zettelkasten.de/) — atomic notes, bidirectional links, claim-based titles

**Three Core Improvements**
- [MEM1: Learning to Synergize Memory and Reasoning](https://arxiv.org/abs/2506.15841) (Zhou et al., 2025) — noise filtering via distillation; agents that discard irrelevant info outperform by 3.5× (NeurIPS 2025)
- [SimpleMem: Efficient Lifelong Memory](https://arxiv.org/abs/2601.02553) (Liu et al., 2026) — selective forgetting; 26.4% F1 improvement, 30× token reduction
- [On the Structural Memory of LLM Agents](https://arxiv.org/abs/2412.15266) (Zeng et al., 2024) — iterative retrieval; consistently outperforms single-step across all benchmarks (HotPotQA 82.1% F1)

**Supporting Research**
- [FadeMem: Biologically-Inspired Forgetting for Efficient Agent Memory](https://arxiv.org/abs/2601.18642) (Wei et al., 2026) — direction for adaptive decay; importance-modulated decay rates
- [CTIM-Rover: Pitfalls of Episodic Memory in SE Agents](https://arxiv.org/abs/2505.23422) (Lindenbauer et al., 2025) — motivation for decaying short-lived contextual memory; episodic memory noise degrades retrieval without active filtering (REALM 2025)
- [Mistake Notebook Learning](https://arxiv.org/abs/2512.11485) (Su et al., 2025) — conceptual basis for `record_failure` / `record_resolution` typed mistake workflow