# KnowledgeBase API Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add a `KnowledgeBase` service class to kbx that exposes all entity, document, memory, search, context, and indexing operations behind a typed Python API.

**Architecture:** Single `api.py` module with a `KnowledgeBase` class that owns DB + config + embedder lifecycle. Delegates to existing modules (`search.py`, `context.py`, `indexer.py`, `glossary.py`, `config.py`). New Pydantic types in `types.py`. Re-exported from `__init__.py`.

**Tech Stack:** Python 3.10+, Pydantic v2 (strict mode), SQLite, LanceDB (lazy), pytest

**Design doc:** `docs/plans/2026-02-24-python-api-design.md`

---

### Task 1: Add new Pydantic types to types.py

**Files:**
- Modify: `src/kb/types.py`
- Test: `tests/test_types.py`

**Step 1: Write the failing test**

Add to `tests/test_types.py`:

```python
class TestApiTypes:
    """Types for the KnowledgeBase API."""

    def test_entity_summary_roundtrip(self):
        from kb.types import EntitySummary

        s = EntitySummary(
            id=1,
            name="Eve",
            entity_type="person",
            metadata={"role": "Lead"},
            mention_count=5,
            pinned=True,
        )
        assert s.name == "Eve"
        assert s.pinned is True
        d = s.model_dump()
        assert d["mention_count"] == 5

    def test_entity_detail_with_facts(self):
        from kb.types import EntityDetail, EntityFact

        f = EntityFact(text="Promoted", date="2026-01-15")
        d = EntityDetail(
            id=1,
            name="Eve",
            entity_type="person",
            aliases=["E"],
            metadata={"role": "Lead"},
            mention_count=5,
            pinned=False,
            source_path="memory/people/eve.md",
            facts=[f],
        )
        assert d.facts[0].text == "Promoted"
        assert d.source_path == "memory/people/eve.md"

    def test_timeline_entry(self):
        from kb.types import TimelineEntry

        t = TimelineEntry(title="Standup", date="2026-02-20", path="meetings/standup.md")
        assert t.title == "Standup"

    def test_entity_pin_result(self):
        from kb.types import EntityPinResult

        r = EntityPinResult(name="Eve", pinned=True)
        assert r.pinned is True

    def test_document_pin_result(self):
        from kb.types import DocumentPinResult

        r = DocumentPinResult(path="memory/notes/foo.md", pinned=False)
        assert r.pinned is False

    def test_memory_tree_node_nested(self):
        from kb.types import MemoryTreeNode

        child = MemoryTreeNode(name="foo.md", node_type="file", path="notes/foo.md", pinned=True)
        parent = MemoryTreeNode(
            name="notes", node_type="dir", path="notes", children=[child], count=1
        )
        assert parent.children[0].pinned is True
        assert parent.count == 1
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_types.py::TestApiTypes -v`
Expected: FAIL — `ImportError: cannot import name 'EntitySummary'`

**Step 3: Write the types**

Add to `src/kb/types.py` after the existing `EntityTypeConfig` class:

```python
# ---------------------------------------------------------------------------
# API response models (used by KnowledgeBase service class)
# ---------------------------------------------------------------------------


class EntityFact(StrictFrozen):
    """A structured fact about an entity."""

    text: str
    date: str | None


class EntitySummary(StrictFrozen):
    """Entity with mention count — the common list/grid view."""

    id: int
    name: str
    entity_type: str
    metadata: dict[str, str]
    mention_count: int
    pinned: bool


class EntityDetail(StrictFrozen):
    """Full entity record with facts and source path."""

    id: int
    name: str
    entity_type: str
    aliases: list[str]
    metadata: dict[str, str]
    mention_count: int
    pinned: bool
    source_path: str | None
    facts: list[EntityFact]


class TimelineEntry(StrictFrozen):
    """A document mentioning an entity, ordered by date."""

    title: str
    date: str | None
    path: str


class EntityPinResult(StrictFrozen):
    """Result of toggling an entity's pin state."""

    name: str
    pinned: bool


class DocumentPinResult(StrictFrozen):
    """Result of toggling a document's pin state."""

    path: str
    pinned: bool


class MemoryTreeNode(StrictFrozen):
    """A file or directory in the memory/ tree."""

    name: str
    node_type: str  # "file" | "dir"
    path: str  # relative to memory/
    pinned: bool = False
    children: list[MemoryTreeNode] = []
    count: int = 0  # file count for dirs
```

**Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_types.py::TestApiTypes -v`
Expected: PASS

**Step 5: Run mypy**

Run: `uv run mypy src/kb/types.py`
Expected: Success

**Step 6: Commit**

```bash
git add src/kb/types.py tests/test_types.py
git commit -m "feat(types): add API response models for KnowledgeBase class (#5)"
```

---

### Task 2: KnowledgeBase constructor & lifecycle

**Files:**
- Create: `src/kb/api.py`
- Modify: `src/kb/__init__.py`
- Test: `tests/test_api.py`

**Step 1: Write the failing test**

Create `tests/test_api.py`:

```python
"""Tests for the KnowledgeBase API."""

from __future__ import annotations

import tempfile
from pathlib import Path

import pytest


@pytest.fixture
def project_root(tmp_path):
    """Create a minimal project root with memory/ directory."""
    (tmp_path / "memory").mkdir()
    (tmp_path / "memory" / "glossary.md").write_text("# Glossary\n")
    return tmp_path


@pytest.fixture
def kb_instance(tmp_path, project_root):
    """Create a KnowledgeBase instance with isolated data dir."""
    from kb.api import KnowledgeBase

    data_dir = tmp_path / "data"
    kb = KnowledgeBase(project_root=project_root, data_dir=data_dir)
    yield kb
    kb.close()


class TestLifecycle:
    """Constructor, close, context manager."""

    def test_constructor_creates_db(self, tmp_path, project_root):
        from kb.api import KnowledgeBase

        data_dir = tmp_path / "data"
        kb = KnowledgeBase(project_root=project_root, data_dir=data_dir)
        assert (data_dir / "metadata.db").exists()
        kb.close()

    def test_context_manager(self, tmp_path, project_root):
        from kb.api import KnowledgeBase

        data_dir = tmp_path / "data"
        with KnowledgeBase(project_root=project_root, data_dir=data_dir) as kb:
            assert (data_dir / "metadata.db").exists()
        # After exit, db should be closed (no error on double-close)
        kb.close()

    def test_thread_safe_mode(self, tmp_path, project_root):
        from kb.api import KnowledgeBase

        data_dir = tmp_path / "data"
        kb = KnowledgeBase(project_root=project_root, data_dir=data_dir, thread_safe=True)
        conn = kb._db.get_sqlite_conn()
        # Verify WAL mode is enabled
        mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
        assert mode == "wal"
        kb.close()

    def test_import_from_package(self):
        from kb import KnowledgeBase

        assert KnowledgeBase is not None

    def test_count_documents_empty(self, kb_instance):
        assert kb_instance.count_documents() == 0
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/test_api.py::TestLifecycle -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'kb.api'`

**Step 3: Write minimal implementation**

Create `src/kb/api.py`:

```python
"""KnowledgeBase — public Python API for kbx.

Single entry point for all knowledge base operations. Owns DB + config +
embedder lifecycle. Consumers create one instance and call methods.

    from kb import KnowledgeBase

    with KnowledgeBase() as kb:
        results = kb.search("cloud migration")
        people = kb.list_entities(entity_type="person")
"""

from __future__ import annotations

import json
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING, Any

from kb.db import Database
from kb.types import (
    DocumentPinResult,
    EntityDetail,
    EntityFact,
    EntityPinResult,
    EntitySummary,
    GlossaryEntry,
    MemoryTreeNode,
    PinnedDocument,
    TimelineEntry,
)

if TYPE_CHECKING:
    from kb.embeddings import Embedder
    from kb.types import ContextOutput, IndexResult, SearchResponse


class KnowledgeBase:
    """Public API for the kbx knowledge base.

    Parameters
    ----------
    project_root:
        Path to the project root (contains ``memory/``, ``meetings/``).
        Auto-discovered from ``kbx.toml`` or CWD walk-up if not provided.
    data_dir:
        Path to the database directory (contains ``metadata.db``, ``vectors/``).
        Auto-discovered from config / ``KB_DATA_DIR`` / ``~/.config/kbx/`` if not provided.
    thread_safe:
        If True, opens the SQLite connection with ``check_same_thread=False``
        and enables WAL mode. Use this when sharing the instance across threads
        (e.g. FastAPI route handlers).
    """

    def __init__(
        self,
        *,
        project_root: Path | None = None,
        data_dir: Path | None = None,
        thread_safe: bool = False,
    ) -> None:
        if project_root is None:
            from kb.config import find_project_root

            project_root = find_project_root()
        if data_dir is None:
            from kb.config import get_data_dir

            data_dir = get_data_dir()

        self._project_root = project_root
        self._data_dir = data_dir
        self._thread_safe = thread_safe
        self._embedder: Embedder | None = None
        self._embedder_failed = False

        self._db = Database(data_dir)

        if thread_safe:
            self._replace_conn_thread_safe()

    def _replace_conn_thread_safe(self) -> None:
        """Replace the DB connection with a thread-safe one."""
        old_conn = self._db.get_sqlite_conn()
        db_path = str(self._data_dir / "metadata.db")
        new_conn = sqlite3.connect(db_path, check_same_thread=False)
        new_conn.row_factory = sqlite3.Row
        new_conn.execute("PRAGMA foreign_keys=ON")
        new_conn.execute("PRAGMA synchronous=NORMAL")
        new_conn.execute("PRAGMA cache_size=-64000")
        new_conn.execute("PRAGMA journal_mode=WAL")
        new_conn.execute("PRAGMA busy_timeout=5000")
        old_conn.close()
        self._db._sqlite_conn = new_conn

    def _get_conn(self) -> sqlite3.Connection:
        """Get the SQLite connection."""
        return self._db.get_sqlite_conn()

    def _get_embedder(self) -> Embedder | None:
        """Lazy-load the embedder, returning None if unavailable."""
        if self._embedder is not None:
            return self._embedder
        if self._embedder_failed:
            return None
        try:
            from kb.embeddings import Embedder as _Embedder

            self._embedder = _Embedder()
            return self._embedder
        except Exception:
            self._embedder_failed = True
            return None

    def close(self) -> None:
        """Release all resources (DB connection, embedder GPU memory)."""
        self._db.close()
        self._embedder = None
        self._embedder_failed = False

    def __enter__(self) -> KnowledgeBase:
        return self

    def __exit__(self, *args: object) -> None:
        self.close()

    # ------------------------------------------------------------------
    # Document operations
    # ------------------------------------------------------------------

    def count_documents(self) -> int:
        """Return the total number of indexed documents."""
        row = self._get_conn().execute("SELECT COUNT(*) AS cnt FROM documents").fetchone()
        return int(row["cnt"])
```

Update `src/kb/__init__.py`:

```python
"""kbx — local knowledge base CLI with hybrid search over markdown files."""

from kb.api import KnowledgeBase

__all__ = ["KnowledgeBase"]
```

**Step 4: Run test to verify it passes**

Run: `uv run pytest tests/test_api.py::TestLifecycle -v`
Expected: PASS

**Step 5: Run mypy**

Run: `uv run mypy src/kb/api.py src/kb/__init__.py`
Expected: Success

**Step 6: Commit**

```bash
git add src/kb/api.py src/kb/__init__.py tests/test_api.py
git commit -m "feat(api): add KnowledgeBase class with constructor and lifecycle (#5)"
```

---

### Task 3: Entity operations — list_entities, get_entity, find_entities

**Files:**
- Modify: `src/kb/api.py`
- Test: `tests/test_api.py`

**Step 1: Write the failing tests**

Add to `tests/test_api.py`:

```python
@pytest.fixture
def kb_with_entities(kb_instance):
    """Seed some test entities into the database."""
    conn = kb_instance._db.get_sqlite_conn()
    conn.execute(
        "INSERT INTO entities (name, entity_type, aliases, metadata, source_path)"
        " VALUES (?, ?, ?, ?, ?)",
        ("Eve Perrin", "person", '["Eve"]',
         '{"role": "Engineering Leader", "team": "Platform"}',
         "memory/people/eve.md"),
    )
    conn.execute(
        "INSERT INTO entities (name, entity_type, aliases, metadata, source_path)"
        " VALUES (?, ?, ?, ?, ?)",
        ("Cloud Migration", "project", '["cloud-migration"]',
         '{"status": "In Progress"}', None),
    )
    # Add some documents and mentions for Eve
    conn.execute(
        "INSERT INTO documents (path, title, doc_date, doc_type, source_system,"
        " source_id, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
        ("meetings/standup.md", "Standup", "2026-02-20", "meeting",
         "granola", "abc", "hash1"),
    )
    conn.execute(
        "INSERT INTO entity_mentions (entity_id, document_id, mention_type)"
        " VALUES (1, 1, 'discussed')"
    )
    conn.execute(
        "INSERT INTO entity_mentions (entity_id, document_id, mention_type)"
        " VALUES (1, 1, 'participant')"
    )
    conn.commit()
    return kb_instance


class TestEntityOperations:
    """list_entities, get_entity, find_entities."""

    def test_list_entities_all(self, kb_with_entities):
        result = kb_with_entities.list_entities()
        assert len(result) == 2
        names = {e.name for e in result}
        assert "Eve Perrin" in names
        assert "Cloud Migration" in names

    def test_list_entities_by_type(self, kb_with_entities):
        result = kb_with_entities.list_entities(entity_type="person")
        assert len(result) == 1
        assert result[0].name == "Eve Perrin"
        assert result[0].mention_count == 2

    def test_list_entities_pinned_first(self, kb_with_entities):
        conn = kb_with_entities._db.get_sqlite_conn()
        conn.execute("UPDATE entities SET pinned = 1 WHERE name = 'Cloud Migration'")
        conn.commit()
        result = kb_with_entities.list_entities()
        assert result[0].name == "Cloud Migration"
        assert result[0].pinned is True

    def test_list_entities_returns_entity_summary(self, kb_with_entities):
        from kb.types import EntitySummary

        result = kb_with_entities.list_entities()
        assert isinstance(result[0], EntitySummary)

    def test_get_entity_found(self, kb_with_entities):
        result = kb_with_entities.get_entity("Eve Perrin")
        assert result is not None
        assert result.name == "Eve Perrin"
        assert result.metadata["role"] == "Engineering Leader"
        assert result.aliases == ["Eve"]
        assert result.mention_count == 2

    def test_get_entity_not_found(self, kb_with_entities):
        result = kb_with_entities.get_entity("Nobody")
        assert result is None

    def test_get_entity_case_insensitive(self, kb_with_entities):
        result = kb_with_entities.get_entity("eve perrin")
        assert result is not None
        assert result.name == "Eve Perrin"

    def test_get_entity_returns_entity_detail(self, kb_with_entities):
        from kb.types import EntityDetail

        result = kb_with_entities.get_entity("Eve Perrin")
        assert isinstance(result, EntityDetail)

    def test_get_entity_with_facts(self, kb_with_entities):
        conn = kb_with_entities._db.get_sqlite_conn()
        conn.execute(
            "INSERT INTO facts (entity_id, fact_text, fact_date)"
            " VALUES (1, 'Promoted to Lead', '2026-01-15')"
        )
        conn.commit()
        result = kb_with_entities.get_entity("Eve Perrin")
        assert result is not None
        assert len(result.facts) == 1
        assert result.facts[0].text == "Promoted to Lead"

    def test_find_entities_exact(self, kb_with_entities):
        result = kb_with_entities.find_entities("Eve Perrin")
        assert len(result) == 1
        assert result[0].name == "Eve Perrin"

    def test_find_entities_alias(self, kb_with_entities):
        result = kb_with_entities.find_entities("Eve")
        assert len(result) == 1
        assert result[0].name == "Eve Perrin"

    def test_find_entities_partial(self, kb_with_entities):
        result = kb_with_entities.find_entities("Perrin")
        assert len(result) == 1

    def test_find_entities_no_match(self, kb_with_entities):
        result = kb_with_entities.find_entities("zzz_nonexistent")
        assert result == []
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_api.py::TestEntityOperations -v`
Expected: FAIL — `AttributeError: 'KnowledgeBase' object has no attribute 'list_entities'`

**Step 3: Implement entity methods**

Add to `src/kb/api.py` inside the `KnowledgeBase` class:

```python
    # ------------------------------------------------------------------
    # Entity operations
    # ------------------------------------------------------------------

    def list_entities(self, entity_type: str | None = None) -> list[EntitySummary]:
        """List entities, optionally filtered by type.

        Returns entities sorted pinned-first, then by name.
        """
        conn = self._get_conn()
        if entity_type:
            rows = conn.execute(
                "SELECT id, name, entity_type, metadata, pinned"
                " FROM entities WHERE entity_type = ? ORDER BY name",
                (entity_type,),
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT id, name, entity_type, metadata, pinned FROM entities ORDER BY name"
            ).fetchall()

        mention_rows = conn.execute(
            "SELECT entity_id, COUNT(*) AS cnt FROM entity_mentions GROUP BY entity_id"
        ).fetchall()
        mention_map: dict[int, int] = {r["entity_id"]: r["cnt"] for r in mention_rows}

        results = [
            EntitySummary(
                id=r["id"],
                name=r["name"],
                entity_type=r["entity_type"],
                metadata=json.loads(r["metadata"]) if r["metadata"] else {},
                mention_count=mention_map.get(r["id"], 0),
                pinned=bool(r["pinned"]),
            )
            for r in rows
        ]
        results.sort(key=lambda e: (not e.pinned, e.name.lower()))
        return results

    def get_entity(self, name: str) -> EntityDetail | None:
        """Get full entity detail by name (case-insensitive, supports aliases).

        Returns None if not found.
        """
        from kb.config import find_entity

        conn = self._get_conn()
        row = find_entity(conn, name)
        if row is None:
            return None

        entity_id: int = row["id"]
        meta = json.loads(row["metadata"]) if row["metadata"] else {}
        aliases = json.loads(row["aliases"]) if row["aliases"] else []

        mention_row = conn.execute(
            "SELECT COUNT(*) AS cnt FROM entity_mentions WHERE entity_id = ?",
            (entity_id,),
        ).fetchone()

        fact_rows = conn.execute(
            "SELECT fact_text, fact_date FROM facts WHERE entity_id = ?"
            " ORDER BY fact_date DESC, id DESC",
            (entity_id,),
        ).fetchall()

        return EntityDetail(
            id=entity_id,
            name=row["name"],
            entity_type=row["entity_type"],
            aliases=aliases,
            metadata=meta,
            mention_count=mention_row["cnt"] if mention_row else 0,
            pinned=bool(row["pinned"]),
            source_path=row["source_path"],
            facts=[EntityFact(text=f["fact_text"], date=f["fact_date"]) for f in fact_rows],
        )

    def find_entities(self, name: str) -> list[EntitySummary]:
        """Find entities by name/alias (case-insensitive, partial match).

        Returns matches in priority order: exact name > exact alias > partial.
        """
        from kb.config import find_entities as _find_entities

        conn = self._get_conn()
        rows = _find_entities(conn, name)
        if not rows:
            return []

        mention_rows = conn.execute(
            "SELECT entity_id, COUNT(*) AS cnt FROM entity_mentions GROUP BY entity_id"
        ).fetchall()
        mention_map: dict[int, int] = {r["entity_id"]: r["cnt"] for r in mention_rows}

        return [
            EntitySummary(
                id=r["id"],
                name=r["name"],
                entity_type=r["entity_type"],
                metadata=json.loads(r["metadata"]) if r["metadata"] else {},
                mention_count=mention_map.get(r["id"], 0),
                pinned=bool(r["pinned"]),
            )
            for r in rows
        ]
```

**Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/test_api.py::TestEntityOperations -v`
Expected: PASS

**Step 5: Run mypy**

Run: `uv run mypy src/kb/api.py`
Expected: Success

**Step 6: Commit**

```bash
git add src/kb/api.py tests/test_api.py
git commit -m "feat(api): add entity list, get, find operations (#5)"
```

---

### Task 4: Entity timeline and pin toggle

**Files:**
- Modify: `src/kb/api.py`
- Test: `tests/test_api.py`

**Step 1: Write the failing tests**

Add to `tests/test_api.py`:

```python
class TestEntityTimeline:
    def test_timeline_returns_entries(self, kb_with_entities):
        result = kb_with_entities.get_entity_timeline("Eve Perrin")
        assert len(result) == 1
        assert result[0].title == "Standup"
        assert result[0].date == "2026-02-20"

    def test_timeline_by_alias(self, kb_with_entities):
        result = kb_with_entities.get_entity_timeline("Eve")
        assert len(result) == 1

    def test_timeline_not_found(self, kb_with_entities):
        result = kb_with_entities.get_entity_timeline("Nobody")
        assert result == []

    def test_timeline_respects_limit(self, kb_with_entities):
        conn = kb_with_entities._db.get_sqlite_conn()
        for i in range(5):
            conn.execute(
                "INSERT INTO documents (path, title, doc_date, doc_type, source_system,"
                " source_id, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
                (f"meetings/m{i}.md", f"Meeting {i}", f"2026-02-{10+i:02d}",
                 "meeting", "granola", f"id{i}", f"hash{i}"),
            )
            conn.execute(
                "INSERT INTO entity_mentions (entity_id, document_id, mention_type)"
                f" VALUES (1, {i + 2}, 'discussed')"
            )
        conn.commit()
        result = kb_with_entities.get_entity_timeline("Eve", limit=3)
        assert len(result) == 3

    def test_timeline_returns_timeline_entry(self, kb_with_entities):
        from kb.types import TimelineEntry

        result = kb_with_entities.get_entity_timeline("Eve")
        assert isinstance(result[0], TimelineEntry)


class TestEntityPin:
    def test_toggle_pin_on(self, kb_with_entities):
        result = kb_with_entities.toggle_entity_pin("Eve Perrin")
        assert result.pinned is True
        assert result.name == "Eve Perrin"

    def test_toggle_pin_off(self, kb_with_entities):
        kb_with_entities.toggle_entity_pin("Eve Perrin")  # on
        result = kb_with_entities.toggle_entity_pin("Eve Perrin")  # off
        assert result.pinned is False

    def test_toggle_pin_not_found(self, kb_with_entities):
        with pytest.raises(ValueError, match="Entity not found"):
            kb_with_entities.toggle_entity_pin("Nobody")
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_api.py::TestEntityTimeline tests/test_api.py::TestEntityPin -v`
Expected: FAIL — `AttributeError`

**Step 3: Implement**

Add to `src/kb/api.py` inside the `KnowledgeBase` class:

```python
    def get_entity_timeline(self, name: str, limit: int = 10) -> list[TimelineEntry]:
        """Return recent documents mentioning an entity.

        Resolves *name* via alias/partial matching. Returns empty list if not found.
        """
        from kb.config import find_entity

        conn = self._get_conn()
        entity = find_entity(conn, name)
        if entity is None:
            return []

        rows = conn.execute(
            "SELECT d.title, d.doc_date AS date, d.path"
            " FROM entity_mentions em"
            " JOIN documents d ON d.id = em.document_id"
            " WHERE em.entity_id = ?"
            " ORDER BY d.doc_date DESC"
            " LIMIT ?",
            (entity["id"], limit),
        ).fetchall()

        return [TimelineEntry(title=r["title"], date=r["date"], path=r["path"]) for r in rows]

    def toggle_entity_pin(self, name: str) -> EntityPinResult:
        """Toggle an entity's pinned state. Raises ValueError if not found."""
        conn = self._get_conn()
        row = conn.execute(
            "SELECT id, name, pinned FROM entities WHERE name = ? COLLATE NOCASE",
            (name,),
        ).fetchone()
        if row is None:
            raise ValueError(f"Entity not found: {name}")

        new_pinned = not bool(row["pinned"])
        conn.execute("UPDATE entities SET pinned = ? WHERE id = ?", (int(new_pinned), row["id"]))
        conn.commit()
        return EntityPinResult(name=row["name"], pinned=new_pinned)
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_api.py::TestEntityTimeline tests/test_api.py::TestEntityPin -v`
Expected: PASS

**Step 5: Commit**

```bash
git add src/kb/api.py tests/test_api.py
git commit -m "feat(api): add entity timeline and pin toggle (#5)"
```

---

### Task 5: Document operations — pin, count, list pinned

**Files:**
- Modify: `src/kb/api.py`
- Test: `tests/test_api.py`

**Step 1: Write the failing tests**

Add to `tests/test_api.py`:

```python
class TestDocumentOperations:
    def test_get_document_pin_false(self, kb_with_entities):
        assert kb_with_entities.get_document_pin("meetings/standup.md") is False

    def test_toggle_document_pin(self, kb_with_entities):
        result = kb_with_entities.toggle_document_pin("meetings/standup.md")
        assert result.pinned is True
        assert result.path == "meetings/standup.md"

    def test_toggle_document_pin_off(self, kb_with_entities):
        kb_with_entities.toggle_document_pin("meetings/standup.md")  # on
        result = kb_with_entities.toggle_document_pin("meetings/standup.md")  # off
        assert result.pinned is False

    def test_toggle_document_pin_not_found(self, kb_with_entities):
        with pytest.raises(ValueError, match="Document not found"):
            kb_with_entities.toggle_document_pin("nonexistent.md")

    def test_list_pinned_documents_empty(self, kb_instance):
        result = kb_instance.list_pinned_documents()
        assert result == []

    def test_list_pinned_documents(self, kb_with_entities):
        kb_with_entities.toggle_document_pin("meetings/standup.md")
        result = kb_with_entities.list_pinned_documents()
        assert len(result) == 1
        assert result[0].path == "meetings/standup.md"
        assert result[0].title == "Standup"

    def test_count_documents(self, kb_with_entities):
        assert kb_with_entities.count_documents() == 1
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_api.py::TestDocumentOperations -v`
Expected: FAIL

**Step 3: Implement**

Add to `src/kb/api.py`:

```python
    def get_document_pin(self, path: str) -> bool:
        """Return whether a document is pinned."""
        row = self._get_conn().execute(
            "SELECT pinned FROM documents WHERE path = ?", (path,)
        ).fetchone()
        if row is None:
            return False
        return bool(row["pinned"])

    def toggle_document_pin(self, path: str) -> DocumentPinResult:
        """Toggle a document's pinned state. Raises ValueError if not found."""
        conn = self._get_conn()
        row = conn.execute(
            "SELECT id, pinned FROM documents WHERE path = ?", (path,)
        ).fetchone()
        if row is None:
            raise ValueError(f"Document not found: {path}")

        new_pinned = not bool(row["pinned"])
        conn.execute("UPDATE documents SET pinned = ? WHERE id = ?", (int(new_pinned), row["id"]))
        conn.commit()
        return DocumentPinResult(path=path, pinned=new_pinned)

    def list_pinned_documents(self) -> list[PinnedDocument]:
        """Return all pinned documents with their section headings."""
        conn = self._get_conn()
        rows = conn.execute(
            "SELECT id, path, title FROM documents WHERE pinned = 1 ORDER BY title"
        ).fetchall()
        pinned: list[PinnedDocument] = []
        for r in rows:
            headings_rows = conn.execute(
                "SELECT DISTINCT heading FROM chunks"
                " WHERE document_id = ? AND heading IS NOT NULL ORDER BY chunk_index",
                (r["id"],),
            ).fetchall()
            headings = [h["heading"] for h in headings_rows]
            pinned.append(PinnedDocument(path=r["path"], title=r["title"], headings=headings))
        return pinned
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_api.py::TestDocumentOperations -v`
Expected: PASS

**Step 5: Commit**

```bash
git add src/kb/api.py tests/test_api.py
git commit -m "feat(api): add document pin, list pinned, count operations (#5)"
```

---

### Task 6: Memory file operations — read, write, list tree

**Files:**
- Modify: `src/kb/api.py`
- Test: `tests/test_api.py`

**Step 1: Write the failing tests**

Add to `tests/test_api.py`:

```python
class TestMemoryOperations:
    def test_read_memory_file(self, kb_instance, project_root):
        (project_root / "memory" / "notes").mkdir(parents=True, exist_ok=True)
        (project_root / "memory" / "notes" / "test.md").write_text("# Test\nHello")
        result = kb_instance.read_memory_file("notes/test.md")
        assert result == "# Test\nHello"

    def test_read_memory_file_not_found(self, kb_instance):
        result = kb_instance.read_memory_file("notes/nonexistent.md")
        assert result is None

    def test_read_memory_file_traversal_blocked(self, kb_instance):
        result = kb_instance.read_memory_file("../../../etc/passwd")
        assert result is None

    def test_read_memory_file_non_md_blocked(self, kb_instance):
        result = kb_instance.read_memory_file("notes/test.txt")
        assert result is None

    def test_write_memory_file(self, kb_instance, project_root):
        (project_root / "memory" / "notes").mkdir(parents=True, exist_ok=True)
        (project_root / "memory" / "notes" / "test.md").write_text("old")
        ok = kb_instance.write_memory_file("notes/test.md", "new content")
        assert ok is True
        assert (project_root / "memory" / "notes" / "test.md").read_text() == "new content"

    def test_write_memory_file_no_create(self, kb_instance):
        ok = kb_instance.write_memory_file("notes/new.md", "content")
        assert ok is False

    def test_list_memory_tree(self, kb_instance, project_root):
        from kb.types import MemoryTreeNode

        (project_root / "memory" / "notes").mkdir(parents=True, exist_ok=True)
        (project_root / "memory" / "notes" / "a.md").write_text("# A")
        (project_root / "memory" / "notes" / "b.md").write_text("# B")
        result = kb_instance.list_memory_tree()
        assert len(result) >= 1  # at least notes/ dir or glossary.md
        # Find the notes dir
        notes = [n for n in result if n.name == "notes"]
        assert len(notes) == 1
        assert notes[0].node_type == "dir"
        assert notes[0].count == 2
        assert isinstance(notes[0], MemoryTreeNode)

    def test_list_memory_tree_pinned_state(self, kb_instance, project_root):
        (project_root / "memory" / "notes").mkdir(parents=True, exist_ok=True)
        (project_root / "memory" / "notes" / "pinned.md").write_text("# Pinned")
        # Index the file so it's in the DB, then pin it
        conn = kb_instance._db.get_sqlite_conn()
        conn.execute(
            "INSERT INTO documents (path, title, doc_date, doc_type, source_system,"
            " source_id, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
            ("memory/notes/pinned.md", "Pinned", None, "memory", "memory",
             "memory/notes/pinned.md", "hash"),
        )
        conn.execute("UPDATE documents SET pinned = 1 WHERE path = 'memory/notes/pinned.md'")
        conn.commit()
        result = kb_instance.list_memory_tree()
        notes = [n for n in result if n.name == "notes"][0]
        pinned_file = [c for c in notes.children if c.name == "pinned.md"][0]
        assert pinned_file.pinned is True
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_api.py::TestMemoryOperations -v`
Expected: FAIL

**Step 3: Implement**

Add to `src/kb/api.py`:

```python
    # ------------------------------------------------------------------
    # Memory file operations
    # ------------------------------------------------------------------

    def _memory_dir(self) -> Path:
        return self._project_root / "memory"

    def _validate_memory_path(self, relative_path: str) -> Path | None:
        """Validate a relative path within memory/. Returns None if invalid."""
        if ".." in relative_path:
            return None
        if not relative_path.endswith(".md"):
            return None
        mem_dir = self._memory_dir()
        resolved = (mem_dir / relative_path).resolve()
        if not resolved.is_relative_to(mem_dir.resolve()):
            return None
        return resolved

    def read_memory_file(self, relative_path: str) -> str | None:
        """Read a markdown file from memory/. Returns None if invalid or not found."""
        resolved = self._validate_memory_path(relative_path)
        if resolved is None or not resolved.is_file():
            return None
        return resolved.read_text(encoding="utf-8")

    def write_memory_file(self, relative_path: str, content: str) -> bool:
        """Write content to an existing markdown file in memory/.

        Returns False if the path is invalid or the file doesn't exist.
        Does not create new files — use ``kbx memory add`` for that.
        """
        resolved = self._validate_memory_path(relative_path)
        if resolved is None or not resolved.is_file():
            return False
        resolved.write_text(content, encoding="utf-8")
        return True

    def list_memory_tree(self) -> list[MemoryTreeNode]:
        """Return the memory/ directory as a nested tree with pinned state."""
        mem_dir = self._memory_dir()
        if not mem_dir.is_dir():
            return []

        pinned_paths: set[str] = set()
        try:
            rows = self._get_conn().execute(
                "SELECT path FROM documents WHERE pinned = 1"
            ).fetchall()
            pinned_paths = {r["path"] for r in rows}
        except Exception:
            pass

        def _scan(directory: Path, prefix: str = "") -> list[MemoryTreeNode]:
            items: list[MemoryTreeNode] = []
            try:
                entries = sorted(
                    directory.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
                )
            except PermissionError:
                return items

            for entry in entries:
                rel = f"{prefix}/{entry.name}".lstrip("/") if prefix else entry.name
                full_rel = f"memory/{rel}"

                if entry.is_dir():
                    children = _scan(entry, rel)
                    file_count = sum(1 for c in children if c.node_type == "file") + sum(
                        c.count for c in children if c.node_type == "dir"
                    )
                    items.append(
                        MemoryTreeNode(
                            name=entry.name,
                            node_type="dir",
                            path=rel,
                            children=children,
                            count=file_count,
                        )
                    )
                elif entry.suffix == ".md":
                    items.append(
                        MemoryTreeNode(
                            name=entry.name,
                            node_type="file",
                            path=rel,
                            pinned=full_rel in pinned_paths,
                        )
                    )
            return items

        return _scan(mem_dir)
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_api.py::TestMemoryOperations -v`
Expected: PASS

**Step 5: Commit**

```bash
git add src/kb/api.py tests/test_api.py
git commit -m "feat(api): add memory file read, write, tree operations (#5)"
```

---

### Task 7: Glossary, search, context, index

**Files:**
- Modify: `src/kb/api.py`
- Test: `tests/test_api.py`

**Step 1: Write the failing tests**

Add to `tests/test_api.py`:

```python
class TestGlossary:
    def test_list_glossary_terms_empty(self, kb_instance):
        result = kb_instance.list_glossary_terms()
        assert result == []

    def test_list_glossary_terms(self, kb_instance, project_root):
        (project_root / "memory" / "glossary.md").write_text(
            "# Glossary\n\n## Acronyms\n\n| Term | Expansion |\n"
            "|------|----------|\n| API | Application Programming Interface |\n"
        )
        result = kb_instance.list_glossary_terms()
        assert len(result) == 1
        assert result[0].term == "API"

    def test_list_glossary_returns_glossary_entry(self, kb_instance, project_root):
        from kb.types import GlossaryEntry

        (project_root / "memory" / "glossary.md").write_text(
            "# Glossary\n\n## Acronyms\n\n| Term | Expansion |\n"
            "|------|----------|\n| API | Application Programming Interface |\n"
        )
        result = kb_instance.list_glossary_terms()
        assert isinstance(result[0], GlossaryEntry)


class TestSearch:
    def test_search_empty_db(self, kb_instance):
        result = kb_instance.search("anything", fast=True)
        assert result.results == []
        assert result.meta.query == "anything"

    def test_search_returns_search_response(self, kb_instance):
        from kb.types import SearchResponse

        result = kb_instance.search("test", fast=True)
        assert isinstance(result, SearchResponse)


class TestContext:
    def test_context_empty_db(self, kb_instance):
        result = kb_instance.context()
        assert result.text is not None
        assert result.stats.documents == 0

    def test_context_human_format(self, kb_instance):
        result = kb_instance.context(fmt="human")
        assert isinstance(result.text, str)

    def test_context_returns_context_output(self, kb_instance):
        from kb.types import ContextOutput

        result = kb_instance.context()
        assert isinstance(result, ContextOutput)


class TestIndex:
    def test_index_empty(self, kb_instance):
        result = kb_instance.index()
        assert result.documents_indexed == 0

    def test_index_returns_index_result(self, kb_instance):
        from kb.types import IndexResult

        result = kb_instance.index()
        assert isinstance(result, IndexResult)
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_api.py::TestGlossary tests/test_api.py::TestSearch tests/test_api.py::TestContext tests/test_api.py::TestIndex -v`
Expected: FAIL

**Step 3: Implement**

Add to `src/kb/api.py`:

```python
    # ------------------------------------------------------------------
    # Glossary
    # ------------------------------------------------------------------

    def list_glossary_terms(self) -> list[GlossaryEntry]:
        """Return all glossary terms from memory/glossary.md."""
        from kb.glossary import list_terms

        return list_terms(self._project_root)

    # ------------------------------------------------------------------
    # Search
    # ------------------------------------------------------------------

    def search(
        self,
        query: str,
        *,
        limit: int = 10,
        fast: bool = False,
        recency: float = 0.15,
        doc_type: str | None = None,
        from_date: str | None = None,
        to_date: str | None = None,
        tag: str | None = None,
        sort_by: str = "score",
    ) -> SearchResponse:
        """Run a hybrid search (FTS5 + vector + RRF fusion).

        Set ``fast=True`` for FTS-only (no embedder loaded).
        The embedder is lazy-loaded on first non-fast search and reused.
        """
        from kb.search import search as do_search
        from kb.staleness import auto_reindex_if_stale

        auto_reindex_if_stale(self._db, self._project_root)

        embedder = None if fast else self._get_embedder()
        return do_search(
            self._db,
            embedder,
            query,
            limit=limit,
            fast=fast or embedder is None,
            recency=recency,
            doc_type=doc_type,
            from_date=from_date,
            to_date=to_date,
            tag=tag,
            sort_by=sort_by,
        )

    # ------------------------------------------------------------------
    # Context
    # ------------------------------------------------------------------

    def context(
        self,
        topic: str | None = None,
        fmt: str = "compact",
    ) -> ContextOutput:
        """Generate compressed entity context for AI agents.

        Parameters
        ----------
        topic: Filter entities relevant to this topic.
        fmt: ``"compact"`` (pipe-delimited, ~2K tokens) or ``"human"`` (markdown).
        """
        from kb.context import generate_context
        from kb.staleness import auto_reindex_if_stale

        auto_reindex_if_stale(self._db, self._project_root)
        return generate_context(self._db, self._project_root, topic=topic, fmt=fmt)

    # ------------------------------------------------------------------
    # Indexing
    # ------------------------------------------------------------------

    def index(self, *, full: bool = False) -> IndexResult:
        """Run the indexing pipeline (walk sources, chunk, embed, store, link entities).

        Incremental by default (skips unchanged files). Pass ``full=True`` to
        force re-indexing everything. Reuses the shared embedder instance.
        """
        from kb.indexer import index_all

        embedder = self._get_embedder()
        return index_all(self._db, embedder, self._project_root, full=full)
```

**Step 4: Run tests**

Run: `uv run pytest tests/test_api.py::TestGlossary tests/test_api.py::TestSearch tests/test_api.py::TestContext tests/test_api.py::TestIndex -v`
Expected: PASS

**Step 5: Run full test suite + mypy**

Run: `uv run pytest -x -q && uv run mypy src/kb/api.py`
Expected: All pass, no mypy errors

**Step 6: Commit**

```bash
git add src/kb/api.py tests/test_api.py
git commit -m "feat(api): add glossary, search, context, index operations (#5)"
```

---

### Task 8: Final verification and docs update

**Files:**
- Modify: `src/kb/cli.py` (update `usage()` to mention API)
- Modify: `docs/architecture.md` (if exists, add API section)

**Step 1: Run full test suite**

Run: `uv run pytest -x -q --cov`
Expected: All pass, coverage >= 90%

**Step 2: Run mypy on everything**

Run: `uv run mypy src/`
Expected: Success

**Step 3: Run ruff**

Run: `uv run ruff check src/ && uv run ruff format --check src/`
Expected: Clean

**Step 4: Update usage() in cli.py**

Add a brief mention of the Python API to the `usage()` function output, so `kb usage` documents it:

```
## Python API

    from kb import KnowledgeBase

    kb = KnowledgeBase()                          # auto-discover config
    kb = KnowledgeBase(thread_safe=True)           # for multi-threaded apps
    kb.search("query")                             # -> SearchResponse
    kb.list_entities(entity_type="person")         # -> list[EntitySummary]
    kb.get_entity("name")                          # -> EntityDetail | None
    kb.context()                                   # -> ContextOutput
    kb.close()                                     # release resources
```

**Step 5: Commit**

```bash
git add src/kb/cli.py
git commit -m "docs(cli): add Python API section to usage output (#5)"
```

**Step 6: Run pre-commit hooks as final check**

Run: `uv run pre-commit run --all-files`
Expected: All pass
