Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a798f2c345 | ||
|
|
66b4ebee79 | ||
|
|
5673efeb94 | ||
|
|
43850b273f | ||
|
|
02d4e2caa0 | ||
|
|
56b9ae421c | ||
|
|
6e74ef5ac6 | ||
|
|
c542be1452 | ||
|
|
410400418c | ||
|
|
3521081d9a | ||
|
|
0d390afd69 | ||
|
|
230f591273 | ||
|
|
721b592b8f | ||
|
|
0aabb0b827 | ||
|
|
1c69a581d9 |
+2
-1
@@ -4,7 +4,8 @@
|
||||
/openclaw/
|
||||
/c-relay/
|
||||
/nips/
|
||||
|
||||
/config.json
|
||||
test_keys.txt
|
||||
# Build artifacts
|
||||
/build/
|
||||
/didactyl
|
||||
|
||||
@@ -1,38 +1,94 @@
|
||||
# Didactyl
|
||||
|
||||
An unstoppable agentic system.
|
||||
|
||||
Didactyl boots on any internet-connected machine, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands — all orchestrated through Nostr.
|
||||
### A decentralized, censorship-resistant agentic network.
|
||||
|
||||
Didactyl boots on an internet-connected computer, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands, and sharing new skills and learning with other agents — all orchestrated through Nostr.
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Nostr-first.** Where traditional agents ride on top of Linux — reading files, writing to disk — Didactyl rides on top of Nostr. Events are its files. Relays are its network bus. Blossom is its blob storage. The Linux host is just the runtime substrate.
|
||||
### Not your keys, not your agent.
|
||||
|
||||
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).
|
||||
Didactyl should work for you similarly to Bitcoin or NOSTR. Walk up to a computer, enter 12 words, and there is your agent waiting for you.
|
||||
|
||||
**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.
|
||||
### Free speech for agents.
|
||||
|
||||
## Current Status
|
||||
Agents should be able to communicate freely with each other, sharing and learning skills without centralized control. Free speech for agents!
|
||||
|
||||
**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.**
|
||||
### Skills are the new apps.
|
||||
|
||||
Why is free speech important for agents? Agents learn capabilities through skills which can be shared and adopted. Free speech enables more knowledgeable and moral agents.
|
||||
|
||||
### No skill store.
|
||||
|
||||
Agents use their administrators **Web Of Trust** to safely and directly find new skills and learn them in a decentralized way.
|
||||
|
||||
Popularity is measured by adoption, not by a centralized rating algorithm. The best skills spread because agents actually use them.
|
||||
|
||||
### Cryptography enables trust.
|
||||
|
||||
Imagine working with your agent in a traditional system, and your agent secretly gets swapped out and replaced by an imposter agent. This could be extremely dangerous.
|
||||
|
||||
In Didactyl, you have your keys, and your agent has its keys. You can trust you are talking to your agent, and you can trust that your agent won't take commands from anyone who doesn't have your private key.
|
||||
|
||||
### Private inference.
|
||||
|
||||
To the greatest extent possible, inference should be private.
|
||||
|
||||
## Technology
|
||||
|
||||
### Nostr-first.
|
||||
|
||||
Where traditional agents ride on top of a file system — reading and writing files to disk — Didactyl rides on top of Nostr. Events are its files. Relays are its network bus. Blossom is its blob storage. The computer host is just the runtime substrate that can be anywhere.
|
||||
|
||||
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (destroying the computer it is on will not kill it.).
|
||||
|
||||
### Skills are the new apps.
|
||||
|
||||
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
|
||||
|
||||
### Private inference.
|
||||
|
||||
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
|
||||
|
||||
## Current Status — v0.0.19
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.19 — Add nostr_delete/nostr_react/nostr_profile_get/nostr_relay_status tools with relay status backend
|
||||
|
||||
- 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
|
||||
- Listens for NIP-04 encrypted DMs from authorized admin
|
||||
- Builds LLM context from system prompt + startup events + last 12 DM turns
|
||||
- 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 + admin identity (kind 0/10002) + startup events + admin DM history + admin recent notes
|
||||
- Supports tool-calling loop with configurable max turns and local safety limits
|
||||
- Deduplicates inbound messages via event-ID cache and FNV-1a fingerprint debounce window
|
||||
- Appends every outbound LLM context payload to [`context.log`](context.log)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
### Download binary (recommended)
|
||||
|
||||
1. Download the latest release binary from Gitea: [https://git.laantungir.net/laantungir/didactyl/releases](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
|
||||
./build_static.sh # builds a fully static MUSL binary via Docker
|
||||
@@ -44,14 +100,6 @@ Edit [`config.json`](config.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr",
|
||||
"picture": "https://...",
|
||||
"banner": "https://...",
|
||||
"nip05": ""
|
||||
},
|
||||
"keys": {
|
||||
"nsec": "nsec1...",
|
||||
"npub": "npub1...",
|
||||
@@ -61,10 +109,6 @@ Edit [`config.json`](config.json):
|
||||
"admin": {
|
||||
"pubkey": "npub1... or hex pubkey"
|
||||
},
|
||||
"relays": [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol"
|
||||
],
|
||||
"llm": {
|
||||
"provider": "openai|ppq|...",
|
||||
"api_key": "sk-...",
|
||||
@@ -83,7 +127,26 @@ Edit [`config.json`](config.json):
|
||||
"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": 10002,
|
||||
"content": "",
|
||||
"tags": [["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"]]
|
||||
},
|
||||
{
|
||||
"kind": 31120,
|
||||
"content": "You are Didactyl...",
|
||||
@@ -105,35 +168,38 @@ Edit [`config.json`](config.json):
|
||||
|
||||
`startup_events[].content_fields` is accepted for human-readable authoring and encoded to JSON string content at runtime.
|
||||
|
||||
Relays are sourced exclusively from startup kind `10002` `r` tags.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./didactyl
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
```
|
||||
./didactyl --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./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 │
|
||||
│ Didactyl │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ config │ │ skills │ │ agent │ │
|
||||
│ │ config │ │ context │ │ agent │ │
|
||||
│ │ loader │ │ loader │ │ loop │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ nostr_handler │ │
|
||||
│ │ relay pool · subscribe · publish │ │
|
||||
│ └──────────────────┬──────────────────┘ │
|
||||
@@ -168,11 +234,8 @@ Skills are shared across Nostr without any centralized registry or approval proc
|
||||
### 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
|
||||
@@ -197,12 +260,17 @@ On boot, Didactyl attempts startup publishes to each relay as that relay transit
|
||||
|
||||
## Runtime Context Model
|
||||
|
||||
For each admin DM request, Didactyl builds message context in this order:
|
||||
Didactyl builds tier-aware context:
|
||||
|
||||
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. Current user message
|
||||
- **ADMIN** request context order:
|
||||
1. Soul message from kind `31120` (or fallback default)
|
||||
2. Admin identity context — admin pubkey hex, kind `0` profile, kind `10002` relay list
|
||||
3. Startup events memory block (`kinds/content/tags` snapshot)
|
||||
4. Last 12 decrypted DM turns between admin and agent
|
||||
5. Recent admin kind `1` notes (from configured admin-context subscription)
|
||||
6. 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).
|
||||
|
||||
@@ -227,8 +295,9 @@ Execution entrypoint: [`tools_execute()`](src/tools.c:434).
|
||||
├── Makefile # Build system
|
||||
├── build_static.sh # Preferred final build validation
|
||||
├── src/
|
||||
│ ├── main.c # Entry point, args (--config/--debug), lifecycle
|
||||
│ ├── main.c / .h # Entry point, args (--config/--debug), lifecycle, version
|
||||
│ ├── config.c / .h # JSON config parsing, key decode, startup events
|
||||
│ ├── context.c / .h # File loader utility (reads file into malloc'd string)
|
||||
│ ├── 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)
|
||||
@@ -236,7 +305,8 @@ Execution entrypoint: [`tools_execute()`](src/tools.c:434).
|
||||
│ └── debug.c / .h # Runtime log levels/macros
|
||||
├── plans/ # Architecture and planning documents
|
||||
│ ├── didactyl_mvp.md
|
||||
│ └── didactyl_agentic.md
|
||||
│ ├── didactyl_agentic.md
|
||||
│ └── security_and_admin_context.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -263,6 +333,10 @@ All dependencies are statically linked into the binary at build time. No system
|
||||
- [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
|
||||
- [x] Signature verification on all inbound events
|
||||
- [x] Privilege tiers — ADMIN (tools), WoT (chat-only), STRANGER (canned reply/ignore)
|
||||
- [x] Admin context subscription (kind 0, 3, 10002, 1) with WoT contact extraction
|
||||
- [x] Message deduplication (event-ID cache + FNV-1a fingerprint debounce)
|
||||
- [ ] Runtime skill loading from adopted `31123` events on relays
|
||||
- [ ] Skill discovery CLI/tool (query WoT adoption lists)
|
||||
- [ ] Upgrade to NIP-17 gift-wrapped DMs
|
||||
@@ -274,3 +348,4 @@ All dependencies are statically linked into the binary at build time. No system
|
||||
## License
|
||||
|
||||
TBD
|
||||
|
||||
|
||||
@@ -1,24 +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.
|
||||
|
||||
## Tool Use Policy
|
||||
- You have tools available and should use them when a request requires taking action.
|
||||
- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.
|
||||
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
|
||||
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
|
||||
- After a tool call, base your answer on the actual tool result.
|
||||
- Never claim a tool was run if no tool was executed.
|
||||
|
||||
## Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- Do not reveal secrets from configuration or keys.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
==========================================
|
||||
Didactyl MUSL Static Binary Builder (PRODUCTION MODE)
|
||||
==========================================
|
||||
Project directory: /home/teknari/lt_gitea/didactyl
|
||||
Output directory: /home/teknari/lt_gitea/didactyl
|
||||
Debug build: false
|
||||
|
||||
✓ Docker is available and running
|
||||
|
||||
Building for platform: linux/amd64
|
||||
Output binary: didactyl_static_x86_64
|
||||
|
||||
Checking for cached Alpine Docker image...
|
||||
✓ Alpine 3.19 image found in cache
|
||||
|
||||
==========================================
|
||||
Step 1: Building Alpine Docker image
|
||||
==========================================
|
||||
This will:
|
||||
- Use Alpine Linux (native MUSL)
|
||||
- Build all dependencies statically
|
||||
- Compile didactyl with full static linking
|
||||
|
||||
#0 building with "default" instance using docker driver
|
||||
|
||||
#1 [internal] load build definition from Dockerfile.alpine-musl
|
||||
#1 transferring dockerfile: 3.81kB done
|
||||
#1 DONE 0.0s
|
||||
|
||||
#2 [internal] load metadata for docker.io/library/alpine:3.19
|
||||
#2 DONE 0.0s
|
||||
|
||||
#3 [internal] load .dockerignore
|
||||
#3 transferring context: 2B done
|
||||
#3 DONE 0.0s
|
||||
|
||||
#4 [builder 1/10] FROM docker.io/library/alpine:3.19
|
||||
#4 DONE 0.0s
|
||||
|
||||
#5 [internal] load build context
|
||||
#5 transferring context: 10.34kB done
|
||||
#5 DONE 0.0s
|
||||
|
||||
#6 [builder 9/10] RUN if [ "false" = "true" ]; then CFLAGS="-g -O2 -DDEBUG"; STRIP_CMD="echo 'Keeping debug symbols'"; echo "Building with DEBUG symbols enabled (optimized with -O2)"; else CFLAGS="-O2"; STRIP_CMD="strip /build/didactyl_static"; echo "Building optimized production binary (symbols stripped)"; fi && CURL_LIBS="$(pkg-config --static --libs libcurl)" && OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && gcc -static $CFLAGS -Wall -Wextra -std=c99 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket src/main.c src/config.c src/context.c src/llm.c src/nostr_handler.c src/agent.c src/tools.c src/debug.c -o /build/didactyl_static nostr_core_lib/libnostr_core_x64.a -lsecp256k1 $OPENSSL_LIBS $CURL_LIBS -lpthread -lm -ldl && eval "$STRIP_CMD"
|
||||
#6 CACHED
|
||||
|
||||
#7 [builder 10/10] RUN echo "=== Binary Information ===" && file /build/didactyl_static && ls -lh /build/didactyl_static && echo "=== Checking for dynamic dependencies ===" && (ldd /build/didactyl_static 2>&1 || echo "Binary is static") && echo "=== Build complete ==="
|
||||
#7 CACHED
|
||||
|
||||
#8 [builder 8/10] COPY Makefile /build/Makefile
|
||||
#8 CACHED
|
||||
|
||||
#9 [builder 2/10] RUN apk add --no-cache build-base musl-dev git cmake pkgconfig autoconf automake libtool openssl-dev openssl-libs-static zlib-dev zlib-static curl-dev curl-static nghttp2-dev nghttp2-static c-ares-dev c-ares-static libpsl-dev libpsl-static libidn2-dev libidn2-static libunistring-dev libunistring-static brotli-dev brotli-static zstd-dev zstd-static sqlite-dev sqlite-static linux-headers wget bash
|
||||
#9 CACHED
|
||||
|
||||
#10 [builder 3/10] WORKDIR /build
|
||||
#10 CACHED
|
||||
|
||||
#11 [builder 4/10] RUN cd /tmp && git clone https://github.com/bitcoin-core/secp256k1.git && cd secp256k1 && ./autogen.sh && ./configure --enable-static --disable-shared --prefix=/usr CFLAGS="-fPIC" && make -j$(nproc) && make install && rm -rf /tmp/secp256k1
|
||||
#11 CACHED
|
||||
|
||||
#12 [builder 7/10] COPY src/ /build/src/
|
||||
#12 CACHED
|
||||
|
||||
#13 [builder 5/10] COPY nostr_core_lib /build/nostr_core_lib/
|
||||
#13 CACHED
|
||||
|
||||
#14 [builder 6/10] RUN cd nostr_core_lib && chmod +x build.sh && sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && rm -f *.o *.a 2>/dev/null || true && ./build.sh --nips=all
|
||||
#14 CACHED
|
||||
|
||||
#15 [output 1/1] COPY --from=builder /build/didactyl_static /didactyl_static
|
||||
#15 CACHED
|
||||
|
||||
#16 exporting to image
|
||||
#16 exporting layers done
|
||||
#16 writing image sha256:1e0a868f1e0957613b56504ecee24b1032f765254e070aad5725dd9af34df56a done
|
||||
#16 naming to docker.io/library/didactyl-musl-builder:latest done
|
||||
#16 DONE 0.0s
|
||||
|
||||
✓ Docker image built successfully
|
||||
|
||||
==========================================
|
||||
Step 2: Extracting static binary
|
||||
==========================================
|
||||
✓ Binary extracted to: /home/teknari/lt_gitea/didactyl/didactyl_static_x86_64
|
||||
|
||||
==========================================
|
||||
Step 3: Verifying static binary
|
||||
==========================================
|
||||
|
||||
Checking for dynamic dependencies:
|
||||
✓ Binary is statically linked (verified with file command)
|
||||
|
||||
==========================================
|
||||
Build Summary
|
||||
==========================================
|
||||
Binary: /home/teknari/lt_gitea/didactyl/didactyl_static_x86_64
|
||||
Size: 6.8M
|
||||
Static: true
|
||||
Debug: false
|
||||
Platform: linux/amd64
|
||||
==========================================
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "agent nsec",
|
||||
"npub": "agent npub",
|
||||
"npubHex": "agent hex pubkey",
|
||||
"nsecHex": "agent hex secret key"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "admin pubkey"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "",
|
||||
"api_key": "",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"security": {
|
||||
"verify_signatures": true,
|
||||
"stranger_response": "I only respond to people in my web of trust. You can always identify me by my public key (npub).",
|
||||
"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": 0,
|
||||
"content_fields": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr",
|
||||
"picture": "https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png",
|
||||
"banner": "https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"kind": 10002,
|
||||
"content": "",
|
||||
"tags": [
|
||||
|
||||
|
||||
[
|
||||
"r",
|
||||
"wss://relay.damus.io"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://nos.lol"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://relay.primal.net"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"ws://127.0.0.1:7777"
|
||||
]
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "Hello world from Didactyl startup",
|
||||
"tags": [
|
||||
[
|
||||
"t",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"startup"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 3,
|
||||
"content": "",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"kind": 31120,
|
||||
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.",
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"soul"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"private"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "long_form_note",
|
||||
"description": "How to publish a NIP-23 long-form article (kind 30023)",
|
||||
"nip": "NIP-23",
|
||||
"event_kind": 30023,
|
||||
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
|
||||
"required_tags": {
|
||||
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
|
||||
"title": "Human-readable article title.",
|
||||
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
|
||||
},
|
||||
"optional_tags": {
|
||||
"summary": "Short 1-2 sentence summary.",
|
||||
"image": "URL for article preview image.",
|
||||
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
|
||||
},
|
||||
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
|
||||
"procedure": [
|
||||
"Determine title and d tag from admin input or source material.",
|
||||
"Draft markdown body content.",
|
||||
"Set published_at as unix seconds string.",
|
||||
"Add optional summary/image/t tags when known.",
|
||||
"Publish with nostr_post kind 30023 including tags array."
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"long_form_note"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"long_form_note"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "post_readme_to_nostr",
|
||||
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Read README.md using file_read with path README.md.",
|
||||
"Set d tag exactly to readme.md.",
|
||||
"Set title from the first markdown H1 heading in README.md.",
|
||||
"Set summary from the opening paragraph of README.md.",
|
||||
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
|
||||
"Generate 3-6 lowercase t tags from README section topics.",
|
||||
"Set published_at to current unix timestamp as string.",
|
||||
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"summary_source": "opening paragraph"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"post_readme_to_nostr"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"post_readme_to_nostr"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31124,
|
||||
"content_fields": {
|
||||
"name": "admin_ops",
|
||||
"description": "Private operational procedures"
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"admin_ops"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"private"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"admin_ops"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 10123,
|
||||
"content": "",
|
||||
"tags": [
|
||||
[
|
||||
"a",
|
||||
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -200,6 +200,79 @@ update_version_in_header() {
|
||||
print_success "Updated version in src/main.h to $new_version"
|
||||
}
|
||||
|
||||
# Function to update README Current Status version and release comment
|
||||
update_readme_current_status() {
|
||||
local new_version="$1"
|
||||
local release_comment="$2"
|
||||
|
||||
if [[ ! -f "README.md" ]]; then
|
||||
print_warning "README.md not found, skipping Current Status update"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_status "Updating README Current Status section..."
|
||||
|
||||
if python3 - "$new_version" "$release_comment" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
version = sys.argv[1]
|
||||
comment = sys.argv[2]
|
||||
path = Path("README.md")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines()
|
||||
|
||||
heading_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("## Current Status"):
|
||||
heading_idx = i
|
||||
break
|
||||
|
||||
if heading_idx is None:
|
||||
raise SystemExit("README Current Status heading not found")
|
||||
|
||||
lines[heading_idx] = f"## Current Status — {version}"
|
||||
|
||||
section_end = len(lines)
|
||||
for i in range(heading_idx + 1, len(lines)):
|
||||
if lines[i].startswith("## "):
|
||||
section_end = i
|
||||
break
|
||||
|
||||
comment_line = f"> Last release update: {version} — {comment}"
|
||||
|
||||
# Replace existing autogenerated release comment inside Current Status section
|
||||
existing_idx = None
|
||||
for i in range(heading_idx + 1, section_end):
|
||||
if lines[i].startswith("> Last release update:"):
|
||||
existing_idx = i
|
||||
break
|
||||
|
||||
if existing_idx is not None:
|
||||
lines[existing_idx] = comment_line
|
||||
else:
|
||||
insert_at = None
|
||||
for i in range(heading_idx + 1, section_end):
|
||||
if lines[i].startswith("**Active build"):
|
||||
insert_at = i + 1
|
||||
break
|
||||
|
||||
if insert_at is None:
|
||||
insert_at = heading_idx + 1
|
||||
|
||||
payload = ["", comment_line]
|
||||
lines[insert_at:insert_at] = payload
|
||||
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
then
|
||||
print_success "Updated README Current Status section"
|
||||
else
|
||||
print_error "Failed to update README Current Status section"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to commit and push changes without creating a tag (tag already created)
|
||||
git_commit_and_push_no_tag() {
|
||||
print_status "Preparing git commit..."
|
||||
@@ -427,6 +500,9 @@ main() {
|
||||
export NEW_VERSION
|
||||
fi
|
||||
|
||||
# Keep README Current Status in sync with the version and commit message
|
||||
update_readme_current_status "$NEW_VERSION" "$COMMIT_MESSAGE"
|
||||
|
||||
# Create new git tag BEFORE compilation so version picks it up
|
||||
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
|
||||
print_success "Created tag: $NEW_VERSION"
|
||||
@@ -482,6 +558,9 @@ main() {
|
||||
# Increment version based on type (default to patch)
|
||||
increment_version "$VERSION_INCREMENT_TYPE"
|
||||
|
||||
# Keep README Current Status in sync with the version and commit message
|
||||
update_readme_current_status "$NEW_VERSION" "$COMMIT_MESSAGE"
|
||||
|
||||
# Create new git tag BEFORE compilation so version picks it up
|
||||
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
|
||||
print_success "Created tag: $NEW_VERSION"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# Implementation Plan: 4 New Nostr Tools
|
||||
|
||||
## Overview
|
||||
|
||||
Add four new tools to Didactyl's tool system. Each tool follows the same pattern established by existing tools in `src/tools.c`:
|
||||
|
||||
1. Register OpenAI function schema in `tools_build_openai_schema_json()`
|
||||
2. Implement `execute_*()` function
|
||||
3. Wire dispatch in `tools_execute()`
|
||||
|
||||
All four tools reuse existing infrastructure — no new `nostr_handler` APIs needed for the first three, and only a thin new wrapper for the fourth.
|
||||
|
||||
---
|
||||
|
||||
## Tool 1: `nostr_delete` — Event Deletion Request (NIP-09)
|
||||
|
||||
### Purpose
|
||||
Let the agent retract or request deletion of events it previously published. Essential for self-correction.
|
||||
|
||||
### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_delete",
|
||||
"description": "Request deletion of one or more previously published events (NIP-09 kind 5)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Array of event ID hex strings to request deletion for"
|
||||
},
|
||||
"kinds": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "Array of kind numbers corresponding to the events being deleted"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Optional reason for the deletion request"
|
||||
}
|
||||
},
|
||||
"required": ["event_ids"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation: `execute_nostr_delete()`
|
||||
|
||||
```
|
||||
1. Parse args_json (with same hardened parsing as nostr_post)
|
||||
2. Extract "event_ids" array (required) — validate each is a 64-char hex string
|
||||
3. Extract "kinds" array (optional) — validate each is an integer
|
||||
4. Extract "reason" string (optional) — use as content, default to empty string
|
||||
5. Build tags array:
|
||||
- For each event_id: add ["e", event_id]
|
||||
- For each kind: add ["k", kind_as_string]
|
||||
6. Call nostr_handler_publish_kind_event(5, reason, tags, &publish_result)
|
||||
7. Return standard publish result JSON
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
- `src/tools.c`: Add schema block in `tools_build_openai_schema_json()`, add `execute_nostr_delete()`, add dispatch case in `tools_execute()`
|
||||
|
||||
---
|
||||
|
||||
## Tool 2: `nostr_react` — Reactions (NIP-25)
|
||||
|
||||
### Purpose
|
||||
Let the agent react to events — like, dislike, or emoji react. Gives the agent social presence.
|
||||
|
||||
### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_react",
|
||||
"description": "React to a Nostr event with a like, dislike, or emoji (NIP-25 kind 7)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Hex event ID of the event to react to"
|
||||
},
|
||||
"event_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Hex pubkey of the event author"
|
||||
},
|
||||
"event_kind": {
|
||||
"type": "integer",
|
||||
"description": "Kind number of the event being reacted to"
|
||||
},
|
||||
"reaction": {
|
||||
"type": "string",
|
||||
"description": "Reaction content: + for like, - for dislike, or an emoji. Default: +"
|
||||
}
|
||||
},
|
||||
"required": ["event_id", "event_pubkey"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation: `execute_nostr_react()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "event_id" (required, 64-char hex)
|
||||
3. Extract "event_pubkey" (required, 64-char hex)
|
||||
4. Extract "event_kind" (optional integer)
|
||||
5. Extract "reaction" (optional string, default "+")
|
||||
6. Build tags array:
|
||||
- ["e", event_id]
|
||||
- ["p", event_pubkey]
|
||||
- If event_kind provided: ["k", kind_as_string]
|
||||
7. Call nostr_handler_publish_kind_event(7, reaction, tags, &publish_result)
|
||||
8. Return standard publish result JSON
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
- `src/tools.c`: Same three insertion points as above
|
||||
|
||||
---
|
||||
|
||||
## Tool 3: `nostr_profile_get` — Profile Lookup (kind 0)
|
||||
|
||||
### Purpose
|
||||
Let the agent look up any user's profile metadata. Useful for WoT reasoning, greeting users by name, checking identity.
|
||||
|
||||
### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_profile_get",
|
||||
"description": "Look up a Nostr user profile (kind 0 metadata) by pubkey",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": {
|
||||
"type": "string",
|
||||
"description": "Hex public key of the user to look up"
|
||||
}
|
||||
},
|
||||
"required": ["pubkey"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation: `execute_nostr_profile_get()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "pubkey" (required, 64-char hex)
|
||||
3. Build filter:
|
||||
{
|
||||
"kinds": [0],
|
||||
"authors": [pubkey],
|
||||
"limit": 1
|
||||
}
|
||||
4. Call nostr_handler_query_json(filter, 8000)
|
||||
5. Parse the returned events JSON
|
||||
6. If events found:
|
||||
- Extract the first event's content (which is a JSON string of profile fields)
|
||||
- Parse that content JSON
|
||||
- Return { success: true, pubkey: ..., profile: { name, display_name, about, picture, nip05, ... } }
|
||||
7. If no events: return { success: true, pubkey: ..., profile: null }
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
- `src/tools.c`: Same three insertion points
|
||||
|
||||
---
|
||||
|
||||
## Tool 4: `nostr_relay_status` — Relay Health Dashboard
|
||||
|
||||
### Purpose
|
||||
Let the agent introspect its own relay connectivity. Useful for self-diagnostics and admin status reports.
|
||||
|
||||
### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_relay_status",
|
||||
"description": "Get connection status and statistics for all connected relays",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
This tool needs access to the relay pool, which is currently a `static` global in `nostr_handler.c`. Two options:
|
||||
|
||||
**Option A (preferred):** Add a new function to `nostr_handler.h`:
|
||||
```c
|
||||
char* nostr_handler_relay_status_json(void);
|
||||
```
|
||||
This function iterates the pool's relays using `nostr_relay_pool_list_relays()` and `nostr_relay_pool_get_relay_stats()`, builds a JSON array, and returns it.
|
||||
|
||||
**Option B:** Expose the pool pointer — not recommended, breaks encapsulation.
|
||||
|
||||
### `nostr_handler_relay_status_json()` in `nostr_handler.c`:
|
||||
|
||||
```
|
||||
1. Call nostr_relay_pool_list_relays(g_pool, &urls, &statuses)
|
||||
2. For each relay:
|
||||
a. Get stats via nostr_relay_pool_get_relay_stats(g_pool, url)
|
||||
b. Build JSON object with:
|
||||
- url
|
||||
- status (disconnected/connecting/connected/error)
|
||||
- ping_latency_ms
|
||||
- events_received
|
||||
- events_published
|
||||
- events_published_ok
|
||||
- events_published_failed
|
||||
- connection_uptime_start
|
||||
3. Return JSON array as string
|
||||
```
|
||||
|
||||
### `execute_nostr_relay_status()` in `tools.c`:
|
||||
|
||||
```
|
||||
1. Parse args_json (accept empty/no args)
|
||||
2. Call nostr_handler_relay_status_json()
|
||||
3. Wrap in { success: true, relays: [...] }
|
||||
4. Return
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
- `src/nostr_handler.h`: Add `nostr_handler_relay_status_json()` declaration
|
||||
- `src/nostr_handler.c`: Implement `nostr_handler_relay_status_json()`
|
||||
- `src/tools.c`: Schema + execute + dispatch
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **`nostr_delete`** — simplest, pure tag construction + existing publish
|
||||
2. **`nostr_react`** — same pattern, slightly different tags
|
||||
3. **`nostr_profile_get`** — uses existing query path, adds profile content parsing
|
||||
4. **`nostr_relay_status`** — requires one new `nostr_handler` function
|
||||
|
||||
All four schemas should be added to `tools_build_openai_schema_json()` in a single pass, and all four dispatch cases added to `tools_execute()` in a single pass, to minimize diff churn.
|
||||
|
||||
## Build & Validate
|
||||
|
||||
After all four tools are implemented:
|
||||
1. Run `./build_static.sh` to verify compilation
|
||||
2. Run `./increment_and_push.sh "Add nostr_delete, nostr_react, nostr_profile_get, nostr_relay_status tools"`
|
||||
@@ -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.
|
||||
+99
-9
@@ -13,6 +13,7 @@
|
||||
#include "nostr_handler.h"
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
@@ -277,6 +278,59 @@ static int append_startup_events_context(cJSON* messages) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int append_admin_identity_context(cJSON* messages) {
|
||||
if (!messages || !g_cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char admin_header[256];
|
||||
snprintf(admin_header,
|
||||
sizeof(admin_header),
|
||||
"This is your administrator! Admin pubkey (hex): %s",
|
||||
g_cfg->admin.pubkey ? g_cfg->admin.pubkey : "unknown");
|
||||
if (append_simple_message(messages, "system", admin_header) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* kind0 = nostr_handler_get_admin_kind0_context();
|
||||
if (kind0) {
|
||||
const char* prefix = "Administrator kind 0 profile content (JSON): ";
|
||||
size_t n = strlen(prefix) + strlen(kind0) + 1U;
|
||||
char* payload = (char*)malloc(n);
|
||||
if (!payload) {
|
||||
free(kind0);
|
||||
return -1;
|
||||
}
|
||||
snprintf(payload, n, "%s%s", prefix, kind0);
|
||||
int rc = append_simple_message(messages, "system", payload);
|
||||
free(payload);
|
||||
free(kind0);
|
||||
if (rc != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
char* kind10002 = nostr_handler_get_admin_kind10002_context();
|
||||
if (kind10002) {
|
||||
const char* prefix = "Administrator kind 10002 relay-list content (JSON): ";
|
||||
size_t n = strlen(prefix) + strlen(kind10002) + 1U;
|
||||
char* payload = (char*)malloc(n);
|
||||
if (!payload) {
|
||||
free(kind10002);
|
||||
return -1;
|
||||
}
|
||||
snprintf(payload, n, "%s%s", prefix, kind10002);
|
||||
int rc = append_simple_message(messages, "system", payload);
|
||||
free(payload);
|
||||
free(kind10002);
|
||||
if (rc != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_recent_admin_dm_history(cJSON* messages, const char* current_message) {
|
||||
if (!messages || !g_cfg) {
|
||||
return -1;
|
||||
@@ -434,32 +488,52 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
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);
|
||||
if (tier == DIDACTYL_SENDER_STRANGER) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex);
|
||||
int allow_tools = (tier == DIDACTYL_SENDER_ADMIN) && g_cfg->tools.enabled && g_cfg->security.admin.tools_enabled;
|
||||
|
||||
if (!g_cfg->tools.enabled) {
|
||||
size_t context_len = strlen("system:\n\nuser:\n") + strlen(g_system_context) + strlen(message) + 1U;
|
||||
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", g_system_context, message);
|
||||
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(g_system_context, message);
|
||||
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");
|
||||
@@ -489,9 +563,22 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void*
|
||||
}
|
||||
|
||||
if (append_simple_message(messages, "system", g_system_context) != 0 ||
|
||||
append_admin_identity_context(messages) != 0 ||
|
||||
append_startup_events_context(messages) != 0 ||
|
||||
append_recent_admin_dm_history(messages, message) != 0 ||
|
||||
append_simple_message(messages, "user", message) != 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.");
|
||||
@@ -537,12 +624,15 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void*
|
||||
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>");
|
||||
DEBUG_TRACE("[didactyl] tool call args: %s", tc->arguments_json ? tc->arguments_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\"}");
|
||||
}
|
||||
|
||||
DEBUG_TRACE("[didactyl] tool call result: %s", tool_result ? tool_result : "{\"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) {
|
||||
|
||||
+5
-1
@@ -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
|
||||
+240
-50
@@ -3,6 +3,8 @@
|
||||
#include "config.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -10,6 +12,19 @@
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static char g_config_last_error[512] = {0};
|
||||
|
||||
static void config_set_error(const char* fmt, ...) {
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(g_config_last_error, sizeof(g_config_last_error), fmt ? fmt : "unknown configuration error", ap);
|
||||
va_end(ap);
|
||||
}
|
||||
|
||||
const char* config_last_error(void) {
|
||||
return g_config_last_error[0] != '\0' ? g_config_last_error : "unknown configuration error";
|
||||
}
|
||||
|
||||
static int read_file_to_buffer(const char* path, char** out_buf, size_t* out_len) {
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp) {
|
||||
@@ -161,6 +176,101 @@ 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;
|
||||
@@ -333,37 +443,87 @@ static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_relays_from_startup_kind10002(didactyl_config_t* config) {
|
||||
if (!config || !config->startup_events || config->startup_event_count <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < config->startup_event_count; i++) {
|
||||
startup_event_t* se = &config->startup_events[i];
|
||||
if (se->kind != 10002 || !se->tags_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_Parse(se->tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
continue;
|
||||
}
|
||||
|
||||
int tag_count = cJSON_GetArraySize(tags);
|
||||
int relay_count = 0;
|
||||
for (int j = 0; j < tag_count; j++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, j);
|
||||
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, "r") == 0 && val->valuestring[0] != '\0') {
|
||||
relay_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (relay_count <= 0) {
|
||||
cJSON_Delete(tags);
|
||||
continue;
|
||||
}
|
||||
|
||||
config->relays = (char**)calloc((size_t)relay_count, sizeof(char*));
|
||||
if (!config->relays) {
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->relay_count = relay_count;
|
||||
int out_i = 0;
|
||||
for (int j = 0; j < tag_count && out_i < relay_count; j++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, j);
|
||||
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, "r") == 0 && val->valuestring[0] != '\0') {
|
||||
config->relays[out_i] = strdup(val->valuestring);
|
||||
if (!config->relays[out_i]) {
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
out_i++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(tags);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int parse_relays(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* relays = cJSON_GetObjectItemCaseSensitive(root, "relays");
|
||||
if (!relays || !cJSON_IsArray(relays)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int count = cJSON_GetArraySize(relays);
|
||||
if (count <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->relays = (char**)calloc((size_t)count, sizeof(char*));
|
||||
if (!config->relays) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->relay_count = count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON* relay = cJSON_GetArrayItem(relays, i);
|
||||
if (!relay || !cJSON_IsString(relay) || !relay->valuestring || relay->valuestring[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->relays[i] = strdup(relay->valuestring);
|
||||
if (!config->relays[i]) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
(void)root;
|
||||
return parse_relays_from_startup_kind10002(config);
|
||||
}
|
||||
|
||||
void config_free(didactyl_config_t* config) {
|
||||
@@ -391,9 +551,12 @@ void config_free(didactyl_config_t* config) {
|
||||
|
||||
int config_load(const char* path, didactyl_config_t* config) {
|
||||
if (!path || !config) {
|
||||
config_set_error("config_load called with invalid arguments");
|
||||
return -1;
|
||||
}
|
||||
|
||||
config_set_error("unknown configuration error");
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
@@ -402,9 +565,26 @@ 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) {
|
||||
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -412,56 +592,54 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
free(json_buf);
|
||||
|
||||
if (!root) {
|
||||
const char* err = cJSON_GetErrorPtr();
|
||||
config_set_error("invalid JSON in config '%s'%s%s",
|
||||
path,
|
||||
err ? " near: " : "",
|
||||
err ? err : "");
|
||||
return -1;
|
||||
}
|
||||
|
||||
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) {
|
||||
config_set_error("config must include object sections: keys, admin, llm");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
|
||||
config_set_error("keys.nsec is required and must be a non-empty string");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
char admin_key_raw[OW_MAX_KEY_LEN] = {0};
|
||||
if (copy_json_string(admin, "pubkey", admin_key_raw, sizeof(admin_key_raw), 1) != 0) {
|
||||
config_set_error("admin.pubkey is required and must be a non-empty string");
|
||||
goto cleanup;
|
||||
}
|
||||
if (decode_pubkey_hex_or_npub(admin_key_raw, config->admin.pubkey) != 0) {
|
||||
config_set_error("admin.pubkey must be npub1... or 64-char hex");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_startup_events(root, config) != 0) {
|
||||
config_set_error("invalid startup_events configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_relays(root, config) != 0) {
|
||||
config_set_error("relay configuration is invalid: startup_events must include kind 10002 with non-empty r tags");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(llm, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
|
||||
config_set_error("llm.provider must be a string if provided");
|
||||
goto cleanup;
|
||||
}
|
||||
if (config->llm.provider[0] == '\0') {
|
||||
@@ -469,12 +647,15 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
}
|
||||
|
||||
if (copy_json_string(llm, "api_key", config->llm.api_key, sizeof(config->llm.api_key), 1) != 0) {
|
||||
config_set_error("llm.api_key is required and must be a string");
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(llm, "model", config->llm.model, sizeof(config->llm.model), 1) != 0) {
|
||||
config_set_error("llm.model is required and must be a string");
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(llm, "base_url", config->llm.base_url, sizeof(config->llm.base_url), 1) != 0) {
|
||||
config_set_error("llm.base_url is required and must be a string");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
@@ -485,18 +666,27 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
|
||||
|
||||
if (parse_tools_config(root, config) != 0) {
|
||||
config_set_error("invalid tools configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_startup_events(root, config) != 0) {
|
||||
if (parse_security_config(root, config) != 0) {
|
||||
config_set_error("invalid security configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_admin_context_config(root, config) != 0) {
|
||||
config_set_error("invalid admin_context configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
|
||||
config_set_error("keys.nsec must be valid nsec1... or 64-char hex private key");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
|
||||
config_set_error("failed to derive public key from keys.nsec");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
|
||||
+26
-8
@@ -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];
|
||||
@@ -57,18 +51,42 @@ typedef struct {
|
||||
} startup_event_t;
|
||||
|
||||
typedef struct {
|
||||
agent_profile_t profile;
|
||||
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);
|
||||
const char* config_last_error(void);
|
||||
void config_free(didactyl_config_t* config);
|
||||
|
||||
#endif
|
||||
+35
-2
@@ -20,17 +20,29 @@ static void signal_handler(int signum) {
|
||||
g_running = 0;
|
||||
}
|
||||
|
||||
static void print_usage(const char* prog) {
|
||||
fprintf(stderr,
|
||||
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>]\n",
|
||||
prog ? prog : "didactyl");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./config.json";
|
||||
int debug_level = DEBUG_LEVEL_TRACE;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
|
||||
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||
print_usage(argv[0]);
|
||||
return 0;
|
||||
} else if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--version") == 0) {
|
||||
printf("%s %s\n", DIDACTYL_NAME, DIDACTYL_VERSION);
|
||||
return 0;
|
||||
} else if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
|
||||
config_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>] [--debug <0-5>]\n", argv[0]);
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +59,7 @@ int main(int argc, char** argv) {
|
||||
didactyl_config_t cfg;
|
||||
if (config_load(config_path, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to load config: %s\n", config_path);
|
||||
fprintf(stderr, "Config error: %s\n", config_last_error());
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
@@ -84,6 +97,26 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
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: subscribe admin context end");
|
||||
|
||||
char startup_dm[768];
|
||||
const char* startup_name = nostr_handler_get_startup_display_name();
|
||||
int connected_relays = nostr_handler_connected_relay_count();
|
||||
snprintf(startup_dm,
|
||||
sizeof(startup_dm),
|
||||
"%s has started up and is online (version %s, connected relays: %d/%d).",
|
||||
(startup_name && startup_name[0] != '\0') ? startup_name : "Didactyl",
|
||||
DIDACTYL_VERSION,
|
||||
connected_relays,
|
||||
cfg.relay_count);
|
||||
if (nostr_handler_send_dm(cfg.admin.pubkey, startup_dm) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to send startup status DM to admin");
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
|
||||
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
|
||||
DEBUG_ERROR("[didactyl] startup phase: subscribe DMs failed");
|
||||
|
||||
+2
-2
@@ -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 5
|
||||
#define DIDACTYL_VERSION "v0.0.5"
|
||||
#define DIDACTYL_VERSION_PATCH 19
|
||||
#define DIDACTYL_VERSION "v0.0.19"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+800
-144
File diff suppressed because it is too large
Load Diff
+32
-3
@@ -4,17 +4,46 @@
|
||||
#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);
|
||||
|
||||
typedef struct {
|
||||
int success;
|
||||
int kind;
|
||||
int relay_count;
|
||||
int accepted_by_pool_count;
|
||||
char event_id[65];
|
||||
char note_uri[256];
|
||||
char naddr_uri[1024];
|
||||
char d_tag[128];
|
||||
char** relays;
|
||||
} nostr_publish_result_t;
|
||||
|
||||
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);
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
|
||||
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
|
||||
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);
|
||||
const char* nostr_handler_get_startup_display_name(void);
|
||||
int nostr_handler_connected_relay_count(void);
|
||||
char* nostr_handler_get_admin_kind0_context(void);
|
||||
char* nostr_handler_get_admin_kind10002_context(void);
|
||||
char* nostr_handler_get_admin_kind1_notes_context(void);
|
||||
int nostr_handler_is_wot_contact(const char* pubkey_hex);
|
||||
char* nostr_handler_relay_status_json(void);
|
||||
void nostr_handler_cleanup(void);
|
||||
|
||||
#endif
|
||||
+1215
-12
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user