# nostr

Most między siecią Nostr a relacyjną/grafową bazą danych — serwer MCP, który równolegle:

- ściąga eventy z relayów (NIP-01 REQ + EOSE) przez WebSockety,
- zapisuje je do storage'u (DuckDB lub Neo4j, wybór przez env),
- udostępnia agentom LLM read-path: `run_sql` (DuckDB), `relay_query` (NIP-01 bez ingest), helpery `npub ↔ hex`.

Write-toole (`update_graph_*`, `backfill_*`, `run_python`) są domyślnie zakomentowane —
agenci dostają tylko czytanie.

## Dwa tryby działania

| Tryb | `NOSTR_BACKEND` | Writer | DuckDB | Neo4j |
|------|-----------------|--------|--------|-------|
| **DuckDB** (domyślny) | `duckdb` | DuckDB (ingest pisze wprost do pliku) | read-write w MCP, write blokowane app-level whitelistą | nie startuje |
| **Neo4j** | `neo4j` | Neo4j (ingest przez Cypher) | pochodna grafu, budowana przez `backfill.py` (DROP+CREATE+INSERT); MCP ATTACH-uje plik READ_ONLY | wymaga `neo4j/docker-compose.yml` |

Daemon ingestu działa jako tło-task w procesie MCP — nie jako osobny proces.
Restart MCP = restart daemona. Co ~5s czyta aktywne relaye z aktualnego storage'u, otwiera/zamyka
WebSockety, aktualizuje `status` / `last_checked_at` / `last_error`.

## Uruchomienie

```bash
cd admin/mcp
./mcp_admin.sh           # tworzy venv, instaluje deps, startuje MCP
```

Domyślnie startuje w trybie DuckDB. Plik bazy: `mcp_nostr_db/duckdb/nostr.duckdb`
(git-ignorowany, nadpisywalny przez `NOSTR_DUCKDB_PATH`).

Tryb Neo4j: ustaw `NOSTR_BACKEND=neo4j` i wystartuj stack:

```bash
cd neo4j
docker compose up -d
./setup-users.sh
```

## Zmienne środowiskowe (`admin/mcp/.env`)

| Zmienna | Znaczenie |
|---------|-----------|
| `NOSTR_BACKEND` | `duckdb` (default) lub `neo4j` |
| `NOSTR_DUCKDB_PATH` | Ścieżka do pliku DuckDB (default `mcp_nostr_db/duckdb/nostr.duckdb` względem `mcp_admin.py`) |
| `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD` | Tylko w trybie neo4j |

## Struktura katalogu

```
admin/mcp/         proces MCP — FastMCP tools, daemon ingestu, helpery
  mcp_admin.py     wejście; bootstrap, dispatch backendu
  ingest.py        Neo4j-podklasa daemona (ingest.py = base + neo4j writer)
  ingest_sql.py    DuckDB-podklasa daemona (idempotentny bootstrap schematu)
  relay_query.py   NIP-01 REQ bez ingest (storage-agnostic)
  update_graph_*.py   helpery orkiestracji ingestu (storage-agnostic)
  sql_whitelist.py    app-level whitelist allowed SQL statements
  run_sql.md       opis narzędzia run_sql (schemat DuckDB, reguły, pułapki)
                   wczytywany przy starcie jako MCP resource
                   pod URI schema://nostr-duckdb
  tests/           pytest dla ingest_sql, prepare_tags, run_sql whitelist
docs/              model grafu (01.md §3-4, schema.md = źródło prawdy)
duckdb/
  schema.sql       relacyjny mirror grafu (używany tylko w trybie neo4j)
  backfill.py      rebuild nostr.duckdb z Neo4j (tylko w trybie neo4j!)
  nostr.duckdb     plik bazy, ~55MB, git-ignorowany
neo4j/             docker-compose + skrypty inicjalizacyjne
users.json         hex → {hex, npub, name} (user-curated)
```

## Zarządzanie relayami

**Tryb DuckDB** (write-tool zakomentowany — edycja człowiekiem):

```sql
INSERT INTO relays(url, active) VALUES ('wss://relay.damus.io', true);
SELECT url, status, last_checked_at, last_error FROM relays;
```

Edycji rejestru trzeba dokonać **przy wyłączonym MCP** — DuckDB trzyma write-lock.

**Tryb Neo4j** (przez Cypher):

```cypher
MERGE (:Relay {url: 'wss://relay.damus.io'})              // dodaj
MATCH (r:Relay {url: '…'}) DETACH DELETE r                 // usuń
MATCH (r:Relay) RETURN r.url, r.status, r.last_checked_at  // status
```

## Model danych

`Event`, `Pubkey`, `Relay`, `Profile`, `Addressable`, `Tag`, `TagElement`.
Powiązania: `AUTHORED_BY`, `SEEN_ON`, `HAS_TAG`, `HAS_ELEMENT`, `REFERS_TO`, `HAS_PROFILE`,
`PUBLISHES_TO` / `READS_FROM` (NIP-65).

Pełny schemat: `docs/schema.md` (Neo4j), `duckdb/schema.sql` (mirror), `admin/mcp/run_sql.md`
(operacyjny brief dla LLM-a).

## Wskazówki przy pisaniu zapytań

Filtrując po nieznanym wymiarze (kategoria, kind, tag, name) najpierw zrób:

```sql
SELECT col, count(*) FROM … GROUP BY col ORDER BY count(*) DESC LIMIT 50
```

— żeby zobaczyć realny słownik wartości. Zero wyników dla pytania, które intuicyjnie powinno
coś zwrócić, to prawie zawsze bug w zapytaniu, nie prawda o danych.
