Files
didactyl/docs/SUBSCRIPTIONS.md

195 lines
8.0 KiB
Markdown

# Didactyl — Nostr Subscriptions
## Overview
Didactyl maintains persistent websocket subscriptions to Nostr relays for the lifetime of the process. Subscriptions are opened during startup and are **never closed** — the relay pool keeps them alive, automatically reconnecting and resubscribing when relays drop.
All subscriptions are created via `nostr_relay_pool_subscribe()` from `nostr_core_lib` and are sent to every relay in the configured relay list.
## Startup Sequence
The subscriptions are opened in a specific order during `main()` startup. The diagram below shows the full sequence:
```mermaid
flowchart TD
A[nostr_handler_init] --> B[Connect to all relays]
B --> C[nostr_handler_reconcile_startup_events]
C --> D[Publish startup events to relays]
D --> E[trigger_manager_init]
E --> F[trigger_manager_load_from_startup_events]
F --> G["Subscribe: Admin Context"]
G --> H["Subscribe: Self Skills"]
H --> I[Send startup DM to admin]
I --> J["Subscribe: DMs"]
J --> K[Enter main poll loop]
K --> L["Poll: nostr_handler_poll + trigger_manager_poll + http_api_poll"]
style F fill:#2a7,stroke:#333,color:#fff
style G fill:#27a,stroke:#333,color:#fff
style H fill:#27a,stroke:#333,color:#fff
style J fill:#27a,stroke:#333,color:#fff
```
## Subscription Categories
### 1. Admin Context Subscription
**Function:** `nostr_handler_subscribe_admin_context()` in `src/nostr_handler.c`
**When:** During startup, before self-skill subscription
**Condition:** Only if `admin_context.enabled` is true in config
This creates up to two persistent subscriptions for the admin's pubkey:
#### Profile Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 0 (profile), 3 (contacts), 10002 (relay list) — each configurable |
| **Authors** | Admin pubkey |
| **Limit** | 32 |
| **Callback** | `on_admin_context_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
Tracks the admin's profile metadata, contact list (WoT), and relay preferences. Used to build agent context about who the admin is.
#### Kind 1 Notes Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 1 |
| **Authors** | Admin pubkey |
| **Limit** | Configurable via `kind_1_limit`, default 10, max 256 |
| **Callback** | `on_admin_context_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
| **Condition** | Only if `admin_context.track_kind_1` is true |
Tracks the admin's recent public notes. Used for agent context and as the event source for triggered skills that watch admin posts.
### 2. Self-Skill Subscription
**Function:** `nostr_handler_subscribe_self_skills()` in `src/nostr_handler.c`
**When:** During startup, after admin context subscription
| Field | Value |
|-------|-------|
| **Kinds** | 31123 (public skill), 31124 (private skill), 10123 (adoption list) |
| **Authors** | Agent's own pubkey |
| **Limit** | 300 |
| **Callback** | `on_self_skill_event` |
| **EOSE Callback** | `on_self_skill_eose` |
| **Dedup** | Disabled (handles dedup internally via cache upsert) |
| **Close on EOSE** | No |
This is the core skill awareness subscription. It serves three purposes:
1. **Cache population** — Every arriving event is stored in the in-memory self-skill cache via `self_skill_cache_upsert_event_locked()`, making skills available for LLM tool calls and context building.
2. **Live trigger registration** — When a kind 31123 or 31124 event arrives with `trigger=nostr-subscription` and a valid `filter` tag, `register_trigger_from_self_skill_event()` immediately calls `trigger_manager_add()` to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart.
3. **Deferred bulk load** — After EOSE, the `on_self_skill_eose` callback fires `trigger_manager_load_from_skills()` as a one-time bulk scan of the adoption list. This catches any skills that were already cached before the per-event path was wired up.
### 3. DM Subscriptions
**Function:** `nostr_handler_subscribe_dms()` in `src/nostr_handler.c`
**When:** During startup, after self-skill subscription and startup DM
**Required:** Yes — startup fails if DM subscription cannot be created
Creates one or two subscriptions depending on the configured DM protocol:
#### NIP-04 DM Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 4 |
| **#p** | Agent's own pubkey |
| **Since** | Process start time |
| **Limit** | 100 |
| **Callback** | `on_event` (routes to `agent_on_message`) |
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
| **Close on EOSE** | No |
| **Condition** | `dm_protocol` is `nip04` or `both` |
#### NIP-17 DM Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 1059 (gift wrap) |
| **#p** | Agent's own pubkey |
| **Since** | Process start time |
| **Limit** | 400 |
| **Callback** | `on_event` (unwraps gift wrap, routes to `agent_on_message`) |
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
| **Close on EOSE** | No |
| **Condition** | `dm_protocol` is `nip17` or `both` |
### 4. Trigger Subscriptions
**Function:** `register_trigger_subscription_locked()` in `src/trigger_manager.c`
**When:** Dynamically, whenever a trigger is registered via `trigger_manager_add()`
**Created by:** `nostr_handler_subscribe_with_filter()` wrapper
Each active trigger gets its own persistent subscription based on the skill's `filter` tag:
| Field | Value |
|-------|-------|
| **Filter** | Parsed from the skill's `filter` tag JSON |
| **Since** | From filter, or defaults to `now - 30s` |
| **Limit** | From filter, or defaults to 200 |
| **Callback** | `on_trigger_subscription_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
When an event matches the filter, `maybe_fire_trigger_locked()` checks cooldown and dedup, then executes the trigger action (LLM or template).
Trigger subscriptions are created at three points:
- **Startup config scan** — `trigger_manager_load_from_startup_events()` parses `startup_events[]` from config for skills with trigger tags
- **Live self-skill event** — `register_trigger_from_self_skill_event()` in the self-skill subscription callback
- **EOSE bulk load** — `trigger_manager_load_from_skills()` after self-skill EOSE
- **Runtime tool call** — `skill_create` tool with trigger parameters
## Subscription Parameters
All subscriptions share these common pool parameters:
| Parameter | Value | Meaning |
|-----------|-------|---------|
| `close_on_eose` | 0 | Subscription stays open after initial EOSE |
| `result_mode` | `NOSTR_POOL_EOSE_FULL_SET` | EOSE fires after all relays respond or timeout |
| `relay_timeout_seconds` | 30 | Per-relay timeout for initial response |
| `eose_timeout_seconds` | 120 | Overall EOSE timeout across all relays |
## Subscription Lifecycle
```mermaid
flowchart LR
INIT["Process Start"] --> CONNECT["Connect Relays"]
CONNECT --> SUB["Open Subscriptions"]
SUB --> LIVE["Live Event Stream"]
LIVE --> |"Relay disconnects"| RECON["Auto-Reconnect"]
RECON --> |"Relay reconnects"| RESUB["Auto-Resubscribe"]
RESUB --> LIVE
LIVE --> |"SIGINT/SIGTERM"| SHUT["Shutdown"]
SHUT --> CLOSE["Close All + Cleanup"]
```
Subscriptions are never manually closed during normal operation. The relay pool handles reconnection and resubscription transparently. On shutdown, `trigger_manager_cleanup()` closes trigger subscriptions and `nostr_handler_cleanup()` destroys the pool.
## Summary Table
| Subscription | Kinds | Target | Persistent | Created At |
|-------------|-------|--------|-----------|------------|
| Admin Profile | 0, 3, 10002 | Admin pubkey | Yes | Startup |
| Admin Notes | 1 | Admin pubkey | Yes | Startup |
| Self Skills | 31123, 31124, 10123 | Own pubkey | Yes | Startup |
| DMs NIP-04 | 4 | Own pubkey (#p) | Yes | Startup |
| DMs NIP-17 | 1059 | Own pubkey (#p) | Yes | Startup |
| Trigger N | Per skill filter | Varies | Yes | Dynamic |
## Related Documentation
- [Skills](SKILLS.md) — Skill event format and trigger tags
- [Tools](TOOLS.md) — `skill_create` tool with trigger parameters
- [API](API.md) — `trigger_list` and `trigger_status` endpoints