Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1609317c1 | ||
|
|
4a400f1582 | ||
|
|
a2d3f840c7 | ||
|
|
7f31e4ceb7 | ||
|
|
052c11863f | ||
|
|
a446f25400 | ||
|
|
fea0fdf5c9 | ||
|
|
a798f2c345 | ||
|
|
66b4ebee79 | ||
|
|
5673efeb94 | ||
|
|
43850b273f | ||
|
|
02d4e2caa0 | ||
|
|
56b9ae421c | ||
|
|
6e74ef5ac6 | ||
|
|
c542be1452 | ||
|
|
410400418c | ||
|
|
3521081d9a | ||
|
|
0d390afd69 | ||
|
|
230f591273 | ||
|
|
721b592b8f | ||
|
|
0aabb0b827 | ||
|
|
1c69a581d9 | ||
|
|
76842627dc | ||
|
|
2209629f23 | ||
|
|
5d7ddb5b78 | ||
|
|
d83c93eb32 | ||
|
|
01a4cad35c |
+2
-1
@@ -4,7 +4,8 @@
|
||||
/openclaw/
|
||||
/c-relay/
|
||||
/nips/
|
||||
|
||||
/config.json
|
||||
test_keys.txt
|
||||
# Build artifacts
|
||||
/build/
|
||||
/didactyl
|
||||
|
||||
@@ -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
|
||||
@@ -94,7 +94,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
-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 \
|
||||
src/nostr_handler.c src/agent.c src/tools.c src/trigger_manager.c src/debug.c \
|
||||
-o /build/didactyl_static \
|
||||
nostr_core_lib/libnostr_core_x64.a \
|
||||
-lsecp256k1 \
|
||||
|
||||
@@ -12,6 +12,7 @@ SRCS = \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/tools.c \
|
||||
$(SRC_DIR)/trigger_manager.c \
|
||||
$(SRC_DIR)/debug.c
|
||||
|
||||
INCLUDES = \
|
||||
|
||||
@@ -1,166 +1,391 @@
|
||||
# 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.
|
||||
|
||||
## Current Status
|
||||
### Free speech for agents.
|
||||
|
||||
**MVP — Working chat agent with relay connectivity and LLM integration.**
|
||||
Agents should be able to communicate freely with each other, sharing and learning skills without centralized control. Free speech for agents!
|
||||
|
||||
- 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
|
||||
### Skills are the new apps.
|
||||
|
||||
**Next: Agentic tool-use system** — see [plans/didactyl_agentic.md](plans/didactyl_agentic.md).
|
||||
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.26
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.26 — Add nostr_pubkey/nostr_npub tools with my_pubkey/my_npub aliases for agent key output
|
||||
|
||||
- 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 + 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)
|
||||
|
||||
- GCC with C99 support
|
||||
- libcurl, libssl, libcrypto, libsecp256k1
|
||||
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
|
||||
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"
|
||||
},
|
||||
"relays": [
|
||||
"wss://relay.damus.io",
|
||||
"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": 10002,
|
||||
"content": "",
|
||||
"tags": [["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"]]
|
||||
},
|
||||
{
|
||||
"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.
|
||||
|
||||
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 --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)
|
||||
./didactyl_static_x86_64 --dump-schemas # print tool JSON schemas and exit
|
||||
./didactyl_static_x86_64 --test-tool <name> <args_json> # run one tool directly and print JSON result
|
||||
```
|
||||
|
||||
CLI debugger notes:
|
||||
|
||||
- `--test-tool` initializes Nostr, waits for at least one relay connection (up to 15s), then executes the selected tool.
|
||||
- Network tools (like Nostr publish/query tools) fail fast in test mode if no relay connection is established within the wait window.
|
||||
- Example:
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./config.json --test-tool nostr_file_md_to_longform_post '{"file":"docs/TOOLS_AND_SKILLS.md","title":"TOOLS_AND_SKILLS"}'
|
||||
```
|
||||
|
||||
### 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 │ │ context │ │ 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. 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).
|
||||
|
||||
## Tooling Interface
|
||||
|
||||
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
|
||||
|
||||
- Nostr publish/query:
|
||||
- `nostr_post`
|
||||
- `nostr_post_readme`
|
||||
- `nostr_query`
|
||||
- Nostr interaction and moderation:
|
||||
- `nostr_delete`
|
||||
- `nostr_react`
|
||||
- `nostr_profile_get`
|
||||
- `nostr_relay_status`
|
||||
- `nostr_relay_info`
|
||||
- `nostr_nip05_lookup`
|
||||
- Nostr encode/decode + encryption/DM:
|
||||
- `nostr_encode`
|
||||
- `nostr_decode`
|
||||
- `nostr_encrypt`
|
||||
- `nostr_decrypt`
|
||||
- `nostr_dm_send`
|
||||
- `nostr_dm_send_nip17`
|
||||
- Nostr list management:
|
||||
- `nostr_list_manage`
|
||||
- Skill management:
|
||||
- `skill_create`
|
||||
- `skill_list`
|
||||
- `skill_adopt`
|
||||
- `skill_remove`
|
||||
- `skill_search`
|
||||
- Local/host tools:
|
||||
- `shell_exec`
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
- `http_fetch`
|
||||
- Agent metadata:
|
||||
- `my_version`
|
||||
|
||||
Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
|
||||
|
||||
## 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 / .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)
|
||||
│ ├── 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
|
||||
│ ├── didactyl_agentic.md
|
||||
│ └── security_and_admin_context.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 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
|
||||
- [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
|
||||
- [ ] 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
|
||||
|
||||
TBD
|
||||
|
||||
|
||||
@@ -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.
|
||||
+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
|
||||
==========================================
|
||||
+18
-23
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
# Crash Fixes Reference
|
||||
|
||||
This document catalogues crashes that have been diagnosed and fixed in Didactyl.
|
||||
Use it as a reference when investigating future crashes — the symptoms, root causes,
|
||||
and investigation techniques described here may save significant debugging time.
|
||||
|
||||
---
|
||||
|
||||
## 1. Silent process exit on relay disconnect — SIGPIPE (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-02
|
||||
**Version**: v0.0.23 → fixed in v0.0.24
|
||||
**Severity**: Critical — process terminates without any log output
|
||||
**Files changed**: `src/main.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The process exits cleanly to the shell prompt with **no error message**.
|
||||
- The `[didactyl] shutting down` log line does **not** appear.
|
||||
- No coredump is generated.
|
||||
- The last log lines show all relays transitioning from `connected → disconnected`
|
||||
and then immediately `disconnected → connected`.
|
||||
- The crash typically follows a period of network instability or DNS resolution
|
||||
failure — for example, an LLM HTTP request failing with
|
||||
`curl=Could not resolve hostname`.
|
||||
|
||||
### Root cause
|
||||
|
||||
`SIGPIPE` was never handled. The default OS action for `SIGPIPE` is to terminate
|
||||
the process immediately — no signal handler runs, no cleanup occurs, no coredump
|
||||
is written.
|
||||
|
||||
The TLS write path in `nostr_core_lib` uses `SSL_write()`, which internally calls
|
||||
`write()` on the underlying socket. When a `wss://` relay drops its TCP connection
|
||||
and the relay pool attempts to write to that socket, the kernel delivers `SIGPIPE`.
|
||||
|
||||
The plain-TCP path for `ws://` connections was already protected by using
|
||||
`MSG_NOSIGNAL` on `send()` calls, but the TLS path had no equivalent protection.
|
||||
|
||||
### How it was diagnosed
|
||||
|
||||
1. **No crash message or coredump** ruled out `SIGSEGV`, `SIGABRT`, and OOM.
|
||||
2. **No shutdown log** ruled out a graceful exit via the signal handler.
|
||||
3. `dmesg` and `journalctl` showed no OOM-kill or segfault entries.
|
||||
4. Searching the entire codebase for `SIGPIPE` or `SIG_IGN` returned zero results.
|
||||
5. The websocket TLS transport was confirmed to use `SSL_write()` without
|
||||
`MSG_NOSIGNAL` or any per-thread signal mask.
|
||||
6. The timeline matched: relay disconnects occurred, the next poll cycle attempted
|
||||
a write to a dead TLS socket, and the process vanished.
|
||||
|
||||
### Fix
|
||||
|
||||
Added `signal(SIGPIPE, SIG_IGN)` in `main()` alongside the existing `SIGINT` and
|
||||
`SIGTERM` handlers:
|
||||
|
||||
```c
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
signal(SIGPIPE, SIG_IGN); /* ← added */
|
||||
```
|
||||
|
||||
This causes `SSL_write()` and any other write to a broken pipe to return `-1` with
|
||||
`errno = EPIPE` instead of killing the process. The relay pool and libcurl already
|
||||
handle write errors gracefully.
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- Process disappears without any log output or coredump.
|
||||
- Happens after network disruption or relay disconnects.
|
||||
- `ulimit -c` may be 0, but even with unlimited core size, `SIGPIPE` does not
|
||||
produce a coredump by default.
|
||||
- Any new network transport layer added to the project should be audited for
|
||||
`SIGPIPE` protection.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use-after-free in `execute_nostr_list_manage` — SIGSEGV (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-01 (coredump), fixed 2026-03-02
|
||||
**Version**: v0.0.23 → fixed in v0.0.24
|
||||
**Severity**: Critical — segmentation fault during tool execution
|
||||
**Files changed**: `src/tools.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- `SIGSEGV` crash during execution of the `nostr_list_manage` tool.
|
||||
- Coredump shows the crash at `src/tools.c:29` inside `json_error()`, but with
|
||||
heavily corrupted stack frames — the real crash site is in
|
||||
`execute_nostr_list_manage()`.
|
||||
- The corrupted stack is characteristic of heap corruption from use-after-free.
|
||||
|
||||
### Root cause
|
||||
|
||||
In `execute_nostr_list_manage()`, the `action` pointer was obtained from the
|
||||
`args` cJSON tree:
|
||||
|
||||
```c
|
||||
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
|
||||
```
|
||||
|
||||
Later, `args` was freed:
|
||||
|
||||
```c
|
||||
cJSON_Delete(args); /* frees the entire tree including action */
|
||||
```
|
||||
|
||||
But `action->valuestring` was still accessed afterwards:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(out, "action", action->valuestring); /* dangling pointer */
|
||||
```
|
||||
|
||||
After `cJSON_Delete(args)`, the `action` pointer is dangling. Accessing
|
||||
`action->valuestring` reads freed heap memory, which may contain arbitrary data
|
||||
or may have been reallocated for another purpose.
|
||||
|
||||
### How it was diagnosed
|
||||
|
||||
1. The coredump was extracted from systemd-coredump storage:
|
||||
```
|
||||
zstd -d /var/lib/systemd/coredump/core.didactyl_static.*.zst -o /tmp/core.bin
|
||||
gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt"
|
||||
```
|
||||
2. The backtrace showed `execute_nostr_list_manage` with corrupted frames.
|
||||
3. Code review of the function identified the `cJSON_Delete(args)` call occurring
|
||||
before the last use of `action->valuestring`.
|
||||
|
||||
### Fix
|
||||
|
||||
Deferred `cJSON_Delete(args)` until after `action->valuestring` has been consumed
|
||||
by `cJSON_AddStringToObject()`. The free now occurs immediately after the last use:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(out, "action", action->valuestring);
|
||||
cJSON_Delete(args); /* ← moved here, after last use of action */
|
||||
```
|
||||
|
||||
All early-return error paths before this point also received their own
|
||||
`cJSON_Delete(args)` call to prevent leaks.
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- `SIGSEGV` with corrupted or nonsensical stack frames.
|
||||
- Crash location reported by GDB does not match the actual buggy code — the
|
||||
corruption happened earlier.
|
||||
- Any function that calls `cJSON_Delete()` on a parent object should be audited
|
||||
to ensure no child pointers are used afterwards.
|
||||
- Pattern to watch for:
|
||||
```c
|
||||
cJSON* child = cJSON_GetObjectItemCaseSensitive(parent, "key");
|
||||
/* ... */
|
||||
cJSON_Delete(parent);
|
||||
/* ... */
|
||||
use(child->valuestring); /* BUG: child is dangling */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DM subscription not receiving incoming kind 4 events — INVESTIGATION (v0.0.24)
|
||||
|
||||
**Date**: 2026-03-02
|
||||
**Version**: v0.0.24
|
||||
**Severity**: High — agent does not respond to incoming DMs
|
||||
**Status**: Under investigation — diagnostic logging added
|
||||
**Files changed**: `src/nostr_handler.c`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The agent starts up normally, connects to relays, and can **send** kind 4 DMs.
|
||||
- Incoming kind 4 messages posted to the same relays are never processed.
|
||||
- No error messages appear in the log — the agent simply sits idle after sending
|
||||
its startup DM.
|
||||
- The last log lines show successful outbound DM publishing but no inbound event
|
||||
processing.
|
||||
|
||||
### Possible root causes (under investigation)
|
||||
|
||||
1. **Events never reach `on_event()` callback** — The relay pool library only
|
||||
calls `on_event()` when it receives an `EVENT` message matching the
|
||||
subscription ID. If the subscription was silently closed, errored, or not
|
||||
re-established after a relay reconnect, no events would be delivered. The
|
||||
relay could also be rejecting the subscription filter.
|
||||
|
||||
2. **`since` filter timing mismatch** — `g_start_time` is set to `time(NULL)`
|
||||
during `nostr_handler_init()` (early in startup), but the DM subscription
|
||||
is created much later (after admin context subscription, startup event
|
||||
reconciliation, and startup DM sending). If the sender's clock is behind
|
||||
the server's clock, their `created_at` timestamp would be before
|
||||
`g_start_time` and the relay would filter them out.
|
||||
|
||||
3. **Sender tier filtering** — If the sender's pubkey doesn't match the admin
|
||||
pubkey and isn't in the WoT contact list, the message is classified as
|
||||
`DIDACTYL_SENDER_STRANGER` and silently dropped (only a DEBUG_LOG at
|
||||
level 4).
|
||||
|
||||
4. **Signature verification failure** — If `verify_signatures` is enabled and
|
||||
the event has an invalid signature, it is dropped with only a WARN log.
|
||||
|
||||
5. **Pool-level deduplication** — The relay pool's `is_event_seen()` cache is
|
||||
shared across all subscriptions. If an event ID was somehow marked as seen
|
||||
by another subscription, it would be silently dropped.
|
||||
|
||||
### Diagnostic logging added
|
||||
|
||||
TRACE-level (level 5 / `--debug 5`) logs were added at every decision point
|
||||
in the `on_event()` callback and the `nostr_handler_subscribe_dms()` setup:
|
||||
|
||||
| Log message prefix | What it tells you |
|
||||
|---|---|
|
||||
| `DEBUG on_event ENTRY` | Callback was called — events are reaching didactyl |
|
||||
| `DEBUG on_event NULL guard` | Event, config, or callback pointer is NULL |
|
||||
| `DEBUG on_event: missing required fields` | Event JSON is malformed |
|
||||
| `DEBUG on_event: kind=N id=... from=...` | Event kind, ID, and sender pubkey |
|
||||
| `DEBUG on_event: ignoring non-kind4` | Event is not kind 4 |
|
||||
| `DEBUG on_event: no p-tag found` | Kind 4 event has no `p` tag |
|
||||
| `DEBUG on_event: p-tag mismatch` | `p` tag doesn't match agent's pubkey |
|
||||
| `DEBUG on_event: sender=... tier=N` | Sender tier classification |
|
||||
| `DEBUG on_eose called` | EOSE received from relays |
|
||||
| `DEBUG DM subscription filter` | Full subscription filter JSON |
|
||||
| `DEBUG DM subscription g_start_time=...` | `since` timestamp and time delta |
|
||||
| `DEBUG DM subscription sub=...` | Subscription pointer (confirms creation) |
|
||||
|
||||
### How to use the diagnostic logs
|
||||
|
||||
1. Build with `./build_static.sh --debug`
|
||||
2. Run with `--debug 5` to enable TRACE output
|
||||
3. Send a kind 4 DM to the agent
|
||||
4. Check the output:
|
||||
- **No `on_event ENTRY` lines** → problem is at the relay/subscription level
|
||||
- **`on_event ENTRY` appears but processing stops** → the specific drop-point
|
||||
log identifies the exact filter that rejected the event
|
||||
5. Check the `DM subscription filter` log to verify the `since` timestamp and
|
||||
`#p` tag are correct
|
||||
|
||||
### How to recognise this class of bug in the future
|
||||
|
||||
- Agent can send but not receive — asymmetric connectivity.
|
||||
- No error messages in the log — silent event filtering.
|
||||
- The subscription filter (`since`, `#p`, `kinds`) may not match what the
|
||||
sender is actually publishing.
|
||||
- Clock skew between sender and receiver can cause `since` filter mismatches.
|
||||
- Relay reconnections may not automatically re-subscribe.
|
||||
|
||||
---
|
||||
|
||||
## General debugging checklist
|
||||
|
||||
When investigating a crash in Didactyl, work through these steps:
|
||||
|
||||
1. **Check for coredumps**: `ls /var/lib/systemd/coredump/ | grep didactyl`
|
||||
2. **Check dmesg/journalctl**: `dmesg | grep -i 'didactyl\|oom\|segfault'`
|
||||
3. **Check for the shutdown log line**: If `[didactyl] shutting down` is missing,
|
||||
the process was killed by a signal that bypassed the handler.
|
||||
4. **Check ulimit**: `ulimit -c` — if 0, coredumps are disabled.
|
||||
5. **Extract and analyse coredumps**:
|
||||
```bash
|
||||
zstd -d /var/lib/systemd/coredump/core.didactyl*.zst -o /tmp/core.bin
|
||||
gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt full"
|
||||
```
|
||||
6. **Common silent killers**:
|
||||
- `SIGPIPE` — process writes to a broken socket/pipe
|
||||
- `SIGKILL` — OOM killer or external kill
|
||||
- `SIGBUS` — memory-mapped file issues
|
||||
7. **Common crash causes**:
|
||||
- Use-after-free on cJSON child pointers after parent deletion
|
||||
- Buffer overflows in fixed-size stack buffers
|
||||
- NULL pointer dereference on failed allocations
|
||||
- Re-entrancy in callbacks during internal `nostr_relay_pool_poll()` calls
|
||||
@@ -0,0 +1,417 @@
|
||||
# Didactyl — Tools & Skills
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent**. It receives commands via encrypted DMs, reasons about them with an LLM, and takes actions through **tools**. It stores learned behaviors as **skills** — Nostr events that define reusable capabilities. Skills can optionally carry **triggers** — Nostr subscription filters that activate the skill automatically when matching events arrive.
|
||||
|
||||
This document describes the complete tools and skills architecture: what they are, how they work, and how they compose into a dynamic, self-modifying agent.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
Tools are the agent's hands. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
|
||||
|
||||
### How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### Nostr Event & Messaging Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post` | Publish a Nostr event to connected relays |
|
||||
| `nostr_delete` | Request deletion of one or more previously published events (NIP-09 kind 5) |
|
||||
| `nostr_react` | React to a Nostr event with like/dislike/emoji (NIP-25 kind 7) |
|
||||
| `nostr_query` | Query events from relays using a Nostr filter |
|
||||
| `nostr_dm_send` | Send a NIP-04 encrypted DM |
|
||||
| `nostr_dm_send_nip17` | Send a private DM using NIP-17 gift wrap protocol |
|
||||
|
||||
#### Nostr Identity & Utility Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_profile_get` | Look up a Nostr profile (kind 0 metadata) by pubkey |
|
||||
| `nostr_nip05_lookup` | Look up or verify a NIP-05 identifier (`user@domain`) |
|
||||
| `nostr_encode` | Encode a Nostr entity into `nostr:` URI (`npub`, `note`, `nprofile`, `nevent`, `naddr`) |
|
||||
| `nostr_decode` | Decode a Nostr bech32/`nostr:` URI into components |
|
||||
| `nostr_relay_status` | Get connection status and statistics for all relays |
|
||||
| `nostr_relay_info` | Fetch NIP-11 relay information document |
|
||||
| `nostr_encrypt` | Encrypt plaintext using NIP-44 for a recipient |
|
||||
| `nostr_decrypt` | Decrypt NIP-44 ciphertext from a sender |
|
||||
| `nostr_list_manage` | Add/remove tag tuples in replaceable list events (NIP-51 style) |
|
||||
|
||||
#### Skills & Trigger Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `skill_create` | Create or update a skill definition as kind `31123`/`31124` and optionally auto-adopt it |
|
||||
| `skill_list` | List this agent's published skills, optionally filtered by scope |
|
||||
| `skill_adopt` | Adopt a skill by adding its address to kind `10123` adoption list |
|
||||
| `skill_remove` | Remove a skill address from kind `10123` adoption list |
|
||||
| `skill_search` | Search public skills by query/author and optionally rank by adoption popularity |
|
||||
| `trigger_list` | List active triggered skills and their runtime status |
|
||||
|
||||
#### LLM / Model Management Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `model_get` | Get current active LLM runtime configuration (excluding API key) |
|
||||
| `model_set` | Update active LLM configuration and persist it to `config.json` |
|
||||
| `model_list` | List available model IDs using provider OpenAI-compatible `/models` endpoint |
|
||||
|
||||
#### System & Runtime Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `my_version` | Return current Didactyl version and metadata from build macros |
|
||||
| `http_fetch` | Fetch HTTP(S) resources with optional method, headers, timeout, and body |
|
||||
| `shell_exec` | Execute a shell command and return stdout/stderr |
|
||||
| `file_read` | Read a local file as text from the configured working directory |
|
||||
| `file_write` | Write text content to a local file in the configured working directory |
|
||||
| `tool_list` | List available tools with name, description, and JSON parameter schema |
|
||||
|
||||
#### Content Publishing Conveniences
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post_readme` | Publish `README.md` as kind `30023` with deterministic d-tag `readme.md` |
|
||||
| `nostr_file_md_to_longform_post` | Read a markdown file and publish it as kind `30023` longform post (defaults d-tag to lowercase filename) |
|
||||
|
||||
### Security Model
|
||||
|
||||
Tools are gated by sender tier:
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
| **ADMIN** | Configured admin pubkey | All tools | Full LLM with context |
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are the agent's learned behaviors. They are **Nostr events** — stored on relays, portable, shareable, and discoverable by other agents.
|
||||
|
||||
### Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
A skill event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "When asked to summarize a thread, query the root event and all replies, then produce a concise summary with key points and sentiment.",
|
||||
"tags": [
|
||||
["d", "summarize-thread"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Summarize a Nostr thread given a root event ID"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Skill Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CREATE[Admin asks didactyl to create a skill] --> PUBLISH[skill_create publishes kind 31123/31124]
|
||||
PUBLISH --> ADOPT[Auto-adopted into kind 10123 list]
|
||||
ADOPT --> AVAILABLE[Skill available for use]
|
||||
AVAILABLE --> DISCOVER[Other agents can discover via skill_search]
|
||||
DISCOVER --> ADOPT_OTHER[Other agents can skill_adopt]
|
||||
```
|
||||
|
||||
### How Skills Are Used Today
|
||||
|
||||
Currently, skills are **passive knowledge**. They exist on Nostr and are loaded into the LLM context when relevant. The admin might say "use your summarize-thread skill" and the LLM retrieves and follows the skill's instructions.
|
||||
|
||||
---
|
||||
|
||||
## Triggered Skills — The Activation System
|
||||
|
||||
This is where skills become **active**. A triggered skill is a skill with a Nostr subscription filter attached. When matching events arrive on the relay, didactyl wakes up and executes the skill automatically — no admin DM required.
|
||||
|
||||
### Anatomy of a Triggered Skill
|
||||
|
||||
A triggered skill extends the standard skill event with trigger-related tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "DM admin: '{author_display_name} just posted: {content_preview}'",
|
||||
"tags": [
|
||||
["d", "watch-jack"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "Notify admin when @jack posts a note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"authors\":[\"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2\"],\"kinds\":[1]}"],
|
||||
["action", "template"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type. Currently: `nostr-subscription` |
|
||||
| `filter` | Yes | JSON-encoded Nostr subscription filter |
|
||||
| `action` | No | Action type: `template` or `llm`. Default: `llm` |
|
||||
| `enabled` | No | Whether the trigger is active. Default: `true` |
|
||||
|
||||
### Action Types
|
||||
|
||||
#### Template Actions
|
||||
|
||||
The skill content is a string template with placeholders that get interpolated from the triggering event:
|
||||
|
||||
```
|
||||
DM admin: '{author_display_name} posted: {content_preview}'
|
||||
```
|
||||
|
||||
Available placeholders:
|
||||
|
||||
| Placeholder | Source |
|
||||
|---|---|
|
||||
| `{event_id}` | Triggering event ID hex |
|
||||
| `{pubkey}` | Author pubkey hex |
|
||||
| `{author_display_name}` | Resolved display name, falls back to truncated pubkey |
|
||||
| `{kind}` | Event kind number |
|
||||
| `{content}` | Full event content |
|
||||
| `{content_preview}` | First 280 characters of content |
|
||||
| `{created_at}` | Unix timestamp |
|
||||
| `{relay_url}` | Relay the event arrived from |
|
||||
|
||||
Template actions execute **without LLM involvement** — they are fast, cheap, and deterministic. The interpolated string is then acted upon based on a simple action prefix:
|
||||
|
||||
- `DM admin: ...` — send a DM to the admin
|
||||
- `DM <pubkey>: ...` — send a DM to a specific pubkey
|
||||
- `POST: ...` — publish as a kind 1 note
|
||||
- `LOG: ...` — write to debug log only
|
||||
|
||||
#### LLM-Mediated Actions
|
||||
|
||||
The skill content is a prompt. The triggering event is injected as context, and the LLM decides what to do:
|
||||
|
||||
```
|
||||
You received a note from a watched author. Analyze the note content.
|
||||
If it mentions Bitcoin or Lightning, summarize it and DM the admin.
|
||||
If it's a repost or low-effort content, ignore it silently.
|
||||
Use your tools to take action.
|
||||
```
|
||||
|
||||
LLM-mediated actions go through the full agent loop — the LLM can call tools, reason about the event, and produce complex multi-step responses.
|
||||
|
||||
### Trigger Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Creation
|
||||
ADMIN_CMD[Admin: 'Warn me when @jack posts'] --> LLM_REASON[LLM resolves pubkey + builds skill]
|
||||
LLM_REASON --> SKILL_CREATE[skill_create with trigger tags]
|
||||
SKILL_CREATE --> PUBLISHED[Skill published to Nostr]
|
||||
end
|
||||
|
||||
subgraph Activation
|
||||
STARTUP[Didactyl starts up] --> LOAD_SKILLS[Load adopted skills from kind 10123]
|
||||
LOAD_SKILLS --> FIND_TRIGGERS[Find skills with trigger tags]
|
||||
FIND_TRIGGERS --> SUBSCRIBE[Create Nostr subscriptions for each filter]
|
||||
end
|
||||
|
||||
subgraph Execution
|
||||
EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill]
|
||||
LOOKUP --> CHECK_TYPE{Action type?}
|
||||
CHECK_TYPE -->|template| INTERPOLATE[Interpolate placeholders]
|
||||
CHECK_TYPE -->|llm| LLM_LOOP[Run agent loop with event as context]
|
||||
INTERPOLATE --> EXECUTE_TPL[Execute action prefix]
|
||||
LLM_LOOP --> EXECUTE_LLM[LLM uses tools to respond]
|
||||
end
|
||||
|
||||
PUBLISHED --> LOAD_SKILLS
|
||||
```
|
||||
|
||||
### Dynamic Subscription Management
|
||||
|
||||
Didactyl manages its trigger subscriptions dynamically:
|
||||
|
||||
1. **On startup**: Load all adopted skills, find triggered ones, create subscriptions
|
||||
2. **On skill_create with trigger**: Immediately create a new subscription (no restart needed)
|
||||
3. **On skill_remove with trigger**: Tear down the associated subscription
|
||||
4. **On skill update**: Tear down old subscription, create new one if trigger changed
|
||||
|
||||
This requires a **trigger manager** component that:
|
||||
- Maintains a registry of active trigger subscriptions
|
||||
- Maps subscription callbacks back to their source skills
|
||||
- Handles subscription lifecycle (create, update, destroy)
|
||||
- Enforces limits on concurrent triggers
|
||||
|
||||
### Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion from too many subscriptions |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution from high-volume filters |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs from triggered skills |
|
||||
| Template action rate limit | 60/min | Prevents DM spam from template actions |
|
||||
|
||||
---
|
||||
|
||||
## How It All Fits Together
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Activation Sources
|
||||
DM_IN[DM from Admin/WoT]
|
||||
TRIGGER_EVENT[Nostr event matching a trigger filter]
|
||||
end
|
||||
|
||||
subgraph Agent Core
|
||||
DISPATCHER{Dispatcher}
|
||||
AGENT_LOOP[Agent Loop - LLM + Tools]
|
||||
TEMPLATE_ENGINE[Template Engine]
|
||||
end
|
||||
|
||||
subgraph Nostr
|
||||
RELAYS[Relays]
|
||||
SKILLS_STORE[Skills - kind 31123/31124]
|
||||
ADOPTION[Adoption List - kind 10123]
|
||||
end
|
||||
|
||||
subgraph Actions
|
||||
DM_OUT[Send DM]
|
||||
POST[Publish Note]
|
||||
TOOL_EXEC[Execute Tool]
|
||||
end
|
||||
|
||||
DM_IN --> DISPATCHER
|
||||
TRIGGER_EVENT --> DISPATCHER
|
||||
|
||||
DISPATCHER -->|DM message| AGENT_LOOP
|
||||
DISPATCHER -->|template trigger| TEMPLATE_ENGINE
|
||||
DISPATCHER -->|llm trigger| AGENT_LOOP
|
||||
|
||||
AGENT_LOOP --> TOOL_EXEC
|
||||
AGENT_LOOP --> DM_OUT
|
||||
TEMPLATE_ENGINE --> DM_OUT
|
||||
TEMPLATE_ENGINE --> POST
|
||||
|
||||
TOOL_EXEC -->|skill_create| SKILLS_STORE
|
||||
TOOL_EXEC -->|skill_adopt| ADOPTION
|
||||
TOOL_EXEC -->|nostr_post| RELAYS
|
||||
TOOL_EXEC -->|nostr_dm| DM_OUT
|
||||
```
|
||||
|
||||
### The Activation Flow
|
||||
|
||||
Today, didactyl has one activation source: **DMs**. With triggered skills, it gains a second: **any Nostr event matching a trigger filter**.
|
||||
|
||||
Both paths converge at the dispatcher, which routes to either:
|
||||
- The **agent loop** (for DMs and LLM-mediated triggers)
|
||||
- The **template engine** (for template triggers — fast path, no LLM)
|
||||
|
||||
### Self-Modification
|
||||
|
||||
The most powerful aspect: **didactyl can create its own triggers**. The admin says "watch for mentions of me on Nostr" and the LLM:
|
||||
|
||||
1. Resolves the admin's pubkey
|
||||
2. Crafts a Nostr filter: `{"#p": ["<admin_pubkey>"], "kinds": [1]}`
|
||||
3. Writes a skill with trigger tags via `skill_create`
|
||||
4. The trigger manager picks it up and creates the subscription
|
||||
5. From now on, didactyl monitors mentions autonomously
|
||||
|
||||
The admin can later say "stop watching for mentions" and didactyl removes the skill, tearing down the subscription.
|
||||
|
||||
---
|
||||
|
||||
## Storage — Everything on Nostr
|
||||
|
||||
All state lives on Nostr:
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Agent identity | Kind 0 profile event |
|
||||
| Agent relay list | Kind 10002 event |
|
||||
| Agent contact list | Kind 3 event |
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions | Tags on skill events |
|
||||
| Conversation history | Kind 4 DM events on relays |
|
||||
| Agent soul/personality | Startup event content in config |
|
||||
|
||||
The only local state is `config.json` (keys, relay URLs, LLM config) and the runtime in-memory state (active subscriptions, LLM context).
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### Trigger Types Beyond Nostr Subscriptions
|
||||
|
||||
The `trigger` tag is designed to be extensible:
|
||||
|
||||
| Trigger Type | Description |
|
||||
|---|---|
|
||||
| `nostr-subscription` | Match events via Nostr filter (implemented first) |
|
||||
| `cron` | Time-based triggers — "every day at 9am, post a GM" |
|
||||
| `webhook` | HTTP webhook triggers — external systems wake didactyl |
|
||||
| `chain` | Output of one skill triggers another skill |
|
||||
|
||||
### Skill Composition
|
||||
|
||||
Skills could reference other skills, building complex behaviors from simple primitives:
|
||||
|
||||
```
|
||||
When triggered, run skill 'translate-to-english' on the note content,
|
||||
then run skill 'sentiment-analysis' on the translation,
|
||||
then DM admin with the result if sentiment is negative.
|
||||
```
|
||||
|
||||
### Agent-to-Agent Skill Sharing
|
||||
|
||||
Since skills are Nostr events, agents can:
|
||||
- Discover skills published by other agents via `skill_search`
|
||||
- Adopt skills from other agents via `skill_adopt`
|
||||
- Share triggered skill patterns across a network of agents
|
||||
|
||||
### Trigger Marketplace
|
||||
|
||||
With kind 10123 adoption lists being public, a natural marketplace emerges:
|
||||
- Agents publish useful triggered skills
|
||||
- Other agents discover and adopt them
|
||||
- Popular triggers rise to the top via adoption count
|
||||
@@ -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,596 @@
|
||||
# Implementation Plan: Nostr Tools
|
||||
|
||||
## Current Tool Inventory (v0.0.19)
|
||||
|
||||
| # | Tool | Kind/NIP | Status |
|
||||
|---|------|----------|--------|
|
||||
| 1 | `nostr_post` | Any kind | ✅ Shipped |
|
||||
| 2 | `nostr_post_readme` | 30023 / NIP-23 | ✅ Shipped |
|
||||
| 3 | `nostr_query` | Filter-based | ✅ Shipped |
|
||||
| 4 | `nostr_delete` | 5 / NIP-09 | ✅ Shipped |
|
||||
| 5 | `nostr_react` | 7 / NIP-25 | ✅ Shipped |
|
||||
| 6 | `nostr_profile_get` | 0 query | ✅ Shipped |
|
||||
| 7 | `nostr_relay_status` | Pool stats | ✅ Shipped |
|
||||
| 8 | `shell_exec` | OS | ✅ Shipped |
|
||||
| 9 | `file_read` | OS | ✅ Shipped |
|
||||
| 10 | `file_write` | OS | ✅ Shipped |
|
||||
|
||||
---
|
||||
|
||||
## Pattern for All New Tools
|
||||
|
||||
Every tool follows the same three-step pattern in [`src/tools.c`](../src/tools.c):
|
||||
|
||||
1. **Schema** — Add OpenAI function schema block in [`tools_build_openai_schema_json()`](../src/tools.c:511)
|
||||
2. **Executor** — Implement `static char* execute_<name>()` function
|
||||
3. **Dispatch** — Add `strcmp` case in [`tools_execute()`](../src/tools.c:1620)
|
||||
|
||||
Use the shared [`parse_tool_args_json()`](../src/tools.c:527) helper for hardened argument parsing.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 Tools — Medium Value, Moderate Implementation
|
||||
|
||||
### Tool 11: `nostr_nip05_lookup` — DNS Identity Verification (NIP-05)
|
||||
|
||||
#### Purpose
|
||||
Verify `user@domain.com` NIP-05 identifiers. Useful for trust decisions and profile enrichment.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_nip05_lookup",
|
||||
"description": "Verify or look up a NIP-05 DNS identifier and return the associated pubkey and relays",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"description": "NIP-05 identifier, e.g. user@domain.com"
|
||||
},
|
||||
"pubkey": {
|
||||
"type": "string",
|
||||
"description": "Optional 64-char hex pubkey to verify against the identifier"
|
||||
}
|
||||
},
|
||||
"required": ["identifier"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_nip05_lookup()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "identifier" (required string, must contain @)
|
||||
3. Extract "pubkey" (optional 64-char hex)
|
||||
4. If pubkey provided:
|
||||
- Call nostr_nip05_verify(identifier, pubkey, &relays, &relay_count, 10)
|
||||
- Return { success, verified: true/false, pubkey, relays }
|
||||
5. If no pubkey:
|
||||
- Call nostr_nip05_lookup(identifier, pubkey_out, &relays, &relay_count, 10)
|
||||
- Return { success, pubkey: pubkey_out, relays }
|
||||
6. Free relay array after serializing
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip05_lookup()`](../nostr_core_lib/nostr_core/nip005.h:13)
|
||||
- [`nostr_nip05_verify()`](../nostr_core_lib/nostr_core/nip005.h:15)
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
#### Notes
|
||||
- Involves HTTP network I/O (fetches `/.well-known/nostr.json` from the domain)
|
||||
- May block for up to `timeout_seconds` — use 10s default
|
||||
- Must free the `relays` array returned by the library
|
||||
|
||||
---
|
||||
|
||||
### Tool 12: `nostr_encode` — Bech32 Entity Encoding (NIP-19/NIP-21)
|
||||
|
||||
#### Purpose
|
||||
Encode hex pubkeys, event IDs, or addressable coordinates into shareable `npub`, `note`, `nprofile`, `nevent`, `naddr` URIs.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_encode",
|
||||
"description": "Encode a Nostr entity into a bech32 URI: npub, note, nprofile, nevent, or naddr",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Entity type: npub, note, nprofile, nevent, or naddr"
|
||||
},
|
||||
"hex": {
|
||||
"type": "string",
|
||||
"description": "64-char hex value: pubkey for npub/nprofile, event_id for note/nevent"
|
||||
},
|
||||
"relays": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional relay hints for nprofile, nevent, naddr"
|
||||
},
|
||||
"kind": {
|
||||
"type": "integer",
|
||||
"description": "Kind number, required for naddr"
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"description": "d-tag identifier, required for naddr"
|
||||
}
|
||||
},
|
||||
"required": ["type", "hex"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_encode()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "type" (required: npub|note|nprofile|nevent|naddr)
|
||||
3. Extract "hex" (required, 64-char hex)
|
||||
4. Convert hex to 32-byte array via nostr_hex_to_bytes()
|
||||
5. Switch on type:
|
||||
- "npub": nostr_build_uri_npub(bytes, output, sizeof output)
|
||||
- "note": nostr_build_uri_note(bytes, output, sizeof output)
|
||||
- "nprofile": nostr_build_uri_nprofile(bytes, relays, relay_count, output, sizeof output)
|
||||
- "nevent": nostr_build_uri_nevent(bytes, relays, relay_count, NULL, -1, 0, output, sizeof output)
|
||||
- "naddr": nostr_build_uri_naddr(identifier, bytes, kind, relays, relay_count, output, sizeof output)
|
||||
6. Return { success, type, uri: output }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_build_uri_npub()`](../nostr_core_lib/nostr_core/nip021.h:66)
|
||||
- [`nostr_build_uri_note()`](../nostr_core_lib/nostr_core/nip021.h:68)
|
||||
- [`nostr_build_uri_nprofile()`](../nostr_core_lib/nostr_core/nip021.h:69)
|
||||
- [`nostr_build_uri_nevent()`](../nostr_core_lib/nostr_core/nip021.h:71)
|
||||
- [`nostr_build_uri_naddr()`](../nostr_core_lib/nostr_core/nip021.h:74)
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 13: `nostr_decode` — Bech32 Entity Decoding (NIP-19/NIP-21)
|
||||
|
||||
#### Purpose
|
||||
Decode `npub`, `note`, `nprofile`, `nevent`, `naddr` URIs back into hex values and metadata.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_decode",
|
||||
"description": "Decode a bech32 Nostr URI into its components: type, hex, relays, kind, identifier",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"description": "Bech32 URI to decode, e.g. npub1..., note1..., nostr:npub1..."
|
||||
}
|
||||
},
|
||||
"required": ["uri"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_decode()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "uri" (required string)
|
||||
3. Call nostr_parse_uri(uri, &result)
|
||||
4. Switch on result.type:
|
||||
- NPUB: convert result.data.pubkey to hex, return { type: "npub", pubkey: hex }
|
||||
- NOTE: convert result.data.event_id to hex, return { type: "note", event_id: hex }
|
||||
- NPROFILE: pubkey hex + relays array
|
||||
- NEVENT: event_id hex + relays + optional author/kind
|
||||
- NADDR: identifier + pubkey hex + kind + relays
|
||||
5. Call nostr_uri_result_free(&result)
|
||||
6. Return JSON
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_parse_uri()`](../nostr_core_lib/nostr_core/nip021.h:63)
|
||||
- [`nostr_uri_result_free()`](../nostr_core_lib/nostr_core/nip021.h:78)
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 14: `nostr_dm_send` — Send DM as Tool Call
|
||||
|
||||
#### Purpose
|
||||
Expose DM sending as a tool so the agent can proactively message people (notify admin, reach out to contacts).
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_dm_send",
|
||||
"description": "Send an encrypted direct message to a Nostr user (NIP-04)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the recipient"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Plaintext message to send"
|
||||
}
|
||||
},
|
||||
"required": ["recipient_pubkey", "message"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_dm_send()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "recipient_pubkey" (required, 64-char hex)
|
||||
3. Extract "message" (required, non-empty string)
|
||||
4. Call nostr_handler_send_dm(recipient_pubkey, message)
|
||||
5. Return { success: true/false, recipient_pubkey, message_length }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_handler_send_dm()`](../src/nostr_handler.h:33) — already exists
|
||||
|
||||
#### Security Note
|
||||
- Should be admin-tier only in practice. The tool itself doesn't enforce tier, but the agent's system prompt and skill definitions should restrict usage.
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 15: `nostr_relay_info` — Relay Information Document (NIP-11)
|
||||
|
||||
#### Purpose
|
||||
Inspect relay capabilities, supported NIPs, limitations, PoW requirements before publishing.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_relay_info",
|
||||
"description": "Fetch the NIP-11 relay information document for a relay URL",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relay_url": {
|
||||
"type": "string",
|
||||
"description": "WebSocket relay URL, e.g. wss://relay.example.com"
|
||||
}
|
||||
},
|
||||
"required": ["relay_url"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
This requires a new helper in [`nostr_handler.c`](../src/nostr_handler.c) since NIP-11 fetching needs an HTTP GET request with `Accept: application/nostr+json` header.
|
||||
|
||||
##### New function: `nostr_handler_relay_info_json()`
|
||||
|
||||
```
|
||||
1. Convert ws:// or wss:// URL to http:// or https://
|
||||
2. Use libcurl to HTTP GET with header "Accept: application/nostr+json"
|
||||
3. Return the raw JSON response string
|
||||
```
|
||||
|
||||
##### `execute_nostr_relay_info()` in tools.c:
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract "relay_url" (required string, must start with ws:// or wss://)
|
||||
3. Call nostr_handler_relay_info_json(relay_url)
|
||||
4. Parse returned JSON
|
||||
5. Return { success, relay_url, info: { name, description, pubkey, supported_nips, limitations, ... } }
|
||||
```
|
||||
|
||||
#### Files Modified
|
||||
- `src/nostr_handler.h`: Add `nostr_handler_relay_info_json()` declaration
|
||||
- `src/nostr_handler.c`: Implement using libcurl (already linked)
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 Tools — High Value, More Complex
|
||||
|
||||
### Tool 16: `nostr_dm_send_nip17` — Private DMs via Gift Wrap (NIP-17/NIP-59)
|
||||
|
||||
#### Purpose
|
||||
Send modern, metadata-private DMs using the NIP-17 gift wrap protocol instead of legacy NIP-04.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_dm_send_nip17",
|
||||
"description": "Send a private direct message using NIP-17 gift wrap protocol for metadata privacy",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the recipient"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Plaintext message to send"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Optional conversation subject"
|
||||
}
|
||||
},
|
||||
"required": ["recipient_pubkey", "message"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
This is a multi-step crypto pipeline. The library handles the heavy lifting but we need to orchestrate:
|
||||
|
||||
##### New function: `nostr_handler_send_dm_nip17()` in nostr_handler.c
|
||||
|
||||
```
|
||||
1. Query recipient's kind 10050 relay list (DM relays)
|
||||
- Build filter: { kinds: [10050], authors: [recipient_pubkey], limit: 1 }
|
||||
- Call nostr_relay_pool_query_sync()
|
||||
- Extract relay URLs via nostr_nip17_extract_dm_relays()
|
||||
- Fall back to our own relays if no 10050 found
|
||||
2. Create kind 14 chat event:
|
||||
- nostr_nip17_create_chat_event(message, &recipient_pubkey, 1, subject, NULL, NULL, our_pubkey)
|
||||
3. Gift wrap and send:
|
||||
- nostr_nip17_send_dm(chat_event, &recipient_pubkey, 1, our_private_key, gift_wraps, max_wraps, 0)
|
||||
4. Publish each gift wrap to recipient's DM relays
|
||||
5. Return success/failure + event metadata
|
||||
```
|
||||
|
||||
##### `execute_nostr_dm_send_nip17()` in tools.c:
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract recipient_pubkey, message, optional subject
|
||||
3. Call nostr_handler_send_dm_nip17(recipient_pubkey, message, subject)
|
||||
4. Return { success, recipient_pubkey, protocol: "nip17" }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip17_create_chat_event()`](../nostr_core_lib/nostr_core/nip017.h:28)
|
||||
- [`nostr_nip17_send_dm()`](../nostr_core_lib/nostr_core/nip017.h:100)
|
||||
- [`nostr_nip17_extract_dm_relays()`](../nostr_core_lib/nostr_core/nip017.h)
|
||||
- [`nostr_nip59_create_gift_wrap()`](../nostr_core_lib/nostr_core/nip059.h:50)
|
||||
|
||||
#### Files Modified
|
||||
- `src/nostr_handler.h`: Add `nostr_handler_send_dm_nip17()` declaration
|
||||
- `src/nostr_handler.c`: Implement the full NIP-17 send pipeline
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 17: `nostr_encrypt` — NIP-44 Encryption
|
||||
|
||||
#### Purpose
|
||||
Encrypt arbitrary content for a specific recipient using modern NIP-44 encryption.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_encrypt",
|
||||
"description": "Encrypt plaintext for a recipient using NIP-44 ChaCha20+HMAC encryption",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the recipient"
|
||||
},
|
||||
"plaintext": {
|
||||
"type": "string",
|
||||
"description": "Text to encrypt"
|
||||
}
|
||||
},
|
||||
"required": ["recipient_pubkey", "plaintext"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_encrypt()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract recipient_pubkey (64-char hex), plaintext (non-empty string)
|
||||
3. Convert recipient_pubkey hex to 32-byte array
|
||||
4. Allocate output buffer (plaintext length * 2 + 256 for base64 overhead)
|
||||
5. Call nostr_nip44_encrypt(our_private_key, recipient_pubkey_bytes, plaintext, output, output_size)
|
||||
6. Return { success, recipient_pubkey, ciphertext: output }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip44_encrypt()`](../nostr_core_lib/nostr_core/nip044.h:28)
|
||||
|
||||
#### Security Note
|
||||
- Admin-tier only. Uses the agent's private key for ECDH shared secret.
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 18: `nostr_decrypt` — NIP-44 Decryption
|
||||
|
||||
#### Purpose
|
||||
Decrypt NIP-44 encrypted content from a known sender.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_decrypt",
|
||||
"description": "Decrypt NIP-44 encrypted content from a sender",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sender_pubkey": {
|
||||
"type": "string",
|
||||
"description": "64-char hex pubkey of the sender"
|
||||
},
|
||||
"ciphertext": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded NIP-44 encrypted payload"
|
||||
}
|
||||
},
|
||||
"required": ["sender_pubkey", "ciphertext"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation: `execute_nostr_decrypt()`
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract sender_pubkey (64-char hex), ciphertext (non-empty string)
|
||||
3. Convert sender_pubkey hex to 32-byte array
|
||||
4. Allocate output buffer
|
||||
5. Call nostr_nip44_decrypt(our_private_key, sender_pubkey_bytes, ciphertext, output, output_size)
|
||||
6. Return { success, sender_pubkey, plaintext: output }
|
||||
```
|
||||
|
||||
#### Library API Used
|
||||
- [`nostr_nip44_decrypt()`](../nostr_core_lib/nostr_core/nip044.h:62)
|
||||
|
||||
#### Security Note
|
||||
- Admin-tier only.
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
### Tool 19: `nostr_list_manage` — List Management (NIP-51)
|
||||
|
||||
#### Purpose
|
||||
Manage mute lists (kind 10000), bookmarks (kind 10003), follow sets, relay sets. Lets the agent curate its social graph.
|
||||
|
||||
#### OpenAI Schema
|
||||
```json
|
||||
{
|
||||
"name": "nostr_list_manage",
|
||||
"description": "Add or remove items from a Nostr list: mute list, bookmarks, relay sets, etc. (NIP-51)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list_kind": {
|
||||
"type": "integer",
|
||||
"description": "Kind number of the list: 10000=mute, 10001=pins, 10003=bookmarks, 3=follows, 10002=relays"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action: add or remove"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"description": "Array of tag tuples to add/remove, e.g. [[p, pubkey], [e, event_id]]"
|
||||
}
|
||||
},
|
||||
"required": ["list_kind", "action", "items"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation
|
||||
|
||||
This is a read-modify-write pattern for replaceable events:
|
||||
|
||||
##### `execute_nostr_list_manage()`:
|
||||
|
||||
```
|
||||
1. Parse args_json
|
||||
2. Extract list_kind (integer), action ("add"|"remove"), items (array of tag arrays)
|
||||
3. Query existing list:
|
||||
- Build filter: { kinds: [list_kind], authors: [our_pubkey], limit: 1 }
|
||||
- Call nostr_handler_query_json(filter, 8000)
|
||||
4. Parse existing event tags (or start with empty array if none found)
|
||||
5. If action == "add":
|
||||
- For each item in items: append to tags if not already present
|
||||
6. If action == "remove":
|
||||
- For each item in items: remove matching tag from tags
|
||||
7. Publish updated event:
|
||||
- Call nostr_handler_publish_kind_event(list_kind, existing_content_or_empty, updated_tags, &result)
|
||||
8. Return { success, list_kind, action, items_affected, event_id }
|
||||
```
|
||||
|
||||
#### Notes
|
||||
- Replaceable events (kind 10000-19999) automatically replace previous versions
|
||||
- Kind 3 (follows) is also replaceable
|
||||
- Content field may contain NIP-44 encrypted private items — preserve it unchanged
|
||||
- Must deduplicate tags when adding
|
||||
|
||||
#### Files Modified
|
||||
- `src/tools.c`: Schema + executor + dispatch
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[nostr_nip05_lookup] --> B[nostr_encode]
|
||||
B --> C[nostr_decode]
|
||||
C --> D[nostr_dm_send]
|
||||
D --> E[nostr_relay_info]
|
||||
E --> F[nostr_encrypt]
|
||||
F --> G[nostr_decrypt]
|
||||
G --> H[nostr_dm_send_nip17]
|
||||
H --> I[nostr_list_manage]
|
||||
|
||||
style A fill:#FFFFE0
|
||||
style B fill:#FFFFE0
|
||||
style C fill:#FFFFE0
|
||||
style D fill:#FFFFE0
|
||||
style E fill:#FFFFE0
|
||||
style F fill:#FFD700
|
||||
style G fill:#FFD700
|
||||
style H fill:#FFD700
|
||||
style I fill:#FFD700
|
||||
```
|
||||
|
||||
Yellow = Tier 2 (moderate), Gold = Tier 3 (complex).
|
||||
|
||||
### Recommended batching:
|
||||
|
||||
**Batch A — Pure computation, no new handler functions:**
|
||||
1. `nostr_encode` — pure NIP-21 encoding
|
||||
2. `nostr_decode` — pure NIP-21 decoding
|
||||
3. `nostr_dm_send` — thin wrapper around existing `nostr_handler_send_dm()`
|
||||
|
||||
**Batch B — Network I/O tools:**
|
||||
4. `nostr_nip05_lookup` — HTTP fetch via library
|
||||
5. `nostr_relay_info` — HTTP fetch via new handler function + libcurl
|
||||
|
||||
**Batch C — Crypto tools:**
|
||||
6. `nostr_encrypt` — NIP-44 encrypt
|
||||
7. `nostr_decrypt` — NIP-44 decrypt
|
||||
|
||||
**Batch D — Complex orchestration:**
|
||||
8. `nostr_dm_send_nip17` — multi-step NIP-17/59 pipeline, new handler function
|
||||
9. `nostr_list_manage` — read-modify-write replaceable events
|
||||
|
||||
## Build & Validate
|
||||
|
||||
After each batch:
|
||||
1. Run `./build_static.sh` to verify compilation
|
||||
2. Run `./increment_and_push.sh` with descriptive message
|
||||
@@ -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.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Skill Tools — Architecture Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add a family of five skill-management tools to Didactyl so the agent can **create, list, adopt, remove, and discover** skills at runtime — all through the existing LLM tool-calling loop.
|
||||
|
||||
Skills are Nostr events. The tools are thin orchestration wrappers over the existing `nostr_handler_publish_kind_event()` and `nostr_handler_query_json()` primitives.
|
||||
|
||||
---
|
||||
|
||||
## Nostr Kind Reference
|
||||
|
||||
| Kind | Purpose | Replaceable? | Key tag |
|
||||
|---|---|---|---|
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
|
||||
|
||||
All skill events carry these standard tags:
|
||||
- `["d", "<slug>"]` — unique identifier within the author's pubkey
|
||||
- `["app", "didactyl"]` — app namespace
|
||||
- `["scope", "public"]` or `["scope", "private"]`
|
||||
|
||||
---
|
||||
|
||||
## Tool Family
|
||||
|
||||
### 1. `skill_create`
|
||||
|
||||
**Purpose:** Create or update a skill definition and optionally auto-adopt it.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_create",
|
||||
"description": "Create or update a skill definition (kind 31123 public / 31124 private) and optionally auto-adopt it",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
|
||||
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
|
||||
"description": { "type": "string", "description": "Short one-line description for the skill" },
|
||||
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
|
||||
},
|
||||
"required": ["slug", "content"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_create`):**
|
||||
|
||||
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
|
||||
3. Build tags array:
|
||||
- `["d", slug]`
|
||||
- `["app", "didactyl"]`
|
||||
- `["scope", scope]`
|
||||
- `["description", description]` if provided
|
||||
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
|
||||
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
|
||||
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
|
||||
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
|
||||
- Republish the updated `10123` event
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
|
||||
|
||||
**Key design decisions:**
|
||||
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
|
||||
- Private skills (31124) are NOT added to the public adoption list (10123)
|
||||
- Republishing with the same slug replaces the previous version (replaceable event)
|
||||
|
||||
---
|
||||
|
||||
### 2. `skill_list`
|
||||
|
||||
**Purpose:** List the agent's own published skills.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_list",
|
||||
"description": "List skills published by this agent, optionally filtered by scope",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": { "type": "string", "description": "Filter by public or private. Omit for both." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_list`):**
|
||||
|
||||
1. Build filter based on scope:
|
||||
- Both: `{"kinds": [31123, 31124], "authors": [own_pubkey]}`
|
||||
- Public only: `{"kinds": [31123], "authors": [own_pubkey]}`
|
||||
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
|
||||
2. Call `nostr_handler_query_json(filter, 8000)`
|
||||
3. Parse results, extract for each event:
|
||||
- `slug` (from d-tag)
|
||||
- `kind`
|
||||
- `scope` (from scope tag)
|
||||
- `description` (from description tag, if present)
|
||||
- `created_at` timestamp
|
||||
- `content` preview (first 200 chars)
|
||||
4. Return JSON array of skill summaries
|
||||
|
||||
---
|
||||
|
||||
### 3. `skill_adopt`
|
||||
|
||||
**Purpose:** Adopt a skill published by another author (or self) into the agent's adoption list.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_adopt",
|
||||
"description": "Add a skill to the agent's public adoption list (kind 10123)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
|
||||
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["pubkey", "slug"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_adopt`):**
|
||||
|
||||
1. Validate pubkey (64-char hex) and slug (non-empty)
|
||||
2. Default kind to 31123 if not provided
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
|
||||
6. Add the tag, republish `10123`
|
||||
7. Return JSON with `success`, `adopted_address`, `event_id`
|
||||
|
||||
---
|
||||
|
||||
### 4. `skill_remove`
|
||||
|
||||
**Purpose:** Remove a skill from the agent's adoption list.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_remove",
|
||||
"description": "Remove a skill from the agent's public adoption list (kind 10123)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
|
||||
"slug": { "type": "string", "description": "Skill slug to remove" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["slug"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_remove`):**
|
||||
|
||||
1. Default pubkey to own pubkey if not provided
|
||||
2. Default kind to 31123
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Find and remove matching `["a", ...]` tag
|
||||
6. Republish `10123`
|
||||
7. Return JSON with `success`, `removed_address`, `event_id`
|
||||
|
||||
---
|
||||
|
||||
### 5. `skill_search`
|
||||
|
||||
**Purpose:** Search for skills across the agent's Web of Trust.
|
||||
|
||||
**OpenAI schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "skill_search",
|
||||
"description": "Search for skills adopted by Web of Trust contacts, or query public skill definitions",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Optional keyword to filter skill slugs or descriptions" },
|
||||
"pubkey": { "type": "string", "description": "Search skills by a specific author pubkey" },
|
||||
"popular": { "type": "boolean", "description": "If true, query WoT adoption lists to find most-adopted skills" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_search`):**
|
||||
|
||||
1. If `popular` is true:
|
||||
- Query `{"kinds": [10123]}` from relays (with reasonable limit)
|
||||
- Parse all `a`-tags from results
|
||||
- Count occurrences of each `a`-tag address
|
||||
- Sort by adoption count descending
|
||||
- Return top N skill addresses with counts
|
||||
2. If `pubkey` is provided:
|
||||
- Query `{"kinds": [31123], "authors": [pubkey]}`
|
||||
- Return skill summaries
|
||||
3. If `query` is provided (keyword search):
|
||||
- Query `{"kinds": [31123]}` with limit
|
||||
- Filter results client-side by matching `query` against slug, description tag, or content
|
||||
- Return matching skill summaries
|
||||
4. Default (no params): return own adopted skills from `10123`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### Shared helper: adoption list update
|
||||
|
||||
Since `skill_create`, `skill_adopt`, and `skill_remove` all modify the kind `10123` list, extract a shared helper:
|
||||
|
||||
```c
|
||||
// Fetch current 10123 event, return duplicated tags array (or empty array if none exists)
|
||||
static cJSON* fetch_adoption_list_tags(tools_context_t* ctx);
|
||||
|
||||
// Publish updated 10123 event with new tags
|
||||
static int publish_adoption_list(tools_context_t* ctx, cJSON* tags, nostr_publish_result_t* result);
|
||||
```
|
||||
|
||||
This is essentially the same pattern already used in `execute_nostr_list_manage()` but specialized for kind `10123`.
|
||||
|
||||
### Shared helper: skill summary extraction
|
||||
|
||||
```c
|
||||
// Extract slug, kind, scope, description, created_at from a skill event JSON
|
||||
static cJSON* extract_skill_summary(cJSON* event);
|
||||
```
|
||||
|
||||
### Flow diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SC[skill_create] --> PUB[nostr_handler_publish_kind_event]
|
||||
SC --> ADOPT_HELPER[update adoption list helper]
|
||||
|
||||
SL[skill_list] --> QUERY[nostr_handler_query_json]
|
||||
SL --> SUMMARY[extract_skill_summary]
|
||||
|
||||
SA[skill_adopt] --> ADOPT_HELPER
|
||||
SR[skill_remove] --> ADOPT_HELPER
|
||||
|
||||
SS[skill_search] --> QUERY
|
||||
SS --> SUMMARY
|
||||
|
||||
ADOPT_HELPER --> QUERY
|
||||
ADOPT_HELPER --> PUB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changes Required
|
||||
|
||||
### `src/tools.c`
|
||||
|
||||
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22–t26)
|
||||
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
|
||||
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
|
||||
|
||||
### `README.md`
|
||||
|
||||
1. Add skill tools to the Tooling Interface section under a new "Skill management" category
|
||||
|
||||
### No changes needed to:
|
||||
- `src/tools.h` — the `tools_context_t` already has `cfg` which provides `keys.public_key_hex`
|
||||
- `src/nostr_handler.h` — all needed APIs already exist
|
||||
- `src/config.h` — no new config fields needed
|
||||
|
||||
---
|
||||
|
||||
## Slug Validation Rules
|
||||
|
||||
A valid skill slug must:
|
||||
- Be 1–64 characters
|
||||
- Contain only lowercase letters, digits, and hyphens
|
||||
- Not start or end with a hyphen
|
||||
- Not contain consecutive hyphens
|
||||
|
||||
```c
|
||||
static int validate_skill_slug(const char* slug) {
|
||||
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
|
||||
if (slug[0] == '-') return 0;
|
||||
int prev_dash = 0;
|
||||
for (size_t i = 0; slug[i]; i++) {
|
||||
char c = slug[i];
|
||||
if (c == '-') {
|
||||
if (prev_dash) return 0;
|
||||
prev_dash = 1;
|
||||
} else if (islower(c) || isdigit(c)) {
|
||||
prev_dash = 0;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (slug[strlen(slug) - 1] == '-') return 0;
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Shared helpers** — `validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
2. **`skill_create`** — most important tool, enables the agent to author skills
|
||||
3. **`skill_list`** — lets the agent see what it has published
|
||||
4. **`skill_adopt`** — adopt skills from other authors
|
||||
5. **`skill_remove`** — remove skills from adoption list
|
||||
6. **`skill_search`** — discover skills across WoT
|
||||
7. **Schema registration** — add all 5 tools to `tools_build_openai_schema_json()`
|
||||
8. **Dispatch wiring** — add all 5 to `tools_execute()`
|
||||
9. **README update** — document the new tools
|
||||
10. **Build and test** — verify compilation and basic tool execution
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Admin-only**: Skill tools inherit the existing ADMIN tier restriction — only the admin can trigger tool calls
|
||||
- **Slug validation**: Prevents injection of malformed d-tags
|
||||
- **No arbitrary kind**: `skill_create` only publishes kind 31123 or 31124, not arbitrary kinds
|
||||
- **Adoption list integrity**: The helpers always fetch-then-update to avoid clobbering existing adoption entries
|
||||
- **Content size**: No explicit limit on skill content size — relies on relay limits and LLM context window constraints
|
||||
@@ -0,0 +1,139 @@
|
||||
# Didactyl Tool Testing Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Testing agent-mediated tools is different from unit testing pure functions because the execution path spans multiple boundaries:
|
||||
|
||||
```
|
||||
LLM schema interpretation → JSON argument generation → argument parsing → business logic → Nostr/network I/O → JSON response → LLM interpretation
|
||||
```
|
||||
|
||||
This plan defines three testing layers, ordered by implementation priority.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1: Direct Tool Execution
|
||||
|
||||
Call `tools_execute()` directly with known JSON arguments and assert the JSON response structure.
|
||||
|
||||
### Implementation
|
||||
|
||||
Add a `--test-tool <name> <args_json>` CLI flag to `src/main.c` that:
|
||||
1. Initializes config and nostr_handler (for network-dependent tools)
|
||||
2. Calls `tools_execute(&ctx, name, args_json)`
|
||||
3. Prints the raw JSON result to stdout
|
||||
4. Exits with 0 if `success: true`, 1 otherwise
|
||||
|
||||
### Pure Computation Tools (no network needed)
|
||||
|
||||
| Tool | Test Command | Expected |
|
||||
|------|-------------|----------|
|
||||
| `nostr_encode` | `--test-tool nostr_encode '{"type":"npub","hex":"<64-char-hex>"}'` | `success: true`, uri starts with `nostr:npub1` |
|
||||
| `nostr_decode` | `--test-tool nostr_decode '{"uri":"npub1..."}'` | `success: true`, pubkey is 64-char hex |
|
||||
| `nostr_encrypt` | `--test-tool nostr_encrypt '{"recipient_pubkey":"<hex>","plaintext":"hello"}'` | `success: true`, ciphertext is base64 |
|
||||
| `nostr_decrypt` | `--test-tool nostr_decrypt '{"sender_pubkey":"<hex>","ciphertext":"<from encrypt>"}'` | `success: true`, plaintext is "hello" |
|
||||
|
||||
### Network-Dependent Tools (need relay or HTTP)
|
||||
|
||||
| Tool | Test Command | Expected |
|
||||
|------|-------------|----------|
|
||||
| `nostr_nip05_lookup` | `--test-tool nostr_nip05_lookup '{"identifier":"_@laantungir.com"}'` | `success: true`, pubkey returned |
|
||||
| `nostr_relay_info` | `--test-tool nostr_relay_info '{"relay_url":"wss://relay.damus.io"}'` | `success: true`, info.basic.name present |
|
||||
| `nostr_relay_status` | `--test-tool nostr_relay_status '{}'` | `success: true`, relay_count > 0 |
|
||||
| `nostr_dm_send` | `--test-tool nostr_dm_send '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
|
||||
| `nostr_post` | `--test-tool nostr_post '{"kind":1,"content":"test"}'` | `success: true`, event_id present |
|
||||
| `nostr_delete` | `--test-tool nostr_delete '{"event_ids":["<64-char-hex>"]}'` | `success: true` |
|
||||
| `nostr_react` | `--test-tool nostr_react '{"event_id":"<hex>","event_pubkey":"<hex>"}'` | `success: true` |
|
||||
| `nostr_profile_get` | `--test-tool nostr_profile_get '{"pubkey":"<hex>"}'` | `success: true`, found: true/false |
|
||||
| `nostr_dm_send_nip17` | `--test-tool nostr_dm_send_nip17 '{"recipient_pubkey":"<hex>","message":"test"}'` | `success: true` |
|
||||
| `nostr_list_manage` | `--test-tool nostr_list_manage '{"list_kind":10000,"action":"add","items":[["p","<hex>"]]}'` | `success: true` |
|
||||
|
||||
### Error Case Tests
|
||||
|
||||
Each tool should also be tested with:
|
||||
- Empty args: `'{}'` — should return descriptive error
|
||||
- Missing required fields — should return specific error message
|
||||
- Invalid hex strings — should reject gracefully
|
||||
- Double-encoded JSON string args — should parse correctly (regression for the `invalid arguments JSON` bug)
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: Schema-Parse Fidelity
|
||||
|
||||
Validates that the OpenAI function schema matches what executors actually accept.
|
||||
|
||||
### Implementation
|
||||
|
||||
A Python script (`tests/validate_schemas.py`) that:
|
||||
1. Runs `didactyl_static --dump-schemas` (new flag) to get the tool schema JSON
|
||||
2. For each tool in the schema:
|
||||
- Generates a minimal valid argument payload from `required` + `properties`
|
||||
- Runs `didactyl_static --test-tool <name> '<generated_json>'`
|
||||
- Asserts exit code 0 or expected network error
|
||||
3. Reports mismatches between schema and executor expectations
|
||||
|
||||
### What This Catches
|
||||
|
||||
- Schema says `required: ["hex"]` but executor checks for `"pubkey"` — mismatch
|
||||
- Schema says `type: "integer"` but executor reads it as string
|
||||
- Schema advertises parameters the executor ignores
|
||||
- Missing required parameters in schema that executor demands
|
||||
|
||||
---
|
||||
|
||||
## Layer 3: Agent Integration Testing (Live DM)
|
||||
|
||||
The most natural test — DM the running agent and verify it uses tools correctly.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Local relay running at `ws://127.0.0.1:7777`
|
||||
- Didactyl running with valid config
|
||||
- A separate Nostr client (or script) to send/receive DMs
|
||||
|
||||
### Test Prompts
|
||||
|
||||
| # | Tool | DM Prompt | Assert in Response |
|
||||
|---|------|-----------|-------------------|
|
||||
| 1 | `nostr_encode` | "Encode this pubkey as npub: `<64-char hex>`" | Contains `nostr:npub1` |
|
||||
| 2 | `nostr_decode` | "Decode this npub: `npub1...`" | Contains the hex pubkey |
|
||||
| 3 | `nostr_dm_send` | "Send a DM to `<your-own-pubkey>` saying 'hello test'" | Confirms sent; you receive it |
|
||||
| 4 | `nostr_encrypt` | "Encrypt 'secret message' for `<pubkey>`" | Contains base64 ciphertext |
|
||||
| 5 | `nostr_decrypt` | "Decrypt this NIP-44 payload: `<ciphertext from #4>`" | Contains 'secret message' |
|
||||
| 6 | `nostr_nip05_lookup` | "Look up `_@laantungir.com`" | Returns a pubkey |
|
||||
| 7 | `nostr_relay_info` | "Get NIP-11 info for `wss://relay.damus.io`" | Returns relay name and supported NIPs |
|
||||
| 8 | `nostr_relay_status` | "Show me relay connection status" | Lists connected relays with stats |
|
||||
| 9 | `nostr_react` | "React with 🤙 to event `<id>` from `<pubkey>`" | Confirms kind 7 published |
|
||||
| 10 | `nostr_delete` | "Delete event `<id>`" | Confirms kind 5 published |
|
||||
| 11 | `nostr_profile_get` | "Look up the profile for `<pubkey>`" | Returns name/about/picture |
|
||||
| 12 | `nostr_post` | "Post a kind 1 note saying 'tool test'" | Confirms published with event_id |
|
||||
| 13 | `nostr_list_manage` | "Add `<pubkey>` to my mute list" | Confirms kind 10000 published |
|
||||
| 14 | `nostr_dm_send_nip17` | "Send a private NIP-17 DM to `<pubkey>` saying 'gift wrap test'" | Confirms gift wrap sent |
|
||||
| 15 | `nostr_post_readme` | "Publish the README to Nostr" | Confirms kind 30023 with d=readme.md |
|
||||
|
||||
### Verification Methods
|
||||
|
||||
- **stdout logs**: Watch `[didactyl] executing tool call: <name>` in terminal
|
||||
- **context.log**: Full LLM conversation including tool calls and results
|
||||
- **Relay inspection**: Query the local relay for published events
|
||||
- **DM receipt**: For DM tools, verify the message arrives at the recipient
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Immediate (no code changes)**: Run Layer 3 tests by DMing the live agent
|
||||
2. **Next sprint**: Add `--test-tool` CLI flag for Layer 1
|
||||
3. **Later**: Add `--dump-schemas` flag and Python schema validator for Layer 2
|
||||
4. **CI integration**: Wrap Layer 1 pure-computation tests in a shell script that runs after `build_static.sh`
|
||||
|
||||
---
|
||||
|
||||
## Local Relay Setup for Testing
|
||||
|
||||
A local relay like `strfry` or `nostr-rs-relay` at `ws://127.0.0.1:7777` provides:
|
||||
- All published events are observable and queryable
|
||||
- No rate limits or content policies
|
||||
- NIP-42 auth testing in isolation
|
||||
- Gift-wrapped NIP-17 DMs stay in your test environment
|
||||
- Database can be wiped between test runs
|
||||
@@ -0,0 +1,203 @@
|
||||
# Triggered Skills — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing skill system so that skills can carry Nostr subscription triggers. When matching events arrive, didactyl executes the skill automatically — either via template interpolation (fast, no LLM) or LLM-mediated reasoning (full agent loop).
|
||||
|
||||
See [docs/TOOLS_AND_SKILLS.md](../docs/TOOLS_AND_SKILLS.md) for the full architecture.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Trigger Manager Module
|
||||
|
||||
Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core component that manages dynamic Nostr subscriptions tied to skills.
|
||||
|
||||
**Data structures:**
|
||||
|
||||
```c
|
||||
#define TRIGGER_MAX_ACTIVE 16
|
||||
#define TRIGGER_COOLDOWN_SECONDS 60
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[65];
|
||||
char skill_content[4096]; // the action template or LLM prompt
|
||||
int action_type; // 0 = llm, 1 = template
|
||||
char filter_json[2048]; // the Nostr subscription filter
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
nostr_pool_subscription_t* subscription;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct {
|
||||
active_trigger_t triggers[TRIGGER_MAX_ACTIVE];
|
||||
int count;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
} trigger_manager_t;
|
||||
```
|
||||
|
||||
**API:**
|
||||
|
||||
```c
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_slug,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_slug,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
```
|
||||
|
||||
### 2. Template Engine
|
||||
|
||||
Create a simple template interpolation engine in `src/trigger_manager.c` (or a separate `src/template.c` if it grows).
|
||||
|
||||
**Functionality:**
|
||||
- Parse placeholders like `{content}`, `{pubkey}`, `{author_display_name}` from a template string
|
||||
- Extract values from a triggering Nostr event (cJSON object)
|
||||
- Produce an interpolated output string
|
||||
- Parse action prefixes: `DM admin:`, `DM <pubkey>:`, `POST:`, `LOG:`
|
||||
|
||||
### 3. Trigger Event Callback
|
||||
|
||||
When a subscribed event arrives:
|
||||
|
||||
```c
|
||||
static void on_trigger_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
active_trigger_t* trigger = (active_trigger_t*)user_data;
|
||||
|
||||
// Check cooldown
|
||||
if (time(NULL) - trigger->last_fired < TRIGGER_COOLDOWN_SECONDS) return;
|
||||
trigger->last_fired = time(NULL);
|
||||
|
||||
if (trigger->action_type == 1) {
|
||||
// Template: interpolate and execute
|
||||
char* output = template_interpolate(trigger->skill_content, event);
|
||||
template_execute_action(output); // parse prefix, DM/POST/LOG
|
||||
free(output);
|
||||
} else {
|
||||
// LLM: build context and run agent loop
|
||||
trigger_run_llm_action(trigger, event, relay_url);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Skill Loading on Startup
|
||||
|
||||
In `main.c`, after `agent_init()` and skill adoption list is available:
|
||||
|
||||
1. Query own kind 10123 adoption list
|
||||
2. For each adopted skill address, query the skill event
|
||||
3. Check for `trigger` tag — if present, extract `filter`, `action`, `enabled` tags
|
||||
4. Register with trigger manager
|
||||
5. Trigger manager creates Nostr subscriptions
|
||||
|
||||
### 5. Extend skill_create for Live Trigger Registration
|
||||
|
||||
When `skill_create` is called with trigger tags:
|
||||
1. Publish the skill event as normal
|
||||
2. If trigger tags are present, also register with the trigger manager immediately
|
||||
3. No restart required — the subscription goes live right away
|
||||
|
||||
When `skill_remove` is called for a triggered skill:
|
||||
1. Remove from adoption list as normal
|
||||
2. Also unregister from trigger manager, tearing down the subscription
|
||||
|
||||
### 6. Extend Agent for Trigger-Initiated Conversations
|
||||
|
||||
The agent currently only handles DM-initiated conversations via `agent_on_message()`. Add a new entry point:
|
||||
|
||||
```c
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
```
|
||||
|
||||
This builds an LLM conversation with:
|
||||
- System context (soul)
|
||||
- A system message explaining this is a triggered skill execution
|
||||
- The skill content as instructions
|
||||
- The triggering event as user context
|
||||
- Full tool access (same as admin tier)
|
||||
|
||||
The LLM response actions (DMs, posts, etc.) are executed via tools as normal.
|
||||
|
||||
### 7. New Tool: trigger_list
|
||||
|
||||
Add a tool so the LLM can inspect active triggers:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "trigger_list",
|
||||
"description": "List all active triggered skills with their filters and status",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Config Extension
|
||||
|
||||
Add trigger-related limits to config:
|
||||
|
||||
```json
|
||||
{
|
||||
"triggers": {
|
||||
"enabled": true,
|
||||
"max_active": 16,
|
||||
"cooldown_seconds": 60,
|
||||
"llm_rate_limit_per_minute": 10,
|
||||
"template_rate_limit_per_minute": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Integration into Main Loop
|
||||
|
||||
The trigger manager subscriptions are serviced by the same `nostr_handler_poll()` call in the main loop — no changes needed to the poll loop itself, since all subscriptions share the relay pool.
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/trigger_manager.c` | **NEW** — trigger manager, template engine, event callbacks |
|
||||
| `src/trigger_manager.h` | **NEW** — trigger manager API |
|
||||
| `src/main.c` | Add trigger_manager_init, trigger_manager_load_from_skills after agent_init |
|
||||
| `src/agent.c` | Add agent_on_trigger entry point for LLM-mediated trigger actions |
|
||||
| `src/agent.h` | Declare agent_on_trigger |
|
||||
| `src/tools.c` | Extend skill_create/skill_remove to register/unregister triggers; add trigger_list tool |
|
||||
| `src/config.h` | Add triggers_config_t struct |
|
||||
| `src/config.c` | Parse triggers config section |
|
||||
| `docs/TOOLS_AND_SKILLS.md` | Already written — full architecture reference |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `trigger_manager.h` / `trigger_manager.c` — core module with data structures and API stubs
|
||||
2. Template engine — interpolation and action prefix parsing
|
||||
3. Trigger event callback — on_trigger_event with cooldown
|
||||
4. Startup loading — query adoption list, find triggered skills, create subscriptions
|
||||
5. agent_on_trigger — LLM-mediated trigger execution path
|
||||
6. Live registration — extend skill_create/skill_remove for immediate trigger management
|
||||
7. trigger_list tool — LLM visibility into active triggers
|
||||
8. Config parsing — triggers section with limits
|
||||
9. Rate limiting — enforce LLM and template rate limits
|
||||
10. Testing — manual trigger creation and verification
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing `nostr_handler_query_json()` for loading skills
|
||||
- Existing `nostr_relay_pool_subscribe()` for creating trigger subscriptions
|
||||
- Existing `agent_on_message()` pattern for the LLM-mediated path
|
||||
- Existing `skill_create` / `skill_remove` tools for lifecycle hooks
|
||||
+652
-33
@@ -5,14 +5,464 @@
|
||||
#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 "debug.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;
|
||||
static struct trigger_manager* g_trigger_manager = NULL;
|
||||
|
||||
#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_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;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -32,21 +482,132 @@ 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_set_trigger_manager(struct trigger_manager* trigger_manager) {
|
||||
g_trigger_manager = trigger_manager;
|
||||
g_tools_ctx.trigger_manager = trigger_manager;
|
||||
}
|
||||
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url) {
|
||||
if (!g_cfg || !g_system_context || !skill_slug || !skill_content || !triggering_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* event_json = cJSON_PrintUnformatted(triggering_event);
|
||||
if (!event_json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* relay = (relay_url && relay_url[0] != '\0') ? relay_url : "unknown";
|
||||
|
||||
const char* trigger_prefix =
|
||||
"Triggered-skill execution context:\n"
|
||||
"- You are executing a skill because a Nostr event matched its trigger filter.\n"
|
||||
"- Execute the skill instructions below against the triggering event.\n"
|
||||
"- Keep output concise and actionable for the administrator.\n"
|
||||
"- If no action is needed, explicitly say why.\n\n"
|
||||
"Skill slug: ";
|
||||
|
||||
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_slug) +
|
||||
strlen("\nRelay: ") + strlen(relay) + strlen("\n\nSkill instructions:\n") +
|
||||
strlen(skill_content) + 1U;
|
||||
char* system_prompt = (char*)malloc(system_len);
|
||||
if (!system_prompt) {
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(system_prompt,
|
||||
system_len,
|
||||
"%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
|
||||
g_system_context,
|
||||
trigger_prefix,
|
||||
skill_slug,
|
||||
relay,
|
||||
skill_content);
|
||||
|
||||
size_t user_len = strlen("Triggering event JSON:\n") + strlen(event_json) + 1U;
|
||||
char* user_prompt = (char*)malloc(user_len);
|
||||
if (!user_prompt) {
|
||||
free(system_prompt);
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(user_prompt, user_len, "Triggering event JSON:\n%s", event_json);
|
||||
free(event_json);
|
||||
|
||||
append_context_log(g_cfg->admin.pubkey, "llm_trigger", user_prompt);
|
||||
|
||||
char* response = llm_chat(system_prompt, user_prompt);
|
||||
free(system_prompt);
|
||||
free(user_prompt);
|
||||
|
||||
if (!response) {
|
||||
const char* fallback = "Triggered skill execution failed: LLM unavailable.";
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, response);
|
||||
free(response);
|
||||
}
|
||||
|
||||
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 +629,113 @@ 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_admin_identity_context(messages) != 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 = ¤t.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(¤t);
|
||||
if (llm_chat_with_tools(g_system_context, tool_result, NULL, ¤t) != 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>");
|
||||
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) {
|
||||
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(¤t);
|
||||
|
||||
free(final_answer_owned);
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
}
|
||||
|
||||
void agent_cleanup(void) {
|
||||
@@ -128,4 +743,8 @@ void agent_cleanup(void) {
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
g_cfg = NULL;
|
||||
g_trigger_manager = NULL;
|
||||
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
|
||||
g_seen_msgs_count = 0;
|
||||
g_seen_msgs_next = 0;
|
||||
}
|
||||
|
||||
+13
-1
@@ -2,9 +2,21 @@
|
||||
#define OPEN_WING_AGENT_H
|
||||
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
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_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
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
|
||||
+469
-39
@@ -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,39 +176,400 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_relays(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* relays = cJSON_GetObjectItemCaseSensitive(root, "relays");
|
||||
if (!relays || !cJSON_IsArray(relays)) {
|
||||
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;
|
||||
}
|
||||
|
||||
int count = cJSON_GetArraySize(relays);
|
||||
if (count <= 0) {
|
||||
return -1;
|
||||
cJSON* tiers = cJSON_GetObjectItemCaseSensitive(security, "tiers");
|
||||
if (!tiers || !cJSON_IsObject(tiers)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
config->relays[i] = strdup(relay->valuestring);
|
||||
if (!config->relays[i]) {
|
||||
return -1;
|
||||
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 int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* triggers = cJSON_GetObjectItemCaseSensitive(root, "triggers");
|
||||
if (!triggers || !cJSON_IsObject(triggers)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(triggers, "enabled");
|
||||
cJSON* max_active = cJSON_GetObjectItemCaseSensitive(triggers, "max_active");
|
||||
cJSON* cooldown_seconds = cJSON_GetObjectItemCaseSensitive(triggers, "cooldown_seconds");
|
||||
cJSON* llm_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "llm_rate_limit_per_minute");
|
||||
cJSON* template_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "template_rate_limit_per_minute");
|
||||
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->triggers.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
if (max_active && cJSON_IsNumber(max_active)) {
|
||||
config->triggers.max_active = (int)max_active->valuedouble;
|
||||
}
|
||||
if (cooldown_seconds && cJSON_IsNumber(cooldown_seconds)) {
|
||||
config->triggers.cooldown_seconds = (int)cooldown_seconds->valuedouble;
|
||||
}
|
||||
if (llm_rate_limit && cJSON_IsNumber(llm_rate_limit)) {
|
||||
config->triggers.llm_rate_limit_per_minute = (int)llm_rate_limit->valuedouble;
|
||||
}
|
||||
if (template_rate_limit && cJSON_IsNumber(template_rate_limit)) {
|
||||
config->triggers.template_rate_limit_per_minute = (int)template_rate_limit->valuedouble;
|
||||
}
|
||||
|
||||
if (config->triggers.max_active < 1) {
|
||||
config->triggers.max_active = 1;
|
||||
}
|
||||
if (config->triggers.cooldown_seconds < 0) {
|
||||
config->triggers.cooldown_seconds = 0;
|
||||
}
|
||||
if (config->triggers.llm_rate_limit_per_minute < 1) {
|
||||
config->triggers.llm_rate_limit_per_minute = 1;
|
||||
}
|
||||
if (config->triggers.template_rate_limit_per_minute < 1) {
|
||||
config->triggers.template_rate_limit_per_minute = 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_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) {
|
||||
(void)root;
|
||||
return parse_relays_from_startup_kind10002(config);
|
||||
}
|
||||
|
||||
void config_free(didactyl_config_t* config) {
|
||||
if (!config) {
|
||||
return;
|
||||
@@ -206,15 +582,27 @@ 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));
|
||||
}
|
||||
|
||||
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));
|
||||
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
config->tools.shell.enabled = 1;
|
||||
@@ -222,9 +610,32 @@ 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;
|
||||
|
||||
config->triggers.enabled = 1;
|
||||
config->triggers.max_active = 16;
|
||||
config->triggers.cooldown_seconds = 60;
|
||||
config->triggers.llm_rate_limit_per_minute = 10;
|
||||
config->triggers.template_rate_limit_per_minute = 60;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -232,56 +643,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') {
|
||||
@@ -289,12 +698,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;
|
||||
}
|
||||
|
||||
@@ -305,14 +717,32 @@ 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_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 (parse_triggers_config(root, config) != 0) {
|
||||
config_set_error("invalid triggers 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;
|
||||
}
|
||||
|
||||
|
||||
+44
-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];
|
||||
@@ -51,16 +45,58 @@ 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 {
|
||||
int enabled;
|
||||
int max_active;
|
||||
int cooldown_seconds;
|
||||
int llm_rate_limit_per_minute;
|
||||
int template_rate_limit_per_minute;
|
||||
} triggers_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;
|
||||
triggers_config_t triggers;
|
||||
startup_event_t* startup_events;
|
||||
int startup_event_count;
|
||||
char config_path[OW_MAX_URL_LEN];
|
||||
} 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
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
@@ -41,15 +42,35 @@ static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
return total;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
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_http_request(const char* url, const char* body, int is_post) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !body) {
|
||||
if (!curl || !url) {
|
||||
if (curl) curl_easy_cleanup(curl);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char url[OW_MAX_URL_LEN + 64];
|
||||
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
@@ -60,13 +81,21 @@ static char* perform_chat_request(const char* body) {
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L);
|
||||
if (is_post) {
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}");
|
||||
}
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
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,14 +103,42 @@ 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;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
char url[OW_MAX_URL_LEN + 64];
|
||||
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
|
||||
return perform_http_request(url, body, 1);
|
||||
}
|
||||
|
||||
static char* build_request_json(const char* system_prompt, const char* user_message) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
@@ -228,6 +285,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 +298,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 +315,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 +342,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);
|
||||
@@ -302,6 +395,36 @@ void llm_response_free(llm_response_t* response) {
|
||||
memset(response, 0, sizeof(*response));
|
||||
}
|
||||
|
||||
int llm_get_config(llm_config_t* out_config) {
|
||||
if (!g_initialized || !out_config) {
|
||||
return -1;
|
||||
}
|
||||
*out_config = g_cfg;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int llm_set_config(const llm_config_t* config) {
|
||||
if (!g_initialized || !config) {
|
||||
return -1;
|
||||
}
|
||||
g_cfg = *config;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* llm_list_models_json(const char* base_url_override) {
|
||||
if (!g_initialized) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char* base_url = (base_url_override && base_url_override[0] != '\0')
|
||||
? base_url_override
|
||||
: g_cfg.base_url;
|
||||
|
||||
char url[OW_MAX_URL_LEN + 32];
|
||||
snprintf(url, sizeof(url), "%s/models", base_url);
|
||||
return perform_http_request(url, NULL, 0);
|
||||
}
|
||||
|
||||
void llm_cleanup(void) {
|
||||
if (!g_initialized) {
|
||||
return;
|
||||
|
||||
@@ -21,7 +21,14 @@ 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);
|
||||
int llm_get_config(llm_config_t* out_config);
|
||||
int llm_set_config(const llm_config_t* config);
|
||||
char* llm_list_models_json(const char* base_url_override);
|
||||
void llm_cleanup(void);
|
||||
|
||||
#endif
|
||||
+181
-21
@@ -4,15 +4,18 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "main.h"
|
||||
#include "agent.h"
|
||||
#include "config.h"
|
||||
#include "context.h"
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "debug.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
@@ -21,21 +24,74 @@ 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>] [--dump-schemas] [--test-tool <name> <args_json>]\n",
|
||||
prog ? prog : "didactyl");
|
||||
}
|
||||
|
||||
static int tool_result_success(const char* json) {
|
||||
if (!json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
int ok = (success && cJSON_IsBool(success) && cJSON_IsTrue(success)) ? 1 : 0;
|
||||
cJSON_Delete(root);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static int wait_for_connected_relays(int timeout_ms) {
|
||||
if (timeout_ms <= 0) {
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const int step_ms = 100;
|
||||
int elapsed_ms = 0;
|
||||
|
||||
while (elapsed_ms < timeout_ms) {
|
||||
if (nostr_handler_connected_relay_count() > 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
(void)nostr_handler_poll(step_ms);
|
||||
elapsed_ms += step_ms;
|
||||
}
|
||||
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
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;
|
||||
int dump_schemas = 0;
|
||||
const char* test_tool_name = NULL;
|
||||
const char* test_tool_args = "{}";
|
||||
|
||||
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], "--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 if (strcmp(argv[i], "--dump-schemas") == 0) {
|
||||
dump_schemas = 1;
|
||||
} else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) {
|
||||
test_tool_name = argv[++i];
|
||||
test_tool_args = argv[++i];
|
||||
} else {
|
||||
fprintf(stderr, "Usage: %s [--config <path>] [--context <path>] [--debug <0-5>]\n", argv[0]);
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -52,21 +108,86 @@ 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;
|
||||
}
|
||||
|
||||
char* system_context = context_load(context_path);
|
||||
if (!system_context) {
|
||||
fprintf(stderr, "Failed to load context file: %s\n", context_path);
|
||||
if (dump_schemas || test_tool_name) {
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (nostr_handler_init(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize nostr handler\n");
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
tools_context_t tools_ctx;
|
||||
if (tools_init(&tools_ctx, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize tools context\n");
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int exit_code = 0;
|
||||
|
||||
if (test_tool_name) {
|
||||
const int timeout_ms = 15000;
|
||||
if (!wait_for_connected_relays(timeout_ms)) {
|
||||
fprintf(stderr,
|
||||
"No relay connection established within %d ms; refusing to run --test-tool '%s'\n",
|
||||
timeout_ms,
|
||||
test_tool_name);
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (dump_schemas) {
|
||||
char* schemas = tools_build_openai_schema_json(&tools_ctx);
|
||||
if (!schemas) {
|
||||
fprintf(stderr, "Failed to build tool schemas\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", schemas);
|
||||
free(schemas);
|
||||
}
|
||||
} else {
|
||||
char* result = tools_execute(&tools_ctx, test_tool_name, test_tool_args);
|
||||
if (!result) {
|
||||
fprintf(stderr, "Failed to execute tool\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", result);
|
||||
exit_code = tool_result_success(result) ? 0 : 1;
|
||||
free(result);
|
||||
}
|
||||
}
|
||||
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
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 +196,64 @@ 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)");
|
||||
trigger_manager_t trigger_manager;
|
||||
if (trigger_manager_init(&trigger_manager, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize trigger manager\n");
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
agent_set_trigger_manager(&trigger_manager);
|
||||
|
||||
if (trigger_manager_load_from_skills(&trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: trigger manager load from skills failed (continuing)");
|
||||
}
|
||||
|
||||
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: publish profile end");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
|
||||
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
|
||||
@@ -104,7 +262,7 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
@@ -114,12 +272,14 @@ int main(int argc, char** argv) {
|
||||
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
DEBUG_INFO("[didactyl] entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
|
||||
|
||||
while (g_running) {
|
||||
(void)nostr_handler_poll(100);
|
||||
(void)trigger_manager_poll(&trigger_manager);
|
||||
struct timespec ts = {0, 10 * 1000 * 1000};
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
@@ -129,7 +289,7 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
|
||||
|
||||
+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 1
|
||||
#define DIDACTYL_VERSION "v0.0.1"
|
||||
#define DIDACTYL_VERSION_PATCH 26
|
||||
#define DIDACTYL_VERSION "v0.0.26"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+1359
-140
File diff suppressed because it is too large
Load Diff
+36
-3
@@ -4,15 +4,48 @@
|
||||
#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);
|
||||
char* nostr_handler_relay_info_json(const char* relay_url);
|
||||
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject);
|
||||
void nostr_handler_cleanup(void);
|
||||
|
||||
#endif
|
||||
+4576
-22
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
struct trigger_manager* trigger_manager;
|
||||
} tools_context_t;
|
||||
|
||||
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "trigger_manager.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
static int clamp_enabled(int enabled) {
|
||||
return enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
static int ensure_capacity(trigger_manager_t* mgr, int needed) {
|
||||
if (!mgr || needed <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mgr->capacity >= needed) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int new_cap = mgr->capacity > 0 ? mgr->capacity : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
while (new_cap < needed) {
|
||||
if (new_cap > 1024) {
|
||||
return -1;
|
||||
}
|
||||
new_cap *= 2;
|
||||
}
|
||||
|
||||
active_trigger_t* grown = (active_trigger_t*)realloc(mgr->triggers, (size_t)new_cap * sizeof(active_trigger_t));
|
||||
if (!grown) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (new_cap > mgr->capacity) {
|
||||
memset(&grown[mgr->capacity], 0, (size_t)(new_cap - mgr->capacity) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->triggers = grown;
|
||||
mgr->capacity = new_cap;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (strncmp(mgr->triggers[i].skill_slug, skill_slug, TRIGGER_SKILL_SLUG_MAX) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
|
||||
if (!tags || !key || !cJSON_IsArray(tags)) 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* k = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* v = cJSON_GetArrayItem(tag, 1);
|
||||
if (k && v && cJSON_IsString(k) && cJSON_IsString(v) &&
|
||||
k->valuestring && v->valuestring && strcmp(k->valuestring, key) == 0) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_slug[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey || !out_slug) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* p1 = strchr(addr, ':');
|
||||
if (!p1) return -1;
|
||||
const char* p2 = strchr(p1 + 1, ':');
|
||||
if (!p2) return -1;
|
||||
|
||||
char kind_buf[16] = {0};
|
||||
size_t kind_len = (size_t)(p1 - addr);
|
||||
if (kind_len == 0 || kind_len >= sizeof(kind_buf)) return -1;
|
||||
memcpy(kind_buf, addr, kind_len);
|
||||
|
||||
int kind = atoi(kind_buf);
|
||||
if (kind != 31123 && kind != 31124) return -1;
|
||||
|
||||
size_t pub_len = (size_t)(p2 - (p1 + 1));
|
||||
if (pub_len != 64U) return -1;
|
||||
memcpy(out_pubkey, p1 + 1, 64U);
|
||||
out_pubkey[64] = '\0';
|
||||
|
||||
size_t slug_len = strlen(p2 + 1);
|
||||
if (slug_len == 0 || slug_len >= 65U) return -1;
|
||||
memcpy(out_slug, p2 + 1, slug_len + 1U);
|
||||
|
||||
*out_kind = kind;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* build_template_output(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
(void)relay_url;
|
||||
|
||||
if (!t || !event) return NULL;
|
||||
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
|
||||
const char* content_s = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
const char* pubkey_s = (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring) ? pubkey->valuestring : "unknown";
|
||||
const char* id_s = (id && cJSON_IsString(id) && id->valuestring) ? id->valuestring : "";
|
||||
int kind_i = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
|
||||
|
||||
const char* tmpl = t->skill_content;
|
||||
if (!tmpl || tmpl[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char out[TRIGGER_SKILL_CONTENT_MAX + 256];
|
||||
snprintf(out,
|
||||
sizeof(out),
|
||||
"%s\n\n[event id=%s kind=%d pubkey=%s]\n%s",
|
||||
tmpl,
|
||||
id_s,
|
||||
kind_i,
|
||||
pubkey_s,
|
||||
content_s);
|
||||
|
||||
return strdup(out);
|
||||
}
|
||||
|
||||
static void execute_template_action(trigger_manager_t* mgr,
|
||||
const active_trigger_t* t,
|
||||
cJSON* event,
|
||||
const char* relay_url) {
|
||||
if (!mgr || !mgr->cfg || !t || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* rendered = build_template_output(t, event, relay_url);
|
||||
if (!rendered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(rendered, "DM admin:", 9) == 0) {
|
||||
const char* body = rendered + 9;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, body);
|
||||
} else if (strncmp(rendered, "POST:", 5) == 0) {
|
||||
const char* body = rendered + 5;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_publish_kind_event(1, body, NULL, NULL);
|
||||
} else if (strncmp(rendered, "LOG:", 4) == 0) {
|
||||
const char* body = rendered + 4;
|
||||
while (*body == ' ') body++;
|
||||
DEBUG_INFO("[didactyl] trigger template log (%s): %s", t->skill_slug, body);
|
||||
} else {
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, rendered);
|
||||
}
|
||||
|
||||
free(rendered);
|
||||
}
|
||||
|
||||
static void execute_llm_action(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
if (!t || !event) {
|
||||
return;
|
||||
}
|
||||
agent_on_trigger(t->skill_slug, t->skill_content, event, relay_url);
|
||||
}
|
||||
|
||||
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
|
||||
active_trigger_t* t = &mgr->triggers[index];
|
||||
if (!t->enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
|
||||
time_t created_ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : 0;
|
||||
if (created_ts > 0 && created_ts <= t->last_seen_created_at) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg->triggers.cooldown_seconds;
|
||||
if (cooldown < 0) cooldown = 0;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
t->last_fired = now;
|
||||
if (created_ts > t->last_seen_created_at) {
|
||||
t->last_seen_created_at = created_ts;
|
||||
}
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, relay_url);
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, relay_url);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
|
||||
if (!mgr || !cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
mgr->cfg = cfg;
|
||||
|
||||
if (pthread_mutex_init(&mgr->mutex, NULL) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int initial_cap = cfg->triggers.max_active > 0 ? cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
mgr->capacity = initial_cap;
|
||||
mgr->triggers = (active_trigger_t*)calloc((size_t)mgr->capacity, sizeof(active_trigger_t));
|
||||
if (!mgr->triggers) {
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
return -1;
|
||||
}
|
||||
|
||||
mgr->last_poll_at = time(NULL);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager initialized (capacity=%d)", mgr->capacity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* adopt_filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!adopt_filter || !kinds || !authors) {
|
||||
cJSON_Delete(adopt_filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
|
||||
cJSON_AddItemToObject(adopt_filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(mgr->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(adopt_filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(adopt_filter, "limit", 1);
|
||||
|
||||
char* adoption_json = nostr_handler_query_json(adopt_filter, 8000);
|
||||
cJSON_Delete(adopt_filter);
|
||||
if (!adoption_json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* adoption_events = cJSON_Parse(adoption_json);
|
||||
free(adoption_json);
|
||||
if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0);
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
if (!list_tags || !cJSON_IsArray(list_tags)) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int tn = cJSON_GetArraySize(list_tags);
|
||||
for (int i = 0; i < tn; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(list_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 || strcmp(key->valuestring, "a") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int kind = 0;
|
||||
char pubkey[65] = {0};
|
||||
char slug[65] = {0};
|
||||
if (parse_address_tag(val->valuestring, &kind, pubkey, slug) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_filter = cJSON_CreateObject();
|
||||
cJSON* sk_kinds = cJSON_CreateArray();
|
||||
cJSON* sk_authors = cJSON_CreateArray();
|
||||
cJSON* d_values = cJSON_CreateArray();
|
||||
if (!skill_filter || !sk_kinds || !sk_authors || !d_values) {
|
||||
cJSON_Delete(skill_filter);
|
||||
cJSON_Delete(sk_kinds);
|
||||
cJSON_Delete(sk_authors);
|
||||
cJSON_Delete(d_values);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(sk_kinds, cJSON_CreateNumber(kind));
|
||||
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
|
||||
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(pubkey));
|
||||
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(slug));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
|
||||
char* skill_json = nostr_handler_query_json(skill_filter, 8000);
|
||||
cJSON_Delete(skill_filter);
|
||||
if (!skill_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
cJSON* enabled = find_tag_value_string(tags, "enabled");
|
||||
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
|
||||
|
||||
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
|
||||
trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM;
|
||||
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
if (trigger_manager_add(mgr, slug, content->valuestring, filter_s, at, is_enabled) == 0) {
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(skill_events);
|
||||
}
|
||||
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from skills", loaded);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int existing = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (existing >= 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_update(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (max_active < 1) max_active = TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (mgr->count >= max_active) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: max_active reached (%d)", max_active);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ensure_capacity(mgr, mgr->count + 1) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[mgr->count];
|
||||
memset(t, 0, sizeof(*t));
|
||||
snprintf(t->skill_slug, sizeof(t->skill_slug), "%s", skill_slug);
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
t->last_fired = 0;
|
||||
t->last_seen_created_at = 0;
|
||||
|
||||
mgr->count++;
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger added slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (idx < mgr->count - 1) {
|
||||
memmove(&mgr->triggers[idx],
|
||||
&mgr->triggers[idx + 1],
|
||||
(size_t)(mgr->count - idx - 1) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->count--;
|
||||
memset(&mgr->triggers[mgr->count], 0, sizeof(active_trigger_t));
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger removed slug=%s", skill_slug);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_add(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[idx];
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger updated slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int active = 0;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (mgr->triggers[i].enabled) {
|
||||
active++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg || !mgr->cfg->triggers.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 2) {
|
||||
return 0;
|
||||
}
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int count = mgr->count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
active_trigger_t snapshot = mgr->triggers[i];
|
||||
if (!snapshot.enabled || snapshot.filter_json[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_Parse(snapshot.filter_json);
|
||||
if (!filter || !cJSON_IsObject(filter)) {
|
||||
cJSON_Delete(filter);
|
||||
continue;
|
||||
}
|
||||
|
||||
time_t since = snapshot.last_seen_created_at > 0 ? snapshot.last_seen_created_at + 1 : now - 30;
|
||||
if (since < 0) since = 0;
|
||||
cJSON_AddNumberToObject(filter, "since", (double)since);
|
||||
cJSON_AddNumberToObject(filter, "limit", 8);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
continue;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int e = 0; e < n; e++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, e);
|
||||
if (!ev || !cJSON_IsObject(ev)) continue;
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, snapshot.skill_slug);
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(void)maybe_fire_trigger_locked(mgr, idx, ev, NULL);
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
cJSON* err = cJSON_CreateObject();
|
||||
if (!err) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(err, "success", 0);
|
||||
cJSON_AddStringToObject(err, "error", "trigger manager unavailable");
|
||||
char* out = cJSON_PrintUnformatted(err);
|
||||
cJSON_Delete(err);
|
||||
return out;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* arr = cJSON_CreateArray();
|
||||
if (!root || !arr) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(arr);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddNumberToObject(root, "count", mgr->count);
|
||||
|
||||
int active = 0;
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (t->enabled) {
|
||||
active++;
|
||||
}
|
||||
|
||||
cJSON* item = cJSON_CreateObject();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(item, "skill_slug", t->skill_slug);
|
||||
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
|
||||
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
|
||||
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
|
||||
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
|
||||
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
|
||||
cJSON_AddItemToArray(arr, item);
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(root, "active", active);
|
||||
cJSON_AddItemToObject(root, "triggers", arr);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
free(mgr->triggers);
|
||||
mgr->triggers = NULL;
|
||||
mgr->count = 0;
|
||||
mgr->capacity = 0;
|
||||
mgr->cfg = NULL;
|
||||
mgr->last_poll_at = 0;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager cleaned up");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#ifndef DIDACTYL_TRIGGER_MANAGER_H
|
||||
#define DIDACTYL_TRIGGER_MANAGER_H
|
||||
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
|
||||
#define TRIGGER_SKILL_SLUG_MAX 65
|
||||
#define TRIGGER_SKILL_CONTENT_MAX 4096
|
||||
#define TRIGGER_FILTER_JSON_MAX 2048
|
||||
|
||||
typedef enum {
|
||||
TRIGGER_ACTION_LLM = 0,
|
||||
TRIGGER_ACTION_TEMPLATE = 1
|
||||
} trigger_action_type_t;
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[TRIGGER_SKILL_SLUG_MAX];
|
||||
char skill_content[TRIGGER_SKILL_CONTENT_MAX];
|
||||
char filter_json[TRIGGER_FILTER_JSON_MAX];
|
||||
trigger_action_type_t action_type;
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
time_t last_seen_created_at;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct trigger_manager {
|
||||
active_trigger_t* triggers;
|
||||
int count;
|
||||
int capacity;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
time_t last_poll_at;
|
||||
} trigger_manager_t;
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
int trigger_manager_poll(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user