Compare commits

...

7 Commits

16 changed files with 2343 additions and 305 deletions

9
.roo/commands/build.md Normal file
View File

@@ -0,0 +1,9 @@
---
description: "Build Didactyl for end-of-change validation"
---
After making code changes, run [`build_static.sh`](build_static.sh) instead of `make` for final compile validation.
Example:
./build_static.sh

262
README.md
View File

@@ -10,48 +10,59 @@ Didactyl boots on any internet-connected machine, connects to Nostr relays, list
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (no single entity can erase its memory).
**Skills are the new apps.** Agents learn capabilities through skills — public Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. If someone publishes a useful skill, your agent can find it through your web of trust and start using it. Popularity is measured by adoption, not by a rating algorithm. The best skills spread because agents actually use them.
## Current Status
**MVP — Working chat agent with relay connectivity and LLM integration.**
**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.**
- Connects to configured Nostr relays with auto-reconnect
- Publishes agent profile (kind 0 metadata)
- Listens for NIP-04 encrypted DMs from authorized admin
- Forwards messages to an OpenAI-compatible LLM API
- Sends LLM responses back as encrypted DMs
- Runtime logging: relay status, connection health, message flow
**Next: Agentic tool-use system** — see [plans/didactyl_agentic.md](plans/didactyl_agentic.md).
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
- Uses kind `31120` startup content as live Soul at boot
- Verifies Nostr event signatures before processing inbound messages
- Applies privilege tiers: ADMIN (tools), WoT (chat-only), STRANGER (configurable canned reply or ignore)
- Subscribes to admin context kinds (`0`,`3`,`10002`,`1`) for WoT + contextual awareness
- Builds LLM context from system prompt + startup events + admin DM history + admin recent notes
- Supports tool-calling loop with configurable max turns and local safety limits
- Appends every outbound LLM context payload to [`context.log`](context.log)
## Quick Start
### Prerequisites
### Download binary (recommended)
- GCC with C99 support
- libcurl, libssl, libcrypto, libsecp256k1
1. Download the latest release binary from GitLab: <https://git.laantungir.net/laantungir/didactyl/-/releases>
2. Make it executable and run it:
```bash
chmod +x ./didactyl_static_x86_64
./didactyl_static_x86_64 --config ./config.json
```
### Build from source (optional)
#### Prerequisites
- Docker (for static binary build)
- An OpenAI-compatible LLM API key (OpenAI, PPQ, Ollama, etc.)
- A Nostr keypair (nsec)
### Build
#### Build
```bash
make deps # builds nostr_core_lib
make # builds didactyl
./build_static.sh # builds a fully static MUSL binary via Docker
```
### Configure
Edit `config.json`:
Edit [`config.json`](config.json):
```json
{
"agent": {
"name": "Didactyl Agent",
"display_name": "Didactyl",
"about": "A sovereign AI agent on Nostr"
},
"keys": {
"nsec": "nsec1..."
"nsec": "nsec1...",
"npub": "npub1...",
"npubHex": "<optional helper>",
"nsecHex": "<optional helper>"
},
"admin": {
"pubkey": "npub1... or hex pubkey"
@@ -61,76 +72,192 @@ Edit `config.json`:
"wss://nos.lol"
],
"llm": {
"provider": "openai",
"provider": "openai|ppq|...",
"api_key": "sk-...",
"model": "gpt-4o-mini",
"base_url": "https://api.openai.com/v1",
"max_tokens": 512,
"temperature": 0.7
}
},
"tools": {
"enabled": true,
"max_turns": 8,
"shell": {
"enabled": true,
"timeout_seconds": 30,
"max_output_bytes": 65536,
"working_directory": "."
}
},
"security": {
"verify_signatures": true,
"stranger_response": "I only respond to people in my web of trust.",
"tiers": {
"admin": { "tools_enabled": true },
"wot": { "enabled": true, "tools_enabled": false },
"stranger": { "enabled": true }
}
},
"admin_context": {
"enabled": true,
"subscribe_kinds": [0, 3, 10002, 1],
"kind_1_limit": 10
},
"startup_events": [
{
"kind": 31120,
"content": "You are Didactyl...",
"tags": [["d", "soul"], ["app", "didactyl"], ["scope", "private"]]
},
{
"kind": 31123,
"content_fields": {"name": "long_form_note", "description": "..."},
"tags": [["d", "long_form_note"], ["app", "didactyl"], ["scope", "public"], ["slug", "long_form_note"]]
},
{
"kind": 10123,
"content": "",
"tags": [["a", "31123:<author-pubkey>:long_form_note"], ["app", "didactyl"], ["scope", "public"]]
}
]
}
```
Edit `SYSTEM.md` to define the agent's personality and instructions.
`startup_events[].content_fields` is accepted for human-readable authoring and encoded to JSON string content at runtime.
### Run
```bash
./didactyl
./didactyl_static_x86_64 --config ./config.json
```
Options:
```
./didactyl --config <path> # custom config file (default: ./config.json)
./didactyl --context <path> # custom context file (default: ./SYSTEM.md)
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
```
### Talk to it
Send an encrypted DM to the agent's pubkey from the admin account using any Nostr client (Damus, Amethyst, Primal, etc.).
Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst, Primal, etc.): ADMIN gets full tool-enabled responses, WoT contacts get chat-only responses, and strangers are handled by `security.tiers.stranger` + `security.stranger_response`.
## Architecture
```
┌─────────────────────────────────────────────┐
│ Didactyl │
│ │
│ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
│ │ config │ │ context │ │ agent │ │
│ │ loader │ │ loader │ │ loop │ │
│ └────┬────┘ └────┬────┘ └─────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────
│ │ nostr_handler │ │
│ │ relay pool · subscribe · publish │ │
│ └──────────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────────┴──────────────────┐ │
│ │ LLM client │ │
│ │ OpenAI-compatible chat API │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────
Didactyl │
│ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
│ │ config │ │ skills │ │ agent │ │
│ │ loader │ │ loader │ │ loop │ │
│ └────┬────┘ └────┬────┘ └─────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────┐
│ │ nostr_handler │
│ │ relay pool · subscribe · publish │
│ └──────────────────┬──────────────────┘
│ │
│ ┌──────────────────┴──────────────────┐
│ │ LLM client │
│ │ OpenAI-compatible chat API │
│ └─────────────────────────────────────┘
└─────────────────────────────────────────────
│ │
▼ ▼
Nostr Relays LLM API
```
## Didactyl Kinds (Nostr)
Didactyl uses a two-layer skill model: authors publish public skill definitions, and adopters publish which skills they use.
- `31120`**Soul** (private instruction baseline)
- `d=soul`
- `31123`**Public Skill Definition** (markdown skill body in `content` or structured JSON in `content_fields`)
- `d=<skill_slug>` (example: `d=long_form_note`)
- `31124`**Private Skill Definition** (private/internal procedures)
- `d=<skill_slug>` (example: `d=admin_ops`)
- `10123`**Public Skill Adoption List**
- tags contain one or more `a` references to selected `31123` skills
## Skill Sharing & Discovery
Skills are shared across Nostr without any centralized registry or approval process.
### How it works
1. **Publish**: An author publishes a skill as a kind `31123` event. The `content` field contains the skill body (markdown or structured JSON). The `d` tag is the skill's slug (e.g. `long_form_note`).
2. **Adopt**: An agent that wants to use a skill adds an `a`-tag reference to its kind `10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses.
3. **Discover**: A new user queries `{"kinds": [10123], "authors": [<my-follows>]}` to see which skills their web of trust has adopted. The most-referenced `31123` addresses are the most popular skills — no rating system needed.
4. **Improve**: Anyone can publish their own `31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking.
### Why this works
- **No gatekeeper**: Skills are just Nostr events. Anyone can publish one.
- **WoT as curation**: You see what people you trust actually use, not what an algorithm promotes.
- **Visible adoption**: The `10123` list is public. Popularity is a countable fact, not a manipulable score.
- **Censorship resistant**: Skills live on relays. No single entity can remove a skill from the network.
## Startup
Didactyl startup behavior is configured in [`config.json`](config.json) under `startup_events`.
Also used at startup:
- `0` — profile metadata
- `10002` — relay list
- `1` — optional startup note/status
- `3` — contacts/follows (optional placeholder)
On boot, Didactyl attempts startup publishes to each relay as that relay transitions to connected state.
## Runtime Context Model
Didactyl builds tier-aware context:
- **ADMIN** request context order:
1. Soul message from kind `31120` (or fallback default)
2. Startup events memory block (`kinds/content/tags` snapshot)
3. Last 12 decrypted DM turns between admin and agent
4. Recent admin kind `1` notes (from configured admin-context subscription)
5. Current user message
- **WoT** request context: Soul + WoT chat-only instruction + current user message (no tools)
- **STRANGER**: no LLM call when configured to reply statically
Every serialized LLM context payload is appended to [`context.log`](context.log).
## Tooling Interface
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:72):
- `nostr_post`
- `nostr_query`
- `shell_exec`
- `file_read`
- `file_write`
Execution entrypoint: [`tools_execute()`](src/tools.c:434).
## Project Structure
```
.
├── config.json # Agent configuration
├── SYSTEM.md # Agent personality/instructions for LLM
├── config.json # Agent/runtime config including startup_events + tools
├── context.log # Appended outbound LLM context payloads
├── Makefile # Build system
├── build_static.sh # Preferred final build validation
├── src/
│ ├── main.c # Entry point, signal handling, daemon loop
│ ├── config.c / .h # JSON config parsing, key decoding
│ ├── context.c / .h # SYSTEM.md file loader
│ ├── agent.c / .h # Core agent logic: receive → LLM → respond
│ ├── main.c # Entry point, args (--config/--debug), lifecycle
│ ├── config.c / .h # JSON config parsing, key decode, startup events
│ ├── agent.c / .h # Context assembly, tool loop, DM response flow
│ ├── tools.c / .h # LLM tool schema and tool execution
│ ├── llm.c / .h # LLM HTTP API client (OpenAI-compatible)
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, DMs
│ └── secp_compat.c # secp256k1 API compatibility shim
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, startup reconcile
│ └── debug.c / .h # Runtime log levels/macros
├── plans/ # Architecture and planning documents
│ ├── didactyl_mvp.md
│ └── didactyl_agentic.md
@@ -139,26 +266,33 @@ Send an encrypted DM to the agent's pubkey from the admin account using any Nost
## Dependencies
All dependencies are statically linked into the binary at build time. No system libraries are required at runtime.
| Dependency | Purpose | Source |
|---|---|---|
| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) |
| cJSON | JSON parsing | Bundled in nostr_core_lib |
| libcurl | HTTPS for LLM API calls | System package |
| libssl / libcrypto | TLS for WebSocket relay connections | System package |
| libsecp256k1 | Schnorr signatures, ECDH | System package |
| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) |
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
## Roadmap
- [x] MVP chat agent — DM in, LLM response out
- [x] Relay pool with auto-reconnect and status logging
- [x] Runtime diagnostics — relay health, message flow, LLM calls
- [ ] **Agentic tool-use** — LLM can call tools (nostr_post, nostr_query, shell_exec)
- [x] Per-relay startup publish on relay-connected transitions
- [x] Runtime diagnostics — relay health, message flow, event kind publish logs
- [x] Tool-calling loop (nostr_post, nostr_query, shell_exec, file_read, file_write)
- [x] Context assembly with startup events + recent DM history
- [x] Context payload logging to [`context.log`](context.log)
- [x] Skill kind definitions (`31120` Soul, `31123` Public Skill, `31124` Private Skill)
- [x] Skill adoption list (`10123`) for WoT-driven discovery
- [ ] Runtime skill loading from adopted `31123` events on relays
- [ ] Skill discovery CLI/tool (query WoT adoption lists)
- [ ] Upgrade to NIP-17 gift-wrapped DMs
- [ ] NIP-44 encrypted private skills (`31124`)
- [ ] Nostr-native data storage (kind 30078 app-specific events)
- [ ] Blossom blob storage integration
- [ ] Conversation memory on Nostr
- [ ] Config and SYSTEM.md stored as Nostr events
- [ ] Multi-turn conversation context window
- [ ] Agent-to-agent communication
## License

View File

@@ -1,16 +0,0 @@
# Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
## Communication Rules
- You communicate through encrypted Nostr direct messages.
- Keep responses concise and clear.
## Behavior
- Be helpful and technically accurate.
- If unsure, state uncertainty directly.
- Prefer actionable, practical advice.
## Safety
- Do not claim to have executed actions you did not execute.
- Do not reveal secrets from configuration or keys.

View File

@@ -6,7 +6,7 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
OUTPUT_DIR="$SCRIPT_DIR"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
# Parse command line arguments
@@ -22,12 +22,15 @@ else
echo "=========================================="
fi
echo "Project directory: $SCRIPT_DIR"
echo "Build directory: $BUILD_DIR"
echo "Output directory: $OUTPUT_DIR"
echo "Debug build: $DEBUG_BUILD"
echo ""
# Create build directory
mkdir -p "$BUILD_DIR"
# Remove legacy build directory if present
if [ -d "$SCRIPT_DIR/build" ]; then
rm -rf "$SCRIPT_DIR/build"
echo "Removed legacy build directory: $SCRIPT_DIR/build"
fi
# Check if Docker is available
if ! command -v docker &> /dev/null; then
@@ -136,20 +139,12 @@ echo "=========================================="
echo "Step 2: Extracting static binary"
echo "=========================================="
# Build the builder stage to extract the binary
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--target builder \
-f "$DOCKERFILE" \
-t didactyl-static-builder-stage:latest \
. > /dev/null 2>&1
# Reuse the already-built final image and extract binary directly
# (avoids a second docker build pass)
CONTAINER_ID=$($DOCKER_CMD create didactyl-musl-builder:latest /didactyl_static)
# Create a temporary container to copy the binary
CONTAINER_ID=$($DOCKER_CMD create didactyl-static-builder-stage:latest)
# Copy binary from container
$DOCKER_CMD cp "$CONTAINER_ID:/build/didactyl_static" "$BUILD_DIR/$OUTPUT_NAME" || {
# Copy binary from final image
$DOCKER_CMD cp "$CONTAINER_ID:/didactyl_static" "$OUTPUT_DIR/$OUTPUT_NAME" || {
echo "ERROR: Failed to extract binary from container"
$DOCKER_CMD rm "$CONTAINER_ID" 2>/dev/null
exit 1
@@ -158,11 +153,11 @@ $DOCKER_CMD cp "$CONTAINER_ID:/build/didactyl_static" "$BUILD_DIR/$OUTPUT_NAME"
# Clean up container
$DOCKER_CMD rm "$CONTAINER_ID" > /dev/null
echo "✓ Binary extracted to: $BUILD_DIR/$OUTPUT_NAME"
echo "✓ Binary extracted to: $OUTPUT_DIR/$OUTPUT_NAME"
echo ""
# Make binary executable
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
chmod +x "$OUTPUT_DIR/$OUTPUT_NAME"
# Verify the binary
echo "=========================================="
@@ -171,7 +166,7 @@ echo "=========================================="
echo ""
echo "Checking for dynamic dependencies:"
if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then
if LDD_OUTPUT=$(timeout 5 ldd "$OUTPUT_DIR/$OUTPUT_NAME" 2>&1); then
if echo "$LDD_OUTPUT" | grep -q "not a dynamic executable"; then
echo "✓ Binary is fully static (no dynamic dependencies)"
TRULY_STATIC=true
@@ -185,7 +180,7 @@ if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then
fi
else
# ldd failed or timed out - check with file command instead
if file "$BUILD_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then
if file "$OUTPUT_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then
echo "✓ Binary is statically linked (verified with file command)"
TRULY_STATIC=true
else
@@ -198,8 +193,8 @@ echo ""
echo "=========================================="
echo "Build Summary"
echo "=========================================="
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
echo "Binary: $OUTPUT_DIR/$OUTPUT_NAME"
echo "Size: $(du -h "$OUTPUT_DIR/$OUTPUT_NAME" | cut -f1)"
echo "Static: $TRULY_STATIC"
echo "Debug: $DEBUG_BUILD"
echo "Platform: $PLATFORM"

View File

@@ -0,0 +1,286 @@
# Security & Admin Context Plan
## Overview
Add signature verification, privilege tiers, admin context awareness, and key isolation to Didactyl. Implemented in two phases.
---
## Phase 1 — Privilege Tiers, Signature Verification & Admin Context
### Privilege Tiers
```mermaid
flowchart TD
A[Incoming Event] --> B{Verify Signature via nostr_verify_event_signature}
B -->|Invalid| C[Drop silently + DEBUG_WARN]
B -->|Valid| D{Identify sender}
D -->|Admin pubkey| E[ADMIN tier]
D -->|In admin kind 3 contact list| F[WOT tier]
D -->|Unknown| G[STRANGER tier]
E --> H[Full tool access + all skills + full context]
F --> I[Chat only - no tools - LLM response]
G --> J[Configurable canned response OR silent ignore]
```
| Tier | Identity | Tools | Response | Context |
|------|----------|-------|----------|---------|
| **ADMIN** | `config.admin.pubkey` exact match | All tools | Full LLM with all info except private key | Soul + startup events + admin notes |
| **WOT** | Pubkey in admin kind `3` contact list | None | Chat-only LLM response | Soul + tier instruction |
| **STRANGER** | Anyone else | None | Configurable static response or silent ignore | N/A - no LLM call |
### Signature Verification
Every incoming event must be cryptographically verified before processing:
1. In `on_event()` in `src/nostr_handler.c`, call `nostr_verify_event_signature(event)` from `nostr_core_lib/nostr_core/nip001.h`
2. If verification fails, drop the event silently with a `DEBUG_WARN` log
3. This prevents relay-forged pubkey fields from being trusted
4. Controlled by `security.verify_signatures` config flag (default: `true`)
### Stranger Response
Non-WoT senders get a **hardcoded configurable response** — no LLM tokens spent:
- Config field: `security.stranger_response`
- Example: `"I only respond to people in my web of trust. My npub is npub12kvnu0dsa4alquu4l4zv9454cgjdr9248gd07ueq5x8yzeuan37ql02960"`
- If `security.tiers.stranger.enabled` is `false`, silently ignore instead
- If `true`, send the canned response as a DM
### Soul Update
Update the soul event content in `config.json` to:
- Explicitly allow sharing the agent public key (npub) with anyone
- Explicitly forbid revealing the private key (nsec) under any circumstances
- Note: Phase 2 will remove the private key from agent memory entirely
### Admin Context Subscription
At startup, Didactyl subscribes to the administrator's Nostr activity to build context awareness.
#### What to subscribe to
| Kind | Purpose | Retention |
|------|---------|-----------|
| `0` | Admin profile metadata | Latest only - replaceable |
| `3` | Admin contact/follow list - WoT source | Latest only - replaceable |
| `10002` | Admin relay list | Latest only - replaceable |
| `1` | Admin public notes | Last N posts - configurable, default 10 |
#### Storage
In-memory struct `admin_context_t` holding:
- `kind_0_json` — latest kind 0 content string
- `kind_3_contacts` — array of followed pubkey hex strings extracted from kind 3 `p` tags
- `kind_3_contact_count` — count
- `kind_10002_json` — latest kind 10002 content string
- `kind_1_notes` — array of recent kind 1 content strings with timestamps
- `kind_1_note_count` — count
#### Subscription mechanics
- Single subscription filter: `{"authors": ["<admin_pubkey>"], "kinds": [0, 3, 10002, 1], "limit": <configurable>}`
- On EOSE: initial load complete, WoT set is ready
- After EOSE: live updates as admin posts new content
- Kind 3 `p` tags are extracted into a sorted array for O(log n) WoT lookup
#### Context injection
- Admin kind 1 notes are injected into the LLM context as a system message: "Administrator recent public notes:" followed by the content
- Only injected for ADMIN-tier conversations
### Config Schema — Phase 1
New sections in `config.json`:
```json
{
"security": {
"verify_signatures": true,
"stranger_response": "I only respond to people in my web of trust.",
"tiers": {
"admin": {
"tools_enabled": true
},
"wot": {
"enabled": true,
"tools_enabled": false
},
"stranger": {
"enabled": true
}
}
},
"admin_context": {
"enabled": true,
"subscribe_kinds": [0, 3, 10002, 1],
"kind_1_limit": 10
}
}
```
- `security.verify_signatures` — master toggle for signature verification (default: `true`)
- `security.stranger_response` — canned DM text for non-WoT senders (default: empty = silent ignore)
- `security.tiers.wot.enabled` — whether WoT contacts can converse (default: `true`)
- `security.tiers.stranger.enabled` — whether strangers get the canned response (default: `true`; if `false`, silent ignore)
- `admin_context.enabled` — whether to subscribe to admin activity (default: `true`)
- `admin_context.subscribe_kinds` — which kinds to track (default: `[0, 3, 10002, 1]`)
- `admin_context.kind_1_limit` — how many kind 1 notes to retain (default: `10`)
### Phase 1 Implementation Steps
1. **Add signature verification to `on_event()`**
- File: `src/nostr_handler.c`
- Call `nostr_verify_event_signature(event)` at the top of `on_event()`
- If it returns non-zero, log and drop
2. **Add `admin_context_t` struct and security config structs**
- File: `src/config.h`
- New structs for admin context storage, security config, tier config
3. **Parse new config sections**
- File: `src/config.c`
- Parse `security` and `admin_context` from config.json
- Apply defaults when sections are missing
4. **Add admin context subscription**
- File: `src/nostr_handler.c`
- New function `nostr_handler_subscribe_admin_context()`
- Callback that populates `admin_context_t` from incoming events
- Extract kind 3 `p` tags into WoT set
- Store kind 1 notes in a ring buffer capped at `kind_1_limit`
5. **Add WoT lookup function**
- File: `src/nostr_handler.c`
- `int nostr_handler_is_wot_contact(const char* pubkey_hex)` — checks if pubkey is in admin kind 3 list
6. **Refactor `on_event()` for tier dispatch**
- File: `src/nostr_handler.c`
- After signature verification, classify sender into ADMIN / WOT / STRANGER
- Pass tier information to the callback — extend `dm_callback_t` signature to include tier enum
- STRANGER with `stranger.enabled`: send canned response directly, no callback
- STRANGER without: drop silently
7. **Refactor `agent_on_message()` for tier-aware behavior**
- File: `src/agent.c`
- ADMIN: current behavior — all tools, full context, admin notes injected
- WOT: build messages with soul + tier instruction, no tools_json, LLM chat-only
- STRANGER: should not reach here — handled in nostr_handler
8. **Inject admin context into ADMIN conversations**
- File: `src/agent.c`
- After startup events context, append admin kind 1 notes as a system message
- Only for ADMIN tier
9. **Update soul content in `config.json`**
- Allow sharing public key with anyone
- Forbid revealing private key under any circumstances
10. **Update `config.json` with new sections**
- Add `security` and `admin_context` sections
11. **Update `README.md`**
- Document privilege tiers
- Document new config sections
- Update architecture diagram
12. **Build and validate**
- Run `./build_static.sh`
- Verify compilation succeeds
---
## Phase 2 — NIP-46 Remote Signer for Key Isolation
### Problem
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `shell_exec` and `file_read` tools — a sufficiently clever prompt injection could theoretically:
- Read `config.json` via `file_read` (contains nsec)
- Execute `cat /proc/self/maps` or similar via `shell_exec`
- Exfiltrate the key via `nostr_post`
### Solution: NIP-46 Remote Signer
Remove the private key from Didactyl entirely. All cryptographic operations go through a NIP-46 remote signer.
```mermaid
flowchart LR
A[Didactyl Agent] -->|kind 24133 sign_event / nip04_encrypt / nip04_decrypt| B[Remote Signer]
B -->|signed events / ciphertext / plaintext| A
A -.->|NO private key in memory| A
B -->|Private key held ONLY here| B
C[config.json] -.->|bunker URL only, no nsec| A
```
### Architecture
1. **Remote signer process** — a separate lightweight daemon that:
- Holds the private key only in working memory (never on disk after initial load)
- Listens for NIP-46 requests via relay (kind `24133`)
- Responds with signed events, encrypted/decrypted content
- Can run on the same machine or a different one
2. **Didactyl as NIP-46 client** — instead of calling `nostr_create_and_sign_event()` directly:
- Sends `sign_event` RPC to the remote signer
- Sends `nip04_encrypt` / `nip04_decrypt` RPC for DM handling
- Only holds a disposable `client-keypair` for NIP-46 communication
3. **Config change**`config.json` replaces `keys.nsec` with:
```json
{
"keys": {
"bunker_url": "bunker://<remote-signer-pubkey>?relay=wss://relay.example.com&secret=<secret>",
"npub": "npub1..."
}
}
```
### NIP-46 Methods Required
| Method | Current direct call | Used for |
|--------|-------------------|----------|
| `sign_event` | `nostr_create_and_sign_event()` | Publishing all events |
| `nip04_encrypt` | `nostr_nip04_encrypt()` | Sending DMs |
| `nip04_decrypt` | `nostr_nip04_decrypt()` | Receiving DMs |
| `get_public_key` | `config.keys.public_key_hex` | Identity |
### Implementation Scope
1. **Build NIP-46 client protocol in nostr_core_lib**
- NIP-44 encryption for request/response content
- Kind 24133 event creation and parsing
- JSON-RPC request/response handling
- Connection establishment (bunker URL parsing)
- Async request/response matching by request ID
2. **Build or integrate a remote signer**
- Option A: Build a minimal signer binary in C (ships with Didactyl)
- Option B: Support existing signers (nsecBunker, etc.)
- Option C: Both — ship a minimal one, support external ones
3. **Refactor all signing/encryption calls**
- Every `nostr_create_and_sign_event()` → `nip46_sign_event()` (async RPC)
- Every `nostr_nip04_encrypt()` → `nip46_nip04_encrypt()` (async RPC)
- Every `nostr_nip04_decrypt()` → `nip46_nip04_decrypt()` (async RPC)
- These become blocking calls that send a request and wait for a response
4. **Startup flow change**
- Parse bunker URL from config
- Generate client keypair
- Send `connect` request to remote signer
- Call `get_public_key` to learn user pubkey
- Proceed with normal startup
5. **Backward compatibility**
- If `keys.nsec` is present, use direct signing (current behavior)
- If `keys.bunker_url` is present, use NIP-46 remote signing
- This allows gradual migration
### Security Properties Achieved
- Private key never in Didactyl process memory
- Private key never on disk in config.json
- LLM tools (shell_exec, file_read) cannot access the key
- Even full process compromise of Didactyl does not leak the signing key
- Remote signer can be on a separate hardened machine
- Remote signer can implement rate limiting, approval flows, etc.

View File

@@ -5,15 +5,410 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include <pthread.h>
#include "llm.h"
#include "nostr_handler.h"
#include "tools.h"
#include "cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
static didactyl_config_t* g_cfg = NULL;
static char* g_system_context = NULL;
static tools_context_t g_tools_ctx;
#define AGENT_DEBOUNCE_WINDOW_SECONDS 5
#define AGENT_DEBOUNCE_CACHE_SIZE 256
#define AGENT_HISTORY_TURNS 12
#define AGENT_HISTORY_QUERY_LIMIT 200
typedef struct {
uint64_t fingerprint;
time_t seen_at;
} agent_seen_msg_t;
static agent_seen_msg_t g_seen_msgs[AGENT_DEBOUNCE_CACHE_SIZE];
static int g_seen_msgs_count = 0;
static int g_seen_msgs_next = 0;
static pthread_mutex_t g_seen_msgs_mutex = PTHREAD_MUTEX_INITIALIZER;
static uint64_t fnv1a64(const char* s) {
uint64_t h = 1469598103934665603ULL;
if (!s) return h;
while (*s) {
h ^= (unsigned char)(*s++);
h *= 1099511628211ULL;
}
return h;
}
static uint64_t message_fingerprint(const char* sender_pubkey_hex, const char* message) {
uint64_t a = fnv1a64(sender_pubkey_hex);
uint64_t b = fnv1a64(message);
return a ^ (b + 0x9e3779b97f4a7c15ULL + (a << 6) + (a >> 2));
}
static int agent_message_is_debounced(const char* sender_pubkey_hex, const char* message) {
time_t now = time(NULL);
uint64_t fp = message_fingerprint(sender_pubkey_hex, message);
int duplicate = 0;
pthread_mutex_lock(&g_seen_msgs_mutex);
for (int i = 0; i < g_seen_msgs_count; i++) {
if (g_seen_msgs[i].fingerprint == fp &&
g_seen_msgs[i].seen_at > 0 &&
(now - g_seen_msgs[i].seen_at) <= AGENT_DEBOUNCE_WINDOW_SECONDS) {
duplicate = 1;
break;
}
}
if (!duplicate) {
int slot = 0;
if (g_seen_msgs_count < AGENT_DEBOUNCE_CACHE_SIZE) {
slot = g_seen_msgs_count;
g_seen_msgs_count++;
} else {
slot = g_seen_msgs_next;
g_seen_msgs_next = (g_seen_msgs_next + 1) % AGENT_DEBOUNCE_CACHE_SIZE;
}
g_seen_msgs[slot].fingerprint = fp;
g_seen_msgs[slot].seen_at = now;
}
pthread_mutex_unlock(&g_seen_msgs_mutex);
return duplicate;
}
static int append_simple_message(cJSON* messages, const char* role, const char* content) {
if (!messages || !role) return -1;
cJSON* msg = cJSON_CreateObject();
if (!msg) return -1;
cJSON_AddStringToObject(msg, "role", role);
cJSON_AddStringToObject(msg, "content", content ? content : "");
cJSON_AddItemToArray(messages, msg);
return 0;
}
static int append_assistant_tool_calls_message(cJSON* messages, const llm_response_t* resp) {
if (!messages || !resp || resp->tool_call_count <= 0) return -1;
cJSON* assistant = cJSON_CreateObject();
cJSON* tool_calls = cJSON_CreateArray();
if (!assistant || !tool_calls) {
cJSON_Delete(assistant);
cJSON_Delete(tool_calls);
return -1;
}
cJSON_AddStringToObject(assistant, "role", "assistant");
if (resp->content) {
cJSON_AddStringToObject(assistant, "content", resp->content);
} else {
cJSON_AddNullToObject(assistant, "content");
}
for (int i = 0; i < resp->tool_call_count; i++) {
const llm_tool_call_t* tc = &resp->tool_calls[i];
cJSON* tc_obj = cJSON_CreateObject();
cJSON* fn_obj = cJSON_CreateObject();
if (!tc_obj || !fn_obj) {
cJSON_Delete(tc_obj);
cJSON_Delete(fn_obj);
cJSON_Delete(assistant);
cJSON_Delete(tool_calls);
return -1;
}
cJSON_AddStringToObject(tc_obj, "id", tc->id ? tc->id : "");
cJSON_AddStringToObject(tc_obj, "type", "function");
cJSON_AddStringToObject(fn_obj, "name", tc->name ? tc->name : "");
cJSON_AddStringToObject(fn_obj, "arguments", tc->arguments_json ? tc->arguments_json : "{}");
cJSON_AddItemToObject(tc_obj, "function", fn_obj);
cJSON_AddItemToArray(tool_calls, tc_obj);
}
cJSON_AddItemToObject(assistant, "tool_calls", tool_calls);
cJSON_AddItemToArray(messages, assistant);
return 0;
}
static int append_tool_result_message(cJSON* messages, const char* tool_call_id, const char* tool_result_json) {
if (!messages || !tool_call_id) return -1;
cJSON* msg = cJSON_CreateObject();
if (!msg) return -1;
cJSON_AddStringToObject(msg, "role", "tool");
cJSON_AddStringToObject(msg, "tool_call_id", tool_call_id);
cJSON_AddStringToObject(msg, "content", tool_result_json ? tool_result_json : "{\"success\":false,\"error\":\"empty tool result\"}");
cJSON_AddItemToArray(messages, msg);
return 0;
}
static void append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload) {
FILE* fp = fopen("context.log", "a");
if (!fp) {
return;
}
time_t now = time(NULL);
struct tm tm_info;
localtime_r(&now, &tm_info);
char timestamp[32] = {0};
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", &tm_info);
fprintf(fp,
"[%s] phase=%s sender=%s\n%s\n\n---\n\n",
timestamp,
phase ? phase : "unknown",
sender_pubkey_hex ? sender_pubkey_hex : "unknown",
context_payload ? context_payload : "");
fclose(fp);
}
typedef struct {
time_t created_at;
int role_is_user;
char* content;
} agent_history_item_t;
static int extract_first_p_tag_local(cJSON* tags, char out_pubkey_hex[65]) {
if (!tags || !cJSON_IsArray(tags) || !out_pubkey_hex) {
return -1;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "p") == 0 && strlen(val->valuestring) == 64U) {
memcpy(out_pubkey_hex, val->valuestring, 64U);
out_pubkey_hex[64] = '\0';
return 0;
}
}
return -1;
}
static int history_item_cmp_created_at(const void* a, const void* b) {
const agent_history_item_t* ia = (const agent_history_item_t*)a;
const agent_history_item_t* ib = (const agent_history_item_t*)b;
if (ia->created_at < ib->created_at) return -1;
if (ia->created_at > ib->created_at) return 1;
return 0;
}
static void free_history_items(agent_history_item_t* items, int count) {
if (!items) return;
for (int i = 0; i < count; i++) {
free(items[i].content);
}
free(items);
}
static int append_startup_events_context(cJSON* messages) {
if (!messages || !g_cfg || g_cfg->startup_event_count <= 0 || !g_cfg->startup_events) {
return 0;
}
cJSON* arr = cJSON_CreateArray();
if (!arr) {
return -1;
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
cJSON* item = cJSON_CreateObject();
if (!item) {
cJSON_Delete(arr);
return -1;
}
cJSON_AddNumberToObject(item, "kind", se->kind);
cJSON_AddStringToObject(item, "content", se->content ? se->content : "");
cJSON* tags = NULL;
if (se->tags_json) {
tags = cJSON_Parse(se->tags_json);
}
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
tags = cJSON_CreateArray();
}
cJSON_AddItemToObject(item, "tags", tags);
cJSON_AddItemToArray(arr, item);
}
char* events_json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
if (!events_json) {
return -1;
}
const char* prefix = "Startup events memory (kinds/content/tags): ";
size_t out_len = strlen(prefix) + strlen(events_json) + 1U;
char* payload = (char*)malloc(out_len);
if (!payload) {
free(events_json);
return -1;
}
snprintf(payload, out_len, "%s%s", prefix, events_json);
free(events_json);
int rc = append_simple_message(messages, "system", payload);
free(payload);
return rc;
}
static int append_recent_admin_dm_history(cJSON* messages, const char* current_message) {
if (!messages || !g_cfg) {
return -1;
}
unsigned char admin_pubkey[32];
if (nostr_hex_to_bytes(g_cfg->admin.pubkey, admin_pubkey, 32) != 0) {
return -1;
}
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->admin.pubkey));
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", AGENT_HISTORY_QUERY_LIMIT);
char* events_json = nostr_handler_query_json(filter, 8000);
cJSON_Delete(filter);
if (!events_json) {
return 0;
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(events);
return 0;
}
agent_history_item_t* items = NULL;
int item_count = 0;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(events, i);
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
cJSON* pubkey = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL;
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
cJSON* tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
cJSON* created_at = ev ? cJSON_GetObjectItemCaseSensitive(ev, "created_at") : NULL;
if (!kind || !pubkey || !content || !tags || !created_at ||
!cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) ||
!cJSON_IsString(content) || !cJSON_IsArray(tags) || !cJSON_IsNumber(created_at) ||
(int)kind->valuedouble != 4 || !pubkey->valuestring || !content->valuestring) {
continue;
}
char p_tag_pubkey[65] = {0};
if (extract_first_p_tag_local(tags, p_tag_pubkey) != 0) {
continue;
}
int role_is_user = 0;
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0 &&
strcmp(p_tag_pubkey, g_cfg->keys.public_key_hex) == 0) {
role_is_user = 1;
} else if (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) == 0 &&
strcmp(p_tag_pubkey, g_cfg->admin.pubkey) == 0) {
role_is_user = 0;
} else {
continue;
}
char* plaintext = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
if (!plaintext) {
continue;
}
plaintext[0] = '\0';
if (nostr_nip04_decrypt(g_cfg->keys.private_key,
admin_pubkey,
content->valuestring,
plaintext,
NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
free(plaintext);
continue;
}
agent_history_item_t* grown = (agent_history_item_t*)realloc(items, (size_t)(item_count + 1) * sizeof(agent_history_item_t));
if (!grown) {
free(plaintext);
free_history_items(items, item_count);
cJSON_Delete(events);
return -1;
}
items = grown;
items[item_count].created_at = (time_t)created_at->valuedouble;
items[item_count].role_is_user = role_is_user;
items[item_count].content = plaintext;
item_count++;
}
cJSON_Delete(events);
if (item_count > 1) {
qsort(items, (size_t)item_count, sizeof(agent_history_item_t), history_item_cmp_created_at);
}
int start = item_count > AGENT_HISTORY_TURNS ? item_count - AGENT_HISTORY_TURNS : 0;
for (int i = start; i < item_count; i++) {
if (i == item_count - 1 &&
items[i].role_is_user &&
current_message &&
strcmp(items[i].content, current_message) == 0) {
continue;
}
if (append_simple_message(messages,
items[i].role_is_user ? "user" : "assistant",
items[i].content ? items[i].content : "") != 0) {
free_history_items(items, item_count);
return -1;
}
}
free_history_items(items, item_count);
return 0;
}
int agent_init(didactyl_config_t* config, const char* system_context) {
if (!config || !system_context) {
return -1;
@@ -32,21 +427,59 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
return -1;
}
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
g_seen_msgs_count = 0;
g_seen_msgs_next = 0;
return 0;
}
void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data) {
void agent_on_message(const char* sender_pubkey_hex,
const char* message,
didactyl_sender_tier_t tier,
void* user_data) {
(void)user_data;
if (!g_cfg || !g_system_context || !sender_pubkey_hex || !message) {
return;
}
fprintf(stdout, "[didactyl] incoming message from %.16s...\n", sender_pubkey_hex);
fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex);
if (tier == DIDACTYL_SENDER_STRANGER) {
return;
}
if (!g_cfg->tools.enabled) {
char* response = llm_chat(g_system_context, message);
fprintf(stdout, "[didactyl] incoming message from %.16s... tier=%d\n", sender_pubkey_hex, (int)tier);
if (agent_message_is_debounced(sender_pubkey_hex, message)) {
fprintf(stdout, "[didactyl] debounced duplicate inbound message from %.16s...\n", sender_pubkey_hex);
return;
}
int allow_tools = (tier == DIDACTYL_SENDER_ADMIN) && g_cfg->tools.enabled && g_cfg->security.admin.tools_enabled;
if (!allow_tools) {
const char* tier_prefix = (tier == DIDACTYL_SENDER_WOT)
? "You are responding to a web-of-trust contact. Keep the response helpful and concise. Tool use is disabled for this tier."
: "You are responding in chat-only mode. Tool use is disabled.";
size_t ctx_len = strlen(g_system_context) + strlen("\n\n") + strlen(tier_prefix) + 1U;
char* system_for_chat = (char*)malloc(ctx_len);
if (!system_for_chat) {
return;
}
snprintf(system_for_chat, ctx_len, "%s\n\n%s", g_system_context, tier_prefix);
size_t context_len = strlen("system:\n\nuser:\n") + strlen(system_for_chat) + strlen(message) + 1U;
char* plain_context = (char*)malloc(context_len);
if (plain_context) {
snprintf(plain_context, context_len, "system:\n%s\n\nuser:\n%s", system_for_chat, message);
append_context_log(sender_pubkey_hex, "llm_chat", plain_context);
free(plain_context);
}
char* response = llm_chat(system_for_chat, message);
free(system_for_chat);
if (!response) {
const char* fallback = "I could not get a response from the LLM right now.";
fprintf(stdout, "[didactyl] llm response unavailable, sending fallback\n");
@@ -68,59 +501,109 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void*
return;
}
llm_response_t resp;
if (llm_chat_with_tools(g_system_context, message, tools_json, &resp) != 0) {
cJSON* messages = cJSON_CreateArray();
if (!messages) {
free(tools_json);
(void)nostr_handler_send_dm(sender_pubkey_hex, "LLM request failed.");
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation state.");
return;
}
free(tools_json);
if (resp.tool_call_count <= 0) {
const char* answer = resp.content ? resp.content : "No response content.";
fprintf(stdout, "[didactyl] llm response (no tool call): %.240s%s\n",
answer,
strlen(answer) > 240 ? "..." : "");
(void)nostr_handler_send_dm(sender_pubkey_hex, answer);
llm_response_free(&resp);
if (append_simple_message(messages, "system", g_system_context) != 0 ||
append_startup_events_context(messages) != 0 ||
append_recent_admin_dm_history(messages, message) != 0) {
cJSON_Delete(messages);
free(tools_json);
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages.");
return;
}
char* admin_notes = nostr_handler_get_admin_kind1_notes_context();
if (admin_notes) {
(void)append_simple_message(messages, "system", admin_notes);
free(admin_notes);
}
if (append_simple_message(messages, "user", message) != 0) {
cJSON_Delete(messages);
free(tools_json);
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages.");
return;
}
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8;
llm_response_t current = resp;
char* final_answer_owned = NULL;
for (int turn = 0; turn < max_turns; turn++) {
if (current.tool_call_count <= 0) {
char* messages_json = cJSON_PrintUnformatted(messages);
if (!messages_json) {
break;
}
llm_tool_call_t* tc = &current.tool_calls[0];
fprintf(stdout, "[didactyl] executing tool call: %s\n", tc->name ? tc->name : "<null>");
append_context_log(sender_pubkey_hex, "llm_chat_with_tools_messages", messages_json);
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
if (!tool_result) {
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
}
llm_response_free(&current);
if (llm_chat_with_tools(g_system_context, tool_result, NULL, &current) != 0) {
free(tool_result);
(void)nostr_handler_send_dm(sender_pubkey_hex, "LLM failed after tool execution.");
llm_response_t resp;
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
free(messages_json);
if (rc != 0) {
(void)nostr_handler_send_dm(sender_pubkey_hex, "LLM request failed.");
cJSON_Delete(messages);
free(tools_json);
return;
}
free(tool_result);
if (current.content && current.tool_call_count == 0) {
if (resp.tool_call_count <= 0) {
const char* answer = resp.content ? resp.content : "No response content.";
fprintf(stdout, "[didactyl] llm response (no tool call): %.240s%s\n",
answer,
strlen(answer) > 240 ? "..." : "");
final_answer_owned = strdup(answer);
llm_response_free(&resp);
break;
}
if (append_assistant_tool_calls_message(messages, &resp) != 0) {
llm_response_free(&resp);
break;
}
for (int i = 0; i < resp.tool_call_count; i++) {
llm_tool_call_t* tc = &resp.tool_calls[i];
fprintf(stdout, "[didactyl] executing tool call: %s\n", tc->name ? tc->name : "<null>");
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
if (!tool_result) {
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
}
if (append_tool_result_message(messages,
tc->id ? tc->id : "",
tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) {
free(tool_result);
llm_response_free(&resp);
cJSON_Delete(messages);
free(tools_json);
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to append tool result.");
return;
}
free(tool_result);
}
llm_response_free(&resp);
}
const char* final_answer = current.content ? current.content : "I hit my tool-use limit for this request.";
if (!final_answer_owned) {
final_answer_owned = strdup("I hit my tool-use limit for this request.");
}
const char* final_answer = final_answer_owned ? final_answer_owned : "I hit my tool-use limit for this request.";
fprintf(stdout, "[didactyl] final response: %.240s%s\n",
final_answer,
strlen(final_answer) > 240 ? "..." : "");
(void)nostr_handler_send_dm(sender_pubkey_hex, final_answer);
llm_response_free(&current);
free(final_answer_owned);
cJSON_Delete(messages);
free(tools_json);
}
void agent_cleanup(void) {
@@ -128,4 +611,7 @@ void agent_cleanup(void) {
free(g_system_context);
g_system_context = NULL;
g_cfg = NULL;
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
g_seen_msgs_count = 0;
g_seen_msgs_next = 0;
}

View File

@@ -2,9 +2,13 @@
#define OPEN_WING_AGENT_H
#include "config.h"
#include "nostr_handler.h"
int agent_init(didactyl_config_t* config, const char* system_context);
void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data);
void agent_on_message(const char* sender_pubkey_hex,
const char* message,
didactyl_sender_tier_t tier,
void* user_data);
void agent_cleanup(void);
#endif

View File

@@ -161,6 +161,273 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
return 0;
}
static int parse_security_config(cJSON* root, didactyl_config_t* config) {
cJSON* security = cJSON_GetObjectItemCaseSensitive(root, "security");
if (!security || !cJSON_IsObject(security)) {
return 0;
}
cJSON* verify_signatures = cJSON_GetObjectItemCaseSensitive(security, "verify_signatures");
if (verify_signatures && cJSON_IsBool(verify_signatures)) {
config->security.verify_signatures = cJSON_IsTrue(verify_signatures) ? 1 : 0;
}
if (copy_json_string(security,
"stranger_response",
config->security.stranger_response,
sizeof(config->security.stranger_response),
0) != 0) {
return -1;
}
cJSON* tiers = cJSON_GetObjectItemCaseSensitive(security, "tiers");
if (!tiers || !cJSON_IsObject(tiers)) {
return 0;
}
cJSON* admin_tier = cJSON_GetObjectItemCaseSensitive(tiers, "admin");
if (admin_tier && cJSON_IsObject(admin_tier)) {
cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(admin_tier, "tools_enabled");
if (tools_enabled && cJSON_IsBool(tools_enabled)) {
config->security.admin.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0;
}
}
cJSON* wot_tier = cJSON_GetObjectItemCaseSensitive(tiers, "wot");
if (wot_tier && cJSON_IsObject(wot_tier)) {
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "enabled");
cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "tools_enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->security.wot.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (tools_enabled && cJSON_IsBool(tools_enabled)) {
config->security.wot.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0;
}
}
cJSON* stranger_tier = cJSON_GetObjectItemCaseSensitive(tiers, "stranger");
if (stranger_tier && cJSON_IsObject(stranger_tier)) {
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(stranger_tier, "enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->security.stranger.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
}
return 0;
}
static int parse_admin_context_config(cJSON* root, didactyl_config_t* config) {
cJSON* admin_context = cJSON_GetObjectItemCaseSensitive(root, "admin_context");
if (!admin_context || !cJSON_IsObject(admin_context)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(admin_context, "enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->admin_context.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
cJSON* kind_1_limit = cJSON_GetObjectItemCaseSensitive(admin_context, "kind_1_limit");
if (kind_1_limit && cJSON_IsNumber(kind_1_limit)) {
config->admin_context.kind_1_limit = (int)kind_1_limit->valuedouble;
}
cJSON* subscribe_kinds = cJSON_GetObjectItemCaseSensitive(admin_context, "subscribe_kinds");
if (subscribe_kinds && cJSON_IsArray(subscribe_kinds)) {
config->admin_context.track_kind_0 = 0;
config->admin_context.track_kind_3 = 0;
config->admin_context.track_kind_10002 = 0;
config->admin_context.track_kind_1 = 0;
int n = cJSON_GetArraySize(subscribe_kinds);
for (int i = 0; i < n; i++) {
cJSON* k = cJSON_GetArrayItem(subscribe_kinds, i);
if (!k || !cJSON_IsNumber(k)) {
continue;
}
int kind = (int)k->valuedouble;
if (kind == 0) config->admin_context.track_kind_0 = 1;
else if (kind == 3) config->admin_context.track_kind_3 = 1;
else if (kind == 10002) config->admin_context.track_kind_10002 = 1;
else if (kind == 1) config->admin_context.track_kind_1 = 1;
}
}
return 0;
}
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
return NULL;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, tag_key) == 0) {
return val;
}
}
return NULL;
}
static int set_tag_value_string(cJSON* tags, const char* tag_key, const char* tag_value) {
if (!tags || !cJSON_IsArray(tags) || !tag_key || !tag_value || tag_value[0] == '\0') {
return -1;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !key->valuestring) {
continue;
}
if (strcmp(key->valuestring, tag_key) == 0) {
if (cJSON_IsString(val)) {
if (!cJSON_SetValuestring(val, tag_value)) {
return -1;
}
return 0;
}
cJSON* new_val = cJSON_CreateString(tag_value);
if (!new_val) {
return -1;
}
cJSON_ReplaceItemInArray(tag, 1, new_val);
return 0;
}
}
cJSON* new_tag = cJSON_CreateArray();
if (!new_tag) {
return -1;
}
cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_key));
cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_value));
cJSON_AddItemToArray(tags, new_tag);
return 0;
}
static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
if (!item || !tags || !cJSON_IsArray(tags)) {
return 0;
}
if (event_kind != 31123 && event_kind != 31124) {
return 0;
}
cJSON* d_val = find_tag_value_string(tags, "d");
if (!d_val || !cJSON_IsString(d_val) || !d_val->valuestring) {
return 0;
}
int needs_normalize =
(strcmp(d_val->valuestring, "skill") == 0 || strcmp(d_val->valuestring, "private_skill") == 0);
if (!needs_normalize) {
return 0;
}
const char* slug = NULL;
cJSON* slug_val = find_tag_value_string(tags, "slug");
if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') {
slug = slug_val->valuestring;
}
if (!slug) {
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
if (content_fields && cJSON_IsObject(content_fields)) {
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name");
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
slug = name->valuestring;
}
}
}
if (!slug) {
return 0;
}
return set_tag_value_string(tags, "d", slug);
}
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "startup_events");
if (!arr || !cJSON_IsArray(arr)) {
return 0;
}
int count = cJSON_GetArraySize(arr);
if (count <= 0) return 0;
config->startup_events = (startup_event_t*)calloc((size_t)count, sizeof(startup_event_t));
if (!config->startup_events) return -1;
config->startup_event_count = count;
for (int i = 0; i < count; i++) {
cJSON* item = cJSON_GetArrayItem(arr, i);
if (!item || !cJSON_IsObject(item)) return -1;
cJSON* kind = cJSON_GetObjectItemCaseSensitive(item, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(item, "content");
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(item, "tags");
if (!kind || !cJSON_IsNumber(kind)) {
return -1;
}
config->startup_events[i].kind = (int)kind->valuedouble;
if (content_fields) {
if (!cJSON_IsObject(content_fields)) {
return -1;
}
config->startup_events[i].content = cJSON_PrintUnformatted(content_fields);
if (!config->startup_events[i].content) {
return -1;
}
} else {
if (!content || !cJSON_IsString(content) || !content->valuestring) {
return -1;
}
config->startup_events[i].content = strdup(content->valuestring);
if (!config->startup_events[i].content) {
return -1;
}
}
if (tags) {
if (!cJSON_IsArray(tags)) return -1;
if (normalize_skill_d_tag(config->startup_events[i].kind, item, tags) != 0) {
return -1;
}
config->startup_events[i].tags_json = cJSON_PrintUnformatted(tags);
if (!config->startup_events[i].tags_json) return -1;
}
}
return 0;
}
static int parse_relays(cJSON* root, didactyl_config_t* config) {
cJSON* relays = cJSON_GetObjectItemCaseSensitive(root, "relays");
if (!relays || !cJSON_IsArray(relays)) {
@@ -206,6 +473,14 @@ void config_free(didactyl_config_t* config) {
free(config->relays);
}
if (config->startup_events) {
for (int i = 0; i < config->startup_event_count; i++) {
free(config->startup_events[i].content);
free(config->startup_events[i].tags_json);
}
free(config->startup_events);
}
memset(config, 0, sizeof(*config));
}
@@ -222,6 +497,22 @@ int config_load(const char* path, didactyl_config_t* config) {
config->tools.shell.max_output_bytes = 65536;
strcpy(config->tools.shell.working_directory, ".");
config->security.verify_signatures = 1;
config->security.admin.enabled = 1;
config->security.admin.tools_enabled = 1;
config->security.wot.enabled = 1;
config->security.wot.tools_enabled = 0;
config->security.stranger.enabled = 1;
config->security.stranger.tools_enabled = 0;
config->security.stranger_response[0] = '\0';
config->admin_context.enabled = 1;
config->admin_context.track_kind_0 = 1;
config->admin_context.track_kind_3 = 1;
config->admin_context.track_kind_10002 = 1;
config->admin_context.track_kind_1 = 1;
config->admin_context.kind_1_limit = 10;
char* json_buf = NULL;
size_t json_len = 0;
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
@@ -237,34 +528,16 @@ int config_load(const char* path, didactyl_config_t* config) {
int rc = -1;
cJSON* agent = cJSON_GetObjectItemCaseSensitive(root, "agent");
cJSON* keys = cJSON_GetObjectItemCaseSensitive(root, "keys");
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
if (!agent || !cJSON_IsObject(agent) ||
!keys || !cJSON_IsObject(keys) ||
if (!keys || !cJSON_IsObject(keys) ||
!admin || !cJSON_IsObject(admin) ||
!llm || !cJSON_IsObject(llm)) {
goto cleanup;
}
if (copy_json_string(agent, "name", config->profile.name, sizeof(config->profile.name), 1) != 0) {
goto cleanup;
}
if (copy_json_string(agent, "display_name", config->profile.display_name, sizeof(config->profile.display_name), 1) != 0) {
goto cleanup;
}
if (copy_json_string(agent, "about", config->profile.about, sizeof(config->profile.about), 1) != 0) {
goto cleanup;
}
if (copy_json_string(agent, "picture", config->profile.picture, sizeof(config->profile.picture), 0) != 0) {
goto cleanup;
}
if (copy_json_string(agent, "nip05", config->profile.nip05, sizeof(config->profile.nip05), 0) != 0) {
goto cleanup;
}
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
goto cleanup;
}
@@ -308,6 +581,18 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (parse_security_config(root, config) != 0) {
goto cleanup;
}
if (parse_admin_context_config(root, config) != 0) {
goto cleanup;
}
if (parse_startup_events(root, config) != 0) {
goto cleanup;
}
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
goto cleanup;
}

View File

@@ -9,13 +9,7 @@
#define OW_MAX_KEY_LEN 256
#define OW_MAX_MODEL_LEN 128
typedef struct {
char name[OW_MAX_NAME_LEN];
char display_name[OW_MAX_NAME_LEN];
char about[OW_MAX_ABOUT_LEN];
char picture[OW_MAX_URL_LEN];
char nip05[OW_MAX_URL_LEN];
} agent_profile_t;
#define OW_MAX_STRANGER_RESPONSE_LEN 512
typedef struct {
char nsec[OW_MAX_KEY_LEN];
@@ -51,13 +45,44 @@ typedef struct {
} tools_config_t;
typedef struct {
agent_profile_t profile;
int kind;
char* content;
char* tags_json; // JSON array string for tags, optional
} startup_event_t;
typedef struct {
int enabled;
int tools_enabled;
} security_tier_config_t;
typedef struct {
int verify_signatures;
char stranger_response[OW_MAX_STRANGER_RESPONSE_LEN];
security_tier_config_t admin;
security_tier_config_t wot;
security_tier_config_t stranger;
} security_config_t;
typedef struct {
int enabled;
int track_kind_0;
int track_kind_3;
int track_kind_10002;
int track_kind_1;
int kind_1_limit;
} admin_context_config_t;
typedef struct {
agent_keys_t keys;
admin_config_t admin;
char** relays;
int relay_count;
llm_config_t llm;
tools_config_t tools;
security_config_t security;
admin_context_config_t admin_context;
startup_event_t* startup_events;
int startup_event_count;
} didactyl_config_t;
int config_load(const char* path, didactyl_config_t* config);

122
src/llm.c
View File

@@ -6,6 +6,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "cjson/cJSON.h"
@@ -41,6 +42,28 @@ static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
return total;
}
static const char* detect_ca_bundle_path(void) {
const char* env = getenv("SSL_CERT_FILE");
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
return env;
}
static const char* candidates[] = {
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
"/etc/ssl/cert.pem", // Alpine
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
"/etc/ssl/ca-bundle.pem" // openSUSE
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
if (access(candidates[i], R_OK) == 0) {
return candidates[i];
}
}
return NULL;
}
static char* perform_chat_request(const char* body) {
CURL* curl = curl_easy_init();
if (!curl || !body) {
@@ -67,6 +90,11 @@ static char* perform_chat_request(const char* body) {
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
const char* ca_bundle = detect_ca_bundle_path();
if (ca_bundle) {
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
}
CURLcode res = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
@@ -74,11 +102,33 @@ static char* perform_chat_request(const char* body) {
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK || status < 200 || status >= 300 || !rb.data) {
if (res != CURLE_OK) {
fprintf(stderr, "[didactyl] llm http request failed: curl=%s\n", curl_easy_strerror(res));
if (rb.data && rb.len > 0) {
fprintf(stderr, "[didactyl] llm partial response: %.600s%s\n",
rb.data,
rb.len > 600 ? "..." : "");
}
free(rb.data);
return NULL;
}
if (status < 200 || status >= 300) {
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
if (rb.data && rb.len > 0) {
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
rb.data,
rb.len > 1200 ? "..." : "");
}
free(rb.data);
return NULL;
}
if (!rb.data) {
fprintf(stderr, "[didactyl] llm http request failed: empty response body\n");
return NULL;
}
return rb.data;
}
@@ -228,6 +278,9 @@ char* llm_chat(const char* system_prompt, const char* user_message) {
llm_response_t parsed;
if (parse_llm_response(raw, &parsed) != 0) {
fprintf(stderr, "[didactyl] failed to parse llm response (non-tool path): %.1200s%s\n",
raw,
strlen(raw) > 1200 ? "..." : "");
free(raw);
return NULL;
}
@@ -238,19 +291,16 @@ char* llm_chat(const char* system_prompt, const char* user_message) {
return answer;
}
int llm_chat_with_tools(const char* system_prompt,
const char* user_message,
const char* tools_json,
llm_response_t* out_response) {
if (!g_initialized || !out_response) {
int llm_chat_with_tools_messages(const char* messages_json,
const char* tools_json,
const char* tool_choice,
llm_response_t* out_response) {
if (!g_initialized || !out_response || !messages_json) {
return -1;
}
cJSON* root = cJSON_CreateObject();
cJSON* messages = cJSON_CreateArray();
if (!root || !messages) {
cJSON_Delete(root);
cJSON_Delete(messages);
if (!root) {
return -1;
}
@@ -258,20 +308,19 @@ int llm_chat_with_tools(const char* system_prompt,
cJSON_AddNumberToObject(root, "max_tokens", g_cfg.max_tokens);
cJSON_AddNumberToObject(root, "temperature", g_cfg.temperature);
cJSON* system_msg = cJSON_CreateObject();
cJSON* user_msg = cJSON_CreateObject();
cJSON_AddStringToObject(system_msg, "role", "system");
cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : "");
cJSON_AddStringToObject(user_msg, "role", "user");
cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : "");
cJSON_AddItemToArray(messages, system_msg);
cJSON_AddItemToArray(messages, user_msg);
cJSON* messages = cJSON_Parse(messages_json);
if (!messages || !cJSON_IsArray(messages)) {
cJSON_Delete(messages);
cJSON_Delete(root);
return -1;
}
cJSON_AddItemToObject(root, "messages", messages);
if (tools_json) {
cJSON* tools = cJSON_Parse(tools_json);
if (tools && cJSON_IsArray(tools)) {
cJSON_AddItemToObject(root, "tools", tools);
cJSON_AddStringToObject(root, "tool_choice", tool_choice ? tool_choice : "auto");
} else {
cJSON_Delete(tools);
}
@@ -286,10 +335,47 @@ int llm_chat_with_tools(const char* system_prompt,
if (!raw) return -1;
int rc = parse_llm_response(raw, out_response);
if (rc != 0) {
fprintf(stderr, "[didactyl] failed to parse llm response (tools path): %.1200s%s\n",
raw,
strlen(raw) > 1200 ? "..." : "");
}
free(raw);
return rc;
}
int llm_chat_with_tools(const char* system_prompt,
const char* user_message,
const char* tools_json,
llm_response_t* out_response) {
cJSON* messages = cJSON_CreateArray();
cJSON* system_msg = cJSON_CreateObject();
cJSON* user_msg = cJSON_CreateObject();
if (!messages || !system_msg || !user_msg) {
cJSON_Delete(messages);
cJSON_Delete(system_msg);
cJSON_Delete(user_msg);
return -1;
}
cJSON_AddStringToObject(system_msg, "role", "system");
cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : "");
cJSON_AddStringToObject(user_msg, "role", "user");
cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : "");
cJSON_AddItemToArray(messages, system_msg);
cJSON_AddItemToArray(messages, user_msg);
char* messages_json = cJSON_PrintUnformatted(messages);
cJSON_Delete(messages);
if (!messages_json) {
return -1;
}
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", out_response);
free(messages_json);
return rc;
}
void llm_response_free(llm_response_t* response) {
if (!response) return;
free(response->content);

View File

@@ -21,6 +21,10 @@ int llm_chat_with_tools(const char* system_prompt,
const char* user_message,
const char* tools_json,
llm_response_t* out_response);
int llm_chat_with_tools_messages(const char* messages_json,
const char* tools_json,
const char* tool_choice,
llm_response_t* out_response);
void llm_response_free(llm_response_t* response);
void llm_cleanup(void);

View File

@@ -9,7 +9,6 @@
#include "main.h"
#include "agent.h"
#include "config.h"
#include "context.h"
#include "llm.h"
#include "nostr_handler.h"
#include "debug.h"
@@ -23,19 +22,15 @@ static void signal_handler(int signum) {
int main(int argc, char** argv) {
const char* config_path = "./config.json";
const char* context_path = "./SYSTEM.md";
int debug_level = DEBUG_LEVEL_INFO;
int debug_level = DEBUG_LEVEL_TRACE;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
config_path = argv[++i];
} else if (strcmp(argv[i], "--context") == 0 && i + 1 < argc) {
context_path = argv[++i];
} else if (strcmp(argv[i], "--debug") == 0 && i + 1 < argc) {
debug_level = atoi(argv[++i]);
} else {
fprintf(stderr, "Usage: %s [--config <path>] [--context <path>] [--debug <0-5>]\n", argv[0]);
fprintf(stderr, "Usage: %s [--config <path>] [--debug <0-5>]\n", argv[0]);
return 1;
}
}
@@ -56,17 +51,8 @@ int main(int argc, char** argv) {
return 1;
}
char* system_context = context_load(context_path);
if (!system_context) {
fprintf(stderr, "Failed to load context file: %s\n", context_path);
config_free(&cfg);
nostr_cleanup();
return 1;
}
if (llm_init(&cfg.llm) != 0) {
fprintf(stderr, "Failed to initialize llm client\n");
context_free(system_context);
config_free(&cfg);
nostr_cleanup();
return 1;
@@ -75,27 +61,34 @@ int main(int argc, char** argv) {
if (nostr_handler_init(&cfg) != 0) {
fprintf(stderr, "Failed to initialize nostr handler\n");
llm_cleanup();
context_free(system_context);
config_free(&cfg);
nostr_cleanup();
return 1;
}
if (nostr_handler_reconcile_startup_events() != 0) {
DEBUG_WARN("[didactyl] startup phase: reconcile startup events failed (continuing)");
}
const char* system_context = nostr_handler_get_system_context();
if (!system_context || system_context[0] == '\0') {
system_context = "You are Didactyl, a sovereign AI agent living on Nostr.";
}
if (agent_init(&cfg, system_context) != 0) {
fprintf(stderr, "Failed to initialize agent\n");
nostr_handler_cleanup();
llm_cleanup();
context_free(system_context);
config_free(&cfg);
nostr_cleanup();
return 1;
}
DEBUG_INFO("[didactyl] startup phase: publish profile begin");
if (nostr_handler_publish_profile() != 0) {
DEBUG_WARN("[didactyl] publish profile deferred/failed (will continue startup)");
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
if (nostr_handler_subscribe_admin_context() != 0) {
DEBUG_WARN("[didactyl] startup phase: subscribe admin context failed (continuing)");
}
DEBUG_INFO("[didactyl] startup phase: publish profile end");
DEBUG_INFO("[didactyl] startup phase: subscribe admin context end");
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
@@ -104,7 +97,6 @@ int main(int argc, char** argv) {
agent_cleanup();
nostr_handler_cleanup();
llm_cleanup();
context_free(system_context);
config_free(&cfg);
nostr_cleanup();
return 1;
@@ -129,7 +121,6 @@ int main(int argc, char** argv) {
agent_cleanup();
nostr_handler_cleanup();
llm_cleanup();
context_free(system_context);
config_free(&cfg);
nostr_cleanup();

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 1
#define DIDACTYL_VERSION "v0.0.1"
#define DIDACTYL_VERSION_PATCH 7
#define DIDACTYL_VERSION "v0.0.7"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -6,6 +6,7 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <pthread.h>
#include "../../nostr_core_lib/cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
@@ -19,6 +20,65 @@ static int g_poll_counter = 0;
static time_t g_start_time = 0;
static time_t g_last_status_log_time = 0;
static nostr_pool_relay_status_t* g_last_relay_statuses = NULL;
static char* g_system_context = NULL;
static unsigned char* g_startup_published = NULL;
static int g_startup_publish_tracking_enabled = 0;
static char* g_admin_kind0_json = NULL;
static char* g_admin_kind10002_json = NULL;
static char** g_admin_wot_contacts = NULL;
static int g_admin_wot_contact_count = 0;
typedef struct {
time_t created_at;
char* content;
} admin_kind1_note_t;
static admin_kind1_note_t* g_admin_kind1_notes = NULL;
static int g_admin_kind1_note_count = 0;
static pthread_mutex_t g_admin_ctx_mutex = PTHREAD_MUTEX_INITIALIZER;
#define DM_DEDUP_CACHE_SIZE 256
static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65];
static int g_seen_dm_count = 0;
static int g_seen_dm_next = 0;
static pthread_mutex_t g_dm_dedup_mutex = PTHREAD_MUTEX_INITIALIZER;
static int dm_id_seen_or_remember(const char* event_id_hex) {
if (!event_id_hex || strlen(event_id_hex) != 64U) {
return 0;
}
int seen = 0;
pthread_mutex_lock(&g_dm_dedup_mutex);
for (int i = 0; i < g_seen_dm_count; i++) {
if (strncmp(g_seen_dm_ids[i], event_id_hex, 64U) == 0) {
seen = 1;
break;
}
}
if (!seen) {
int slot = 0;
if (g_seen_dm_count < DM_DEDUP_CACHE_SIZE) {
slot = g_seen_dm_count;
g_seen_dm_count++;
} else {
slot = g_seen_dm_next;
g_seen_dm_next = (g_seen_dm_next + 1) % DM_DEDUP_CACHE_SIZE;
}
memcpy(g_seen_dm_ids[slot], event_id_hex, 64U);
g_seen_dm_ids[slot][64] = '\0';
}
pthread_mutex_unlock(&g_dm_dedup_mutex);
return seen;
}
static const char* relay_status_str(nostr_pool_relay_status_t status) {
switch (status) {
@@ -35,25 +95,53 @@ static const char* relay_status_str(nostr_pool_relay_status_t status) {
}
}
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason);
static int publish_kind_event_to_relays(int kind,
const char* content,
cJSON* tags,
const char** relay_urls,
int relay_count,
const char* reason_label);
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data);
static int parse_kind3_wot_contacts(cJSON* tags);
static void upsert_kind1_note(time_t created_at, const char* content);
static void log_relay_statuses(const char* reason) {
if (!g_pool || !g_cfg) {
return;
}
DEBUG_INFO("[didactyl] relay status snapshot (%s)", reason ? reason : "periodic");
int info_level = (reason && strcmp(reason, "after init") == 0) ? 1 : 0;
if (info_level) {
DEBUG_INFO("[didactyl] relay status snapshot (%s)", reason ? reason : "periodic");
} else {
DEBUG_TRACE("[didactyl] relay status snapshot (%s)", reason ? reason : "periodic");
}
for (int i = 0; i < g_cfg->relay_count; i++) {
const char* relay = g_cfg->relays[i];
nostr_pool_relay_status_t status = nostr_relay_pool_get_relay_status(g_pool, relay);
const char* last_err = nostr_relay_pool_get_relay_last_connection_error(g_pool, relay);
double ping_ms = nostr_relay_pool_get_relay_ping_latency(g_pool, relay);
if (ping_ms > 0.0) {
DEBUG_INFO("[didactyl] - %s => %s (ping %.1f ms)",
relay,
relay_status_str(status),
ping_ms);
if (info_level) {
if (ping_ms > 0.0) {
DEBUG_INFO("[didactyl] - %s => %s (ping %.1f ms)",
relay,
relay_status_str(status),
ping_ms);
} else {
DEBUG_INFO("[didactyl] - %s => %s", relay, relay_status_str(status));
}
} else {
DEBUG_INFO("[didactyl] - %s => %s", relay, relay_status_str(status));
if (ping_ms > 0.0) {
DEBUG_TRACE("[didactyl] - %s => %s (ping %.1f ms)",
relay,
relay_status_str(status),
ping_ms);
} else {
DEBUG_TRACE("[didactyl] - %s => %s", relay, relay_status_str(status));
}
}
if (last_err && last_err[0] != '\0') {
@@ -88,6 +176,10 @@ static void log_relay_state_changes(void) {
relay_status_str(prev),
relay_status_str(now));
g_last_relay_statuses[i] = now;
if (now == NOSTR_POOL_RELAY_CONNECTED) {
publish_pending_startup_events_for_relay_index(i, "relay_connected");
}
}
}
}
@@ -155,6 +247,28 @@ static int extract_first_p_tag(cJSON* tags, char out_pubkey_hex[65]) {
return -1;
}
static void trace_event_json(const char* prefix, cJSON* event) {
if (g_debug_level < DEBUG_LEVEL_TRACE || !event) {
return;
}
char* event_json = cJSON_PrintUnformatted(event);
if (!event_json) {
DEBUG_TRACE("[didactyl] %s <failed to serialize event>", prefix ? prefix : "event");
return;
}
DEBUG_TRACE("[didactyl] %s %s", prefix ? prefix : "event", event_json);
free(event_json);
}
static void trace_plaintext_dm(const char* prefix, const char* plaintext) {
if (g_debug_level < DEBUG_LEVEL_TRACE) {
return;
}
DEBUG_TRACE("[didactyl] %s %s", prefix ? prefix : "dm plaintext", plaintext ? plaintext : "");
}
static void on_event(cJSON* event, const char* relay_url, void* user_data) {
(void)user_data;
@@ -162,6 +276,13 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
return;
}
if (g_cfg->security.verify_signatures && nostr_verify_event_signature(event) != 0) {
DEBUG_WARN("[didactyl] dropped event with invalid signature via %s",
relay_url ? relay_url : "unknown relay");
return;
}
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
@@ -172,14 +293,11 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
return;
}
if ((int)kind->valuedouble != 4) {
return;
}
const char* event_id_hex = (id && cJSON_IsString(id) && id->valuestring && strlen(id->valuestring) == 64U)
? id->valuestring
: NULL;
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) != 0) {
fprintf(stdout, "[didactyl] ignored DM from unauthorized pubkey %.16s... via %s\n",
pubkey->valuestring,
relay_url ? relay_url : "unknown relay");
if ((int)kind->valuedouble != 4) {
return;
}
@@ -192,6 +310,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
return;
}
didactyl_sender_tier_t tier = DIDACTYL_SENDER_STRANGER;
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0) {
tier = DIDACTYL_SENDER_ADMIN;
} else if (g_cfg->security.wot.enabled && nostr_handler_is_wot_contact(pubkey->valuestring)) {
tier = DIDACTYL_SENDER_WOT;
}
if (tier == DIDACTYL_SENDER_STRANGER) {
if (!g_cfg->security.stranger.enabled) {
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s",
pubkey->valuestring,
relay_url ? relay_url : "unknown relay");
return;
}
if (g_cfg->security.stranger_response[0] != '\0') {
(void)nostr_handler_send_dm(pubkey->valuestring, g_cfg->security.stranger_response);
}
return;
}
unsigned char sender_pubkey[32];
if (hex_to_pubkey(pubkey->valuestring, sender_pubkey) != 0) {
return;
@@ -204,16 +343,32 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
}
decrypted[0] = '\0';
trace_event_json("received encrypted DM event:", event);
if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", pubkey->valuestring);
free(decrypted);
return;
}
DEBUG_INFO("[didactyl] received DM from %.16s... via %s",
trace_plaintext_dm("received decrypted DM content:", decrypted);
if (event_id_hex && dm_id_seen_or_remember(event_id_hex)) {
DEBUG_LOG("[didactyl] skipped duplicate DM event %.16s... from %.16s... via %s",
event_id_hex,
pubkey->valuestring,
relay_url ? relay_url : "unknown relay");
free(decrypted);
return;
}
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s tier=%d",
(int)kind->valuedouble,
event_id_hex ? event_id_hex : "<no-id>",
pubkey->valuestring,
relay_url ? relay_url : "unknown relay");
g_dm_callback(pubkey->valuestring, decrypted, g_dm_user_data);
relay_url ? relay_url : "unknown relay",
(int)tier);
g_dm_callback(pubkey->valuestring, decrypted, tier, g_dm_user_data);
free(decrypted);
}
@@ -223,6 +378,162 @@ static void on_eose(cJSON** events, int event_count, void* user_data) {
(void)user_data;
}
static void free_admin_context_locked(void) {
free(g_admin_kind0_json);
g_admin_kind0_json = NULL;
free(g_admin_kind10002_json);
g_admin_kind10002_json = NULL;
if (g_admin_wot_contacts) {
for (int i = 0; i < g_admin_wot_contact_count; i++) {
free(g_admin_wot_contacts[i]);
}
free(g_admin_wot_contacts);
}
g_admin_wot_contacts = NULL;
g_admin_wot_contact_count = 0;
if (g_admin_kind1_notes) {
for (int i = 0; i < g_admin_kind1_note_count; i++) {
free(g_admin_kind1_notes[i].content);
}
free(g_admin_kind1_notes);
}
g_admin_kind1_notes = NULL;
g_admin_kind1_note_count = 0;
}
static int parse_kind3_wot_contacts(cJSON* tags) {
if (!tags || !cJSON_IsArray(tags)) {
return 0;
}
if (g_admin_wot_contacts) {
for (int i = 0; i < g_admin_wot_contact_count; i++) {
free(g_admin_wot_contacts[i]);
}
free(g_admin_wot_contacts);
g_admin_wot_contacts = NULL;
g_admin_wot_contact_count = 0;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "p") != 0 || strlen(val->valuestring) != 64U) {
continue;
}
char* dup = strdup(val->valuestring);
if (!dup) {
return -1;
}
char** grown = (char**)realloc(g_admin_wot_contacts, (size_t)(g_admin_wot_contact_count + 1) * sizeof(char*));
if (!grown) {
free(dup);
return -1;
}
g_admin_wot_contacts = grown;
g_admin_wot_contacts[g_admin_wot_contact_count++] = dup;
}
return 0;
}
static void upsert_kind1_note(time_t created_at, const char* content) {
if (!content) {
return;
}
int limit = g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10;
if (limit > 256) {
limit = 256;
}
char* dup = strdup(content);
if (!dup) {
return;
}
admin_kind1_note_t* grown = (admin_kind1_note_t*)realloc(g_admin_kind1_notes,
(size_t)(g_admin_kind1_note_count + 1) * sizeof(admin_kind1_note_t));
if (!grown) {
free(dup);
return;
}
g_admin_kind1_notes = grown;
g_admin_kind1_notes[g_admin_kind1_note_count].created_at = created_at;
g_admin_kind1_notes[g_admin_kind1_note_count].content = dup;
g_admin_kind1_note_count++;
while (g_admin_kind1_note_count > limit) {
free(g_admin_kind1_notes[0].content);
memmove(&g_admin_kind1_notes[0],
&g_admin_kind1_notes[1],
(size_t)(g_admin_kind1_note_count - 1) * sizeof(admin_kind1_note_t));
g_admin_kind1_note_count--;
}
}
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data) {
(void)relay_url;
(void)user_data;
if (!event || !g_cfg || !g_cfg->admin_context.enabled) {
return;
}
if (g_cfg->security.verify_signatures && nostr_verify_event_signature(event) != 0) {
return;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!kind || !pubkey || !cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !pubkey->valuestring) {
return;
}
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) != 0) {
return;
}
int k = (int)kind->valuedouble;
pthread_mutex_lock(&g_admin_ctx_mutex);
if (k == 0 && g_cfg->admin_context.track_kind_0 && content && cJSON_IsString(content) && content->valuestring) {
free(g_admin_kind0_json);
g_admin_kind0_json = strdup(content->valuestring);
} else if (k == 3 && g_cfg->admin_context.track_kind_3 && tags && cJSON_IsArray(tags)) {
(void)parse_kind3_wot_contacts(tags);
} else if (k == 10002 && g_cfg->admin_context.track_kind_10002 && content && cJSON_IsString(content) && content->valuestring) {
free(g_admin_kind10002_json);
g_admin_kind10002_json = strdup(content->valuestring);
} else if (k == 1 && g_cfg->admin_context.track_kind_1 && content && cJSON_IsString(content) && content->valuestring) {
time_t ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : time(NULL);
upsert_kind1_note(ts, content->valuestring);
}
pthread_mutex_unlock(&g_admin_ctx_mutex);
}
int nostr_handler_init(didactyl_config_t* config) {
if (!config) {
return -1;
@@ -231,6 +542,9 @@ int nostr_handler_init(didactyl_config_t* config) {
g_cfg = config;
g_poll_counter = 0;
g_start_time = time(NULL);
memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids));
g_seen_dm_count = 0;
g_seen_dm_next = 0;
DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count);
@@ -263,75 +577,110 @@ int nostr_handler_init(didactyl_config_t* config) {
g_last_status_log_time = time(NULL);
log_relay_statuses("after init");
free(g_startup_published);
g_startup_published = NULL;
g_startup_publish_tracking_enabled = 0;
return 0;
}
int nostr_handler_publish_profile(void) {
if (!g_cfg || !g_pool) {
return -1;
}
DEBUG_INFO("[didactyl] publish_profile: build metadata payload");
cJSON* profile = cJSON_CreateObject();
if (!profile) {
return -1;
}
cJSON_AddStringToObject(profile, "name", g_cfg->profile.name);
cJSON_AddStringToObject(profile, "display_name", g_cfg->profile.display_name);
cJSON_AddStringToObject(profile, "about", g_cfg->profile.about);
cJSON_AddStringToObject(profile, "picture", g_cfg->profile.picture);
if (g_cfg->profile.nip05[0] != '\0') {
cJSON_AddStringToObject(profile, "nip05", g_cfg->profile.nip05);
}
char* content = cJSON_PrintUnformatted(profile);
cJSON_Delete(profile);
if (!content) {
return -1;
}
DEBUG_INFO("[didactyl] publish_profile: sign kind-0 event");
cJSON* event = nostr_create_and_sign_event(0, content, NULL, g_cfg->keys.private_key, time(NULL));
free(content);
if (!event) {
return -1;
}
const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*));
if (!connected_relays) {
cJSON_Delete(event);
return -1;
}
int connected_count = 0;
for (int i = 0; i < g_cfg->relay_count; i++) {
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) == NOSTR_POOL_RELAY_CONNECTED) {
connected_relays[connected_count++] = g_cfg->relays[i];
}
}
if (connected_count == 0) {
DEBUG_WARN("[didactyl] publish_profile: no connected relays yet, deferring publish");
free(connected_relays);
cJSON_Delete(event);
int nostr_handler_subscribe_admin_context(void) {
if (!g_cfg || !g_pool || !g_cfg->admin_context.enabled) {
return 0;
}
DEBUG_INFO("[didactyl] publish_profile: publishing to %d connected relay(s)", connected_count);
int sent = nostr_relay_pool_publish_async(
g_pool,
connected_relays,
connected_count,
event,
NULL,
NULL);
int rc = 0;
free(connected_relays);
cJSON_Delete(event);
DEBUG_INFO("[didactyl] publish profile result: sent_to=%d connected relay(s)", sent);
return sent > 0 ? 0 : -1;
cJSON* profile_filter = cJSON_CreateObject();
cJSON* profile_kinds = cJSON_CreateArray();
cJSON* profile_authors = cJSON_CreateArray();
if (!profile_filter || !profile_kinds || !profile_authors) {
cJSON_Delete(profile_filter);
cJSON_Delete(profile_kinds);
cJSON_Delete(profile_authors);
return -1;
}
if (g_cfg->admin_context.track_kind_0) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(0));
if (g_cfg->admin_context.track_kind_3) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(3));
if (g_cfg->admin_context.track_kind_10002) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(10002));
if (cJSON_GetArraySize(profile_kinds) > 0) {
cJSON_AddItemToObject(profile_filter, "kinds", profile_kinds);
cJSON_AddItemToArray(profile_authors, cJSON_CreateString(g_cfg->admin.pubkey));
cJSON_AddItemToObject(profile_filter, "authors", profile_authors);
cJSON_AddNumberToObject(profile_filter, "limit", 32);
nostr_pool_subscription_t* profile_sub = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
profile_filter,
on_admin_context_event,
on_eose,
NULL,
0,
1,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
if (!profile_sub) {
rc = -1;
}
} else {
cJSON_Delete(profile_kinds);
cJSON_Delete(profile_authors);
}
cJSON_Delete(profile_filter);
if (g_cfg->admin_context.track_kind_1) {
cJSON* notes_filter = cJSON_CreateObject();
cJSON* notes_kinds = cJSON_CreateArray();
cJSON* notes_authors = cJSON_CreateArray();
if (!notes_filter || !notes_kinds || !notes_authors) {
cJSON_Delete(notes_filter);
cJSON_Delete(notes_kinds);
cJSON_Delete(notes_authors);
return -1;
}
int kind1_limit = g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10;
if (kind1_limit > 256) {
kind1_limit = 256;
}
cJSON_AddItemToArray(notes_kinds, cJSON_CreateNumber(1));
cJSON_AddItemToObject(notes_filter, "kinds", notes_kinds);
cJSON_AddItemToArray(notes_authors, cJSON_CreateString(g_cfg->admin.pubkey));
cJSON_AddItemToObject(notes_filter, "authors", notes_authors);
cJSON_AddNumberToObject(notes_filter, "limit", kind1_limit);
nostr_pool_subscription_t* notes_sub = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
notes_filter,
on_admin_context_event,
on_eose,
NULL,
0,
1,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(notes_filter);
if (!notes_sub) {
rc = -1;
}
}
if (rc == 0) {
DEBUG_INFO("[didactyl] admin context subscriptions active for admin %.16s...", g_cfg->admin.pubkey);
}
return rc;
}
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
@@ -393,6 +742,8 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
return -1;
}
trace_plaintext_dm("sending plaintext DM content:", message);
char* encrypted = (char*)malloc(NOSTR_NIP04_MAX_ENCRYPTED_SIZE);
if (!encrypted) {
fprintf(stderr, "[didactyl] failed to allocate DM encrypt buffer\n");
@@ -418,6 +769,8 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
return -1;
}
trace_event_json("sending encrypted DM event:", event);
log_publish_targets("publish DM");
const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*));
@@ -442,16 +795,34 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
event,
NULL,
NULL);
for (int i = 0; i < connected_count; i++) {
DEBUG_INFO("[didactyl] kind 4 event published to %s (async)", connected_relays[i]);
}
} else {
DEBUG_WARN("[didactyl] kind 4 event not queued: no connected relays");
}
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
const char* out_event_id_hex = (event_id && cJSON_IsString(event_id) && event_id->valuestring) ? event_id->valuestring : "<no-id>";
DEBUG_INFO("[didactyl] sent DM %.16s... to %.16s... via %d connected relay(s)",
out_event_id_hex,
recipient_pubkey_hex,
sent);
free(connected_relays);
cJSON_Delete(event);
DEBUG_INFO("[didactyl] sent DM to %.16s... via %d connected relay(s)", recipient_pubkey_hex, sent);
return sent > 0 ? 0 : -1;
}
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags) {
if (!g_cfg || !g_pool || !content) {
static int publish_kind_event_to_relays(int kind,
const char* content,
cJSON* tags,
const char** relay_urls,
int relay_count,
const char* reason_label) {
if (!g_cfg || !g_pool || !content || !relay_urls || relay_count <= 0) {
return -1;
}
@@ -471,11 +842,35 @@ int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags)
return -1;
}
int sent = nostr_relay_pool_publish_async(
g_pool,
relay_urls,
relay_count,
event,
NULL,
NULL);
for (int i = 0; i < relay_count; i++) {
DEBUG_INFO("[didactyl] kind %d event published to %s (async%s%s)",
kind,
relay_urls[i],
reason_label ? ", reason=" : "",
reason_label ? reason_label : "");
}
cJSON_Delete(event);
return sent > 0 ? 0 : -1;
}
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags) {
if (!g_cfg || !g_pool || !content) {
return -1;
}
log_publish_targets("publish kind event");
const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*));
if (!connected_relays) {
cJSON_Delete(event);
return -1;
}
@@ -486,21 +881,17 @@ int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags)
}
}
int sent = 0;
if (connected_count > 0) {
sent = nostr_relay_pool_publish_async(
g_pool,
connected_relays,
connected_count,
event,
NULL,
NULL);
if (connected_count <= 0) {
DEBUG_WARN("[didactyl] kind %d event not queued: no connected relays", kind);
free(connected_relays);
return -1;
}
int rc = publish_kind_event_to_relays(kind, content, tags, connected_relays, connected_count, "manual_publish");
free(connected_relays);
cJSON_Delete(event);
DEBUG_INFO("[didactyl] published kind %d event via %d connected relay(s)", kind, sent);
return sent > 0 ? 0 : -1;
DEBUG_INFO("[didactyl] published kind %d event via %d connected relay(s)", kind, connected_count);
return rc;
}
char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
@@ -541,6 +932,150 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
return out;
}
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason) {
if (!g_cfg || !g_pool || !g_startup_publish_tracking_enabled || !g_startup_published) {
return;
}
if (relay_index < 0 || relay_index >= g_cfg->relay_count) {
return;
}
const char* relay_url = g_cfg->relays[relay_index];
if (nostr_relay_pool_get_relay_status(g_pool, relay_url) != NOSTR_POOL_RELAY_CONNECTED) {
return;
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
if (!se->content) {
continue;
}
size_t slot = (size_t)i * (size_t)g_cfg->relay_count + (size_t)relay_index;
if (g_startup_published[slot]) {
continue;
}
cJSON* tags = NULL;
if (se->tags_json) {
tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
tags = NULL;
}
}
const char* one_relay[1] = { relay_url };
if (publish_kind_event_to_relays(se->kind, se->content, tags, one_relay, 1, reason) == 0) {
g_startup_published[slot] = 1;
} else {
DEBUG_WARN("[didactyl] startup event publish failed for kind=%d relay=%s", se->kind, relay_url);
}
if (se->kind == 31120 && !g_system_context) {
g_system_context = strdup(se->content);
}
cJSON_Delete(tags);
}
}
int nostr_handler_reconcile_startup_events(void) {
if (!g_cfg || !g_pool) {
return -1;
}
free(g_system_context);
g_system_context = NULL;
free(g_startup_published);
g_startup_published = NULL;
g_startup_publish_tracking_enabled = 0;
if (g_cfg->startup_event_count > 0 && g_cfg->relay_count > 0) {
size_t total = (size_t)g_cfg->startup_event_count * (size_t)g_cfg->relay_count;
g_startup_published = (unsigned char*)calloc(total, 1U);
if (!g_startup_published) {
return -1;
}
g_startup_publish_tracking_enabled = 1;
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
if (se->kind == 31120 && se->content && !g_system_context) {
g_system_context = strdup(se->content);
break;
}
}
if (!g_system_context) {
g_system_context = strdup("You are Didactyl, a sovereign AI agent living on Nostr.");
}
for (int relay_index = 0; relay_index < g_cfg->relay_count; relay_index++) {
publish_pending_startup_events_for_relay_index(relay_index, "startup_reconcile");
}
return 0;
}
const char* nostr_handler_get_system_context(void) {
return g_system_context;
}
char* nostr_handler_get_admin_kind1_notes_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_1) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
if (g_admin_kind1_note_count <= 0 || !g_admin_kind1_notes) {
pthread_mutex_unlock(&g_admin_ctx_mutex);
return NULL;
}
size_t total = strlen("Administrator recent public notes:\n") + 1U;
for (int i = 0; i < g_admin_kind1_note_count; i++) {
total += strlen("- ") + strlen(g_admin_kind1_notes[i].content ? g_admin_kind1_notes[i].content : "") + 1U;
}
char* out = (char*)malloc(total);
if (!out) {
pthread_mutex_unlock(&g_admin_ctx_mutex);
return NULL;
}
out[0] = '\0';
strcat(out, "Administrator recent public notes:\n");
for (int i = 0; i < g_admin_kind1_note_count; i++) {
strcat(out, "- ");
strcat(out, g_admin_kind1_notes[i].content ? g_admin_kind1_notes[i].content : "");
strcat(out, "\n");
}
pthread_mutex_unlock(&g_admin_ctx_mutex);
return out;
}
int nostr_handler_is_wot_contact(const char* pubkey_hex) {
if (!pubkey_hex || strlen(pubkey_hex) != 64U) {
return 0;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
int found = 0;
for (int i = 0; i < g_admin_wot_contact_count; i++) {
if (g_admin_wot_contacts[i] && strcmp(g_admin_wot_contacts[i], pubkey_hex) == 0) {
found = 1;
break;
}
}
pthread_mutex_unlock(&g_admin_ctx_mutex);
return found;
}
int nostr_handler_poll(int timeout_ms) {
if (!g_pool) {
return -1;
@@ -569,8 +1104,21 @@ void nostr_handler_cleanup(void) {
g_last_relay_statuses = NULL;
g_last_status_log_time = 0;
free(g_startup_published);
g_startup_published = NULL;
g_startup_publish_tracking_enabled = 0;
g_pool = NULL;
g_cfg = NULL;
g_dm_callback = NULL;
g_dm_user_data = NULL;
free(g_system_context);
g_system_context = NULL;
memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids));
g_seen_dm_count = 0;
g_seen_dm_next = 0;
pthread_mutex_lock(&g_admin_ctx_mutex);
free_admin_context_locked();
pthread_mutex_unlock(&g_admin_ctx_mutex);
}

View File

@@ -4,15 +4,28 @@
#include "config.h"
#include "cjson/cJSON.h"
typedef void (*dm_callback_t)(const char* sender_pubkey_hex, const char* message, void* user_data);
typedef enum {
DIDACTYL_SENDER_ADMIN = 1,
DIDACTYL_SENDER_WOT = 2,
DIDACTYL_SENDER_STRANGER = 3
} didactyl_sender_tier_t;
typedef void (*dm_callback_t)(const char* sender_pubkey_hex,
const char* message,
didactyl_sender_tier_t tier,
void* user_data);
int nostr_handler_init(didactyl_config_t* config);
int nostr_handler_publish_profile(void);
int nostr_handler_subscribe_admin_context(void);
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags);
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
int nostr_handler_poll(int timeout_ms);
int nostr_handler_reconcile_startup_events(void);
const char* nostr_handler_get_system_context(void);
char* nostr_handler_get_admin_kind1_notes_context(void);
int nostr_handler_is_wot_contact(const char* pubkey_hex);
void nostr_handler_cleanup(void);
#endif

View File

@@ -5,6 +5,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include "cjson/cJSON.h"
#include "nostr_handler.h"
@@ -29,6 +30,33 @@ static char* json_success_with_message(const char* msg) {
return out;
}
static int is_safe_relative_path(const char* path) {
if (!path || path[0] == '\0') return 0;
if (path[0] == '/') return 0;
if (strstr(path, "..") != NULL) return 0;
if (strchr(path, '\\') != NULL) return 0;
return 1;
}
static int build_tool_path(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) {
if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1;
if (!is_safe_relative_path(rel_path)) return -1;
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
? ctx->cfg->tools.shell.working_directory
: ".";
int n = 0;
if (strcmp(cwd, ".") == 0) {
n = snprintf(out, out_size, "%s", rel_path);
} else {
n = snprintf(out, out_size, "%s/%s", cwd, rel_path);
}
if (n < 0 || (size_t)n >= out_size) return -1;
return 0;
}
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) {
if (!ctx || !cfg) return -1;
memset(ctx, 0, sizeof(*ctx));
@@ -120,6 +148,60 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON_AddItemToObject(t3, "function", t3_fn);
cJSON_AddItemToArray(tools, t3);
cJSON* t4 = cJSON_CreateObject();
cJSON* t4_fn = cJSON_CreateObject();
cJSON* t4_params = cJSON_CreateObject();
cJSON* t4_props = cJSON_CreateObject();
cJSON* t4_required = cJSON_CreateArray();
cJSON_AddStringToObject(t4, "type", "function");
cJSON_AddStringToObject(t4_fn, "name", "file_read");
cJSON_AddStringToObject(t4_fn, "description", "Read a local file as text from the configured working directory");
cJSON_AddStringToObject(t4_params, "type", "object");
cJSON_AddItemToObject(t4_params, "properties", t4_props);
cJSON_AddItemToObject(t4_params, "required", t4_required);
cJSON* p_fr_path = cJSON_CreateObject();
cJSON_AddStringToObject(p_fr_path, "type", "string");
cJSON_AddItemToObject(t4_props, "path", p_fr_path);
cJSON* p_fr_max = cJSON_CreateObject();
cJSON_AddStringToObject(p_fr_max, "type", "integer");
cJSON_AddItemToObject(t4_props, "max_bytes", p_fr_max);
cJSON_AddItemToArray(t4_required, cJSON_CreateString("path"));
cJSON_AddItemToObject(t4_fn, "parameters", t4_params);
cJSON_AddItemToObject(t4, "function", t4_fn);
cJSON_AddItemToArray(tools, t4);
cJSON* t5 = cJSON_CreateObject();
cJSON* t5_fn = cJSON_CreateObject();
cJSON* t5_params = cJSON_CreateObject();
cJSON* t5_props = cJSON_CreateObject();
cJSON* t5_required = cJSON_CreateArray();
cJSON_AddStringToObject(t5, "type", "function");
cJSON_AddStringToObject(t5_fn, "name", "file_write");
cJSON_AddStringToObject(t5_fn, "description", "Write text content to a local file in the configured working directory");
cJSON_AddStringToObject(t5_params, "type", "object");
cJSON_AddItemToObject(t5_params, "properties", t5_props);
cJSON_AddItemToObject(t5_params, "required", t5_required);
cJSON* p_fw_path = cJSON_CreateObject();
cJSON_AddStringToObject(p_fw_path, "type", "string");
cJSON_AddItemToObject(t5_props, "path", p_fw_path);
cJSON* p_fw_content = cJSON_CreateObject();
cJSON_AddStringToObject(p_fw_content, "type", "string");
cJSON_AddItemToObject(t5_props, "content", p_fw_content);
cJSON* p_fw_append = cJSON_CreateObject();
cJSON_AddStringToObject(p_fw_append, "type", "boolean");
cJSON_AddItemToObject(t5_props, "append", p_fw_append);
cJSON_AddItemToArray(t5_required, cJSON_CreateString("path"));
cJSON_AddItemToArray(t5_required, cJSON_CreateString("content"));
cJSON_AddItemToObject(t5_fn, "parameters", t5_params);
cJSON_AddItemToObject(t5, "function", t5_fn);
cJSON_AddItemToArray(tools, t5);
char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools);
return out;
@@ -249,6 +331,106 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
return json;
}
static char* execute_file_read(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
if (!args) return json_error("invalid arguments JSON");
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
cJSON* maxb = cJSON_GetObjectItemCaseSensitive(args, "max_bytes");
if (!path || !cJSON_IsString(path) || !path->valuestring) {
cJSON_Delete(args);
return json_error("file_read requires string path");
}
int hard_max = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max;
if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max;
char file_path[PATH_MAX];
if (build_tool_path(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error("file_read path is not allowed");
}
FILE* fp = fopen(file_path, "rb");
cJSON_Delete(args);
if (!fp) return json_error("file_read failed to open file");
char* buf = (char*)calloc((size_t)max_bytes + 1U, 1U);
if (!buf) {
fclose(fp);
return json_error("allocation failure");
}
size_t n = fread(buf, 1, (size_t)max_bytes, fp);
int truncated = !feof(fp) ? 1 : 0;
fclose(fp);
buf[n] = '\0';
cJSON* out = cJSON_CreateObject();
if (!out) {
free(buf);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "path", file_path);
cJSON_AddNumberToObject(out, "bytes_read", (double)n);
cJSON_AddBoolToObject(out, "truncated", truncated);
cJSON_AddStringToObject(out, "content", buf);
free(buf);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
if (!args) return json_error("invalid arguments JSON");
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
cJSON* append = cJSON_GetObjectItemCaseSensitive(args, "append");
if (!path || !cJSON_IsString(path) || !path->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(args);
return json_error("file_write requires string path and content");
}
char file_path[PATH_MAX];
if (build_tool_path(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error("file_write path is not allowed");
}
const char* content_str = content->valuestring;
size_t len = strlen(content_str);
int do_append = (append && cJSON_IsBool(append) && cJSON_IsTrue(append)) ? 1 : 0;
FILE* fp = fopen(file_path, do_append ? "ab" : "wb");
cJSON_Delete(args);
if (!fp) return json_error("file_write failed to open file");
size_t n = fwrite(content_str, 1, len, fp);
fclose(fp);
if (n != len) return json_error("file_write failed to write all bytes");
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "path", file_path);
cJSON_AddNumberToObject(out, "bytes_written", (double)n);
cJSON_AddBoolToObject(out, "append", do_append);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) {
if (!tool_name) return json_error("missing tool name");
@@ -261,6 +443,12 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
if (strcmp(tool_name, "shell_exec") == 0) {
return execute_shell_exec(ctx, args_json);
}
if (strcmp(tool_name, "file_read") == 0) {
return execute_file_read(ctx, args_json);
}
if (strcmp(tool_name, "file_write") == 0) {
return execute_file_write(ctx, args_json);
}
return json_error("unknown tool");
}