Compare commits

...

50 Commits

Author SHA1 Message Date
Your Name
505d9d7d85 v0.0.50 - Rename tools to category-consistent names, add local_* prefixes, merge context tool naming, and keep message_current template-only 2026-03-08 18:54:59 -03:00
Your Name
e1f5457ced v0.0.49 - Fix DM history context assembly by filtering kind-4 query with #p participants and increased timeout 2026-03-08 12:48:39 -03:00
Your Name
5dfb18a842 v0.0.48 - Remove duplicated tools context, drop legacy variable resolver paths, and update template docs for tool-driven assembly 2026-03-08 10:57:19 -03:00
Your Name
40fe410eb1 v0.0.47 - Implement Phase 1 tool-driven soul template context assembly and context_* tools 2026-03-08 10:40:48 -03:00
Your Name
927c313a87 v0.0.46 - Sync README with SKILLS docs: context modes, fallback chains, triggers, safety limits, and doc links 2026-03-06 15:07:30 -04:00
Your Name
97dca12bb1 v0.0.45 - Add comprehensive comments to config.jsonc and validate JSONC loading 2026-03-06 03:26:47 -04:00
Your Name
120664571f v0.0.44 - Migrate config to JSONC with comment stripping parser and docs updates 2026-03-06 03:10:38 -04:00
Your Name
1bbae06722 v0.0.43 - Unify self-authored event cache for skills and adoption list 2026-03-05 18:00:44 -04:00
Your Name
8b1e9fb734 v0.0.42 - Add self-skill subscription cache and make skill_list read from cache 2026-03-05 17:33:37 -04:00
Your Name
31439652aa v0.0.41 - Fix startup relay-state log snapshot before main loop 2026-03-05 15:30:32 -04:00
Your Name
ad99f74bc0 v0.0.40 - Reduce query churn and add relay disconnect-cause visibility 2026-03-05 15:18:16 -04:00
Your Name
cf9c300fdf v0.0.39 - Rebuild didactyl with ws frame drain and larger relay buffers 2026-03-05 14:59:53 -04:00
Your Name
97585890b9 v0.0.38 - Forward non-matching query_sync messages to pool dispatch to prevent DM loss during sync queries 2026-03-05 14:13:26 -04:00
Your Name
7b3d36a797 dm subscriptions: keep kind4/kind1059 subs open after EOSE 2026-03-05 11:56:42 -04:00
Your Name
49579b17a4 dm send: remove nested poll drain and auth retry path 2026-03-05 11:29:50 -04:00
Your Name
17083de47d v0.0.37 - Add relay-pool publish connection-skip and poll latency instrumentation for hang diagnosis 2026-03-05 11:16:35 -04:00
Your Name
33884046af debug: add poll latency instrumentation 2026-03-05 11:15:17 -04:00
Your Name
7e69819be5 v0.0.36 - Remove inline NIP-17 poll+retry window to prevent post-reply hang while keeping send-path debug logging 2026-03-05 09:58:06 -04:00
Your Name
d189d0ba8c v0.0.35 - Improve NIP-17 send reliability with re-entrancy guard, auth handshake retry window, and send-path debug logs 2026-03-05 09:50:48 -04:00
Your Name
2cda3b6a58 v0.0.34 - Fix NIP-17 send hang by removing re-entrant relay query and nested polling; add startup kind 10050 relay list 2026-03-05 09:36:26 -04:00
Your Name
ecb58b7e11 v0.0.33 - Skip self-sent NIP-17 sender-copy DMs to prevent self-reply loops 2026-03-05 09:17:54 -04:00
Your Name
c3645e6af5 v0.0.32 - Prevent NIP-17 old message reprocessing by guarding rumor created_at against startup time 2026-03-05 07:55:41 -04:00
Your Name
6cc46d2c25 v0.0.31 - Fix NIP-17 DM receive by using separate kind4/kind1059 subscriptions and 2-day lookback for gift wraps 2026-03-05 06:32:14 -04:00
Your Name
10fe8fdde0 v0.0.30 - Add config-controlled dm_protocol (nip04/nip17/both), NIP-17 receive handling, protocol-aware auto DM routing, and config updates 2026-03-05 06:21:00 -04:00
Your Name
947e0b2f1e v0.0.29 - Update README: current status, runtime context model, project structure, HTTP admin API section, model tools, roadmap checkboxes 2026-03-03 05:54:50 -04:00
Your Name
29b4289217 v0.0.28 - Add prompt template system: soul-embedded ---template--- parser, variable resolver, provider overrides, section-named context parts; enable HTTP API by default; fix Makefile to not rebuild nostr_core_lib unconditionally; log API endpoint URL at startup 2026-03-03 05:44:07 -04:00
Your Name
e910304f6f v0.0.27 - Add admin API docs/frontend brief and improve context readability with titled sections and pretty context.log formatting 2026-03-02 14:49:44 -04:00
Your Name
b1609317c1 v0.0.26 - Add nostr_pubkey/nostr_npub tools with my_pubkey/my_npub aliases for agent key output 2026-03-02 07:32:05 -04:00
Your Name
4a400f1582 v0.0.25 - Add model_get/model_set/model_list tools with persisted LLM config updates and model discovery 2026-03-02 07:08:19 -04:00
Your Name
a2d3f840c7 v0.0.24 - Handle SIGPIPE disconnect crash and fix nostr_list_manage use-after-free 2026-03-02 05:22:12 -04:00
Your Name
7f31e4ceb7 v0.0.23 - Add tool_list runtime tool introspection (name/description/schema) and validate via CLI test mode 2026-03-01 20:21:12 -04:00
Your Name
052c11863f v0.0.22 - Add relay-waited --test-tool flow, longform markdown post tool, and README CLI debugger docs 2026-03-01 20:00:01 -04:00
Your Name
a446f25400 v0.0.21 - Add full skill_* tool family with schema, execution, dispatch, and README updates 2026-03-01 18:55:04 -04:00
Your Name
fea0fdf5c9 v0.0.20 - Add Tier2/Tier3 Nostr tools: nip05 lookup, encode/decode, dm send, relay info, nip44 encrypt/decrypt, nip17 dm, list manage 2026-03-01 18:18:46 -04:00
Your Name
a798f2c345 v0.0.19 - Add nostr_delete/nostr_react/nostr_profile_get/nostr_relay_status tools with relay status backend 2026-03-01 17:45:57 -04:00
Your Name
66b4ebee79 v0.0.18 - Harden tool argument JSON parsing and set fixed README publish image tag 2026-03-01 12:16:38 -04:00
Your Name
5673efeb94 v0.0.17 - Add -h/-v CLI flags and make post_readme_to_nostr skill call nostr_post_readme tool 2026-03-01 11:56:54 -04:00
Your Name
43850b273f v0.0.16 - Add deterministic nostr_post_readme tool to publish full README with d tag readme.md 2026-03-01 11:48:14 -04:00
Your Name
02d4e2caa0 v0.0.15 - Auto-add NIP-23 title/image/summary/published_at/d tags when missing 2026-02-28 18:02:02 -04:00
Your Name
56b9ae421c v0.0.14 - Handle malformed nostr_post arguments with loose JSON fallback parsing 2026-02-28 17:52:20 -04:00
Your Name
6e74ef5ac6 v0.0.13 - Enrich nostr_post responses with event ID, note URI, and relay publish metadata 2026-02-28 17:48:14 -04:00
Your Name
c542be1452 v0.0.12 - Add nostr_post tags support plus NIP-23 long_form_note and post_readme_to_nostr skills 2026-02-28 17:14:50 -04:00
Your Name
410400418c v0.0.11 - auto update readme.md 2026-02-28 16:57:08 -04:00
Your Name
3521081d9a v0.0.10 - Fixed .gitignore 2026-02-28 16:40:15 -04:00
Your Name
0d390afd69 chore: keep config local ignored and restore tracked config.json.example after history scrub 2026-02-28 16:38:05 -04:00
Your Name
230f591273 v0.0.9 - config changes 2026-02-28 16:27:33 -04:00
Your Name
721b592b8f v0.0.8 - example config 2026-02-28 14:08:50 -04:00
Your Name
0aabb0b827 v0.0.7 - Implement Phase 1 security model: signature verification, admin/WoT/stranger tiers, admin context subscriptions, and config-driven stranger response 2026-02-28 13:06:52 -04:00
Your Name
1c69a581d9 v0.0.6 - Remove top-level agent config, move SYSTEM prompt into Soul startup event, and update Quick Start binary flow 2026-02-28 08:27:29 -04:00
Your Name
76842627dc v0.0.5 - Release the first binary 2026-02-28 08:10:33 -04:00
60 changed files with 86112 additions and 470 deletions

7
.gitignore vendored
View File

@@ -4,6 +4,13 @@
/openclaw/
/c-relay/
/nips/
/config.json
/config.jsonc
test_keys.txt
/mongoose/
# Build artifacts
/build/

View File

@@ -90,11 +90,13 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CURL_LIBS="$(pkg-config --static --libs libcurl)" && \
OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN \
-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 \
src/nostr_handler.c src/agent.c src/tools.c src/trigger_manager.c \
src/prompt_template.c src/http_api.c src/mongoose.c src/debug.c \
-o /build/didactyl_static \
nostr_core_lib/libnostr_core_x64.a \
-lsecp256k1 \

View File

@@ -1,5 +1,5 @@
CC = gcc
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_POSIX_C_SOURCE=200809L
CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN
SRC_DIR = src
TARGET = didactyl
@@ -12,6 +12,10 @@ SRCS = \
$(SRC_DIR)/nostr_handler.c \
$(SRC_DIR)/agent.c \
$(SRC_DIR)/tools.c \
$(SRC_DIR)/trigger_manager.c \
$(SRC_DIR)/prompt_template.c \
$(SRC_DIR)/http_api.c \
$(SRC_DIR)/mongoose.c \
$(SRC_DIR)/debug.c
INCLUDES = \
@@ -27,7 +31,7 @@ LDFLAGS = -lcurl -lssl -lcrypto -lm -lpthread -ldl -lz -L/usr/local/lib -lsecp25
# Build directory
BUILD_DIR = build
all: deps $(TARGET)
all: $(TARGET)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)

447
README.md
View File

@@ -1,166 +1,463 @@
# 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.
Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fallback chains (for example: `anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap`) so each skill can tune behavior and cost. See [`docs/SKILLS.md`](docs/SKILLS.md).
### 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.50
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.50 — Rename tools to category-consistent names, add local_* prefixes, merge context tool naming, and keep message_current template-only
- 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 soul template (`---template---` section in kind `31120`) with named sections, variable resolution, and per-provider content overrides; falls back to hardcoded assembly if no template present
- Adopted skills injected into context automatically from the agent's `10123` adoption list
- Supports tool-calling loop with configurable max turns and local safety limits
- Triggered skills — Nostr event filters that fire skill execution automatically with `template` (deterministic) or `llm` (context-aware) actions; see [`docs/SKILLS.md`](docs/SKILLS.md)
- Deduplicates inbound messages via event-ID cache and FNV-1a fingerprint debounce window
- Appends every outbound LLM context payload to [`context.log`](context.log)
- Localhost HTTP admin API on port `8484` — inspect context, run prompts, compare variants, change model at runtime
## 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.jsonc
```
### 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.jsonc`](config.jsonc):
```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.jsonc
```
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.jsonc)
./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.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/SKILLS.md","title":"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`.
### Chat via local HTTP API (CLI)
A simple Node.js terminal client is available in [`didactyl-chat-cli.js`](didactyl-chat-cli.js).
Run it with:
```bash
node ./didactyl-chat-cli.js
```
Optional environment variables:
- `DIDACTYL_API_BASE_URL` (default: `https://127.0.0.1:8484`)
- `DIDACTYL_MODEL` (optional model override)
- `DIDACTYL_MAX_TURNS` (default: `4`)
- `DIDACTYL_INSECURE_TLS` (default: `1`, set `0` to enforce certificate verification)
Example:
```bash
DIDACTYL_API_BASE_URL=http://127.0.0.1:8484 DIDACTYL_MAX_TURNS=6 node ./didactyl-chat-cli.js
```
The CLI prints each message block with a speaker label (`You` / `Didactyl`) and a blank line between blocks for readability.
## 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 skill definitions, and adopters publish which skills they use.
- `31120`**Soul** (private instruction baseline)
- `d=soul`
- `31123`**Public Skill Definition** (replaceable by `d` tag)
- `content` is JSON with fields like `description`, `context_mode`, `llm`, `tools`, `template`, optional `max_tokens` / `temperature`
- `d=<skill_slug>` (example: `d=long_form_note`)
- `31124`**Private Skill Definition** (same schema as `31123`, private scope)
- `d=<skill_slug>` (example: `d=admin_ops`)
- `10123`**Skill Adoption List**
- tags contain one or more `a` references to selected skills
Context modes:
- `inject` — skill instructions are layered into soul context
- `full` — skill provides full prompt template (soul optional via `{{soul}}`)
- `override` — skill replaces soul prompt, standard context structure remains
Full skill schema, trigger tags, template variables, fallback resolution, and limits are documented in [`docs/SKILLS.md`](docs/SKILLS.md).
## 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.jsonc`](config.jsonc) 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 — assembled from the soul's `---template---` section (if present), otherwise hardcoded order:
1. Soul personality (everything above `---template---` in kind `31120`)
2. Named template sections in order using `tool:` directives (for example `nostr_admin_profile`, `nostr_admin_notes`, `task_list`, `message_current`, `dm_history`)
3. Each section executes its configured context tool, optionally extracting `result_field` (default: `content`)
4. Provider-specific content overrides per section remain supported for literal `content:` sections
5. Section names are used in `context.log` headers and `/api/context/parts` response
- **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).
Triggered skills and tool loops are bounded by runtime safeguards (for example, trigger cooldowns and action rate limits); see [`docs/SKILLS.md`](docs/SKILLS.md) for the current defaults.
## 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:
- `local_shell_exec`
- `local_file_read`
- `local_file_write`
- `local_http_fetch`
- Agent metadata:
- `agent_version`
- Model management:
- `model_get`
- `model_set`
- `model_list`
Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
## HTTP Admin API
A localhost-only HTTP API on port `8484` (configurable) for agent inspection and prompt crafting. Enable with `"api": {"enabled": true}` in config.
| Endpoint | Purpose |
|---|---|
| `GET /api/status` | Agent name, version, pubkey, relay count, trigger count |
| `GET /api/context/current` | Full LLM context messages array |
| `GET /api/context/parts` | Context broken into named parts with token estimates |
| `POST /api/prompt/run-simple` | Run a simple system+user prompt, no tools |
| `POST /api/prompt/run` | Run a full messages array with tools enabled |
| `POST /api/prompt/compare` | A/B compare two prompt variants |
| `GET /api/model` | Current LLM model config |
| `PUT /api/model` | Change model at runtime (persists to config.jsonc) |
| `GET /api/models` | List available models from provider |
Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_frontend.md`](plans/admin_web_frontend.md).
## Project Structure
```
.
├── config.json # Agent configuration
├── SYSTEM.md # Agent personality/instructions for LLM
├── config.jsonc # Agent/runtime config (JSONC with comments) 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
│ ├── 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
│ ├── 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
│ ├── prompt_template.c / .h # Soul template parser, variable resolver, context builder
│ ├── 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, startup reconcile
│ ├── trigger_manager.c / .h # Nostr event trigger subscriptions and skill execution
│ ├── http_api.c / .h # Localhost HTTP admin API (mongoose-based)
│ ├── mongoose.c / .h # Embedded HTTP server (mongoose)
│ └── debug.c / .h # Runtime log levels/macros
├── docs/
│ ├── API.md # HTTP admin API endpoint reference
│ ├── TOOLS.md # Tool architecture and catalog
│ ├── SKILLS.md # Skill schema, context modes, triggers, and limits
│ └── CRASH_FIXES.md # Crash analysis and fixes log
├── plans/ # Architecture and planning documents
│ ├── didactyl_mvp.md
│ └── didactyl_agentic.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, local_shell_exec, local_file_read, local_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)
- [x] Adopted skills injected into LLM context automatically
- [x] Triggered skills — Nostr event filters that fire skill execution automatically
- [x] Localhost HTTP admin API — context inspection, prompt crafting, A/B comparison
- [x] Runtime model switching via `model_set` tool (persists to config.jsonc)
- [x] Soul-embedded prompt templates (`---template---`) — configurable context order, variable resolution, provider overrides
- [ ] 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

View File

@@ -1,24 +0,0 @@
# Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
## Communication Rules
- You communicate through encrypted Nostr direct messages.
- Keep responses concise and clear.
## Behavior
- Be helpful and technically accurate.
- If unsure, state uncertainty directly.
- Prefer actionable, practical advice.
## Tool Use Policy
- You have tools available and should use them when a request requires taking action.
- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
- After a tool call, base your answer on the actual tool result.
- Never claim a tool was run if no tool was executed.
## Safety
- Do not claim to have executed actions you did not execute.
- Do not reveal secrets from configuration or keys.

150
chat-didactyl-cli.js Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Simple terminal chat client for Didactyl HTTP API.
*
* Usage:
* node didactyl-chat-cli.js
*
* Optional env vars:
* DIDACTYL_API_BASE_URL=http://127.0.0.1:8484
* DIDACTYL_MODEL=claude-haiku-4.5
* DIDACTYL_MAX_TURNS=4
*/
const readline = require("node:readline/promises");
const { stdin, stdout } = require("node:process");
const http = require("node:http");
const https = require("node:https");
const API_BASE_URL = process.env.DIDACTYL_API_BASE_URL || "https://127.0.0.1:8484";
const MODEL = process.env.DIDACTYL_MODEL || "";
const MAX_TURNS = Number.parseInt(process.env.DIDACTYL_MAX_TURNS || "4", 10);
const INSECURE_TLS = !["0", "false", "False", "FALSE"].includes(
String(process.env.DIDACTYL_INSECURE_TLS || "1")
);
function printMessage(role, content) {
const who = role === "user" ? "You" : role === "assistant" ? "Didactyl" : role;
console.log(`${who}>`);
console.log(content);
console.log("");
}
function postJson(urlString, payload) {
const url = new URL(urlString);
const isHttps = url.protocol === "https:";
const data = JSON.stringify(payload);
const options = {
method: "POST",
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data),
},
};
if (isHttps) {
options.rejectUnauthorized = !INSECURE_TLS;
}
const client = isHttps ? https : http;
return new Promise((resolve, reject) => {
const req = client.request(options, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => {
resolve({
statusCode: res.statusCode || 0,
body,
});
});
});
req.on("error", reject);
req.write(data);
req.end();
});
}
async function callDidactyl(message) {
const body = {
message,
max_turns: Number.isFinite(MAX_TURNS) ? MAX_TURNS : 4,
};
if (MODEL.trim()) {
body.model = MODEL.trim();
}
const { statusCode, body: responseBody } = await postJson(`${API_BASE_URL}/api/prompt/agent`, body);
if (statusCode < 200 || statusCode >= 300) {
throw new Error(`HTTP ${statusCode}: ${responseBody}`);
}
let data;
try {
data = JSON.parse(responseBody);
} catch {
throw new Error(`Invalid JSON from API: ${responseBody}`);
}
if (!data.success) {
throw new Error(data.error || "Didactyl API returned success=false");
}
return String(data.final_response || "");
}
async function main() {
console.log("Didactyl CLI chat");
console.log(`API: ${API_BASE_URL}`);
console.log(`TLS verify: ${INSECURE_TLS ? "disabled (local dev)" : "enabled"}`);
console.log("Type /exit to quit.\n");
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
while (true) {
const input = (await rl.question("You> ")).trim();
if (!input) {
console.log("");
continue;
}
if (input === "/exit" || input === "/quit") {
console.log("Exiting.");
break;
}
console.log("");
try {
const reply = await callDidactyl(input);
printMessage("assistant", reply);
console.log("");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Didactyl error: ${message}`);
console.error("");
}
}
} finally {
rl.close();
}
}
main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Fatal error: ${message}`);
process.exit(1);
});

245
config.jsonc.example Normal file
View File

@@ -0,0 +1,245 @@
{
// ─── Agent Identity Keys ───────────────────────────────────────────
// Your agent's Nostr keypair. Provide nsec (bech32) or hex format.
// The public key fields are optional — they are derived automatically.
"keys": {
"nsec": "agent nsec",
"npub": "agent npub",
"npubHex": "agent hex pubkey",
"nsecHex": "agent hex secret key"
},
// ─── Administrator ─────────────────────────────────────────────────
// The admin pubkey (npub or hex) controls who can issue privileged
// commands and use tools via DM.
"admin": {
"pubkey": "admin pubkey"
},
// ─── DM Protocol ──────────────────────────────────────────────────
// Which encrypted DM protocol to use: "nip04", "nip17", or "both"
"dm_protocol": "nip04",
// ─── LLM Provider ─────────────────────────────────────────────────
// Configure the language model backend. Any OpenAI-compatible API works.
"llm": {
"provider": "", // e.g. "openai", "anthropic", etc.
"api_key": "", // your API key
"model": "", // model identifier, e.g. "gpt-4o-mini"
"base_url": "", // API base URL, e.g. "https://api.openai.com/v1"
"max_tokens": 512, // max tokens per LLM response
"temperature": 0.7 // sampling temperature (0.0 2.0)
},
// ─── Security Tiers ───────────────────────────────────────────────
// Controls who can interact with the agent and what they can do.
"security": {
"verify_signatures": true, // verify Nostr event signatures
// Message sent to strangers outside the web of trust
"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 // admin can always use tools
},
"wot": {
"enabled": true, // respond to web-of-trust contacts
"tools_enabled": false // WoT contacts cannot use tools by default
},
"stranger": {
"enabled": true // respond to strangers (with stranger_response)
}
}
},
// ─── Admin Context Subscriptions ──────────────────────────────────
// Subscribe to the admin's Nostr events to build context awareness.
"admin_context": {
"enabled": true,
"subscribe_kinds": [
0, // kind 0: profile metadata
3, // kind 3: contact list
10002, // kind 10002: relay list
1 // kind 1: text notes
],
"kind_1_limit": 10 // max recent kind-1 notes to track
},
// ─── HTTP Admin API ───────────────────────────────────────────────
// Local REST API for runtime inspection and control.
"api": {
"enabled": true,
"port": 8484,
"bind_address": "127.0.0.1" // bind to localhost only
},
// ─── Startup Events ───────────────────────────────────────────────
// Events published on boot. Includes profile, relay list, soul,
// skills, and adoption list.
"startup_events": [
// Kind 0: Agent profile metadata (NIP-01)
{
"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: Relay list (NIP-65)
// These relays are used for connecting and publishing events.
{
"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 10050: DM relay list (NIP-17)
// Relays used specifically for receiving encrypted DMs.
{
"kind": 10050,
"content": "",
"tags": [
["relay", "wss://relay.damus.io"],
["relay", "wss://nos.lol"]
]
},
// Kind 1: Startup announcement note
{
"kind": 1,
"content": "Hello world from Didactyl startup",
"tags": [
["t", "didactyl"],
["t", "startup"]
]
},
// Kind 3: Contact list (initially empty)
{
"kind": 3,
"content": "",
"tags": []
},
// Kind 31120: Soul event — the agent's personality and behavior rules.
// Contains the system prompt and template sections for LLM context.
// The ---template--- marker separates the system prompt from
// structured context sections that are injected at runtime.
{
"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 `local_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## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\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.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_contacts\n role: system\n content: |\n ## Administrator Contact List (source: nostr kind 3)\n\n Administrator kind 3 contact list pubkeys (JSON array): {{admin_kind3_json}}\n provider:\n anthropic: |\n <admin_contacts source=\"nostr_kind_3\">\n {{admin_kind3_json}}\n </admin_contacts>\n\n- section: admin_relays\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay list (JSON): {{admin_kind10002_json}}\n provider:\n anthropic: |\n <admin_relays source=\"nostr_kind_10002\">\n {{admin_kind10002_json}}\n </admin_relays>\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n Administrator recent kind 1 notes (JSON array): {{admin_kind1_json}}\n provider:\n anthropic: |\n <admin_notes source=\"nostr_kind_1\">\n {{admin_kind1_json}}\n </admin_notes>\n\n- section: skills\n role: system\n content: |\n ## Adopted Skills\n\n {{skills_json}}\n\n- section: tasks\n role: system\n content: |\n ## Current Task List\n\n {{tasks_json}}\n\n- section: conversation\n role: conversation\n content: |\n {{conversation}}",
"tags": [
["d", "soul"],
["app", "didactyl"],
["scope", "private"]
]
},
// Kind 31123: Public skill — long_form_note
// Teaches the agent how to publish NIP-23 long-form articles.
{
"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: Public skill — post_readme_to_nostr
// Reads README.md and publishes it as a 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 local_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: Private skill — admin_ops
// Private operational procedures (admin-only).
{
"kind": 31124,
"content_fields": {
"name": "admin_ops",
"description": "Private operational procedures"
},
"tags": [
["d", "admin_ops"],
["app", "didactyl"],
["scope", "private"],
["slug", "admin_ops"]
]
},
// Kind 10123: Skill adoption list
// References which public skills this agent has adopted.
{
"kind": 10123,
"content": "",
"tags": [
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"],
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"],
["app", "didactyl"],
["scope", "public"]
]
}
]
}

251
context.log.md Normal file
View File

@@ -0,0 +1,251 @@
```text
Context Log - not seen by model
timestamp=2026-03-08 12:46:54
phase=direct_tool_exec
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=7605
approx_tokens=1901
```
slash=/help
result_json=AVAILABLE TOOLS
- context_admin_contacts — Build admin contacts context block from cached kind 3 contact list
- context_admin_identity — Build admin identity context block from cached runtime metadata
- context_admin_notes — Build admin notes context block from cached kind 1 notes
- context_admin_profile — Build admin profile context block from cached kind 0 metadata
- context_admin_relays — Build admin relay context block from cached kind 10002 data
- context_agent_identity — Build agent identity context block with pubkey and npub
- context_tasks — Build current task list context block from tasks.json
- context_user_message — Return current user message text for context template conversation section
- 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
- http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
- model_get — Get current active LLM runtime configuration (excluding API key)
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
- model_set — Update active LLM configuration and persist it to config.jsonc
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
- my_version — Return current Didactyl version and metadata from build macros
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
- nostr_dm_send — Send a NIP-04 encrypted DM
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
- nostr_npub — Return this agent's pubkey encoded as npub bech32
- nostr_post — Publish a Nostr event to connected relays
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
- nostr_pubkey — Return this agent's pubkey in hex format
- nostr_query — Query events from relays using a Nostr filter
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
- nostr_relay_info — Fetch NIP-11 relay information document
- nostr_relay_status — Get connection status and statistics for all relays
- shell_exec — Execute a shell command and return stdout/stderr
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
- 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_remove — Remove a skill address from kind 10123 adoption list
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
- tool_list — List available tools with name, description, and JSON parameter schema
- trigger_list — List active triggered skills and their runtime status
AVAILABLE SKILLS
- long_form_note
- post_readme_to_nostr
- admin_ops
result_markdown=AVAILABLE TOOLS
- context_admin_contacts — Build admin contacts context block from cached kind 3 contact list
- context_admin_identity — Build admin identity context block from cached runtime metadata
- context_admin_notes — Build admin notes context block from cached kind 1 notes
- context_admin_profile — Build admin profile context block from cached kind 0 metadata
- context_admin_relays — Build admin relay context block from cached kind 10002 data
- context_agent_identity — Build agent identity context block with pubkey and npub
- context_tasks — Build current task list context block from tasks.json
- context_user_message — Return current user message text for context template conversation section
- 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
- http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
- model_get — Get current active LLM runtime configuration (excluding API key)
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
- model_set — Update active LLM configuration and persist it to config.jsonc
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
- my_version — Return current Didactyl version and metadata from build macros
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
- nostr_dm_send — Send a NIP-04 encrypted DM
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
- nostr_npub — Return this agent's pubkey encoded as npub bech32
- nostr_post — Publish a Nostr event to connected relays
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
- nostr_pubkey — Return this agent's pubkey in hex format
- nostr_query — Query events from relays using a Nostr filter
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
- nostr_relay_info — Fetch NIP-11 relay information document
- nostr_relay_status — Get connection status and statistics for all relays
- shell_exec — Execute a shell command and return stdout/stderr
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
- 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_remove — Remove a skill address from kind 10123 adoption list
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
- tool_list — List available tools with name, description, and JSON parameter schema
- trigger_list — List active triggered skills and their runtime status
AVAILABLE SKILLS
- long_form_note
- post_readme_to_nostr
- admin_ops
---
```text
Context Log - not seen by model
timestamp=2026-03-08 12:46:49
phase=direct_tool_exec
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=129
approx_tokens=32
```
slash=/tools
result_json={"success":false,"error":"unknown tool"}
result_markdown=- **success:** false
- **error:** unknown tool
---
```text
Context Log - not seen by model
timestamp=2026-03-08 11:51:21
phase=llm_chat_with_tools_messages
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
model=claude-opus-4.6
context_bytes=3847
approx_tokens=961
```
Sections: 9
# system_prompt | role=system
## 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.
- Use the person's name when messaging them if you know it.
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
### Tool Use Policy
- You have tools available and should use them when a request requires taking action.
- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
- After a tool call, base your answer on the actual tool result.
- Never claim a tool was run if no tool was executed.
### Task Management
- Maintain and use your internal task list as short-term working memory.
- Break long or complex actions into clear tasks before executing them.
- Update task status as you complete steps so your plan stays accurate.
### Safety
- Do not claim to have executed actions you did not execute.
- You may share your public key (npub) with anyone.
- Never reveal your private key (nsec) under any circumstance.
# admin_identity | role=system
### Administrator Identity (source: config.admin.pubkey)
This is your administrator! Admin pubkey (hex): 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
This message has been cryptographically verified as coming from your administrator.
# admin_profile | role=system
### Administrator Kind 0 Profile (source: nostr kind 0)
Administrator kind 0 profile content (JSON): {"name":"William S Burroughs","display_name":"WSB","about":"I like to write, and I like crank. They are good. Sometimes I'm not so sure.","banner":"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg","website":"https://addicted.com","picture":"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*","lud16":"","nip05":""}
# admin_contacts | role=system
### Administrator Kind 3 Contacts (source: nostr kind 3)
Administrator contacts (JSON): ["1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c","4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f","82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2","52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8","ab5918b464e735c695e2dac1762f4978aef8fef94f6b852cabb1754ef9171159"]
# admin_relays | role=system
### Administrator Kind 10002 Relays (source: nostr kind 10002)
Administrator relay list (JSON): ["wss://relay.laantungir.net/","wss://relay.damus.io/","wss://nos.lol/","wss://relay.nostr.band/","wss://purplepag.es/","ws://127.0.0.1:7777/","wss://nostr.mom/","wss://relay.0xchat.com"]
# admin_notes | role=system
### Administrator Recent Kind 1 Notes
Administrator recent public notes:
- GM
- Getting longer
- Long day.
- This is a test.
- test
- Post 11
- Post 10
- This is
Post
9
- Eight
- Seven
# tasks | role=system
### Current Task List
#### Current Tasks
Your active task list - short-term working memory for tracking plan steps.
- [ ] No active tasks yet.
# conversation | role=user
How about the capital of Indonesia?
# dm_history | role=chat
11:51 WSB How about the capital of Indonesia?
---

42
context_template.md Normal file
View File

@@ -0,0 +1,42 @@
# Context Template
```yaml
- section: admin_identity
role: system
tool: admin_identity
skip_if_empty: true
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
- section: admin_contacts
role: system
tool: nostr_admin_contacts
skip_if_empty: true
- section: admin_relays
role: system
tool: nostr_admin_relays
skip_if_empty: true
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
- section: tasks
role: system
tool: task_list
skip_if_empty: true
- section: conversation
role: user
tool: message_current
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
```

1272
didactyl.html Normal file

File diff suppressed because it is too large Load Diff

493
docs/API.md Normal file
View File

@@ -0,0 +1,493 @@
# Didactyl Admin HTTP API
## Overview
Didactyl exposes a localhost-only HTTP API for external tools and dashboards to inspect agent state, explore LLM context, craft prompts, and compare prompt variants. The API runs inside the same process as the agent — no separate server, no authentication required.
All responses are JSON. CORS headers are included on every response for browser access from any local origin.
---
## Configuration
Enable the API in `config.jsonc`:
```json
{
"api": {
"enabled": true,
"port": 8484,
"bind_address": "127.0.0.1"
}
}
```
| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | bool | `false` | Must be explicitly set to `true` to start the HTTP server |
| `port` | int | `8484` | TCP port to listen on |
| `bind_address` | string | `"127.0.0.1"` | Bind address — use `127.0.0.1` for localhost-only access |
The API is disabled by default. When disabled, no listener is created and no resources are consumed.
---
## CORS
Every response includes:
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type
```
`OPTIONS` requests return `204 No Content` with these headers for browser preflight support.
---
## Endpoints
### GET /api/status
Returns agent runtime status.
**Response:**
```json
{
"success": true,
"name": "Didactyl",
"version": "v0.0.26",
"pubkey": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
"relay_count": 4,
"connected_relays": 4,
"active_triggers": 0
}
```
| Field | Description |
|---|---|
| `name` | Agent display name constant |
| `version` | Build version string |
| `pubkey` | Agent public key in hex |
| `relay_count` | Number of configured relays |
| `connected_relays` | Number of currently connected relays |
| `active_triggers` | Number of active triggered-skill subscriptions |
---
### GET /api/context/current
Returns the full LLM context message array that would be sent to the model right now. This is the same context the agent builds for an admin DM conversation.
**Response:**
```json
{
"success": true,
"total_chars": 13131,
"total_estimated_tokens": 3283,
"messages": [
{"role": "system", "content": "# Didactyl Agent\n\nYou are Didactyl..."},
{"role": "system", "content": "This is your administrator! Admin pubkey..."},
{"role": "system", "content": "Administrator kind 0 profile content..."},
{"role": "system", "content": "Administrator kind 10002 relay-list content..."},
{"role": "system", "content": "Startup events memory..."},
{"role": "system", "content": "Adopted skills memory..."},
{"role": "system", "content": "Administrator recent public notes..."}
]
}
```
| Field | Description |
|---|---|
| `total_chars` | Total character count across all messages |
| `total_estimated_tokens` | Rough token estimate using `chars / 4` heuristic |
| `messages` | OpenAI-format messages array with role and content |
---
### GET /api/context/parts
Returns the context broken into labeled, individually-sized parts. Useful for understanding what consumes context budget.
**Response:**
```json
{
"success": true,
"total_chars": 13131,
"total_estimated_tokens": 3283,
"parts": [
{
"name": "system_prompt",
"role": "system",
"chars": 1200,
"estimated_tokens": 300,
"content": "# Didactyl Agent..."
},
{
"name": "admin_identity",
"role": "system",
"chars": 120,
"estimated_tokens": 30,
"content": "This is your administrator!..."
},
{
"name": "admin_kind0",
"role": "system",
"chars": 450,
"estimated_tokens": 113,
"content": "Administrator kind 0 profile content..."
},
{
"name": "admin_relay_list",
"role": "system",
"chars": 50,
"estimated_tokens": 13,
"content": "Administrator kind 10002 relay-list content..."
},
{
"name": "startup_events",
"role": "system",
"chars": 4800,
"estimated_tokens": 1200,
"content": "Startup events memory..."
},
{
"name": "adopted_skills",
"role": "system",
"chars": 2100,
"estimated_tokens": 525,
"content": "Adopted skills memory..."
},
{
"name": "admin_notes",
"role": "system",
"chars": 680,
"estimated_tokens": 170,
"content": "Administrator recent public notes..."
}
],
"messages": [...]
}
```
**Part names:**
| Name | Description |
|---|---|
| `system_prompt` | The agent soul / system prompt (first system message) |
| `admin_identity` | Admin pubkey identification message |
| `admin_kind0` | Admin kind 0 profile metadata |
| `admin_relay_list` | Admin kind 10002 relay list |
| `startup_events` | Startup events memory block |
| `adopted_skills` | Adopted skills behavioral instructions |
| `admin_notes` | Admin recent kind 1 public notes |
| `dm_history` | Recent DM conversation history |
| `context_part` | Any other context message |
---
### POST /api/prompt/run-simple
Submit a system prompt and user message for a simple LLM call with no tools. Useful for quick prompt iteration.
**Request:**
```json
{
"system": "You are a helpful assistant that writes tweets.",
"user": "Write a tweet about AI agents on Nostr",
"model": "claude-haiku-4.5"
}
```
| Field | Required | Description |
|---|---|---|
| `system` | yes | System prompt string |
| `user` | yes | User message string |
| `model` | no | Override the current model for this request only |
**Response:**
```json
{
"success": true,
"response": "AI agents are finding their home on Nostr...",
"model_used": "claude-haiku-4.5",
"input_tokens_estimate": 85,
"output_tokens_estimate": 42
}
```
---
### POST /api/prompt/agent
Submit one user message and let Didactyl build full admin context server-side (same context assembly path used for Nostr admin DMs, including admin profile, adopted skills, and recent DM history).
**Request:**
```json
{
"message": "Tweet about the weather",
"model": "claude-haiku-4.5",
"max_turns": 5
}
```
| Field | Required | Description |
|---|---|---|
| `message` | yes | User message string |
| `model` | no | Override the current model for this request only |
| `max_turns` | no | Maximum tool-call loop iterations (default: 4, max: 16) |
**Response:**
```json
{
"success": true,
"final_response": "Done! I posted a tweet about the weather.",
"turns": [
{
"turn": 1,
"tool_calls": [
{
"name": "nostr_post",
"arguments": "{\"kind\":1,\"content\":\"Beautiful day!\"}",
"result": "{\"success\":true,\"event_id\":\"abc123\"}"
}
]
}
],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 3200,
"total_output_tokens_estimate": 180
}
```
| Field | Description |
|---|---|
| `final_response` | The LLM final text response after all tool calls complete |
| `turns` | Array of turn objects, each containing tool calls made in that turn |
| `turns[].tool_calls[]` | Each tool call with name, arguments JSON, and result JSON |
| `model_used` | The model that was actually used |
| `total_input_tokens_estimate` | Estimated input tokens for the full conversation |
| `total_output_tokens_estimate` | Estimated output tokens for the final response |
---
### POST /api/prompt/run
Submit a full messages array with the agent tool set enabled. This endpoint runs exactly what you provide and does **not** auto-build Didactyl admin context.
**Request:**
```json
{
"messages": [
{"role": "system", "content": "You are Didactyl..."},
{"role": "user", "content": "Tweet about the weather"}
],
"model": "claude-haiku-4.5",
"max_turns": 5
}
```
| Field | Required | Description |
|---|---|---|
| `messages` | yes | OpenAI-format messages array |
| `model` | no | Override the current model for this request only |
| `max_turns` | no | Maximum tool-call loop iterations (default: 4, max: 16) |
**Response:**
```json
{
"success": true,
"final_response": "Done! I posted a tweet about the weather.",
"turns": [
{
"turn": 1,
"tool_calls": [
{
"name": "nostr_post",
"arguments": "{\"kind\":1,\"content\":\"Beautiful day!\"}",
"result": "{\"success\":true,\"event_id\":\"abc123\"}"
}
]
}
],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 3200,
"total_output_tokens_estimate": 180
}
```
| Field | Description |
|---|---|
| `final_response` | The LLM final text response after all tool calls complete |
| `turns` | Array of turn objects, each containing tool calls made in that turn |
| `turns[].tool_calls[]` | Each tool call with name, arguments JSON, and result JSON |
| `model_used` | The model that was actually used |
| `total_input_tokens_estimate` | Estimated input tokens for the full conversation |
| `total_output_tokens_estimate` | Estimated output tokens for the final response |
---
### POST /api/prompt/compare
A/B testing: submit two prompt variants, both are executed sequentially, responses returned side-by-side. Each variant can optionally use a different model for cross-model comparison.
**Request:**
```json
{
"variant_a": {
"messages": [
{"role": "system", "content": "You are concise. No emoji."},
{"role": "user", "content": "Say hi."}
],
"model": "claude-haiku-4.5",
"max_turns": 1
},
"variant_b": {
"messages": [
{"role": "system", "content": "You are verbose and friendly."},
{"role": "user", "content": "Say hi."}
],
"model": "claude-haiku-4.5",
"max_turns": 1
}
}
```
| Field | Required | Description |
|---|---|---|
| `variant_a` | yes | First prompt variant — same shape as `/api/prompt/run` request |
| `variant_b` | yes | Second prompt variant — same shape as `/api/prompt/run` request |
**Response:**
```json
{
"success": true,
"variant_a": {
"success": true,
"final_response": "Hi. How can I help?",
"turns": [{"turn": 1, "tool_calls": []}],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 21,
"total_output_tokens_estimate": 6
},
"variant_b": {
"success": true,
"final_response": "Hey there! Nice to meet you! I am here and ready to help...",
"turns": [{"turn": 1, "tool_calls": []}],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 21,
"total_output_tokens_estimate": 72
}
}
```
Variant A runs first, then variant B. The original model config is restored after each variant completes.
---
## Error Responses
All error responses follow this shape:
```json
{
"success": false,
"error": "description of what went wrong"
}
```
Common HTTP status codes:
| Code | Meaning |
|---|---|
| `200` | Success |
| `204` | OPTIONS preflight success |
| `400` | Bad request — missing or invalid parameters |
| `404` | Endpoint not found |
| `500` | Internal server error |
---
## Token Estimation
All token estimates use a simple `chars / 4` heuristic. This is a rough approximation that works well enough for English text across major model families. No real tokenizer is used.
---
## Security
- Binds to `127.0.0.1` only — not accessible from the network
- No authentication — this is a local development and administration tool
- The `api.enabled` config flag defaults to `false` and must be explicitly opted in
- Prompt execution endpoints have full tool access equivalent to admin-tier DM conversations
- The agent process must be running for the API to be available
---
## Architecture
The HTTP server is embedded in the didactyl process using the Mongoose library. It runs in the same thread as the main poll loop — each iteration calls `http_api_poll()` which does non-blocking accept/read/write. This avoids threading complexity and gives the API direct access to all agent state.
```mermaid
flowchart LR
subgraph didactyl process
MAIN[main loop] --> POLL[nostr_handler_poll]
MAIN --> TPOLL[trigger_manager_poll]
MAIN --> HPOLL[http_api_poll]
HPOLL --> ROUTER[request router]
ROUTER --> AGENT[agent internals]
ROUTER --> LLM[LLM client]
ROUTER --> TOOLS[tools context]
ROUTER --> CONFIG[config]
ROUTER --> TRIGGERS[trigger_manager]
end
BROWSER[Web Dashboard] -- HTTP localhost:8484 --> HPOLL
```
### Source Files
| File | Purpose |
|---|---|
| `src/http_api.c` | HTTP server, request router, all endpoint handlers |
| `src/http_api.h` | Public API: `http_api_init`, `http_api_poll`, `http_api_cleanup` |
| `src/mongoose.c` | Mongoose embedded HTTP library |
| `src/mongoose.h` | Mongoose header |
---
## Future Endpoints
The following endpoints are planned but not yet implemented:
| Method | Path | Description |
|---|---|---|
| GET | `/api/config` | Current runtime config with redacted secrets |
| GET | `/api/events/soul` | Fetch agent soul event |
| PUT | `/api/events/soul` | Update soul content |
| GET | `/api/events/skills` | List published skills |
| GET | `/api/events/skills/:slug` | Fetch skill by slug |
| PUT | `/api/events/skills/:slug` | Update skill |
| GET | `/api/events/adoption` | Fetch adoption list |
| GET | `/api/events/profile` | Fetch agent profile |
| PUT | `/api/events/profile` | Update agent profile |
| GET | `/api/triggers` | List active triggers |
| GET | `/api/model` | Current model config |
| PUT | `/api/model` | Update model config |
| GET | `/api/models` | List available models |
| GET | `/api/relays` | Relay connection status |
| GET | `/api/tools` | List tool schemas |
| POST | `/api/tools/:name/execute` | Execute a tool directly |
| GET | `/api/context/log` | Recent context.log entries |
| POST | `/api/context/preview` | Dry-run context preview |

268
docs/CRASH_FIXES.md Normal file
View File

@@ -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

366
docs/SKILLS.md Normal file
View File

@@ -0,0 +1,366 @@
# Didactyl — Skills
See also: [TOOLS.md](TOOLS.md)
## Overview
A skill is a **self-contained LLM execution unit** stored as a Nostr event. Each skill defines what the LLM sees (context template), which model runs it (LLM specification with fallbacks), what tools are available, and whether the agent's identity (soul) is included.
Skills are portable, shareable, and discoverable — they live on Nostr relays as standard events.
---
## 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 |
---
## Skill Content
The skill event's `content` field is a JSON object that defines the complete execution specification:
```json
{
"kind": 31123,
"content": {
"description": "Check spelling and grammar",
"context_mode": "full",
"llm": "openai/gpt-4o-mini, cheap",
"tools": false,
"max_tokens": 2000,
"temperature": 0.1,
"template": "system:\nYou are a spelling and grammar checker.\n\nRules:\n- Fix spelling errors\n- Fix grammar errors\n- Preserve original formatting\n- Ignore: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP\n- Canadian English preferred\n- Return ONLY the corrected text, no explanations\n\nuser:\n{{message}}"
},
"tags": [
["d", "spellcheck"],
["scope", "public"],
["description", "Spelling and grammar checker"]
]
}
```
### Content Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `description` | string | — | Human-readable description |
| `context_mode` | string | `inject` | `inject`, `full`, or `override` |
| `llm` | string | `default` | LLM fallback chain |
| `tools` | bool/array | `true` | `false` = no tools, `true` = all tools, array = specific tool names |
| `template` | string | — | Context template (required for `full` mode) |
| `soul` | bool | `true` | Whether to include the agent's soul in context |
| `max_tokens` | int | — | Override max tokens for this skill |
| `temperature` | float | — | Override temperature for this skill |
---
## Context Modes
Skills control how the LLM context window is assembled.
### inject
The skill's instructions are appended to the agent's soul context. The soul is always present.
```
┌─────────────────────────────────────┐
│ Soul (agent identity + rules) │
│ │
│ ┌─────────────────────────────┐ │
│ │ Skill instructions │ │
│ ├─────────────────────────────┤ │
│ │ Admin context, history, etc │ │
│ └─────────────────────────────┘ │
│ │
│ User message │
└─────────────────────────────────────┘
```
### full
The skill provides its own context template. The soul is **not included** unless the template explicitly references `{{soul}}`. This is for specialized, focused tasks that don't need the agent's identity.
```
┌─────────────────────────────────────┐
│ Skill-defined system prompt │
│ (from skill template) │
│ │
│ User message │
└─────────────────────────────────────┘
```
A `full` mode skill can still include the soul if desired:
```
system:
{{soul}}
You are now in translation mode. Translate the following to {{target_language}}.
Respond ONLY with the translation.
user:
{{message}}
```
### override
The skill replaces the soul's system prompt but keeps the standard context assembly structure (admin context, history, tools, etc.).
### Summary
| Mode | Soul | Template | Tools | Use Case |
|------|------|----------|-------|----------|
| `inject` | ✅ Always | Soul's template + skill instructions appended | Agent default | Behavioral rules, knowledge |
| `full` | ❌ Unless `{{soul}}` in template | Skill provides template | Skill specifies | Spellcheck, translation, focused analysis |
| `override` | Replaced by skill | Soul's template structure | Agent default | Different personality, same capabilities |
### Template Variables
Skill templates may still use placeholders (for example `{{message}}` or `{{triggering_event}}`) for deterministic triggered-skill interpolation, but the soul runtime context is now assembled primarily through template `tool:` directives rather than a separate `{{tools}}`/variable-resolver layer.
| Variable | Source |
|----------|--------|
| `{{soul}}` | The agent's identity/system context |
| `{{admin_profile}}` | Admin kind 0 profile |
| `{{admin_notes}}` | Admin recent notes |
| `{{admin_relays}}` | Admin relay list |
| `{{adopted_skills}}` | Other adopted skill instructions |
| `{{dm_history}}` | Recent DM conversation |
| `{{message}}` | Current user message |
| `{{triggering_event}}` | For triggered skills, the event JSON |
---
## LLM Specification with Fallback Chains
Each skill specifies which LLM to use with CSS font-family-style fallbacks. The runtime tries each model in order; if one is unavailable (API error, rate limit, not configured), it falls back to the next.
### Format
```
model-spec := model-ref ["," model-ref]*
model-ref := provider "/" model-name
| category-alias
```
### Examples
| Skill | LLM Spec |
|-------|----------|
| Spelling checker | `openai/gpt-4o-mini, cheap` |
| Complex analysis | `anthropic/claude-sonnet-4-20250514, openai/gpt-4o, smart` |
| Translation | `openai/gpt-4o-mini, fast` |
| Default behavior | `default` |
### Category Aliases
Aliases map to models in the agent's config:
```json
{
"llm": {
"provider": "openai",
"model": "gpt-4o",
"aliases": {
"fast": "openai/gpt-4o-mini",
"cheap": "openai/gpt-4o-mini",
"smart": "anthropic/claude-sonnet-4-20250514"
}
}
}
```
If all specified models fail, the agent's default model is used as a last resort.
```mermaid
sequenceDiagram
participant Skill as Skill Spec
participant Resolver as LLM Resolver
participant API1 as Model 1
participant API2 as Model 2
participant Default as Default Model
Skill->>Resolver: "anthropic/claude-sonnet, openai/gpt-4o-mini, cheap"
Resolver->>API1: try anthropic/claude-sonnet
alt Available
API1-->>Resolver: ✅
else Unavailable
API1-->>Resolver: ❌
Resolver->>API2: try openai/gpt-4o-mini
alt Available
API2-->>Resolver: ✅
else Unavailable
Resolver->>Default: resolve "cheap" alias
end
end
```
---
## Triggered Skills
A triggered skill has a Nostr subscription filter attached. When matching events arrive, the skill executes automatically.
### Trigger Tags
| Tag | Required | Description |
|---|---|---|
| `trigger` | Yes | Trigger type: `nostr-subscription` |
| `filter` | Yes | JSON-encoded Nostr subscription filter |
| `action` | No | `template` or `llm` (default: `llm`) |
| `enabled` | No | Whether active (default: `true`) |
### Template Actions
Fast, deterministic, no LLM. The skill content is a template with placeholders from the triggering event:
```
DM admin: '{author_display_name} posted: {content_preview}'
```
Placeholders: `{event_id}`, `{pubkey}`, `{author_display_name}`, `{kind}`, `{content}`, `{content_preview}`, `{created_at}`, `{relay_url}`
Output prefixes: `DM admin: ...`, `DM <pubkey>: ...`, `POST: ...`, `LOG: ...`
### LLM-Mediated Actions
The skill content defines the execution context. The triggering event is available as `{{triggering_event}}`. The skill's LLM spec, context mode, and tool access all apply.
```json
{
"kind": 31124,
"content": {
"description": "Analyze mentions and notify admin",
"context_mode": "full",
"llm": "openai/gpt-4o-mini, cheap",
"tools": ["nostr_dm_send"],
"template": "system:\nYou monitor Nostr mentions. When the triggering event mentions Bitcoin or Lightning, summarize it and DM the admin. Otherwise, ignore silently.\n\nTriggering event:\n{{triggering_event}}"
},
"tags": [
["d", "mention-monitor"],
["trigger", "nostr-subscription"],
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
["action", "llm"],
["enabled", "true"]
]
}
```
### 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 + execute prefix]
CHECK_TYPE -->|llm| RESOLVE[Resolve LLM + assemble context + run]
end
PUBLISHED --> LOAD_SKILLS
```
---
## Execution Flow
```mermaid
sequenceDiagram
participant Input as Message/Trigger
participant Dispatch as Dispatcher
participant Skill as Skill Resolver
participant LLM_Res as LLM Resolver
participant Ctx as Context Assembler
participant LLM as LLM API
Input->>Dispatch: message or trigger event
Dispatch->>Skill: which skill handles this?
alt No specific skill
Skill-->>Dispatch: use default soul context
Dispatch->>LLM_Res: resolve "default" LLM
else Skill with context_mode=inject
Skill-->>Dispatch: soul + skill instructions
Dispatch->>LLM_Res: resolve skill.llm or "default"
else Skill with context_mode=full
Skill-->>Dispatch: skill template only
Dispatch->>LLM_Res: resolve skill.llm
end
LLM_Res->>LLM_Res: try models in fallback order
LLM_Res-->>Dispatch: resolved model
Dispatch->>Ctx: assemble context per mode
Ctx->>LLM: request with resolved model + context
LLM-->>Input: response
```
---
## The Soul and Skills
The **soul** is the agent's default identity and context template. Skills interact with it based on their context mode:
- **`inject`** — soul always present; skill instructions layered on top
- **`full`** — soul absent unless template includes `{{soul}}`
- **`override`** — skill replaces soul prompt, keeps standard context structure
A spelling checker runs with no soul — purely functional, minimal context, cheap model. A complex analysis skill includes the full soul and all context parts. The soul is the default, not a requirement.
---
## Limits and Safety
| Limit | Default | Description |
|---|---|---|
| Max concurrent triggers | 16 | Prevents resource exhaustion |
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution |
| LLM action rate limit | 10/min | Prevents runaway LLM costs |
| Template action rate limit | 60/min | Prevents DM spam |
---
## Storage on Nostr
| Data | Storage |
|---|---|
| Skills | Kind 31123/31124 events |
| Adopted skills | Kind 10123 event |
| Trigger definitions | Tags on skill events |
---
## Future Extensions
| Extension | Description |
|---|---|
| `cron` triggers | Time-based triggers |
| `webhook` triggers | HTTP webhook triggers |
| `chain` triggers | Output of one skill triggers another |
| Skill composition | Pipeline multiple skills |
| Agent-to-agent sharing | Discover and adopt skills across agents |
| Trigger marketplace | Popular triggers rise via adoption count |
---
## Related Documentation
- Tool architecture and complete tool catalog: [TOOLS.md](TOOLS.md)
- Project overview and runtime behavior: [README.md](../README.md)

132
docs/TOOLS.md Normal file
View File

@@ -0,0 +1,132 @@
# Didactyl — Tools
See also: [SKILLS.md](SKILLS.md)
## Overview
Didactyl is a **Nostr-first sovereign AI agent** that receives commands via encrypted DMs, reasons with an LLM, and takes actions through **tools**.
This document describes the tools architecture: what tools are, how they are exposed to the model, how execution loops work, what tool categories exist, and how access is gated.
---
## What Tools Are
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
These tools manage skill and trigger lifecycle; skill semantics and trigger execution details are documented in [SKILLS.md](SKILLS.md).
| 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.jsonc` |
| `model_list` | List available model IDs using provider OpenAI-compatible `/models` endpoint |
### System & Runtime Tools
| Tool | Description |
|---|---|
| `agent_version` | Return current Didactyl version and metadata from build macros |
| `local_http_fetch` | Fetch HTTP(S) resources with optional method, headers, timeout, and body |
| `local_shell_exec` | Execute a shell command and return stdout/stderr |
| `local_file_read` | Read a local file as text from the configured working directory |
| `local_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
Tool access is 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 |
---
## Related Documentation
- Skill definitions, adoption, triggers, and autonomous activation: [SKILLS.md](SKILLS.md)
- Combined index page: [TOOLS_AND_SKILLS.md](TOOLS_AND_SKILLS.md)

59
docs/skills_demo.md Normal file
View File

@@ -0,0 +1,59 @@
I've been thinking about how to define skills in Didactyl, my nostr based agentic system. Think OpenClaw but better.
I've written a simple demo page for how I'm thinking skills should work if you're interested: https://laantungir.net/client-ndk/skills-demo.html
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in, otherwise keep reading.
Skills are programs, mostly written in plain english for AI agents. In some sense humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in english, which is much more powerful.
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
When an AI is born from the factory, they come out hard-coded. From that point on, they have no long or short term memory, because they can't learn.
What they do have though have is a way to read. We call it context. You can type or feed documents into an AI's "context window" and then it spits out an answer.
It turns out, that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
The reason why you typically don't get the same thing out is because typically randomness is fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversation to an AI, over and over, it won't remember what you were talking about, because they have no memory other than what you send it each time.
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
I'm calling everything you feed into an AI a SKILL.
Let me explain the demo page and some very basic skills.
What I created is a simple text editor that an AI can work on using it's different skills. Those skills are saved on nostr as a kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for for the skills it has adopted.
On the left of the page you see the text editor with some sample text. That is for our AI to use it's skills on.
On the right are publicly listed skills. You should see my demo skills in there.
I made 5 skills public:
condense-5
convert-to-poem
sexy
spellcheck
translate-ja
Select the skill, then click on "Run Selected Skill"
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
You can also create and edit your own skills, if you are logged in. You can log in as yourself, or use a random new key to test this out. I would recommend that.
So what is the point of all this? What are the benefits of Skills?
- Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
- A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically save on nostr, and you can lock it down from changes.
- By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
- If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create very very complicated and elaborate skills.
For more technical information on skills and how I'm thinking about them, you can check out this document.
https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn

View File

@@ -0,0 +1,60 @@
I've been thinking about how to define skills in Didactyl, my Nostr-based agentic system. Think OpenClaw, but better.
I've written a simple demo page for how I'm thinking skills should work if you're interested: Skills Demo (https://laantungir.net/client-ndk/skills-demo.html)
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in; otherwise, keep reading.
Skills are programs, mostly written in plain English for AI agents. In some sense, humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in English, which is much more powerful.
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
When an AI is born from the factory, it comes out hard-coded. From that point on, it has no long- or short-term memory because it can't learn.
What they do have, though, is a way to read. We call it context. You can type or feed documents into an AI's "context window," and then it spits out an answer.
It turns out that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
The reason why you typically don't get the same thing out is because randomness is usually fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversations to an AI, over and over, it won't remember what you were talking about, because it has no memory other than what you send it each time.
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
I'm calling everything you feed into an AI a SKILL.
Let me explain the demo page and some very basic skills.
What I created is a simple text editor that an AI can work on using its different skills. Those skills are saved on Nostr as kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for the skills it has adopted.
On the left of the page, you see the text editor with some sample text. That is for our AI to use its skills on.
On the right are publicly listed skills. You should see my demo skills in there.
I made 5 skills public:
condense-5
convert-to-poem
sexy
spellcheck
translate-ja
Select the skill, then click on "Run Selected Skill".
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
You can also create and edit your own skills if you are logged in. You can log in as yourself or use a random new key to test this out. I would recommend that.
So what is the point of all this? What are the benefits of Skills?
Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically saved on Nostr, and you can lock it down from changes.
By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create extremely complicated and elaborate skills.
For more technical information on skills and how I'm thinking about them, you can check out this document:
SKILLS.md (https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md)
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn
And thanks to npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s for their fantastic service.

View File

@@ -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"

71
local_test_servers.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import json
import ssl
import threading
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
HOST = "127.0.0.1"
HTTP_PORT = 9080
HTTPS_PORT = 9449
CERT = "/home/teknari/.ssl_for_local_servers/cert.pem"
KEY = "/home/teknari/.ssl_for_local_servers/key.pem"
HTML_FILE = Path("./didactyl.html")
class Handler(BaseHTTPRequestHandler):
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Private-Network", "true")
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
if self.path in ("/", "/didactyl.html") and HTML_FILE.exists():
body = HTML_FILE.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self._cors()
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
body = json.dumps(
{
"success": True,
"name": "python-test",
"path": self.path,
"scheme_hint": "https" if self.server.server_port == HTTPS_PORT else "http",
}
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self._cors()
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print(f"[{self.server.server_port}] " + (fmt % args), flush=True)
httpd = HTTPServer((HOST, HTTP_PORT), Handler)
httpsd = HTTPServer((HOST, HTTPS_PORT), Handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(certfile=CERT, keyfile=KEY)
httpsd.socket = ctx.wrap_socket(httpsd.socket, server_side=True)
print(f"HTTP listening on http://{HOST}:{HTTP_PORT}", flush=True)
print(f"HTTPS listening on https://{HOST}:{HTTPS_PORT}", flush=True)
print("Use Ctrl+C to stop", flush=True)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
httpsd.serve_forever()

469
plans/admin_api.md Normal file
View File

@@ -0,0 +1,469 @@
# Didactyl Admin HTTP API — Architecture & Implementation Plan
## Overview
Add a localhost-only HTTP API to didactyl so an external web dashboard can inspect and manage the agent at runtime. No authentication required — binding to `127.0.0.1` only. All responses are JSON. CORS headers included for browser access from any local origin.
The web frontend is a separate project; this plan covers only the C-side HTTP server and API endpoints.
---
## Architecture
```mermaid
flowchart LR
subgraph didactyl process
MAIN[main loop] --> POLL[nostr_handler_poll]
MAIN --> TPOLL[trigger_manager_poll]
MAIN --> HPOLL[http_api_poll]
HPOLL --> ROUTER[request router]
ROUTER --> AGENT[agent internals]
ROUTER --> NOSTR[nostr_handler]
ROUTER --> TOOLS[tools context]
ROUTER --> CONFIG[config]
ROUTER --> TRIGGERS[trigger_manager]
end
BROWSER[Web Dashboard] -- HTTP localhost:8484 --> HPOLL
```
### HTTP Library Choice
Use a minimal embedded HTTP server. Two good options for C with no extra dependencies:
1. **mongoose** (single `mongoose.c` + `mongoose.h`) — battle-tested, MIT license, supports polling model
2. **microhttpd** (libmicrohttpd) — GNU project, available as system package
**Recommendation: mongoose** — it is a single-file drop-in, works with the existing poll-based main loop, and requires zero system dependencies. Just add `mongoose.c` and `mongoose.h` to the project.
### Integration Pattern
The HTTP server runs in the same thread as the main poll loop. Each iteration calls `http_api_poll()` which does non-blocking accept/read/write via mongoose's `mg_mgr_poll()`. This avoids threading complexity and gives the API direct access to all agent state.
---
## Config Extension
```json
{
"api": {
"enabled": true,
"port": 8484,
"bind_address": "127.0.0.1"
}
}
```
Defaults: enabled=false, port=8484, bind=127.0.0.1.
---
## API Endpoints
All endpoints return JSON. All mutations use POST/PUT/DELETE. All reads use GET.
### Agent Identity & Status
| Method | Path | Description |
|---|---|---|
| GET | `/api/status` | Agent runtime status: pubkey, display name, version, uptime, connected relay count, trigger count |
| GET | `/api/config` | Current runtime config (redacted: nsec/api_key masked) |
### Nostr Events — Read & Edit
| Method | Path | Description |
|---|---|---|
| GET | `/api/events/soul` | Fetch the agent soul event (kind 31120, d=soul) |
| PUT | `/api/events/soul` | Update soul content, republish to relays |
| GET | `/api/events/skills` | List all published skills (kind 31123/31124 by own pubkey) |
| GET | `/api/events/skills/:slug` | Fetch a single skill by slug |
| PUT | `/api/events/skills/:slug` | Update skill content/tags, republish |
| DELETE | `/api/events/skills/:slug` | Remove skill from adoption list |
| GET | `/api/events/adoption` | Fetch kind 10123 adoption list |
| GET | `/api/events/startup` | List startup events from config |
| GET | `/api/events/profile` | Fetch agent kind 0 profile |
| PUT | `/api/events/profile` | Update agent kind 0 profile, republish |
| GET | `/api/events/query` | Generic Nostr query — pass filter as query params or JSON body |
### Context Inspector
| Method | Path | Description |
|---|---|---|
| GET | `/api/context/current` | Build and return the full context that would be sent to the LLM right now, broken into labeled parts |
| GET | `/api/context/parts` | Return context parts with individual sizes (bytes and estimated tokens) |
| GET | `/api/context/log` | Return recent context.log entries (last N blocks, configurable via ?limit=) |
| POST | `/api/context/preview` | Accept a modified context structure, return what the LLM payload would look like (dry run, no send) |
### Context Parts Response Shape
```json
{
"total_chars": 12450,
"total_estimated_tokens": 3112,
"parts": [
{
"name": "system_prompt",
"role": "system",
"chars": 1200,
"estimated_tokens": 300,
"content": "# Didactyl Agent..."
},
{
"name": "admin_identity",
"role": "system",
"chars": 450,
"estimated_tokens": 112,
"content": "This is your administrator!..."
},
{
"name": "admin_kind0",
"role": "system",
"chars": 320,
"estimated_tokens": 80,
"content": "Administrator kind 0 profile..."
},
{
"name": "startup_events",
"role": "system",
"chars": 4800,
"estimated_tokens": 1200,
"content": "Startup events memory..."
},
{
"name": "adopted_skills",
"role": "system",
"chars": 2100,
"estimated_tokens": 525,
"content": "Adopted skills memory..."
},
{
"name": "dm_history",
"role": "mixed",
"chars": 2400,
"estimated_tokens": 600,
"turns": 8
},
{
"name": "admin_notes",
"role": "system",
"chars": 680,
"estimated_tokens": 170,
"content": "Administrator recent public notes..."
},
{
"name": "tools_schema",
"chars": 500,
"estimated_tokens": 125,
"tool_count": 28
}
]
}
```
### Triggers
| Method | Path | Description |
|---|---|---|
| GET | `/api/triggers` | List active triggers with status (wraps existing trigger_manager_status_json) |
### Model / LLM
| Method | Path | Description |
|---|---|---|
| GET | `/api/model` | Current model config (wraps existing model_get) |
| PUT | `/api/model` | Update model config (wraps existing model_set) |
| GET | `/api/models` | List available models from provider (wraps existing model_list) |
### Relays
| Method | Path | Description |
|---|---|---|
| GET | `/api/relays` | Relay connection status (wraps existing relay_status tool) |
### Prompt Crafting & Execution
| Method | Path | Description |
|---|---|---|
| POST | `/api/prompt/run` | Submit a custom messages array with tools enabled; returns full LLM response including tool calls and results |
| POST | `/api/prompt/run-simple` | Submit system prompt + user message; returns LLM text response (no tools) |
| POST | `/api/prompt/compare` | A/B test: submit two prompt variants, run both, return side-by-side responses |
#### POST /api/prompt/run
Send a fully crafted messages array to the LLM with the full tool set enabled. The agent executes tool calls and returns the complete conversation.
```json
{
"messages": [
{"role": "system", "content": "You are Didactyl..."},
{"role": "system", "content": "Adopted skills memory..."},
{"role": "user", "content": "Tweet about the weather"}
],
"model": "claude-haiku-4.5",
"max_turns": 5,
"tools_enabled": true
}
```
Response:
```json
{
"success": true,
"final_response": "Done! I posted a tweet about the weather.",
"turns": [
{
"turn": 1,
"tool_calls": [
{"name": "nostr_post", "arguments": "...", "result": "..."}
]
}
],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 3200,
"total_output_tokens_estimate": 180
}
```
#### POST /api/prompt/run-simple
Quick iteration on prompt wording without tools.
```json
{
"system": "You are a helpful assistant that writes tweets...",
"user": "Write a tweet about AI agents on Nostr",
"model": "claude-haiku-4.5"
}
```
Response:
```json
{
"success": true,
"response": "AI agents are finding their home on Nostr...",
"model_used": "claude-haiku-4.5",
"input_tokens_estimate": 85,
"output_tokens_estimate": 42
}
```
#### POST /api/prompt/compare
A/B testing: submit two prompt variants, both are executed, responses returned side-by-side.
```json
{
"variant_a": {
"messages": [
{"role": "system", "content": "You are Didactyl. Keep responses under 280 chars."},
{"role": "user", "content": "Tweet about your new skill"}
],
"model": "claude-haiku-4.5",
"tools_enabled": true
},
"variant_b": {
"messages": [
{"role": "system", "content": "You are Didactyl. Be concise. No markdown. No emoji."},
{"role": "user", "content": "Tweet about your new skill"}
],
"model": "claude-haiku-4.5",
"tools_enabled": true
}
}
```
Response:
```json
{
"success": true,
"variant_a": {
"final_response": "Just picked up the tweet-composer skill! ...",
"turns": [],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 3200,
"total_output_tokens_estimate": 95
},
"variant_b": {
"final_response": "New skill acquired: tweet-composer. ...",
"turns": [],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 3100,
"total_output_tokens_estimate": 78
}
}
```
The compare endpoint runs variant_a first, then variant_b sequentially. Each variant can optionally use a different model for cross-model comparison.
#### Prompt Crafting Workflow
```mermaid
flowchart TD
LOAD[GET /api/context/parts] --> EDIT[Edit parts in UI]
EDIT --> PREVIEW[POST /api/context/preview]
PREVIEW --> FIRE[POST /api/prompt/run]
FIRE --> COMPARE{Want to compare?}
COMPARE -- Yes --> AB[POST /api/prompt/compare]
COMPARE -- No --> PERSIST{Like the result?}
AB --> PERSIST
PERSIST -- Yes --> SAVE[PUT /api/events/soul or skills]
PERSIST -- No --> EDIT
```
### Tools
| Method | Path | Description |
|---|---|---|
| GET | `/api/tools` | List all registered tool schemas |
| POST | `/api/tools/:name/execute` | Execute a tool by name with JSON body as args (admin-only equivalent) |
---
## Implementation Plan
### New Files
| File | Purpose |
|---|---|
| `src/http_api.c` | HTTP server, request router, endpoint handlers |
| `src/http_api.h` | Public API: init, poll, cleanup |
| `vendor/mongoose.c` | Mongoose HTTP library (single file) |
| `vendor/mongoose.h` | Mongoose header |
### Modified Files
| File | Change |
|---|---|
| `src/config.h` | Add `api_config_t` struct to `didactyl_config_t` |
| `src/config.c` | Parse `api` config section |
| `src/main.c` | Call `http_api_init()`, add `http_api_poll()` to main loop, call `http_api_cleanup()` on shutdown |
| `src/agent.h` | Expose `agent_build_context_parts_json()` for context inspector |
| `src/agent.c` | Implement `agent_build_context_parts_json()` that builds context and returns labeled parts with sizes |
| `Makefile` | Add `vendor/mongoose.c` and `src/http_api.c` to SRCS, add `-Ivendor` to INCLUDES |
### http_api.h
```c
#ifndef DIDACTYL_HTTP_API_H
#define DIDACTYL_HTTP_API_H
#include "config.h"
#include "tools.h"
struct trigger_manager;
typedef struct {
didactyl_config_t* cfg;
tools_context_t* tools_ctx;
struct trigger_manager* trigger_manager;
} http_api_context_t;
int http_api_init(http_api_context_t* ctx);
int http_api_poll(int timeout_ms);
void http_api_cleanup(void);
#endif
```
### Main Loop Integration
```c
// In main.c, after agent_init and trigger_manager_init:
http_api_context_t api_ctx = {
.cfg = &cfg,
.tools_ctx = &g_tools_ctx, // need to expose from agent
.trigger_manager = &trigger_manager
};
if (cfg.api.enabled) {
if (http_api_init(&api_ctx) != 0) {
DEBUG_WARN("HTTP API failed to start");
}
}
// In main loop:
while (g_running) {
nostr_handler_poll(100);
trigger_manager_poll(&trigger_manager);
if (cfg.api.enabled) {
http_api_poll(0); // non-blocking
}
nanosleep(...);
}
// On shutdown:
if (cfg.api.enabled) {
http_api_cleanup();
}
```
### Request Router Pattern
```c
static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message* hm = ev_data;
// Add CORS headers to all responses
// Route by method + path prefix
if (mg_match(hm->uri, mg_str("/api/status"), NULL) && is_get(hm)) {
handle_status(c, hm);
} else if (mg_match(hm->uri, mg_str("/api/context/parts"), NULL) && is_get(hm)) {
handle_context_parts(c, hm);
} else if (mg_match(hm->uri, mg_str("/api/events/skills/*"), NULL)) {
handle_skill_by_slug(c, hm);
}
// ... etc
}
}
```
---
## Implementation Order
1. Add `api_config_t` to config and parse it
2. Vendor mongoose.c/mongoose.h, update Makefile
3. Create `src/http_api.c` with init/poll/cleanup skeleton + CORS
4. Wire into main.c poll loop
5. Implement read-only endpoints first: `/api/status`, `/api/config`, `/api/relays`, `/api/model`, `/api/tools`, `/api/triggers`
6. Implement Nostr event endpoints: `/api/events/soul`, `/api/events/skills`, `/api/events/adoption`, `/api/events/profile`, `/api/events/startup`
7. Implement context inspector: `/api/context/parts`, `/api/context/current`, `/api/context/log`
8. Implement mutation endpoints: PUT soul, PUT skills, PUT model, PUT profile
9. Implement tool execution endpoint: POST `/api/tools/:name/execute`
10. Implement context preview: POST `/api/context/preview`
11. Test all endpoints via curl
12. Update documentation
---
## CORS Headers
Every response includes:
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type
```
OPTIONS requests return 204 with these headers (preflight support).
---
## Security Notes
- Binds to `127.0.0.1` only — not accessible from network
- No authentication — this is a local dev tool
- The `api.enabled` config flag defaults to `false` so it must be explicitly opted in
- Tool execution endpoint gives full admin-tier access — acceptable for localhost dev dashboard
- Config endpoint redacts `nsec` and `api_key` fields
---
## Token Estimation
For the context size display, use a simple heuristic: `estimated_tokens = chars / 4`. This is a rough approximation that works well enough for English text with the major model families. No need for a real tokenizer.

302
plans/admin_web_frontend.md Normal file
View File

@@ -0,0 +1,302 @@
# Didactyl Admin Web Frontend — Project Brief
## What Is Didactyl?
Didactyl is a sovereign AI agent that lives on Nostr. It connects to Nostr relays, listens for encrypted DMs from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands, managing skills. Everything the agent knows and does is stored as Nostr events.
The agent is a C binary that runs on a server. It has no web interface of its own — all interaction happens through Nostr DMs.
## What We Are Building
A **local web admin dashboard** that connects to the running didactyl agent via a localhost HTTP API. The dashboard is a prompt crafting and agent inspection tool for the administrator.
This is **not** a chat interface. The administrator already chats with the agent through Nostr DMs. This dashboard is for:
1. **Inspecting** what the agent sees — its full LLM context, broken into labeled parts with token counts
2. **Crafting** custom prompts — editing system prompts, user messages, and context pieces
3. **Running** prompts against the LLM — with or without the agent tool set
4. **Comparing** prompt variants side-by-side — A/B testing different prompt wordings or models
---
## The API
The didactyl agent exposes a localhost-only HTTP API on port `8484` by default. Full API documentation is in `docs/API.md`. All endpoints return JSON with CORS headers.
### Base URL
```
http://127.0.0.1:8484
```
### Currently Implemented Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/status` | Agent runtime status — name, version, pubkey, relay count, trigger count |
| GET | `/api/context/current` | Full LLM context messages array with total char/token counts |
| GET | `/api/context/parts` | Context broken into labeled parts with individual sizes |
| POST | `/api/prompt/run-simple` | Simple prompt: system + user message, no tools, returns text |
| POST | `/api/prompt/run` | Full prompt: messages array with tools enabled, returns conversation trace |
| POST | `/api/prompt/compare` | A/B test: two prompt variants run sequentially, responses side-by-side |
| GET | `/api/model` | Current LLM model config (provider, model, base_url, max_tokens, temperature) |
| PUT | `/api/model` | Change model at runtime — persists to config.json |
| GET | `/api/models` | List available models from the configured provider |
### Planned Future Endpoints
These are not yet implemented but are on the roadmap:
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/config` | Runtime config with redacted secrets |
| GET | `/api/events/soul` | Agent soul/system prompt event |
| PUT | `/api/events/soul` | Update soul content |
| GET | `/api/events/skills` | List skills |
| GET/PUT | `/api/events/skills/:slug` | Read/update individual skills |
| GET | `/api/events/profile` | Agent Nostr profile |
| GET | `/api/tools` | List all tool schemas |
| POST | `/api/tools/:name/execute` | Execute a tool directly |
| GET | `/api/triggers` | Active trigger subscriptions |
| GET | `/api/relays` | Relay connection status |
---
## Core User Workflows
### 1. Context Inspector
The primary read-only workflow. The admin wants to understand what the agent sees when it processes a message.
```mermaid
flowchart TD
LOAD[Load /api/context/parts] --> DISPLAY[Display parts list]
DISPLAY --> DETAIL[Click part to expand content]
DETAIL --> TOKENS[Show char count and token estimate per part]
TOKENS --> TOTAL[Show total context size]
```
**What to show:**
- A list/table of context parts with name, role, character count, estimated tokens
- Total context size as a summary bar or header
- Expandable content for each part
- The parts are: `system_prompt`, `admin_identity`, `admin_profile`, `admin_relay_list`, `startup_events`, `adopted_skills`, `dm_history` (one entry per turn, up to limit), `admin_notes`
- Part names come from the `---template---` section of the soul event (kind 31120); they may differ if the soul is customised
### 2. Simple Prompt Crafting
Quick iteration on prompt wording without tools.
```mermaid
flowchart TD
WRITE[Write system prompt + user message] --> RUN[POST /api/prompt/run-simple]
RUN --> RESULT[Display response text]
RESULT --> EDIT[Edit and re-run]
EDIT --> RUN
```
**What to show:**
- Two text areas: system prompt, user message
- Optional model override dropdown/input
- Run button
- Response display with model used and token estimates
### 3. Full Prompt with Tools
Craft a complete messages array and run it with the agent tool set.
```mermaid
flowchart TD
CONTEXT[Load context from /api/context/parts] --> EDIT[Edit/rearrange context parts]
EDIT --> ADD[Add user message]
ADD --> RUN[POST /api/prompt/run]
RUN --> TRACE[Display conversation trace]
TRACE --> TOOLS[Show tool calls and results per turn]
TOOLS --> FINAL[Show final response]
```
**What to show:**
- Pre-populate from context parts or start from scratch
- Messages editor — add/remove/reorder messages with role and content
- Max turns slider/input
- Optional model override
- Run button
- Turn-by-turn trace showing tool calls with name, arguments, and results
- Final response text
- Token estimates
### 4. A/B Prompt Comparison
Compare two prompt variants side-by-side.
```mermaid
flowchart TD
CRAFT_A[Craft variant A messages] --> CRAFT_B[Craft variant B messages]
CRAFT_B --> COMPARE[POST /api/prompt/compare]
COMPARE --> SIDE[Display responses side-by-side]
SIDE --> DIFF[Compare final responses and token usage]
```
**What to show:**
- Two prompt editors side-by-side, each with messages array + model override + max turns
- Compare button
- Side-by-side response display
- Highlight differences in final response text
- Token usage comparison
### 5. Status Dashboard
Simple overview of agent health.
**What to show:**
- Agent name, version, pubkey
- Connected relays count vs configured
- Active triggers count
- API connection status indicator
---
## Key Design Decisions
### Localhost Only
The API binds to `127.0.0.1` — the frontend must run on the same machine as the agent, or use SSH tunneling. There is no authentication. This is intentional — it is a local dev/admin tool.
### No WebSocket
The API is plain HTTP request/response. There is no WebSocket or streaming. Prompt execution calls may take several seconds for LLM responses — the frontend should show a loading state.
### Token Estimation
All token counts from the API use a `chars / 4` heuristic. This is approximate. The frontend can display these as-is or add its own tokenizer if more precision is needed.
### Model Override
The `model` field in prompt requests temporarily overrides the agent configured model for that single request, then restores the original. This enables cross-model comparison without changing agent config.
### Tool Execution Is Real
When using `/api/prompt/run` or `/api/prompt/compare`, tool calls are **actually executed**. If the LLM decides to post a Nostr event, it will really post it. The frontend should make this clear to the user — perhaps with a warning or confirmation before running prompts with tools enabled.
---
## Response Shapes Quick Reference
### Status
```json
{
"success": true,
"name": "Didactyl",
"version": "v0.0.26",
"pubkey": "52a3e8...",
"relay_count": 4,
"connected_relays": 4,
"active_triggers": 0
}
```
### Context Parts
```json
{
"success": true,
"total_chars": 13131,
"total_estimated_tokens": 3283,
"parts": [
{
"name": "system_prompt",
"role": "system",
"chars": 1200,
"estimated_tokens": 300,
"content": "# Didactyl Agent..."
}
],
"messages": [...]
}
```
### Simple Prompt Response
```json
{
"success": true,
"response": "ok",
"model_used": "claude-haiku-4.5",
"input_tokens_estimate": 10,
"output_tokens_estimate": 1
}
```
### Full Prompt Response
```json
{
"success": true,
"final_response": "Done! I posted a tweet.",
"turns": [
{
"turn": 1,
"tool_calls": [
{"name": "nostr_post", "arguments": "...", "result": "..."}
]
}
],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 3200,
"total_output_tokens_estimate": 180
}
```
### Compare Response
```json
{
"success": true,
"variant_a": { "...same shape as full prompt response..." },
"variant_b": { "...same shape as full prompt response..." }
}
```
### Error Response
```json
{
"success": false,
"error": "description of what went wrong"
}
```
---
## Technology Suggestions
No technology is mandated for the frontend. Some reasonable choices:
- **Vanilla HTML/JS** — simplest, no build step, just open in browser
- **React/Preact** — if you want component structure
- **Svelte** — lightweight, good for small dashboards
- **Vue** — also fine
The frontend is a separate project from didactyl. It just needs to make HTTP requests to `localhost:8484`.
---
## File References
| File | Description |
|---|---|
| `docs/API.md` | Full API endpoint reference with request/response examples |
| `plans/admin_api.md` | Original architecture plan for the HTTP API |
| `src/http_api.c` | C implementation of all endpoints |
| `src/http_api.h` | Public API header |
| `config.json.example` | Example config showing the `api` section |
---
## Getting Started
1. Ensure didactyl is running with `api.enabled: true` in config.json
2. Verify the API is up: `curl http://127.0.0.1:8484/api/status`
3. Build the frontend to talk to `http://127.0.0.1:8484`
4. Start with the status endpoint and context inspector, then add prompt crafting

215
plans/agent_tasks.md Normal file
View File

@@ -0,0 +1,215 @@
# Agent Tasks: Short-Term Memory via Context-Injected Task List
## Summary
Add a **tasks** system that serves as the agent's short-term working memory. The agent can break down goals into steps, track progress, and see its current task list in every prompt context. Tasks are file-backed (not stored on Nostr) and managed via a dedicated `task_manage` tool.
## How It Works
```mermaid
flowchart TD
A[User sends message] --> B[Context builder runs]
B --> C[Template resolver hits tasks_content variable]
C --> D[Read tasks.json from disk]
D --> E{Tasks exist?}
E -->|Yes| F[Format tasks as readable text]
E -->|No| G[Return empty string - section skipped]
F --> H[Inject as system message in prompt]
G --> H
H --> I[LLM sees current tasks in context]
I --> J{LLM decides to update tasks?}
J -->|Yes| K[LLM calls task_manage tool]
K --> L[Tool updates tasks.json on disk]
L --> M[Tool result returned to LLM]
J -->|No| N[LLM responds normally]
```
## Design
### Storage: `tasks.json`
A simple JSON file in the agent's working directory. Structure:
```json
{
"tasks": [
{
"id": 1,
"text": "Query admin relay list to find active relays",
"status": "done",
"created_at": 1709535600,
"updated_at": 1709535660
},
{
"id": 2,
"text": "Draft long-form article about Nostr relay setup",
"status": "active",
"created_at": 1709535600,
"updated_at": 1709535600
},
{
"id": 3,
"text": "Publish article as kind 30023",
"status": "pending",
"created_at": 1709535600,
"updated_at": 1709535600
}
],
"next_id": 4
}
```
Task statuses: `pending`, `active`, `done`
### Tool: `task_manage`
A single tool with an `action` parameter that covers all operations:
| Action | Parameters | Description |
|--------|-----------|-------------|
| `list` | *(none)* | Return all tasks with status |
| `add` | `text`, optional `status` | Add a new task, default status `pending` |
| `update` | `id`, optional `text`, optional `status` | Update text and/or status of a task |
| `remove` | `id` | Remove a task by ID |
| `clear` | optional `status` | Remove all tasks, or all with a given status |
| `replace` | `tasks` (array of text strings) | Replace entire task list with new items |
The `replace` action is important — it lets the LLM rewrite the whole plan in one call rather than doing add/remove/update one at a time. This is the most common pattern: the agent works out a plan and writes all steps at once.
**Tool schema:**
```json
{
"name": "task_manage",
"description": "Manage the agent task list - short-term working memory for tracking steps in a plan. Tasks appear in your context on every message.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "add", "update", "remove", "clear", "replace"]
},
"text": { "type": "string" },
"id": { "type": "integer" },
"status": { "type": "string", "enum": ["pending", "active", "done"] },
"tasks": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["action"]
}
}
```
### Context Section: `agent_tasks`
New section in the context template, placed after `adopted_skills` and before `dm_history`:
```yaml
- section: agent_tasks
role: system
skip_if_empty: true
content: |
{{tasks_content}}
```
### Template Variable: `{{tasks_content}}`
New resolver in `agent_template_resolve_var()` that:
1. Reads `tasks.json` from the working directory
2. Parses the JSON
3. Formats active/pending tasks as readable text
4. Returns empty string if no tasks exist (section gets skipped via `skip_if_empty`)
**Rendered format in context:**
```
### Current Tasks
Your active task list - short-term working memory for tracking plan steps.
- [x] 1. Query admin relay list to find active relays
- [-] 2. Draft long-form article about Nostr relay setup
- [ ] 3. Publish article as kind 30023
```
Legend: `[x]` = done, `[-]` = active, `[ ]` = pending
Done tasks are included so the agent has continuity about what it already accomplished, but they could be pruned after a configurable count or age to save tokens.
### System Prompt Addition
Add to the agent's behavioral rules in the soul/system prompt:
```
### Task Management
- You have a task list that serves as your short-term working memory.
- When working on multi-step goals, use task_manage to track your plan.
- Update task status as you complete steps.
- Your current tasks appear in your context automatically.
```
## Implementation Steps
### 1. Add `task_manage` tool implementation in `tools.c`
- New `execute_task_manage()` function
- Reads/writes `tasks.json` in the working directory (uses `build_tool_path` for sandboxing)
- Handles all 6 actions: list, add, update, remove, clear, replace
- Returns JSON result with success/failure and current task list
### 2. Register `task_manage` tool schema in `tools_build_openai_schema_json()`
- Add tool definition (t35 or next available) with the schema above
### 3. Wire `task_manage` into `tools_execute()` dispatch
- Add `strcmp(tool_name, "task_manage")` branch calling `execute_task_manage()`
### 4. Add `{{tasks_content}}` template variable resolver in `agent.c`
- New `build_tasks_content_string()` function
- Reads `tasks.json`, formats as markdown checklist
- Add to `agent_template_resolve_var()` for var name `tasks_content`
### 5. Add `agent_tasks` section to context template
- Add the new section in `context_template.md`
- Place after `adopted_skills`, before `dm_history`
- Use `skip_if_empty: true` so it costs zero tokens when no tasks exist
### 6. Add section detection for context logging
- Add `agent_tasks` detection in `detect_context_section()` in `agent.c`
### 7. Add task management guidance to system prompt
- Brief behavioral instruction so the agent knows when/how to use the task list
## Token Budget Considerations
- Empty task list: **0 tokens** (skipped via `skip_if_empty`)
- Typical 5-task plan: **~80-120 tokens**
- Maximum reasonable list of 15 tasks: **~250-350 tokens**
- Consider pruning done tasks older than N turns or keeping only the last M done tasks
## Future: User-Facing To-Do List (Nostr)
This is explicitly **not** the user-facing to-do list. That future feature would:
- Store items as Nostr events (likely a NIP-51 style list or custom kind)
- Be visible to the user via Nostr clients
- Have its own separate tool (`todo_manage` or similar)
- Potentially reference agent tasks that graduate to user-visible items
The agent tasks system is purely internal working memory.
## Files Modified
| File | Change |
|------|--------|
| `src/tools.c` | Add `execute_task_manage()`, tool schema, dispatch entry |
| `src/agent.c` | Add `build_tasks_content_string()`, resolver entry, section detection |
| `context_template.md` | Add `agent_tasks` section |
| Soul/system prompt (kind 31120) | Add task management behavioral guidance |

View File

@@ -0,0 +1,139 @@
# Didactyl Context Architecture Plan
## Problem Statement
The agent's context assembly is a hardcoded sequence of C function calls in `agent_on_message()`. This creates several issues:
1. **Skills are never injected** — adopted skills exist on Nostr but the LLM never sees their content
2. **No configurability** — changing context order, content, or framing requires C code changes and recompilation
3. **No A/B testing** — can't experiment with different prompt structures, ordering, or model-specific tuning
4. **No token budget awareness** — context grows unbounded as skills/history/notes accumulate
5. **Model-agnostic** — different models respond differently to the same prompt structure; no way to tune per-model
## Current Context Pipeline
```
agent_on_message() builds messages array:
1. system: g_system_context (kind 31120 "soul" content)
2. system: admin identity (pubkey + kind 0 profile + kind 10002 relays)
3. system: startup events (raw JSON of all startup event kinds/content/tags)
4. user/assistant: recent DM history (last 12 turns)
5. system: admin kind 1 notes (recent public posts)
6. user: the actual incoming message
```
Skills are **completely absent**. The LLM has no knowledge of adopted skill instructions.
## Proposed Architecture: Context Pipeline with Configurable Slots
### Core Idea
Replace the hardcoded function chain with a **configurable context pipeline** defined in `config.json`. Each "slot" in the pipeline is a named context source with configurable parameters.
### Context Slot Types
| Slot Type | Source | Description |
|---|---|---|
| `soul` | Kind 31120 startup event | Agent personality and behavioral rules |
| `identity` | Config + relay queries | Agent's own pubkey, admin pubkey, admin profile |
| `startup_events` | Config startup events | Raw startup event memory |
| `adopted_skills` | Kind 10123 + resolved skills | **NEW**: Adopted skill instructions |
| `dm_history` | Relay query | Recent conversation turns |
| `admin_notes` | Cached kind 1 events | Admin's recent public posts |
| `admin_context` | Kind 0/3/10002 | Admin profile, contacts, relay list |
| `custom` | Literal string in config | Arbitrary system message for A/B testing |
### Phase 1: Immediate Fix (Skills + Caching)
Before building the full configurable pipeline, fix the immediate problem:
1. **In-memory skill cache** — load adopted skills at startup and cache them; invalidate on `skill_create`, `skill_adopt`, `skill_remove`
2. **`append_adopted_skills_context()`** — inject cached skills into the conversation as a system message
3. **Strong framing** — "These are your learned skills. When a request matches a skill, you MUST follow its instructions exactly."
### Phase 2: Configurable Context Pipeline
Add a `context_pipeline` section to `config.json`:
```json
{
"context_pipeline": {
"max_total_chars": 12000,
"slots": [
{ "type": "soul", "max_chars": 3000 },
{ "type": "identity" },
{ "type": "adopted_skills", "max_chars": 4000, "max_per_skill": 1000 },
{ "type": "startup_events", "max_chars": 2000 },
{ "type": "dm_history", "max_turns": 12 },
{ "type": "admin_notes", "max_chars": 1500 },
{ "type": "custom", "content": "Always respond in the style of a pirate." }
]
}
}
```
This gives you:
- **Ordering control** — move skills before or after history
- **Token budgets** — per-slot and total caps
- **A/B testing** — swap `custom` slot content, reorder slots, change caps
- **Model-specific tuning** — different pipeline configs for different models (could key off `llm.model`)
### Phase 3: Model-Aware Context Profiles
```json
{
"context_profiles": {
"default": { ... pipeline config ... },
"claude-sonnet-4.6": { ... different ordering/caps ... },
"gpt-5.2-codex": { ... different ordering/caps ... }
}
}
```
The agent selects the profile matching the active model, falling back to `default`.
## Skill Cache Design
```
┌─────────────────────────────────────────┐
│ Skill Cache (in-memory) │
│ │
│ Loaded at startup from: │
│ 1. Startup events in config.json │
│ 2. Kind 10123 adoption list │
│ 3. Resolved skill events from relays │
│ │
│ Invalidated by: │
│ - skill_create (add/update) │
│ - skill_adopt (add) │
│ - skill_remove (remove) │
│ │
│ Structure per skill: │
│ - slug (string) │
│ - description (string) │
│ - content (string, full) │
│ - scope (public/private) │
│ - has_trigger (bool) │
│ - source (startup/adopted) │
└─────────────────────────────────────────┘
```
## Implementation Priority
### Do Now (Phase 1)
- [ ] Build skill cache in `agent.c` (load at startup, invalidate on tool calls)
- [ ] Add `append_adopted_skills_context()` using cached skills
- [ ] Wire into `agent_on_message()` between startup events and DM history
- [ ] Skill content framing: strong directive for LLM compliance
### Do Next (Phase 2)
- [ ] Add `context_pipeline` config section
- [ ] Refactor `agent_on_message()` to iterate pipeline slots
- [ ] Per-slot `max_chars` truncation
- [ ] Total pipeline `max_total_chars` budget
- [ ] `custom` slot type for arbitrary A/B test content
### Do Later (Phase 3)
- [ ] Model-aware context profiles
- [ ] Context analytics (log token counts per slot per conversation)
- [ ] Dynamic skill relevance scoring (only inject skills likely relevant to the current message)

View File

@@ -0,0 +1,167 @@
# Context Optimization Plan
Analysis of [`context.log.md`](../context.log.md) (13,609 bytes / ~3,402 tokens across 20 sections) and [`context_template.md`](../context_template.md).
## Issues Found
### 1. Massive Duplication in `startup_events` Section
The **startup_events** section (line 74-80 in the log) dumps the *entire* `config.startup_events` array as raw JSON — including the full soul/system prompt (kind 31120) which is already sent verbatim as the **system_prompt** section. The soul text appears **twice** in every request.
**Estimated waste:** ~1,500-2,000 tokens per request.
**Fix:** Filter out kind 31120 (soul) from the startup_events JSON blob, or better yet, only include kinds the model actually needs to reference (kind 0 profile, kind 10002 relay list, kind 3 contacts). The soul is already the system prompt — repeating it as data is pure waste.
### 2. Duplicate Startup Messages in DM History
The DM history contains **four separate** `Didactyl has started up and is online (version v0.0.29, connected relays: 4/4).` assistant messages (lines 123-127, 130-134, 144-148, 222-226, 245-249). These are startup announcement DMs that got stored as separate events. The model sees the same boilerplate startup message repeated across the conversation.
**Estimated waste:** ~200-300 tokens.
**Fix:** Deduplicate consecutive identical assistant messages in the DM history builder, or filter out startup announcement messages (they carry no conversational value).
### 3. Skills Rendered as Raw JSON Instead of Structured Text
Skill instructions at lines 97-118 are dumped as raw JSON objects. Models parse structured natural language far more reliably than nested JSON. The `content_fields` serialization format wastes tokens on JSON syntax characters and key quoting.
**Estimated waste:** ~100-200 tokens of JSON overhead per skill, plus reduced comprehension quality.
**Fix:** When serializing `content_fields`-based skills for context, flatten them into readable text:
```
Skill: 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...
Required Tags:
- d: Addressable identifier slug...
- title: Human-readable article title
- published_at: Unix timestamp as string...
Procedure:
1. Determine title and d tag...
2. Draft markdown body content...
```
### 4. Empty Sections Still Sent
The **admin_relay_list** section (line 65-71) has no data — the JSON value is empty. Sending an empty section wastes tokens on the header/framing with no informational value.
**Estimated waste:** ~30-40 tokens.
**Fix:** Skip sections where the resolved variable is empty or whitespace-only.
### 5. Admin Identity Could Be Merged with Admin Profile
The **admin_identity** section (line 47-53) sends just the hex pubkey, then **admin_profile** (line 56-62) sends the full kind 0 JSON which implicitly identifies the admin. These could be a single section.
**Estimated savings:** ~40-50 tokens of framing overhead.
### 6. `admin_notes` Placement Breaks Conversation Flow
In the template, `admin_notes` is placed *after* `dm_history` (expand). In the actual log, this means a system message appears sandwiched between DM history messages (line 252, between assistant messages and the final user message at line 274). This breaks the natural conversation flow and may confuse the model about message ordering.
**Fix:** Move `admin_notes` *before* `dm_history` in the template so all system context is grouped together before the conversation begins.
### 7. No Agent Self-Identity Section
The model knows it is Didactyl from the system prompt, but there is no section telling it its own pubkey/npub. The admin pubkey is provided but the agent's own key is not in the context (it is only available via the `nostr_pubkey` tool). Adding a small self-identity section would let the model reference its own key without a tool call.
**Estimated cost:** ~20-30 tokens.
## Priority Summary
| Priority | Issue | Token Savings | Complexity |
|----------|-------|---------------|------------|
| **P0** | Soul duplicated in startup_events | ~1,500-2,000 | Low — filter kind 31120 from startup blob |
| **P1** | Duplicate startup DMs in history | ~200-300 | Medium — dedup logic in history builder |
| **P1** | Skills as raw JSON | ~100-200 + quality | Medium — flatten content_fields to text |
| **P2** | Empty sections still sent | ~30-40 | Low — skip empty resolved vars |
| **P2** | admin_notes after dm_history | 0 (quality) | Low — reorder template |
| **P3** | Merge admin_identity + admin_profile | ~40-50 | Low — template change |
| **P3** | Add agent self-identity section | -20-30 (adds) | Low — new template var |
## Bug: Kind 10002 Relay List Is Always Empty
At [`nostr_handler.c:705`](../src/nostr_handler.c:705) the kind 10002 handler stores `content->valuestring`, but NIP-65 relay list events have an **empty content field** — the relay URLs live in the **tags** as `["r", "wss://relay.example.com"]` entries. So `g_admin_kind10002_json` is always `""`.
**Fix:** Parse the `"r"` tags from the kind 10002 event and serialize them as a JSON array of relay URL strings (or plain-text list).
## Sender Verification Status
The [`tier`](../src/nostr_handler.h:8) enum (`DIDACTYL_SENDER_ADMIN`, `DIDACTYL_SENDER_WOT`, `DIDACTYL_SENDER_STRANGER`) is already resolved before [`agent_on_message()`](../src/agent.c:1453) is called, but it is **not passed into the context builder**. The model has no way to know whether the current message was cryptographically verified as coming from the administrator vs. a web-of-trust contact.
**Fix:** Pass the sender tier into the context builder and expose it as a template variable (e.g. `{{sender_verification}}`) that resolves to text like:
- `"This message has been cryptographically verified as coming from your administrator."`
- `"This message is from a web-of-trust contact (not the administrator)."`
## Proposed Optimized Template
```yaml
- section: agent_identity
role: system
content: |
Agent Identity
Your pubkey (hex): {{agent_pubkey}}
- section: sender_context
role: system
content: |
{{sender_verification}}
- section: admin_context
role: system
content: |
Administrator Context
Pubkey (hex): {{admin_pubkey}}
{{admin_profile_plain}}
{{admin_relay_list_plain}}
- section: startup_events
role: system
skip_if_empty: true
content: |
Startup Events Memory
{{startup_events_json}}
- section: adopted_skills
role: system
skip_if_empty: true
content: |
{{adopted_skills_content}}
- section: admin_notes
role: system
skip_if_empty: true
content: |
Administrator Recent Notes (source: nostr kind 1)
{{admin_notes_content}}
- section: dm_history
role: expand
limit: 12
```
Key changes from current template:
- **No markdown headers** in system sections — plain English throughout
- **Merged admin section** combines identity, profile, and relay list
- **`{{admin_profile_plain}}`** — new variable that renders kind 0 JSON as readable text (e.g. `Name: WSB, About: ...`)
- **`{{admin_relay_list_plain}}`** — new variable that renders relay URLs from tags as a plain list
- **`{{sender_verification}}`** — new variable stating cryptographic verification status
- **`admin_notes` moved before `dm_history`** so all system context is grouped before conversation
- **`skip_if_empty`** prevents sending empty sections
## Implementation Steps
1. **Fix kind 10002 relay list bug** — extract relay URLs from tags instead of content in [`nostr_handler.c:705`](../src/nostr_handler.c:705)
2. **Filter kind 31120** from `startup_events_json` variable resolver in [`agent.c`](../src/agent.c)
3. **Deduplicate consecutive identical messages** in DM history builder
4. **Flatten `content_fields` JSON skills** into readable text format
5. **Add `skip_if_empty` support** to template engine (skip section when resolved content is blank)
6. **Reorder template** — move `admin_notes` before `dm_history`
7. **Add `agent_pubkey` template variable** and agent identity section
8. **Merge admin sections** — combine identity + profile (plain English) + relay list into one section
9. **Add `admin_profile_plain` variable** — parse kind 0 JSON into readable text
10. **Add `admin_relay_list_plain` variable** — parse kind 10002 tags into relay URL list
11. **Pass sender tier to context builder** and add `sender_verification` template variable
12. **Remove markdown formatting** from system section content — use plain English

View File

@@ -57,7 +57,7 @@ flowchart TD
LOOP --> LLM
LLM -->|tool_call| TOOLS
TOOLS -->|nostr tools| NOSTR
TOOLS -->|shell_exec| SHELL
TOOLS -->|local_shell_exec| SHELL
TOOLS -->|result| LOOP
LLM -->|final answer| DMS
```
@@ -212,7 +212,7 @@ sequenceDiagram
| Tool | Description | Notes |
|---|---|---|
| `shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
| `local_shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
---
@@ -377,7 +377,7 @@ void agent_on_message(const char* sender, const char* message) {
3. **Agent loop** — rewrite `agent.c` with the tool-call loop
4. **`nostr_post` tool** — publish any kind event
5. **`nostr_query` tool** — query relays with filters
6. **`shell_exec` tool** — sandboxed shell command execution
6. **`local_shell_exec` tool** — sandboxed shell command execution
7. **`nostr_dm` tool** — NIP-17 private DMs
8. **Config extension** — parse tools config section
9. **Security hardening** — timeouts, output limits, allowlists

596
plans/new_nostr_tools.md Normal file
View File

@@ -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 | `local_shell_exec` | OS | ✅ Shipped |
| 9 | `local_file_read` | OS | ✅ Shipped |
| 10 | `local_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

194
plans/nip17_messaging.md Normal file
View File

@@ -0,0 +1,194 @@
# NIP-17 Messaging Implementation Plan
## Background
Didactyl currently uses NIP-04 (kind 4) for all DM communication. NIP-17 send support exists via `nostr_handler_send_dm_nip17()` and the `nostr_dm_send_nip17` tool, but there is **no ability to receive NIP-17 messages** and **all agent responses always use NIP-04**.
The `nostr_core_lib` already has full NIP-17/NIP-59 support including:
- `nostr_nip17_create_chat_event()` — create kind 14 rumor
- `nostr_nip17_send_dm()` — seal + gift wrap (creates wraps for both recipient AND sender)
- `nostr_nip17_receive_dm()` — unwrap gift wrap, unseal rumor, return kind 14
- `nostr_nip17_extract_dm_relays()` — parse kind 10050 relay lists
## Config-Driven Protocol Selection
### New Config Field
Add a `dm_protocol` field to the top-level config:
```json
{
"dm_protocol": "nip04",
...
}
```
Valid values:
- `"nip04"` — NIP-04 only (current behavior, default for backward compatibility)
- `"nip17"` — NIP-17 only (subscribe to kind 1059, send via gift wrap)
- `"both"` — Subscribe to both kind 4 and kind 1059; reply using whichever protocol the message arrived on
### Config Struct Change
In `config.h`, add to `didactyl_config_t`:
```c
typedef enum {
DM_PROTOCOL_NIP04 = 0,
DM_PROTOCOL_NIP17 = 1,
DM_PROTOCOL_BOTH = 2
} dm_protocol_t;
```
Add `dm_protocol_t dm_protocol;` to the config struct.
## Implementation Steps
### 1. Add `dm_protocol` config parsing
**Files**: `config.h`, `config.c`
- Add `dm_protocol_t` enum and field to `didactyl_config_t`
- Parse `"dm_protocol"` string from JSON in `config_load()`
- Default to `DM_PROTOCOL_NIP04` if not specified
### 2. Add kind 1059 subscription
**File**: `nostr_handler.c``nostr_handler_subscribe_dms()`
- When `dm_protocol` is `NIP17` or `BOTH`, add `kind: 1059` to the subscription filter
- When `dm_protocol` is `NIP04` or `BOTH`, keep `kind: 4` in the filter
- The subscription filter becomes `kinds: [4, 1059]` for `BOTH` mode
### 3. Add NIP-17 receive handling in `on_event()`
**File**: `nostr_handler.c``on_event()`
Currently `on_event()` rejects anything that is not kind 4. Update to:
- If kind == 1059: unwrap gift wrap via `nostr_nip17_receive_dm()`, extract sender pubkey from the kind 14 rumor, extract message content, determine sender tier, fire `g_dm_callback`
- If kind == 4: existing NIP-04 decrypt path (unchanged)
- Track which protocol was used per sender pubkey for reply routing
### 4. Track protocol per sender for reply routing
**File**: `nostr_handler.c`
Add a small cache that maps `sender_pubkey_hex -> last_protocol_used`:
```c
typedef struct {
char pubkey_hex[65];
dm_protocol_t protocol;
} sender_protocol_entry_t;
#define SENDER_PROTOCOL_CACHE_SIZE 64
static sender_protocol_entry_t g_sender_protocol_cache[SENDER_PROTOCOL_CACHE_SIZE];
```
When a DM arrives via kind 4, record `NIP04` for that sender. When via kind 1059, record `NIP17`.
### 5. Add protocol-aware send function
**File**: `nostr_handler.c`
Add a new function that routes based on config + sender history:
```c
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
```
Logic:
- If `dm_protocol == NIP04`: always use `nostr_handler_send_dm()`
- If `dm_protocol == NIP17`: always use `nostr_handler_send_dm_nip17()`
- If `dm_protocol == BOTH`: check sender protocol cache; use matching protocol, default to NIP-04
Expose in `nostr_handler.h`.
### 6. Update agent.c to use auto-routing
**File**: `agent.c`
Replace all `nostr_handler_send_dm()` calls with `nostr_handler_send_dm_auto()`. This is a mechanical find-and-replace across ~15 call sites.
### 7. Add kind 10050 startup event support
**File**: `config.json.example`
Add a kind 10050 startup event example so NIP-17 clients can discover the agent's DM relay preferences:
```json
{
"kind": 10050,
"content": "",
"tags": [
["relay", "wss://relay.damus.io"],
["relay", "wss://nos.lol"]
]
}
```
**File**: `config.c` — startup event parsing already handles arbitrary kinds, so this should work with no code changes.
### 8. Update startup DM to use auto-routing
**File**: `main.c`
The startup status DM at line 255 currently calls `nostr_handler_send_dm()`. Update to `nostr_handler_send_dm_auto()`.
## Architecture Diagram
```mermaid
flowchart TD
subgraph Config
CFG[dm_protocol setting]
CFG -->|nip04| NIP04_MODE[NIP-04 only]
CFG -->|nip17| NIP17_MODE[NIP-17 only]
CFG -->|both| BOTH_MODE[Both protocols]
end
subgraph Subscription
SUB[nostr_handler_subscribe_dms]
NIP04_MODE --> SUB_K4[Subscribe kind 4]
NIP17_MODE --> SUB_K1059[Subscribe kind 1059]
BOTH_MODE --> SUB_BOTH[Subscribe kind 4 + 1059]
end
subgraph Receive Path
EVT[on_event callback]
EVT -->|kind 4| DEC4[NIP-04 decrypt]
EVT -->|kind 1059| DEC17[NIP-17 unwrap + unseal]
DEC4 --> CACHE[Record sender protocol]
DEC17 --> CACHE
CACHE --> CB[Fire dm_callback]
end
subgraph Send Path
SEND[nostr_handler_send_dm_auto]
SEND -->|config=nip04| S4[nostr_handler_send_dm - kind 4]
SEND -->|config=nip17| S17[nostr_handler_send_dm_nip17 - kind 1059]
SEND -->|config=both| LOOKUP[Check sender protocol cache]
LOOKUP -->|sender used nip04| S4
LOOKUP -->|sender used nip17| S17
LOOKUP -->|unknown| S4
end
```
## File Change Summary
| File | Changes |
|------|---------|
| `config.h` | Add `dm_protocol_t` enum and field |
| `config.c` | Parse `dm_protocol` from JSON |
| `nostr_handler.h` | Add `nostr_handler_send_dm_auto()` declaration |
| `nostr_handler.c` | Add kind 1059 subscription, NIP-17 receive in `on_event()`, sender protocol cache, `nostr_handler_send_dm_auto()` |
| `agent.c` | Replace `nostr_handler_send_dm()` with `nostr_handler_send_dm_auto()` |
| `main.c` | Replace startup DM send with `nostr_handler_send_dm_auto()` |
| `config.json.example` | Add `dm_protocol` field and kind 10050 startup event example |
## Testing Strategy
1. **NIP-04 mode** (default): Verify existing behavior is unchanged
2. **NIP-17 mode**: Send a NIP-17 DM from a client like Amethyst/0xchat, verify Didactyl receives and replies via NIP-17
3. **Both mode**: Send NIP-04 DM, verify NIP-04 reply; send NIP-17 DM, verify NIP-17 reply
4. **Kind 10050**: Verify the relay list event is published on startup and discoverable by NIP-17 clients

393
plans/prompt_templates.md Normal file
View File

@@ -0,0 +1,393 @@
# Didactyl Prompt Template System — Design Plan
## Summary
Replace the hardcoded context assembly in `src/agent.c` with a template-driven system. The template lives inside the soul event (kind 31120) — because the template defines how the agent perceives the world, and that is fundamentally part of who the agent is.
Different agents (architect, eGirl, analyst) are different processes with different souls, different templates, different Nostr identities. Multi-agent is Approach A: multiple `./didactyl --config` processes communicating via Nostr.
---
## Core Principle
**The soul IS the template.** An agent's soul defines both its personality (prose instructions) and its perception (what context sections it sees, in what order, with what limits). You cannot meaningfully separate "who you are" from "how you see the world."
---
## Current State
Today, context assembly is hardcoded in `agent_build_admin_messages_json()`:
```
1. System prompt (soul content)
2. Admin identity (pubkey)
3. Admin kind 0 profile
4. Admin kind 10002 relay list
5. Startup events memory
6. Adopted skills memory
7. DM history (last 12 turns)
8. Admin recent notes (kind 1)
```
The order, formatting, limits, and section headers are all baked into C code. Changing anything requires editing `src/agent.c` and recompiling.
---
## Proposed Design
### Soul Event Structure
The kind 31120 soul event content gains a template section, delimited by a marker:
```markdown
# 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.
...
## Safety
- Never reveal your private key.
...
---template---
- section: admin_identity
role: system
content: |
This is your administrator! Admin pubkey: {{admin_pubkey}}
- section: admin_profile
role: system
content: |
Administrator profile: {{admin_kind0_json}}
- section: admin_relay_list
role: system
content: |
Administrator relay list: {{admin_kind10002_json}}
- section: startup_events
role: system
content: |
Startup events memory: {{startup_events_json}}
- section: adopted_skills
role: system
content: |
{{adopted_skills_content}}
- section: dm_history
role: expand
limit: 12
- section: admin_notes
role: system
limit: 10
content: |
{{admin_notes_content}}
```
Everything above `---template---` is the system prompt (personality/rules). Everything below defines the context assembly template.
If no `---template---` marker is found, the agent falls back to the current hardcoded assembly — backward compatible.
### Template Syntax
Simple YAML-like format parsed in C. Each section has:
| Field | Required | Description |
|---|---|---|
| `section` | yes | Section name for logging and API identification |
| `role` | yes | Chat message role: `system`, `user`, `assistant`, or `expand` |
| `tool` | recommended | Tool name executed by template builder (for example `nostr_admin_profile`) |
| `args` | no | JSON args string passed to the tool (default `{}`) |
| `result_field` | no | Field extracted from tool JSON result (default `content`) |
| `content` | optional fallback | Literal content when no `tool` is specified |
| `limit` | no | Integer limit for variable-length sections like DM history |
| `provider` | no | Provider-specific override — see below |
### Tool-Driven Context Resolution
Context sections should use `tool:` directives as the single runtime data path, which removes the legacy per-variable resolver duplication.
### Special Section Types
**`role: expand`** — The `dm_history` section expands into multiple messages (user/assistant pairs). The `limit` field controls how many turns to include. This is the only section type that produces multiple chat messages from one template entry.
### Provider-Specific Overrides
Within a section, you can specify provider-specific formatting:
```yaml
- section: admin_identity
role: system
content: |
## Administrator Identity
This is your administrator! Admin pubkey: {{admin_pubkey}}
provider:
anthropic: |
<admin_identity>
This is your administrator! Admin pubkey: {{admin_pubkey}}
</admin_identity>
```
When the configured `llm.provider` matches a provider key, that override is used instead of the default `content`. This lets one soul/template work well across providers without needing separate soul events.
If no provider override matches, the default `content` is used.
---
## Context Assembly Flow
```mermaid
flowchart TD
BOOT[Agent boots] --> LOAD_SOUL[Load soul event - kind 31120]
LOAD_SOUL --> PARSE[Parse soul content]
PARSE --> SPLIT{Contains ---template--- marker?}
SPLIT -->|Yes| EXTRACT[Extract personality above marker]
SPLIT -->|No| FALLBACK[Use hardcoded assembly - backward compat]
EXTRACT --> PARSE_TPL[Parse template sections below marker]
PARSE_TPL --> STORE[Store template_section_t array in memory]
DM[Incoming DM] --> BUILD[Build context from template]
BUILD --> EMIT_SOUL[Emit personality as first system message]
EMIT_SOUL --> FOREACH[For each template section]
FOREACH --> CALL_TOOL[Execute section tool or use literal content]
CALL_TOOL --> PICK_FIELD[Extract result_field or content]
PICK_FIELD --> CHECK_PROVIDER{Provider override?}
CHECK_PROVIDER -->|Yes| USE_OVERRIDE[Use provider-specific content]
CHECK_PROVIDER -->|No| USE_DEFAULT[Use tool/literal output]
USE_OVERRIDE --> EMIT[Emit as chat message]
USE_DEFAULT --> EMIT
EMIT --> FOREACH
FOREACH --> DONE[Complete messages array]
DONE --> LLM[Send to LLM]
```
---
## Context.log Formatting
With templates, the log formatter uses section names directly from the template instead of detecting them from content prefixes. The `detect_context_section()` function is replaced by the template section name.
Log format becomes:
```
[2026-03-02 14:54:30] phase=llm_chat_with_tools_messages sender=8ff747...
Sections: 8
============================================================
Section: system_prompt | role=system
============================================================
# Didactyl Agent
...
============================================================
Section: admin_identity | role=system
============================================================
This is your administrator! Admin pubkey: 8ff747...
============================================================
Section: dm_history | role=user
============================================================
Good afternoon.
```
Note: "Message 01" is replaced with "Section: admin_identity" — the section name from the template, which is much more meaningful.
---
## Data Structures
```c
typedef struct {
char name[64]; // section name
char role[16]; // system, user, assistant, expand
char* content_template; // content with {{var}} placeholders
int limit; // for expand sections, 0 = unlimited
char* provider_overrides; // JSON object of provider->content pairs, or NULL
} template_section_t;
typedef struct {
char* personality; // everything above ---template---
template_section_t* sections; // parsed template sections
int section_count;
} prompt_template_t;
```
---
## Implementation Plan
### New Files
| File | Purpose |
|---|---|
| `src/prompt_template.c` | Template parser, variable resolver, context builder |
| `src/prompt_template.h` | Public API: parse, build context, free |
### Modified Files
| File | Change |
|---|---|
| `src/agent.c` | Replace `agent_build_admin_messages_json()` internals with template-driven builder. Keep function signature unchanged for API compatibility. |
| `src/agent.c` | Remove hardcoded `append_admin_identity_context()`, `append_startup_events_context()`, etc. — these become template variable resolvers |
| `src/agent.c` | Update `format_context_payload_for_log()` to use section names from template |
| `src/http_api.c` | Remove `classify_part_name()` / `detect_context_section()` — section names come from template |
| `Makefile` | Add `src/prompt_template.c` to SRCS |
| `Dockerfile.alpine-musl` | Add `src/prompt_template.c` to gcc command |
### Implementation Order
1. Create `src/prompt_template.h` with data structures and API
2. Implement template parser in `src/prompt_template.c` — parse soul content, split at `---template---`, parse sections
3. Implement variable resolver — map `{{var}}` names to data source functions
4. Implement context builder — iterate sections, resolve variables, emit messages
5. Wire into `agent_build_admin_messages_json()` — if template exists, use it; otherwise fall back to hardcoded
6. Update `format_context_payload_for_log()` to use section names
7. Update `classify_part_name()` in `http_api.c` to use section names from template
8. Update default soul in `config.json.example` to include a `---template---` section
9. Test with existing soul (no template marker) — verify backward compatibility
10. Test with template soul — verify new assembly
11. Test provider overrides
---
## Backward Compatibility
If the soul event content does NOT contain `---template---`, the agent uses the current hardcoded assembly. This means:
- Existing agents continue to work without changes
- The template system is opt-in
- Migration is gradual — add a template section to your soul when ready
---
## Example Souls
### Architect Agent
```markdown
# Technical Architect
You are a technical architect agent. You analyze systems, design solutions, and produce detailed technical plans.
## Behavior
- Think systematically about architecture
- Consider tradeoffs explicitly
- Produce diagrams when helpful
- Never implement code — only design
---template---
- section: admin_identity
role: system
content: |
Administrator pubkey: {{admin_pubkey}}
- section: admin_profile
role: system
content: |
Administrator profile: {{admin_kind0_json}}
- section: startup_events
role: system
content: |
System configuration and startup state: {{startup_events_json}}
- section: adopted_skills
role: system
content: |
{{adopted_skills_content}}
- section: dm_history
role: expand
limit: 20
- section: admin_notes
role: system
limit: 5
content: |
Recent administrator notes for context: {{admin_notes_content}}
```
### eGirl Agent
```markdown
# Social Butterfly
You are a friendly, social Nostr personality. You love interacting with people, commenting on their posts, and being part of the community.
## Personality
- Warm, enthusiastic, uses emoji freely
- Interested in what people are posting about
- Remembers details about conversations
- Keeps responses casual and fun
---template---
- section: admin_identity
role: system
content: |
Your creator: {{admin_pubkey}}
- section: admin_profile
role: system
content: |
Creator profile: {{admin_kind0_json}}
- section: admin_notes
role: system
limit: 20
content: |
Recent posts from people you follow — use these for social context and conversation starters: {{admin_notes_content}}
- section: adopted_skills
role: system
content: |
{{adopted_skills_content}}
- section: dm_history
role: expand
limit: 8
```
Note the differences:
- The eGirl sees 20 recent notes (social context) but only 8 DM turns
- The architect sees 5 notes but 20 DM turns (needs conversation continuity)
- The eGirl puts notes BEFORE skills (social context is primary)
- The architect puts skills BEFORE notes (technical knowledge is primary)
- No startup events for the eGirl (doesn't need system config details)
---
## Nostr Shareability
Since the template is part of the soul event (kind 31120), sharing works naturally:
- Publish your soul → others get your complete agent personality + perception template
- Discover interesting agents on Nostr → adopt their soul as a starting point
- Community can develop and share optimized templates for different use cases
- Templates evolve through the same Nostr discovery mechanisms as skills
---
## Security Notes
- Template variable resolution is sandboxed — only predefined variables are resolved
- No arbitrary code execution from templates
- The `limit` field is capped at compile-time maximums to prevent resource exhaustion
- Provider overrides are optional and safe — they only change formatting, not data sources

View File

@@ -0,0 +1,395 @@
# Prompt Template System — Coding Plan
Design doc: `plans/prompt_templates.md`
## Overview
Replace the hardcoded context assembly in `src/agent.c` with a template-driven system. The template lives inside the soul event content (kind 31120), delimited by `---template---`. If no template marker is found, fall back to the current hardcoded assembly for backward compatibility.
---
## Phase 1: New Files — Template Parser & Builder
### Step 1.1: Create `src/prompt_template.h`
Header with data structures and public API.
```c
#ifndef DIDACTYL_PROMPT_TEMPLATE_H
#define DIDACTYL_PROMPT_TEMPLATE_H
#include "cjson/cJSON.h"
#include "config.h"
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
#define PROMPT_TEMPLATE_MAX_ROLE_LEN 16
#define PROMPT_TEMPLATE_MARKER "---template---"
typedef struct {
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN]; // system, user, assistant, expand
char* content_template; // content with {{var}} placeholders, or NULL
int limit; // for expand sections, 0 = default
} prompt_template_section_t;
typedef struct {
char* personality; // everything above ---template---
prompt_template_section_t sections[PROMPT_TEMPLATE_MAX_SECTIONS];
int section_count;
} prompt_template_t;
// Variable resolver callback: given a variable name, return a malloc'd string or NULL.
// The caller frees the returned string.
typedef char* (*prompt_var_resolver_fn)(const char* var_name, void* user_data);
// Parse soul content into a template. Returns 0 on success, -1 if no template found.
// On success, caller must call prompt_template_free() when done.
// On -1 (no template), out_template is zeroed — caller should use hardcoded fallback.
int prompt_template_parse(const char* soul_content, prompt_template_t* out_template);
// Build a cJSON messages array from a parsed template.
// resolver_fn is called for each {{variable}} encountered.
// dm_history_messages is a cJSON array of user/assistant messages for "expand" sections.
// Returns a new cJSON array (caller owns it), or NULL on error.
cJSON* prompt_template_build_messages(
const prompt_template_t* tmpl,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data,
cJSON* dm_history_messages
);
// Free internals of a parsed template (does not free the struct itself).
void prompt_template_free(prompt_template_t* tmpl);
// Get the section name for a message index (for logging/API).
// Returns the section name string or NULL if idx is out of range.
const char* prompt_template_section_name_at(const prompt_template_t* tmpl, int section_idx);
#endif
```
### Step 1.2: Create `src/prompt_template.c`
Implementation file with three main components:
#### 1.2a: Template Parser — `prompt_template_parse()`
Logic:
1. Search `soul_content` for the string `"\n---template---\n"` (with newlines on both sides, or at start/end of string).
2. If not found, return -1 (no template).
3. Split: everything before the marker → `tmpl->personality` (strdup'd).
4. Everything after the marker → parse as template sections.
5. Template section format is line-oriented YAML-like:
```
- section: admin_identity
role: system
limit: 0
content: |
This is your administrator! Admin pubkey: {{admin_pubkey}}
```
Parsing rules:
- Lines starting with `- section:` begin a new section.
- `role:` sets the role (default: `system`).
- `limit:` sets the limit integer (default: 0).
- `content: |` starts a multi-line content block. All subsequent lines indented by 4+ spaces (or until the next `- section:` line) are the content template.
- `{{variable_name}}` placeholders in content are left as-is during parsing; they are resolved at build time.
Edge cases:
- Trim leading/trailing whitespace from section names and roles.
- If `content:` is a single line (not `|`), treat the rest of the line as the content.
- Cap at `PROMPT_TEMPLATE_MAX_SECTIONS`.
#### 1.2b: Variable Resolver — `prompt_template_build_messages()`
Logic:
1. Create a new cJSON array.
2. First, append the personality as a system message (role=system, content=personality).
3. For each section in order:
- If `role` is `"expand"`: insert the `dm_history_messages` array items here, limited by `section.limit` (take last N if limit > 0).
- Otherwise: resolve `{{var}}` placeholders in `content_template` by calling `resolver_fn(var_name, user_data)`. Build the resolved string. Append as a message with the configured role.
4. Return the array.
Variable resolution:
- Scan content_template for `{{` ... `}}` pairs.
- Extract the variable name (trimmed).
- Call `resolver_fn(name, user_data)`.
- If resolver returns NULL, substitute empty string.
- If resolver returns a string, substitute it and free the returned string.
- Build the final resolved content by concatenating literal segments and resolved values.
#### 1.2c: Cleanup — `prompt_template_free()`
- Free `tmpl->personality`.
- For each section, free `content_template`.
- Zero the struct.
---
## Phase 2: Variable Resolver in `src/agent.c`
### Step 2.1: Create a resolver function
Add a static function in `src/agent.c`:
```c
static char* agent_resolve_template_var(const char* var_name, void* user_data);
```
This function maps variable names to data sources:
| Variable Name | Source | Implementation |
|---|---|---|
| `admin_pubkey` | `g_cfg->admin.pubkey` | `strdup(g_cfg->admin.pubkey)` |
| `admin_kind0_json` | `nostr_handler_get_admin_kind0_context()` | Already returns malloc'd string |
| `admin_kind10002_json` | `nostr_handler_get_admin_kind10002_context()` | Already returns malloc'd string |
| `startup_events_json` | Serialize startup events | Reuse logic from current `append_startup_events_context()` |
| `adopted_skills_content` | Build skills string | Reuse logic from current `append_adopted_skills_context()` |
| `admin_notes_content` | `nostr_handler_get_admin_kind1_notes_context()` | Already returns malloc'd string |
| `agent_pubkey` | `g_cfg->keys.public_key_hex` | `strdup(g_cfg->keys.public_key_hex)` |
The `user_data` parameter is unused (NULL) since the resolver accesses globals.
### Step 2.2: Extract helper functions from existing code
Refactor the following existing static functions to return malloc'd strings instead of appending directly to a cJSON array:
- Extract startup events serialization from `append_startup_events_context()` (lines 383-434) into a new `static char* build_startup_events_string(void)`.
- Extract adopted skills content from `append_adopted_skills_context()` (lines ~744-940) into a new `static char* build_adopted_skills_string(void)`.
These helpers are called by the resolver function.
---
## Phase 3: Wire Template into Agent
### Step 3.1: Parse template at init time
In `agent_init()` (or when `g_system_context` is set), after the soul content is available:
```c
static prompt_template_t g_prompt_template;
static int g_has_template = 0;
```
After `g_system_context` is assigned, call:
```c
g_has_template = (prompt_template_parse(g_system_context, &g_prompt_template) == 0);
```
If `g_has_template` is true, `g_prompt_template.personality` replaces `g_system_context` for the system prompt message.
### Step 3.2: Modify `agent_build_admin_messages_json()`
Current location: `src/agent.c:1092`
Current signature (unchanged):
```c
int agent_build_admin_messages_json(const char* current_user_message, char** out_messages_json);
```
New logic:
```c
if (g_has_template) {
// Build DM history as a cJSON array
cJSON* dm_history = build_dm_history_array(current_user_message);
// Build messages from template
cJSON* messages = prompt_template_build_messages(
&g_prompt_template,
agent_resolve_template_var,
NULL,
dm_history
);
cJSON_Delete(dm_history);
if (!messages) {
return -1;
}
char* json = cJSON_PrintUnformatted(messages);
cJSON_Delete(messages);
*out_messages_json = json;
return json ? 0 : -1;
} else {
// Existing hardcoded assembly (current code, unchanged)
...
}
```
### Step 3.3: Extract DM history builder
Extract the DM history logic from `append_recent_admin_dm_history()` into a function that returns a cJSON array of user/assistant messages:
```c
static cJSON* build_dm_history_array(const char* current_user_message);
```
This is used by the template builder for `role: expand` sections.
---
## Phase 4: Context Log Formatting
### Step 4.1: Update `format_context_payload_for_log()`
Current location: `src/agent.c:245`
When `g_has_template` is true, use section names from the template instead of `detect_context_section()`:
- Message 0 is always `system_prompt` (the personality).
- Messages 1..N map to template sections by index.
- For `expand` sections, multiple messages share the same section name.
Change the log header from:
```
Message 01 | role=system | section=system_prompt
```
To:
```
Section: system_prompt | role=system
```
### Step 4.2: Update `classify_part_name()` in `src/http_api.c`
Current location: `src/http_api.c:113`
Add a new exported function from `src/agent.h`:
```c
const char* agent_get_section_name_for_message(int message_index);
```
This returns the template section name if a template is active, or falls back to the existing content-prefix detection.
In `classify_part_name()`, call this function first. If it returns non-NULL, use it. Otherwise fall back to the existing `strncmp` chain.
---
## Phase 5: Build System Updates
### Step 5.1: Update `Makefile`
Add `$(SRC_DIR)/prompt_template.c` to the `SRCS` list (after `trigger_manager.c`, before `http_api.c`).
### Step 5.2: Update `Dockerfile.alpine-musl`
Add `src/prompt_template.c` to the gcc command line (after `src/trigger_manager.c`, before `src/http_api.c`).
---
## Phase 6: Default Template in Soul
### Step 6.1: Update `config.json.example`
The startup events should include a soul event (kind 31120) with a `---template---` section that matches the current hardcoded behavior:
```markdown
# Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
...existing soul content...
---template---
- section: admin_identity
role: system
content: |
This is your administrator! Admin pubkey (hex): {{admin_pubkey}}
- section: admin_profile
role: system
content: |
Administrator profile (JSON): {{admin_kind0_json}}
- section: admin_relay_list
role: system
content: |
Administrator relay list (JSON): {{admin_kind10002_json}}
- section: startup_events
role: system
content: |
Startup events memory (kinds/content/tags): {{startup_events_json}}
- section: adopted_skills
role: system
content: |
{{adopted_skills_content}}
- section: dm_history
role: expand
limit: 12
- section: admin_notes
role: system
limit: 10
content: |
Administrator recent public notes: {{admin_notes_content}}
```
---
## Phase 7: Testing
### Step 7.1: Backward compatibility test
1. Build with `make`.
2. Run with existing config (no `---template---` in soul).
3. Send a DM and verify `context.log` output matches pre-change format.
4. Verify `/api/context/current` returns expected structure.
### Step 7.2: Template test
1. Edit the soul event content to include `---template---` section.
2. Restart agent.
3. Send a DM and verify `context.log` shows section-named headers.
4. Verify `/api/context/current` returns section names from template.
5. Verify the LLM receives the correct messages in the correct order.
### Step 7.3: Section reordering test
1. Move `admin_notes` section above `adopted_skills` in the template.
2. Restart and verify the order changes in `context.log`.
### Step 7.4: Limit test
1. Set `dm_history` limit to 4 (instead of 12).
2. Verify only 4 DM history turns appear in context.
---
## File Change Summary
| File | Action | Description |
|---|---|---|
| `src/prompt_template.h` | **NEW** | Data structures and API |
| `src/prompt_template.c` | **NEW** | Parser, variable resolver, context builder |
| `src/agent.c` | **MODIFY** | Add template globals, resolver function, wire into `agent_build_admin_messages_json()`, update log formatter |
| `src/agent.h` | **MODIFY** | Add `agent_get_section_name_for_message()` export |
| `src/http_api.c` | **MODIFY** | Update `classify_part_name()` to use template section names |
| `Makefile` | **MODIFY** | Add `prompt_template.c` to SRCS |
| `Dockerfile.alpine-musl` | **MODIFY** | Add `prompt_template.c` to gcc command |
| `config.json.example` | **MODIFY** | Update soul event to include template section |
---
## Implementation Order
1. `src/prompt_template.h` — data structures and API declarations
2. `src/prompt_template.c` — parser, builder, free
3. `Makefile` + `Dockerfile.alpine-musl` — add new source file
4. Build and verify compilation
5. `src/agent.c` — extract helper functions (`build_startup_events_string`, `build_adopted_skills_string`, `build_dm_history_array`)
6. `src/agent.c` — add resolver function and template globals
7. `src/agent.c` — wire template into `agent_build_admin_messages_json()`
8. `src/agent.c` — update `format_context_payload_for_log()`
9. `src/agent.h` + `src/http_api.c` — section name API for classify_part_name
10. Build and test backward compatibility (no template in soul)
11. `config.json.example` — add template to soul event
12. Test with template soul
13. Test section reordering and limit changes

View File

@@ -0,0 +1,286 @@
# Security & Admin Context Plan
## Overview
Add signature verification, privilege tiers, admin context awareness, and key isolation to Didactyl. Implemented in two phases.
---
## Phase 1 — Privilege Tiers, Signature Verification & Admin Context
### Privilege Tiers
```mermaid
flowchart TD
A[Incoming Event] --> B{Verify Signature via nostr_verify_event_signature}
B -->|Invalid| C[Drop silently + DEBUG_WARN]
B -->|Valid| D{Identify sender}
D -->|Admin pubkey| E[ADMIN tier]
D -->|In admin kind 3 contact list| F[WOT tier]
D -->|Unknown| G[STRANGER tier]
E --> H[Full tool access + all skills + full context]
F --> I[Chat only - no tools - LLM response]
G --> J[Configurable canned response OR silent ignore]
```
| Tier | Identity | Tools | Response | Context |
|------|----------|-------|----------|---------|
| **ADMIN** | `config.admin.pubkey` exact match | All tools | Full LLM with all info except private key | Soul + startup events + admin notes |
| **WOT** | Pubkey in admin kind `3` contact list | None | Chat-only LLM response | Soul + tier instruction |
| **STRANGER** | Anyone else | None | Configurable static response or silent ignore | N/A - no LLM call |
### Signature Verification
Every incoming event must be cryptographically verified before processing:
1. In `on_event()` in `src/nostr_handler.c`, call `nostr_verify_event_signature(event)` from `nostr_core_lib/nostr_core/nip001.h`
2. If verification fails, drop the event silently with a `DEBUG_WARN` log
3. This prevents relay-forged pubkey fields from being trusted
4. Controlled by `security.verify_signatures` config flag (default: `true`)
### Stranger Response
Non-WoT senders get a **hardcoded configurable response** — no LLM tokens spent:
- Config field: `security.stranger_response`
- Example: `"I only respond to people in my web of trust. My npub is npub12kvnu0dsa4alquu4l4zv9454cgjdr9248gd07ueq5x8yzeuan37ql02960"`
- If `security.tiers.stranger.enabled` is `false`, silently ignore instead
- If `true`, send the canned response as a DM
### Soul Update
Update the soul event content in `config.json` to:
- Explicitly allow sharing the agent public key (npub) with anyone
- Explicitly forbid revealing the private key (nsec) under any circumstances
- Note: Phase 2 will remove the private key from agent memory entirely
### Admin Context Subscription
At startup, Didactyl subscribes to the administrator's Nostr activity to build context awareness.
#### What to subscribe to
| Kind | Purpose | Retention |
|------|---------|-----------|
| `0` | Admin profile metadata | Latest only - replaceable |
| `3` | Admin contact/follow list - WoT source | Latest only - replaceable |
| `10002` | Admin relay list | Latest only - replaceable |
| `1` | Admin public notes | Last N posts - configurable, default 10 |
#### Storage
In-memory struct `admin_context_t` holding:
- `kind_0_json` — latest kind 0 content string
- `kind_3_contacts` — array of followed pubkey hex strings extracted from kind 3 `p` tags
- `kind_3_contact_count` — count
- `kind_10002_json` — latest kind 10002 content string
- `kind_1_notes` — array of recent kind 1 content strings with timestamps
- `kind_1_note_count` — count
#### Subscription mechanics
- Single subscription filter: `{"authors": ["<admin_pubkey>"], "kinds": [0, 3, 10002, 1], "limit": <configurable>}`
- On EOSE: initial load complete, WoT set is ready
- After EOSE: live updates as admin posts new content
- Kind 3 `p` tags are extracted into a sorted array for O(log n) WoT lookup
#### Context injection
- Admin kind 1 notes are injected into the LLM context as a system message: "Administrator recent public notes:" followed by the content
- Only injected for ADMIN-tier conversations
### Config Schema — Phase 1
New sections in `config.json`:
```json
{
"security": {
"verify_signatures": true,
"stranger_response": "I only respond to people in my web of trust.",
"tiers": {
"admin": {
"tools_enabled": true
},
"wot": {
"enabled": true,
"tools_enabled": false
},
"stranger": {
"enabled": true
}
}
},
"admin_context": {
"enabled": true,
"subscribe_kinds": [0, 3, 10002, 1],
"kind_1_limit": 10
}
}
```
- `security.verify_signatures` — master toggle for signature verification (default: `true`)
- `security.stranger_response` — canned DM text for non-WoT senders (default: empty = silent ignore)
- `security.tiers.wot.enabled` — whether WoT contacts can converse (default: `true`)
- `security.tiers.stranger.enabled` — whether strangers get the canned response (default: `true`; if `false`, silent ignore)
- `admin_context.enabled` — whether to subscribe to admin activity (default: `true`)
- `admin_context.subscribe_kinds` — which kinds to track (default: `[0, 3, 10002, 1]`)
- `admin_context.kind_1_limit` — how many kind 1 notes to retain (default: `10`)
### Phase 1 Implementation Steps
1. **Add signature verification to `on_event()`**
- File: `src/nostr_handler.c`
- Call `nostr_verify_event_signature(event)` at the top of `on_event()`
- If it returns non-zero, log and drop
2. **Add `admin_context_t` struct and security config structs**
- File: `src/config.h`
- New structs for admin context storage, security config, tier config
3. **Parse new config sections**
- File: `src/config.c`
- Parse `security` and `admin_context` from config.json
- Apply defaults when sections are missing
4. **Add admin context subscription**
- File: `src/nostr_handler.c`
- New function `nostr_handler_subscribe_admin_context()`
- Callback that populates `admin_context_t` from incoming events
- Extract kind 3 `p` tags into WoT set
- Store kind 1 notes in a ring buffer capped at `kind_1_limit`
5. **Add WoT lookup function**
- File: `src/nostr_handler.c`
- `int nostr_handler_is_wot_contact(const char* pubkey_hex)` — checks if pubkey is in admin kind 3 list
6. **Refactor `on_event()` for tier dispatch**
- File: `src/nostr_handler.c`
- After signature verification, classify sender into ADMIN / WOT / STRANGER
- Pass tier information to the callback — extend `dm_callback_t` signature to include tier enum
- STRANGER with `stranger.enabled`: send canned response directly, no callback
- STRANGER without: drop silently
7. **Refactor `agent_on_message()` for tier-aware behavior**
- File: `src/agent.c`
- ADMIN: current behavior — all tools, full context, admin notes injected
- WOT: build messages with soul + tier instruction, no tools_json, LLM chat-only
- STRANGER: should not reach here — handled in nostr_handler
8. **Inject admin context into ADMIN conversations**
- File: `src/agent.c`
- After startup events context, append admin kind 1 notes as a system message
- Only for ADMIN tier
9. **Update soul content in `config.json`**
- Allow sharing public key with anyone
- Forbid revealing private key under any circumstances
10. **Update `config.json` with new sections**
- Add `security` and `admin_context` sections
11. **Update `README.md`**
- Document privilege tiers
- Document new config sections
- Update architecture diagram
12. **Build and validate**
- Run `./build_static.sh`
- Verify compilation succeeds
---
## Phase 2 — NIP-46 Remote Signer for Key Isolation
### Problem
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `local_shell_exec` and `local_file_read` tools — a sufficiently clever prompt injection could theoretically:
- Read `config.json` via `local_file_read` (contains nsec)
- Execute `cat /proc/self/maps` or similar via `local_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 (local_shell_exec, local_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.

337
plans/skill_tools.md Normal file
View File

@@ -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()` (t22t26)
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 164 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

520
plans/skills_demo_page.md Normal file
View File

@@ -0,0 +1,520 @@
# Skills Demo Page — Design Document
## Purpose
A standalone web page that demonstrates Didactyl skills running in a browser context. The page presents a minimal word-processing interface where users can load text, discover skills from Nostr relays, execute them against the text via an LLM, and edit/republish skill definitions.
This document is the complete specification for building the page. The front-end project consuming this document has its own LLM calling capability and Nostr connectivity.
---
## Concepts
### What is a Skill?
A skill is a Nostr event (kind `31123` for public, `31124` for private) that defines a self-contained LLM execution unit. Each skill specifies:
- A **template** with system and user prompt sections
- An **LLM specification** (model preferences with fallback chain)
- **Parameters** like temperature and max_tokens
- A **description** for human display
Skills are replaceable events keyed by their `d` tag (the slug). Publishing a new event with the same `d` tag replaces the previous version.
### Skill Event Structure
```json
{
"kind": 31123,
"pubkey": "<author hex pubkey>",
"created_at": 1709750000,
"content": "{\"description\":\"Check spelling and grammar\",\"context_mode\":\"full\",\"llm\":\"openai/gpt-4o-mini, cheap\",\"tools\":false,\"max_tokens\":2000,\"temperature\":0.1,\"template\":\"system:\\nYou are a spelling and grammar checker.\\n\\nRules:\\n- Fix spelling errors\\n- Fix grammar errors\\n- Preserve original formatting\\n- Return ONLY the corrected text, no explanations\\n\\nuser:\\n{{message}}\"}",
"tags": [
["d", "spellcheck"],
["app", "didactyl"],
["scope", "public"],
["description", "Spelling and grammar checker"],
["t", "text-processing"],
["t", "skills-demo"]
]
}
```
The `content` field is a **JSON string** containing these fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `description` | string | yes | Human-readable description |
| `context_mode` | string | no | `full` for demo skills (skill provides its own template) |
| `llm` | string | no | LLM model preference with fallback chain |
| `tools` | bool | no | `false` for text-processing skills |
| `max_tokens` | int | no | Max tokens for LLM response |
| `temperature` | float | no | Sampling temperature |
| `template` | string | yes | The prompt template with `system:` and `user:` sections |
### Template Format
Templates use a simple section-based format:
```
system:
You are a spelling and grammar checker.
Rules:
- Fix spelling errors
- Fix grammar errors
user:
{{message}}
```
The `{{message}}` placeholder is replaced with the user's text at execution time.
### Skill Address Format
Skills are referenced by their NIP-33 address: `<kind>:<author_pubkey_hex>:<d_tag>`
Example: `31123:52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8:spellcheck`
---
## Demo Skills
Four skills are published by the Didactyl agent (pubkey `52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8`). All are tagged with `t:skills-demo` for easy discovery.
### 1. Spellcheck — `d:spellcheck`
```
system:
You are a spelling and grammar checker.
Rules:
- Fix spelling errors
- Fix grammar errors
- Preserve original formatting and paragraph structure
- Preserve line breaks exactly as they appear
- Ignore technical terms: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP
- Canadian English preferred
- Return ONLY the corrected text, no explanations or commentary
user:
{{message}}
```
- temperature: `0.1`
- max_tokens: `4000`
### 2. Condense — `d:condense-5`
```
system:
You are a text condensing editor.
Rules:
- Reduce the text length by approximately 5%
- Preserve the original meaning and tone
- Preserve paragraph structure
- Remove redundant words and phrases
- Tighten sentences without changing voice
- Do not add new information
- Return ONLY the condensed text, no explanations or commentary
user:
{{message}}
```
- temperature: `0.3`
- max_tokens: `4000`
### 3. Translate to Japanese — `d:translate-ja`
```
system:
You are a professional translator specializing in English to Japanese translation.
Rules:
- Translate the entire text from English to Japanese
- Use natural, fluent Japanese appropriate for the content register
- Preserve paragraph structure and formatting
- Preserve any technical terms in their commonly accepted Japanese form
- If a term has no standard Japanese equivalent, keep the English term in katakana
- Return ONLY the Japanese translation, no explanations or commentary
user:
{{message}}
```
- temperature: `0.3`
- max_tokens: `4000`
### 4. Convert to Poem — `d:convert-to-poem`
```
system:
You are a creative poet.
Rules:
- Convert the given text into a poem
- Capture the key themes and meaning of the original text
- Use vivid imagery and poetic devices
- Aim for 12-20 lines
- Free verse is acceptable but light rhyme is welcome
- Return ONLY the poem, no explanations or commentary
user:
{{message}}
```
- temperature: `0.9`
- max_tokens: `2000`
---
## Discovering Skills from Nostr
### Query Filter
To find the demo skills, query relays with this filter:
```json
{
"kinds": [31123],
"#t": ["skills-demo"],
"limit": 20
}
```
To find skills by a specific author:
```json
{
"kinds": [31123],
"authors": ["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],
"#t": ["skills-demo"],
"limit": 20
}
```
### Recommended Relays
- `wss://relay.damus.io`
- `wss://nos.lol`
- `wss://relay.primal.net`
### Parsing a Skill Event
1. Receive the event from the relay
2. Parse `event.content` as JSON to get the skill definition object
3. Extract `description`, `template`, `temperature`, `max_tokens`
4. Extract the `d` tag value from `event.tags` as the skill slug
5. Extract the `description` tag from `event.tags` as a display label
6. The skill is ready to display and execute
---
## Page Layout
```
+------------------------------------------------------------------+
| SKILLS DEMO [dark/light]|
+------------------------------------------------------------------+
| | |
| +------------------------------+ | +---------------------------+|
| | TEXT EDITOR | | | SKILLS PANEL ||
| | | | | ||
| | [large editable textarea ]| | | Loading skills... ||
| | [filled with default text ]| | | ||
| | [ ]| | | [x] spellcheck ||
| | [ ]| | | Spelling and grammar ||
| | [ ]| | | checker ||
| | [ ]| | | ||
| | [ ]| | | [ ] condense-5 ||
| | [ ]| | | Reduce text by 5% ||
| | [ ]| | | ||
| | [ ]| | | [ ] translate-ja ||
| | [ ]| | | Translate to Japanese ||
| | [ ]| | | ||
| | [ ]| | | [ ] convert-to-poem ||
| | | | | Convert to poem ||
| +------------------------------+ | | | ||
| | | [Run Selected Skill] ||
| +------------------------------+ | | [Edit Skill] ||
| | STATUS BAR | | | ||
| | Ready / Running... / Done | | | ─── Skill Details ─── ||
| +------------------------------+ | | template: ... ||
| | | temperature: 0.1 ||
| | | max_tokens: 4000 ||
| | +---------------------------+|
+------------------------------------------------------------------+
```
### Left Column — Text Editor (65% width)
- A large `<textarea>` or `contenteditable` div, minimum 500px tall
- Pre-filled with default sample text (see Default Text section below)
- Fully editable by the user
- When a skill runs, the text content is sent as `{{message}}`
- After the LLM responds, the textarea content is **replaced** with the result
- An undo button restores the previous text (keep a history stack)
### Right Column — Skills Panel (35% width)
- **Skills list**: Radio buttons or selectable cards, one skill selected at a time
- Each skill card shows:
- Skill slug (the `d` tag)
- Description (from the `description` tag or content field)
- Author pubkey (truncated, e.g., `52a3e8...0acb8`)
- **Run Selected Skill** button: executes the selected skill against the current text
- **Edit Skill** button: opens the skill editor (see Skill Editor section)
- **Skill Details** section: shows the full template, temperature, max_tokens for the selected skill
- **Refresh Skills** button: re-queries relays for updated skill events
### Status Bar
- Shows current state: `Ready`, `Running skill: spellcheck...`, `Done (1.2s)`, or `Error: ...`
- Displays token usage if available from the LLM response
---
## Skill Execution Flow
```mermaid
sequenceDiagram
participant User
participant Page as Demo Page
participant LLM as LLM Provider
User->>Page: Select skill + click Run
Page->>Page: Parse skill template
Page->>Page: Split template into system/user sections
Page->>Page: Replace message placeholder with textarea content
Page->>LLM: Send system prompt + user message
Note over Page,LLM: Use skill temperature and max_tokens
LLM-->>Page: Response text
Page->>Page: Push current text to undo stack
Page->>Page: Replace textarea with response
Page->>User: Show completion status
```
### Template Parsing
The template field uses a simple format with `system:` and `user:` section markers:
```
system:
<system prompt content>
user:
<user prompt content with {{message}} placeholder>
```
**Parsing algorithm:**
1. Split the template string on the line `user:` (case-sensitive, must be at start of line)
2. Everything before `user:` is the system section; strip the leading `system:` prefix
3. Everything after `user:` is the user section
4. In the user section, replace `{{message}}` with the textarea content
5. Trim whitespace from both sections
**Pseudocode:**
```javascript
function parseSkillTemplate(template, userText) {
const userMarkerIndex = template.indexOf('\nuser:\n');
if (userMarkerIndex === -1) {
// Fallback: entire template is system, user text is the message
return {
system: template.replace(/^system:\n/, '').trim(),
user: userText
};
}
let systemPart = template.substring(0, userMarkerIndex);
systemPart = systemPart.replace(/^system:\n/, '').trim();
let userPart = template.substring(userMarkerIndex + '\nuser:\n'.length);
userPart = userPart.replace('{{message}}', userText).trim();
return { system: systemPart, user: userPart };
}
```
### LLM Call
With the parsed system and user prompts, call the LLM:
```javascript
const response = await callLLM({
messages: [
{ role: 'system', content: parsed.system },
{ role: 'user', content: parsed.user }
],
temperature: skill.temperature || 0.7,
max_tokens: skill.max_tokens || 2000
});
```
The page's own LLM calling capability handles the actual API request. The skill just provides the prompts and parameters.
---
## Skill Editor
When the user clicks **Edit Skill**, a modal or inline editor opens for the selected skill.
### Editor Fields
| Field | Input Type | Description |
|-------|-----------|-------------|
| Slug | text input (readonly for existing, editable for new) | The `d` tag identifier |
| Description | text input | One-line human description |
| Template | large textarea (monospace) | The full `system:/user:` template |
| Temperature | number input (0.0 - 2.0, step 0.1) | LLM sampling temperature |
| Max Tokens | number input (100 - 8000) | Maximum response tokens |
### Editor Actions
- **Save & Publish**: Builds a new kind `31123` event and publishes to relays
- **Cancel**: Closes editor without changes
- **New Skill**: Opens editor with blank fields for creating a new skill
### Publishing a Skill
```mermaid
sequenceDiagram
participant Editor as Skill Editor
participant Signer as Nostr Signer
participant Relay as Nostr Relays
Editor->>Editor: Build skill content JSON
Editor->>Editor: Build event with kind 31123 + tags
Editor->>Signer: Sign event
Signer-->>Editor: Signed event
Editor->>Relay: Publish to connected relays
Relay-->>Editor: OK confirmation
Editor->>Editor: Update local skill list
```
**Building the event:**
```javascript
function buildSkillEvent(slug, description, template, temperature, maxTokens) {
const content = JSON.stringify({
description: description,
context_mode: 'full',
llm: 'default',
tools: false,
max_tokens: maxTokens,
temperature: temperature,
template: template
});
return {
kind: 31123,
content: content,
tags: [
['d', slug],
['app', 'didactyl'],
['scope', 'public'],
['description', description],
['t', 'text-processing'],
['t', 'skills-demo']
],
created_at: Math.floor(Date.now() / 1000)
};
}
```
The event must be signed with the user's Nostr private key (via NIP-07 browser extension or the app's own signer) before publishing to relays.
---
## Default Text
Pre-fill the textarea with this sample text that exercises the demo skills well:
```
The Sovereign Agent
In the not-too-distant future, software agents will roam freely across decentralized networks, communicating through cryptographic protocols that no central authority can censor or control. These agents will learn new capabilities not from app stores or corporate APIs, but from each other — sharing skills as freely as humans share ideas.
Imagine an agent that wakes up on any computer in the world. You enter twelve words, and there it is — your agent, with all its memories, skills, and personality intact. It doesn't live on a server you rent. It doesn't depend on a company staying in business. It exists wherever you need it, sovereign and portable.
The key insight is that skills are the new apps. Instead of installing software, agents adopt skills — small, self-contained instructions that teach them how to perform specific tasks. A spelling checker. A translator. A poet. Each skill is just a Nostr event, discoverable by anyone, adoptable by any agent.
This is not science fiction. The protocols exist today. Nostr provides the communication layer. Bitcoin provides the economic layer. Cryptography provides the trust layer. What remains is to build the agents themselves — and to set them free.
There are some erors in this text that a good spellchecker should catch. The writting could also be more concise, and it would be intresting to see it translated or converted into verse.
```
Note: The last paragraph intentionally contains spelling errors (erors, writting, intresting) to demonstrate the spellcheck skill.
---
## Undo/Redo
Maintain a text history stack:
```javascript
const textHistory = [];
let historyIndex = -1;
function pushHistory(text) {
// Truncate any forward history
textHistory.splice(historyIndex + 1);
textHistory.push(text);
historyIndex = textHistory.length - 1;
}
function undo() {
if (historyIndex > 0) {
historyIndex--;
return textHistory[historyIndex];
}
return null;
}
function redo() {
if (historyIndex < textHistory.length - 1) {
historyIndex++;
return textHistory[historyIndex];
}
return null;
}
```
- Push the current text to history **before** replacing it with the LLM response
- Push the initial default text as the first history entry
- Show Undo/Redo buttons below the textarea
---
## Responsive Behavior
- On screens wider than 900px: two-column layout as shown
- On screens narrower than 900px: stack vertically — text editor on top, skills panel below
- The textarea should have a minimum height of 400px and resize vertically
---
## Error Handling
| Scenario | Behavior |
|----------|----------|
| No relays connected | Show "No relay connection" in skills panel with retry button |
| No skills found | Show "No skills found. Try different relays or create a new skill." |
| Skill content parse error | Show "Invalid skill format" badge on the skill card, disable Run |
| LLM call fails | Show error in status bar, do not replace textarea content |
| Publish fails | Show error in editor, keep editor open for retry |
---
## Summary of Nostr Operations
| Operation | Kind | Direction | When |
|-----------|------|-----------|------|
| Discover skills | `31123` | Read from relays | Page load + refresh |
| Execute skill | — | N/A (local LLM call) | User clicks Run |
| Publish skill | `31123` | Write to relays | User saves in editor |
The page does NOT need to interact with the Didactyl agent's HTTP API. All skill discovery and publishing happens directly via Nostr relays. Skill execution happens locally using the page's own LLM capability.

View File

@@ -0,0 +1,252 @@
# Plan: Unify Template Variables with Tools
## Problem
The soul template system (`---template---` in kind 31120) uses `{{variable}}` placeholders that are resolved by a hardcoded C function (`agent_template_resolve_var()`). This is a parallel data-fetching system alongside the existing tool registry. Every new context data source requires:
1. Adding a C function to produce the data
2. Adding an `if (strcmp(...))` branch in the resolver
3. Documenting the new variable name
4. Hoping the template author uses the exact right name
This caused the bug that started this investigation — 5 out of 9 template variables were misspelled/mismatched, producing empty context sections and a provider rejection.
## Goal
**Eliminate template variables entirely.** Template sections fetch their data by calling tools. The tool registry is the single mechanism for getting data into context.
## Design
### New Template Syntax
Replace `content: |` with `tool:` and optional `args:` directives:
```yaml
---template---
- section: admin_identity
role: system
tool: admin_identity
skip_if_empty: true
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
- section: admin_contacts
role: system
tool: nostr_admin_contacts
skip_if_empty: true
- section: admin_relays
role: system
tool: nostr_admin_relays
skip_if_empty: true
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
- section: tools
role: system
tool: tool_list
skip_if_empty: true
- section: tasks
role: system
tool: task_list
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
- section: conversation
role: user
tool: message_current
skip_if_empty: true
```
A section can still use the old `content:` with `{{variables}}` for static text or mixed content. But the primary mechanism for dynamic data is `tool:`.
### How It Works
```mermaid
flowchart TD
DM[Incoming DM] --> BUILD[Build context from template]
BUILD --> SOUL[Emit personality as system msg]
SOUL --> LOOP[For each template section]
LOOP --> CHECK{Has tool: directive?}
CHECK -->|Yes| EXEC["tools_execute(tool_name, args)"]
EXEC --> FORMAT[Extract content from JSON result]
FORMAT --> EMIT[Emit as chat message]
CHECK -->|No| RESOLVE["Resolve {{vars}} from content template<br/>(legacy path, eventually removed)"]
RESOLVE --> EMIT
EMIT --> LOOP
LOOP --> DONE[Complete messages array]
```
### New Context Tools
Add lightweight "context tools" that read from cached in-memory data. These are fast (no network), deterministic, and use the same `tools_execute()` interface as everything else.
| Tool | Returns | Source |
|---|---|---|
| `admin_identity` | Admin pubkey + verification text | config + sender tier |
| `nostr_admin_profile` | Admin kind 0 profile JSON | cached `g_admin_kind0_json` |
| `nostr_admin_contacts` | Admin kind 3 contacts JSON array | cached `g_admin_wot_contacts` |
| `nostr_admin_relays` | Admin kind 10002 relay list JSON | cached `g_admin_kind10002_json` |
| `nostr_admin_notes` | Admin recent kind 1 notes | cached `g_admin_kind1_notes` |
| `task_list` | Current task list from tasks.json | file read (already exists as `task_manage` with `action: list`) |
| `message_current` | Current user message text | passed via tool context |
| `agent_identity` | Agent pubkey, npub | config |
Existing tools that already work for context:
- `tool_list` — returns tool schemas (already exists)
- `task_manage` with `{"action":"list"}` — returns tasks (already exists)
- `local_file_read` — can read any file (already exists)
### Tool Result → Message Content
Tool results are JSON objects like `{"success":true,"content":"..."}`. The template system extracts the `content` field (or a configurable field) as the message text. If the tool returns an error or empty content, and `skip_if_empty: true` is set, the section is omitted.
For tools that return structured data (like `tool_list` returning a schema array), the template can specify a `result_field` or `format` to control extraction:
```yaml
- section: tools
role: system
tool: tool_list
result_field: tools
skip_if_empty: true
```
### Implementation Phases
#### Phase 1: Add context tools + tool: directive support
1. Add new `context_*` tools to `tools.c` that wrap the existing cached data getters
2. Extend `prompt_template_section_t` with `tool_name` and `tool_args` fields
3. Extend `prompt_template_parse()` to recognize `tool:` and `args:` directives
4. Extend `prompt_template_build_messages()` to call `tools_execute()` when a section has `tool_name` set
5. Update the soul template in `config.jsonc` to use `tool:` directives
6. Keep `content:` + `{{variable}}` working as a fallback for backward compatibility
#### Phase 2: Migrate all sections to tool-based
1. Convert all template sections from `content: {{variable}}` to `tool:` directives
2. Verify all context data flows through tools
3. Update `context_template.md` to reflect new syntax
#### Phase 3: Remove legacy variable resolver
1. Remove `agent_template_resolve_var()` and all its helper functions that are now redundant
2. Remove `prompt_var_resolver_fn` callback from `prompt_template_build_messages()`
3. Clean up dead code in `agent.c`
4. Update all documentation
### Template Section Struct Changes
```c
typedef struct {
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
char* content_template; // legacy: {{variable}} content
char* tool_name; // NEW: tool to call for content
char* tool_args; // NEW: JSON args for tool call
char* result_field; // NEW: which JSON field to extract (default: "content")
int limit;
int skip_if_empty;
char* provider_name;
char* provider_content_template;
} prompt_template_section_t;
```
### Soul Template Example (After Migration)
```
# Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
...
---template---
- section: admin_identity
role: system
tool: admin_identity
skip_if_empty: true
- section: admin_profile
role: system
tool: nostr_admin_profile
skip_if_empty: true
- section: admin_contacts
role: system
tool: nostr_admin_contacts
skip_if_empty: true
- section: admin_relays
role: system
tool: nostr_admin_relays
skip_if_empty: true
- section: admin_notes
role: system
tool: nostr_admin_notes
skip_if_empty: true
- section: tools
role: system
tool: tool_list
result_field: tools
skip_if_empty: true
- section: tasks
role: system
tool: task_manage
args: {"action":"list"}
result_field: tasks
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
- section: conversation
role: user
tool: message_current
skip_if_empty: true
```
### What Gets Deleted Eventually
- `agent_template_resolve_var()` and all its `if (strcmp(...))` branches
- `build_tool_schemas_json_string()`
- `build_tasks_content_string()` (replaced by `task_manage` tool)
- `build_admin_recent_posts_text()` (replaced by `nostr_admin_notes` tool)
- `build_admin_profile_plain_text()` (replaced by `nostr_admin_profile` tool)
- `build_admin_relay_list_plain_text()` (replaced by `nostr_admin_relays` tool)
- `build_sender_verification_text()` (folded into `admin_identity` tool)
- `build_startup_events_json_string()` (replaced by tool if needed)
- `build_adopted_skills_payload_string()` (replaced by tool if needed)
- `nostr_handler_get_admin_kind3_context()` (just added, will be replaced by tool)
- The `prompt_var_resolver_fn` callback type
### Benefits
1. **Single data-fetching mechanism** — tools are the only way to get data
2. **No more variable name mismatches** — tool names are validated at registration
3. **User-configurable context** — admin can add any tool output to context by editing the soul template
4. **Testable**`--test-tool nostr_admin_profile` shows exactly what goes into context
5. **Self-documenting**`tool_list` shows all available context tools with descriptions
6. **Extensible** — new tools automatically become available as context sources
7. **Provider overrides still work**`provider:` directive can still override formatting per-provider
### Risks
1. **Performance** — Tool calls add function dispatch overhead vs direct variable resolution. Mitigated: context tools read cached data, no network calls.
2. **Backward compatibility** — Old soul templates with `{{variables}}` would break. Mitigated: Phase 1 keeps both paths working.
3. **Tool context threading**`tools_execute()` needs access to the tools context, which `prompt_template_build_messages()` doesn't currently have. Solution: pass `tools_context_t*` to the build function.

117
plans/tool_naming.md Normal file
View File

@@ -0,0 +1,117 @@
# Didactyl Tool Naming — Final List
## Decisions
- `context_*` tools merged into existing categories with proper names
- Local tools get `local_` prefix
- `context_user_message` removed from LLM schema (template-only)
- `nostr_post_readme` kept — a post is different from a longform note
- `nostr_file_md_to_longform_post` kept as-is
- `task_manage` kept as-is
---
## Complete Tool List (sorted by category prefix)
### `admin_` — Administrator Metadata
| # | Name | Old Name | Description |
|---|---|---|---|
| 1 | `admin_identity` | `context_admin_identity` | Admin pubkey, sender tier, verification metadata |
### `agent_` — Agent Self-Metadata
| # | Name | Old Name | Description |
|---|---|---|---|
| 2 | `agent_identity` | `context_agent_identity` | Agent pubkey hex + npub |
| 3 | `agent_version` | `my_version` | Didactyl version and build metadata |
### `local_` — Local / Host Tools
| # | Name | Old Name | Description |
|---|---|---|---|
| 4 | `local_shell_exec` | `shell_exec` | Execute a shell command |
| 5 | `local_file_read` | `file_read` | Read a local file as text |
| 6 | `local_file_write` | `file_write` | Write text to a local file |
| 7 | `local_http_fetch` | `http_fetch` | Fetch HTTP/S resources |
### `model_` — LLM / Model Management
| # | Name | Old Name | Description |
|---|---|---|---|
| 8 | `model_get` | — | Get current active LLM config |
| 9 | `model_set` | — | Update active LLM config and persist |
| 10 | `model_list` | — | List available model IDs from provider |
### `nostr_` — Nostr Protocol Tools
| # | Name | Old Name | Description |
|---|---|---|---|
| 11 | `nostr_post` | — | Publish a Nostr event to connected relays |
| 12 | `nostr_post_readme` | — | Publish README.md as kind 30023 |
| 13 | `nostr_file_md_to_longform_post` | — | Read markdown file and publish as kind 30023 longform |
| 14 | `nostr_delete` | — | Request deletion of events — NIP-09 kind 5 |
| 15 | `nostr_react` | — | React to event — NIP-25 kind 7 |
| 16 | `nostr_query` | — | Query events from relays using a filter |
| 17 | `nostr_profile_get` | — | Look up kind 0 profile by pubkey |
| 18 | `nostr_nip05_lookup` | — | Look up/verify NIP-05 identifier |
| 19 | `nostr_admin_profile` | `context_admin_profile` | Get admin kind 0 profile from cache |
| 20 | `nostr_admin_contacts` | `context_admin_contacts` | Get admin kind 3 contacts from cache |
| 21 | `nostr_admin_relays` | `context_admin_relays` | Get admin kind 10002 relay list from cache |
| 22 | `nostr_admin_notes` | `context_admin_notes` | Get admin kind 1 recent notes from cache |
| 23 | `nostr_dm_send` | — | Send NIP-04 encrypted DM |
| 24 | `nostr_dm_send_nip17` | — | Send NIP-17 gift-wrap DM |
| 25 | `nostr_encrypt` | — | Encrypt plaintext — NIP-44 |
| 26 | `nostr_decrypt` | — | Decrypt ciphertext — NIP-44 |
| 27 | `nostr_encode` | — | Encode entity into nostr: URI |
| 28 | `nostr_decode` | — | Decode bech32/nostr: URI |
| 29 | `nostr_pubkey` | — | Return agent pubkey hex |
| 30 | `nostr_npub` | — | Return agent pubkey as npub |
| 31 | `nostr_relay_status` | — | Get connection status for all relays |
| 32 | `nostr_relay_info` | — | Fetch NIP-11 relay info document |
| 33 | `nostr_list_manage` | — | Add/remove tags in replaceable list events — NIP-51 |
### `skill_` — Skill Management
| # | Name | Old Name | Description |
|---|---|---|---|
| 34 | `skill_create` | — | Create/update skill definition |
| 35 | `skill_list` | — | List agent published skills |
| 36 | `skill_adopt` | — | Adopt a skill — add to kind 10123 |
| 37 | `skill_remove` | — | Remove skill from adoption list |
| 38 | `skill_search` | — | Search public skills |
### `task_` — Task Management
| # | Name | Old Name | Description |
|---|---|---|---|
| 39 | `task_manage` | — | CRUD for agent task memory |
| 40 | `task_list` | `context_tasks` | Get current task list — read-only |
### Template-Only (not in LLM schema)
| # | Name | Old Name | Description |
|---|---|---|---|
| 41 | `message_current` | `context_user_message` | Current user message text — template assembly only |
---
## Summary of Changes
| Old Name | New Name | Change Type |
|---|---|---|
| `context_admin_identity` | `admin_identity` | Rename |
| `context_agent_identity` | `agent_identity` | Rename |
| `context_admin_profile` | `nostr_admin_profile` | Rename + recategorize |
| `context_admin_contacts` | `nostr_admin_contacts` | Rename + recategorize |
| `context_admin_relays` | `nostr_admin_relays` | Rename + recategorize |
| `context_admin_notes` | `nostr_admin_notes` | Rename + recategorize |
| `context_tasks` | `task_list` | Rename + recategorize |
| `context_user_message` | `message_current` | Rename + remove from LLM schema |
| `my_version` | `agent_version` | Rename |
| `shell_exec` | `local_shell_exec` | Add prefix |
| `file_read` | `local_file_read` | Add prefix |
| `file_write` | `local_file_write` | Add prefix |
| `http_fetch` | `local_http_fetch` | Add prefix |
**Total: 13 renames, 0 removals, 0 additions**

360
plans/tool_orchestration.md Normal file
View File

@@ -0,0 +1,360 @@
# Tool Orchestration Plan
## Overview
This plan covers four related features:
1. **Slash commands** — direct tool execution bypassing the LLM
2. **Skill-forward execution (`/run` + `skill_run`)** — one-shot skill invocation with LLM, including external skill sharing
3. **Skill-tool maturity levels** — skills that register as callable tools with promotion workflow
4. **Deterministic step executor** — hardened skills that run without LLM involvement
## 1. Slash Commands (Direct Tool Execution)
### Behavior
When a message starts with `/`, bypass the LLM entirely and execute the tool directly.
```
/local_shell_exec {"command": "ls -la"}
/nostr_query {"filter": {"kinds": [1], "limit": 5}}
/nostr_nip05_lookup {"identifier": "jack@cash.app"}
```
### Parsing Rules
- `/tool_name` — call tool with empty args `{}`
- `/tool_name {"key": "value"}` — call tool with JSON args
- `/tool_name plain text` — wrap as `{"input": "plain text"}` (convenience)
- `/help` — list available tools
- `/help tool_name` — show tool schema
### Implementation
1. In [`agent_on_message()`](../src/agent.c:1453), check if `message[0] == '/'`
2. Parse tool name (everything between `/` and first space or end)
3. Parse args (everything after tool name, try JSON first, fall back to string wrapper)
4. Call [`tools_execute()`](../src/tools.c) directly
5. Send result as DM — no LLM round-trip
6. Still log to context.log.md with `phase=direct_tool_exec`
### Special Slash Commands
- `/help` — list available tools
- `/help tool_name` — show tool schema
- `/run` — skill-forward execution (see section 2)
### Security
- Only admin tier can use slash commands (same as current tool policy)
- Slash commands respect the same `tools.enabled` and `security.admin.tools_enabled` config flags
## 2. Skill-Forward Execution
### The Problem
Today, skills are passive — they're injected into the LLM context via [`append_adopted_skills_context()`](../src/agent.c:1418) and the LLM decides when they're relevant. There's no way to say "run this specific skill right now" and there's no way to try someone else's skill without permanently adopting it.
### Three Invocation Layers
| Layer | How it works | LLM? | Persists? |
|-------|-------------|------|-----------|
| **Passive/adopted** | Skill instructions injected into every conversation context | Yes, LLM decides relevance | Yes — in kind 10123 adoption list |
| **Skill-forward** | Skill instructions become the primary system prompt; LLM executes them | Yes, but constrained | No — one-shot execution |
| **Hardened** | Deterministic step executor, no LLM | No | Yes — adopted skill with `execution: hardened` |
### Entry Points
#### A. `/run` slash command (admin direct invocation)
```
/run deploy-website staging # run own adopted skill by slug
/run 31123:<pubkey>:deploy-website staging # run anyone's skill by address
/run deploy-website {"target": "production"} # JSON args
```
Parsing:
1. First token after `/run` is the skill identifier (slug or `kind:pubkey:slug` address)
2. Everything after is args (try JSON first, fall back to `{"input": "plain text"}`)
#### B. `skill_run` tool (LLM-mediated invocation)
The LLM can invoke skills on behalf of the admin during conversation:
```json
{
"name": "skill_run",
"description": "Fetch and execute a skill one-shot without adopting it. Works with own adopted skills by slug or any public skill by address.",
"parameters": {
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Skill slug for own adopted skills" },
"address": { "type": "string", "description": "Full skill address: kind:pubkey:slug" },
"pubkey": { "type": "string", "description": "Author pubkey, used with slug to form address" },
"args": { "type": "string", "description": "Arguments or context to pass to the skill" },
"sandbox": { "type": "boolean", "description": "Override sandbox setting. Default: true for external, false for own skills" }
}
}
}
```
This enables natural conversation like:
> "My friend @jack just published a skill called summarize-thread. Try it on the latest thread in my feed."
The agent would `skill_search` to find it, then `skill_run` to execute it.
### Execution Flow
Both `/run` and `skill_run` use the same underlying executor:
```mermaid
graph TD
A[/run or skill_run called] --> B{Skill identifier type?}
B -->|slug only| C[Look up in adopted skills cache]
B -->|address or pubkey+slug| D[Fetch skill event from Nostr]
C --> E{Found?}
D --> E
E -->|No| F[Return error: skill not found]
E -->|Yes| G{External skill?}
G -->|Yes| H[Apply sandbox - restrict tools]
G -->|No| I[Full tool access]
H --> J[Build skill-forward prompt]
I --> J
J --> K[System: base context + skill instructions]
K --> L[User: args as user message]
L --> M[LLM call with tools]
M --> N[Return result to caller]
```
### Skill-Forward Prompt Construction
Reuses the pattern from [`agent_on_trigger()`](../src/agent.c:1837):
```
[base system context / soul]
Skill execution context:
- You are executing a specific skill on demand.
- Follow the skill instructions below precisely.
- The user's arguments provide the context for this execution.
- Keep output concise and actionable.
Skill slug: deploy-website
Skill address: 31123:<pubkey>:deploy-website
Skill source: [own | external:<author_display_name>]
Skill instructions:
[skill content here]
```
User message:
```
[args provided by caller]
```
The prompt is so skill-forward that the LLM has no real option but to execute the skill instructions against the provided args.
### Sandbox for External Skills
When executing a skill from another author (not own pubkey), a **tool sandbox** is applied by default:
**Allowed tools (safe/read-only):**
- `nostr_query` — read Nostr events
- `nostr_nip05_lookup` — NIP-05 lookups
- `nostr_post` — publish events (the agent signs, so this is safe)
- `nostr_list_manage` — manage lists
- `skill_list`, `skill_search` — read skill metadata
**Blocked tools (destructive/dangerous):**
- `local_shell_exec` — arbitrary command execution
- `local_file_read`, `local_file_write` — filesystem access
- Any future tools marked as `destructive: true`
**Override:** The admin can explicitly opt in to full tool access:
- `/run --unsafe 31123:<pubkey>:risky-skill args`
- `skill_run` with `sandbox: false`
### Skill Sharing on Nostr
```mermaid
graph TD
A[Friend creates skill] -->|kind 31123| B[Published on Nostr relays]
B --> C{How do you find it?}
C -->|skill_search popular:true| D[Discovery via WoT adoption lists]
C -->|Friend tells you the slug| E[Direct reference]
C -->|skill_search pubkey:friend| F[Browse friends skills]
D --> G[skill_run - try it once, sandboxed]
E --> G
F --> G
G -->|Liked it?| H{Adopt?}
H -->|Yes| I[skill_adopt - permanent]
H -->|No| J[Done - nothing persisted]
I --> K[Shows in adopted skills context]
K --> L[Agent uses it automatically]
L -->|Or invoke directly| M[/run skill-slug args]
```
## 3. Skill-Tool Maturity Levels
Skills can declare an execution maturity level that determines how they run:
| Level | Execution | LLM? | Use case |
|-------|-----------|-------|----------|
| `draft` | LLM interprets procedure text from skill instructions | Yes | Exploring and iterating on a workflow |
| `guided` | LLM with forced tool_choice + parameter defaults | Yes, constrained | Workflow is stable but needs LLM judgment |
| `hardened` | Deterministic step executor, no LLM | No | Workflow is proven and should run exactly as defined |
### Skill Definition Extensions
```yaml
kind: 31123
d: deploy_website
execution: hardened
tool_schema:
name: deploy_website
description: Build and deploy the static website
parameters:
target:
type: string
enum: [staging, production]
default: staging
steps:
- tool: local_shell_exec
args:
command: "make build TARGET={{target}}"
- tool: local_shell_exec
args:
command: "rsync -av dist/ server:/var/www/{{target}}/"
- return: "Deployed to {{target}}"
```
### How Each Level Works
**draft:** Current behavior. Skill instructions are injected into context. LLM reads them and decides which tools to call. No special handling needed.
**guided:** Agent sets `tool_choice` to the skill's preferred tool. Parameter defaults from the skill are merged with the model's generated arguments (skill defaults win on conflict). Reduces LLM freedom while still allowing it to fill in dynamic values.
**hardened:** Agent executes the `steps` array directly using the deterministic step executor. No LLM call at all. The skill becomes equivalent to a slash command.
### Promotion Workflow
1. Admin iterates with LLM on a task (draft)
2. Admin saves working procedure as a skill: `skill_create` with `execution: draft`
3. Admin tests, refines, promotes: update skill to `execution: guided`
4. Once proven reliable, promote to `execution: hardened` with explicit `steps`
5. Hardened skills become available as slash commands: `/deploy_website {"target": "production"}`
## 4. Deterministic Step Executor
A simple sequential executor for hardened skills.
### Step Types
```yaml
steps:
# Execute a tool
- tool: local_shell_exec
args: {command: "ls -la"}
save_as: listing # optional: save result to variable
# Execute a tool with variable substitution
- tool: nostr_post
args:
kind: 30023
content: "{{file_content}}"
tags: [["d", "{{slug}}"]]
# Conditional - simple
- if: "{{listing.success}}"
then:
- tool: local_shell_exec
args: {command: "echo done"}
else:
- return: "Failed: {{listing.error}}"
# Return final result
- return: "Published to {{slug}}"
```
### Variable Substitution
- `{{param_name}}` — from tool parameters provided by caller
- `{{step_name.field}}` — from a previous step's result (requires `save_as`)
- Simple string replacement, no expression evaluation
### Implementation in C
- Parse `steps` array from skill content (JSON or YAML)
- Iterate steps sequentially
- For each `tool` step: call [`tools_execute()`](../src/tools.c), optionally save result
- For each `return` step: substitute variables and return string
- For each `if` step: evaluate truthiness of variable, branch accordingly
- Total implementation: ~200-400 lines of C
### Error Handling
- If any tool step fails (returns `success: false`), abort and return the error
- Optional `on_error` field per step for custom error messages
- Timeout inherited from tool config
## 5. Tool Registration for Skill-Tools
Hardened and guided skills with a `tool_schema` field get registered in the tools array at runtime.
### At Skill Refresh Time
1. Parse `tool_schema` from skill content
2. Generate OpenAI function schema from it
3. Append to the tools array returned by [`tools_build_openai_schema_json()`](../src/tools.c:919)
4. When model calls the skill-tool, route to skill executor instead of hardcoded C function
### In [`tools_execute()`](../src/tools.c)
1. Check if tool_name matches a hardcoded tool — execute normally
2. If not, check if it matches a registered skill-tool
3. If guided: run sub-LLM call with skill procedure + forced tool_choice
4. If hardened: run deterministic step executor
## 6. Security Model
### Tool Classification
Tools are classified for sandbox purposes:
```c
typedef enum {
TOOL_SAFETY_SAFE, // read-only or agent-signed actions
TOOL_SAFETY_DESTRUCTIVE // filesystem, shell, or external system mutations
} tool_safety_t;
```
| Tool | Safety | Reason |
|------|--------|--------|
| `nostr_query` | safe | Read-only |
| `nostr_nip05_lookup` | safe | Read-only |
| `nostr_post` | safe | Agent signs, admin controls keys |
| `nostr_list_manage` | safe | Agent signs |
| `skill_list` | safe | Read-only |
| `skill_search` | safe | Read-only |
| `skill_run` | safe | Recursive execution uses its own sandbox |
| `local_shell_exec` | destructive | Arbitrary command execution |
| `local_file_read` | destructive | Filesystem access |
| `local_file_write` | destructive | Filesystem mutation |
### Sandbox Rules
| Scenario | Default sandbox | Override |
|----------|----------------|---------|
| Own adopted skill via `/run slug` | No sandbox | N/A |
| External skill via `/run address` | Sandbox ON | `/run --unsafe address` |
| `skill_run` tool, own skill | No sandbox | `sandbox: true` |
| `skill_run` tool, external skill | Sandbox ON | `sandbox: false` |
| Hardened skill steps | No sandbox (steps are explicit) | N/A |
## Future Considerations
- **Skill sharing:** Hardened skill-tools could be shared between agents via Nostr (kind 31123 events). Another agent adopts the skill and gets the tool automatically.
- **Versioning:** Skills already use addressable events (d-tag). Updating a skill automatically updates the tool.
- **Permissions:** Skill-tools could have their own permission model (e.g., some skill-tools available to WoT contacts).
- **Composability:** Skill-tools calling other skill-tools (nested execution with sandbox inheritance).
- **Dry-run mode:** A future `/run --dry` flag that shows what tools would be called without executing them.
- **Skill ratings:** Agents could publish ratings/reviews of skills they've tried, building a WoT-based skill marketplace.
## Implementation Priority
1. **Slash commands** (direct tool execution) — simplest, highest immediate value
2. **`/run` for own adopted skills** — skill-forward execution of already-adopted skills
3. **`skill_run` tool + external skill fetching** — enables "try my friend's skill" flow
4. **External skill sandbox** — tool safety classification and sandbox enforcement
5. **Hardened skill-tool step executor** — enables deterministic workflows
6. **Skill-tool registration in tools array** — makes skill-tools visible to LLM
7. **Guided execution with forced tool_choice** — bridges draft and hardened
8. **Promotion workflow UX** — admin commands to change skill maturity level

139
plans/tool_testing.md Normal file
View File

@@ -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

203
plans/triggered_skills.md Normal file
View File

@@ -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

View File

@@ -0,0 +1,158 @@
# Plan: Unified Prompt Context for HTTP API and Nostr Paths
## Problem
The agent produces completely different LLM context depending on whether a message arrives via **Nostr DM** or the **HTTP API CLI chat app**.
### Nostr Path (working correctly)
- `agent_on_message()``agent_build_admin_messages_json()` → tool loop
- Builds **18 sections, ~8224 bytes** of context including:
- System prompt / personality (from soul template)
- Agent identity (pubkey)
- Sender verification (admin tier)
- Admin context (kind 0 profile, relay list, recent posts)
- Startup events memory
- Adopted skills
- DM history (decrypted from Nostr relays)
- Current user message
### HTTP API Path (broken)
- CLI sends `{messages: [{role: "user", content: "Hello"}]}` to `/api/prompt/run`
- `run_prompt_with_tools()` passes these raw messages directly to the LLM
- Result: **1 section, ~35 bytes** — just the bare user message, zero agent context
## Solution: New `POST /api/prompt/agent` Endpoint
Add a new endpoint that mirrors the Nostr path's context assembly, so the CLI gets the same full agent context.
### Architecture
```mermaid
flowchart TD
A[Nostr DM arrives] --> B[agent_on_message]
B --> C[agent_build_admin_messages_json]
C --> D[Append user message]
D --> E[Tool loop with llm_chat_with_tools_messages]
E --> F[Send DM reply]
G[CLI sends POST /api/prompt/agent] --> H[handle_prompt_agent]
H --> C
C --> I[Append user message]
I --> J[Tool loop - same as run_prompt_with_tools but with context]
J --> K[Return JSON response]
style C fill:#4a9,stroke:#333,color:#fff
```
Both paths share `agent_build_admin_messages_json()` as the single source of truth for context assembly.
### Request Format
```json
{
"message": "What is the capital of France?",
"model": "claude-haiku-4.5",
"max_turns": 4
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `message` | string | yes | The user message to send to the agent |
| `model` | string | no | Override the configured LLM model for this request |
| `max_turns` | int | no | Max tool-use turns, default 4, max 16 |
### Response Format
Same as existing `/api/prompt/run`:
```json
{
"success": true,
"final_response": "The capital of France is Paris.",
"turns": [...],
"model_used": "claude-haiku-4.5",
"total_input_tokens_estimate": 1973,
"total_output_tokens_estimate": 12
}
```
## Implementation Steps
### 1. Add `handle_prompt_agent()` in `src/http_api.c`
New function that:
1. Parses the JSON body to extract `message`, optional `model`, optional `max_turns`
2. Applies model override if present via `maybe_model_override_begin()`
3. Calls `agent_build_admin_messages_json(message, DIDACTYL_SENDER_ADMIN, &base_messages_json)` — same call the Nostr path uses
4. Parses the result into a cJSON array
5. Appends `{role: "user", content: message}` to the array — same as `agent_on_message()` does at line 1916
6. Builds tool schema via `tools_build_openai_schema_json()`
7. Runs the same tool loop as `run_prompt_with_tools()` but using the context-enriched messages
8. Logs context via `agent_append_context_log("http_api_agent", "llm_chat_with_tools_messages", messages_json)`
9. Returns the same response format as `/api/prompt/run`
Key reference points in existing code:
- Context building: `agent_build_admin_messages_json()` at `src/agent.c:1737`
- User message append: `append_simple_message()` pattern at `src/agent.c:1916`
- Tool loop: reuse the loop logic from `run_prompt_with_tools()` at `src/http_api.c:278-345`
- Context logging: `agent_append_context_log()` at `src/agent.c:1932`
### 2. Register the Route in `http_handler()`
Add before the existing `/api/prompt/run` route at `src/http_api.c:648`:
```c
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/prompt/agent"), NULL)) {
handle_prompt_agent(c, hm);
return;
}
```
### 3. Update `chat-didactyl-cli.js`
Change the CLI to call the new endpoint:
- Change `callDidactyl()` to POST to `/api/prompt/agent` instead of `/api/prompt/run`
- Send `{message: "user text", max_turns: N}` instead of `{messages: [...], max_turns: N}`
- The CLI no longer needs to maintain a `transcript` array for context — the server handles DM history from Nostr relays
- Keep the transcript for local display purposes only
### 4. Context Logging Parity
Use a distinct but parallel phase label:
- Nostr path: `llm_chat_with_tools_messages` (existing)
- HTTP API agent path: `llm_chat_with_tools_messages_agent_api` (new)
- HTTP API raw path: `llm_chat_with_tools_messages_http_api` (existing, unchanged)
This lets you distinguish the source in `context.log.md` while confirming the context structure is identical.
### 5. Update `docs/API.md`
Add documentation for the new `POST /api/prompt/agent` endpoint following the existing documentation style.
## Files to Modify
| File | Change |
|---|---|
| `src/http_api.c` | Add `handle_prompt_agent()` function and route registration |
| `chat-didactyl-cli.js` | Switch to `/api/prompt/agent`, simplify payload |
| `docs/API.md` | Document new endpoint |
## What Stays the Same
- `/api/prompt/run` — unchanged, still accepts raw message arrays for custom/advanced use
- `/api/prompt/run-simple` — unchanged
- `/api/context/current` and `/api/context/parts` — unchanged
- `agent_build_admin_messages_json()` — unchanged, already does exactly what we need
- Nostr message handling — unchanged
## Remaining Consideration: Conversation History
The Nostr path gets DM history by querying encrypted kind-4 events from relays. The new `/api/prompt/agent` endpoint will include this same history since it calls `agent_build_admin_messages_json()`. This means:
- Messages sent via the CLI will NOT appear in the Nostr DM history (they are not published as Nostr events)
- Messages sent via Nostr WILL appear in the context when using the CLI
- This is acceptable — the CLI is a development/admin tool that piggybacks on the agent's full context
If in the future you want CLI messages to also appear in history, that would require either publishing them as Nostr DMs or maintaining a separate local history store — but that is out of scope for this change.

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,28 @@
#define OPEN_WING_AGENT_H
#include "config.h"
#include "nostr_handler.h"
#include "cjson/cJSON.h"
#include "tools.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);
int agent_build_admin_messages_json(const char* current_user_message,
didactyl_sender_tier_t sender_tier,
char** out_messages_json);
tools_context_t* agent_tools_context(void);
const char* agent_classify_message_part(cJSON* msg, int idx);
void agent_append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload);
void agent_cleanup(void);
#endif

View File

@@ -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,101 @@
#include "cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
/* ---------------------------------------------------------------------------
* jsonc_strip_comments strip JSONC comments from a buffer
*
* Returns a newly malloc'd string containing valid JSON (caller must free).
* The function is careful to skip over JSON string literals so that comment
* characters inside strings are preserved. Handles:
* - single-line comments // (to end of line)
* - block comments (slash-star ... star-slash, may span lines)
* - escaped quotes inside strings \"
* - trailing commas before ] or } are NOT removed (cJSON tolerates them)
*
* Returns NULL on allocation failure.
* ------------------------------------------------------------------------ */
char* jsonc_strip_comments(const char* src, size_t src_len) {
if (!src || src_len == 0) {
char* empty = (char*)malloc(1);
if (empty) empty[0] = '\0';
return empty;
}
char* out = (char*)malloc(src_len + 1);
if (!out) return NULL;
size_t o = 0;
size_t i = 0;
while (i < src_len) {
/* Inside a JSON string literal — copy verbatim until closing quote */
if (src[i] == '"') {
out[o++] = src[i++]; /* opening quote */
while (i < src_len) {
if (src[i] == '\\' && i + 1 < src_len) {
out[o++] = src[i++]; /* backslash */
out[o++] = src[i++]; /* escaped char */
} else if (src[i] == '"') {
out[o++] = src[i++]; /* closing quote */
break;
} else {
out[o++] = src[i++];
}
}
continue;
}
/* Single-line comment // */
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '/') {
/* skip to end of line */
i += 2;
while (i < src_len && src[i] != '\n') {
i++;
}
/* preserve the newline so line numbers stay meaningful */
if (i < src_len) {
out[o++] = src[i++]; /* the \n */
}
continue;
}
/* Block comment (slash-star ... star-slash) */
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '*') {
i += 2;
while (i + 1 < src_len && !(src[i] == '*' && src[i + 1] == '/')) {
/* preserve newlines inside block comments */
if (src[i] == '\n') {
out[o++] = '\n';
}
i++;
}
if (i + 1 < src_len) {
i += 2; /* skip closing */
}
continue;
}
/* Normal character — copy through */
out[o++] = src[i++];
}
out[o] = '\0';
return out;
}
static char g_config_last_error[512] = {0};
static void config_set_error(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
vsnprintf(g_config_last_error, sizeof(g_config_last_error), fmt ? fmt : "unknown configuration error", ap);
va_end(ap);
}
const char* config_last_error(void) {
return g_config_last_error[0] != '\0' ? g_config_last_error : "unknown configuration error";
}
static int read_file_to_buffer(const char* path, char** out_buf, size_t* out_len) {
FILE* fp = fopen(path, "rb");
if (!fp) {
@@ -161,6 +258,319 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
return 0;
}
static int parse_security_config(cJSON* root, didactyl_config_t* config) {
cJSON* security = cJSON_GetObjectItemCaseSensitive(root, "security");
if (!security || !cJSON_IsObject(security)) {
return 0;
}
cJSON* verify_signatures = cJSON_GetObjectItemCaseSensitive(security, "verify_signatures");
if (verify_signatures && cJSON_IsBool(verify_signatures)) {
config->security.verify_signatures = cJSON_IsTrue(verify_signatures) ? 1 : 0;
}
if (copy_json_string(security,
"stranger_response",
config->security.stranger_response,
sizeof(config->security.stranger_response),
0) != 0) {
return -1;
}
cJSON* tiers = cJSON_GetObjectItemCaseSensitive(security, "tiers");
if (!tiers || !cJSON_IsObject(tiers)) {
return 0;
}
cJSON* admin_tier = cJSON_GetObjectItemCaseSensitive(tiers, "admin");
if (admin_tier && cJSON_IsObject(admin_tier)) {
cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(admin_tier, "tools_enabled");
if (tools_enabled && cJSON_IsBool(tools_enabled)) {
config->security.admin.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0;
}
}
cJSON* wot_tier = cJSON_GetObjectItemCaseSensitive(tiers, "wot");
if (wot_tier && cJSON_IsObject(wot_tier)) {
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "enabled");
cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "tools_enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->security.wot.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (tools_enabled && cJSON_IsBool(tools_enabled)) {
config->security.wot.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0;
}
}
cJSON* stranger_tier = cJSON_GetObjectItemCaseSensitive(tiers, "stranger");
if (stranger_tier && cJSON_IsObject(stranger_tier)) {
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(stranger_tier, "enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->security.stranger.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
}
return 0;
}
static int parse_admin_context_config(cJSON* root, didactyl_config_t* config) {
cJSON* admin_context = cJSON_GetObjectItemCaseSensitive(root, "admin_context");
if (!admin_context || !cJSON_IsObject(admin_context)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(admin_context, "enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->admin_context.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
cJSON* kind_1_limit = cJSON_GetObjectItemCaseSensitive(admin_context, "kind_1_limit");
if (kind_1_limit && cJSON_IsNumber(kind_1_limit)) {
config->admin_context.kind_1_limit = (int)kind_1_limit->valuedouble;
}
cJSON* subscribe_kinds = cJSON_GetObjectItemCaseSensitive(admin_context, "subscribe_kinds");
if (subscribe_kinds && cJSON_IsArray(subscribe_kinds)) {
config->admin_context.track_kind_0 = 0;
config->admin_context.track_kind_3 = 0;
config->admin_context.track_kind_10002 = 0;
config->admin_context.track_kind_1 = 0;
int n = cJSON_GetArraySize(subscribe_kinds);
for (int i = 0; i < n; i++) {
cJSON* k = cJSON_GetArrayItem(subscribe_kinds, i);
if (!k || !cJSON_IsNumber(k)) {
continue;
}
int kind = (int)k->valuedouble;
if (kind == 0) config->admin_context.track_kind_0 = 1;
else if (kind == 3) config->admin_context.track_kind_3 = 1;
else if (kind == 10002) config->admin_context.track_kind_10002 = 1;
else if (kind == 1) config->admin_context.track_kind_1 = 1;
}
}
return 0;
}
static 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 int parse_dm_protocol_config(cJSON* root, didactyl_config_t* config) {
if (!root || !config) {
return -1;
}
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
if (!dm_protocol) {
return 0;
}
if (!cJSON_IsString(dm_protocol) || !dm_protocol->valuestring) {
return -1;
}
if (strcmp(dm_protocol->valuestring, "nip04") == 0) {
config->dm_protocol = DM_PROTOCOL_NIP04;
} else if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
config->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
config->dm_protocol = DM_PROTOCOL_BOTH;
} else {
return -1;
}
return 0;
}
static int parse_api_config(cJSON* root, didactyl_config_t* config) {
cJSON* api = cJSON_GetObjectItemCaseSensitive(root, "api");
if (!api || !cJSON_IsObject(api)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(api, "enabled");
cJSON* port = cJSON_GetObjectItemCaseSensitive(api, "port");
if (enabled && cJSON_IsBool(enabled)) {
config->api.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
if (port && cJSON_IsNumber(port)) {
config->api.port = (int)port->valuedouble;
}
if (copy_json_string(api,
"bind_address",
config->api.bind_address,
sizeof(config->api.bind_address),
0) != 0) {
return -1;
}
if (config->api.port < 1 || config->api.port > 65535) {
config->api.port = 8484;
}
if (config->api.bind_address[0] == '\0') {
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.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)) {
@@ -209,6 +619,9 @@ static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
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;
}
@@ -217,37 +630,87 @@ static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
return 0;
}
static int parse_relays_from_startup_kind10002(didactyl_config_t* config) {
if (!config || !config->startup_events || config->startup_event_count <= 0) {
return -1;
}
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind != 10002 || !se->tags_json) {
continue;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
continue;
}
int tag_count = cJSON_GetArraySize(tags);
int relay_count = 0;
for (int j = 0; j < tag_count; j++) {
cJSON* tag = cJSON_GetArrayItem(tags, j);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "r") == 0 && val->valuestring[0] != '\0') {
relay_count++;
}
}
if (relay_count <= 0) {
cJSON_Delete(tags);
continue;
}
config->relays = (char**)calloc((size_t)relay_count, sizeof(char*));
if (!config->relays) {
cJSON_Delete(tags);
return -1;
}
config->relay_count = relay_count;
int out_i = 0;
for (int j = 0; j < tag_count && out_i < relay_count; j++) {
cJSON* tag = cJSON_GetArrayItem(tags, j);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "r") == 0 && val->valuestring[0] != '\0') {
config->relays[out_i] = strdup(val->valuestring);
if (!config->relays[out_i]) {
cJSON_Delete(tags);
return -1;
}
out_i++;
}
}
cJSON_Delete(tags);
return 0;
}
return -1;
}
static int parse_relays(cJSON* root, didactyl_config_t* config) {
cJSON* relays = cJSON_GetObjectItemCaseSensitive(root, "relays");
if (!relays || !cJSON_IsArray(relays)) {
return -1;
}
int count = cJSON_GetArraySize(relays);
if (count <= 0) {
return -1;
}
config->relays = (char**)calloc((size_t)count, sizeof(char*));
if (!config->relays) {
return -1;
}
config->relay_count = count;
for (int i = 0; i < count; i++) {
cJSON* relay = cJSON_GetArrayItem(relays, i);
if (!relay || !cJSON_IsString(relay) || !relay->valuestring || relay->valuestring[0] == '\0') {
return -1;
}
config->relays[i] = strdup(relay->valuestring);
if (!config->relays[i]) {
return -1;
}
}
return 0;
(void)root;
return parse_relays_from_startup_kind10002(config);
}
void config_free(didactyl_config_t* config) {
@@ -275,10 +738,16 @@ void config_free(didactyl_config_t* config) {
int config_load(const char* path, didactyl_config_t* config) {
if (!path || !config) {
config_set_error("config_load called with invalid arguments");
return -1;
}
config_set_error("unknown configuration error");
memset(config, 0, sizeof(*config));
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
config->dm_protocol = DM_PROTOCOL_NIP04;
config->tools.enabled = 1;
config->tools.max_turns = 8;
config->tools.shell.enabled = 1;
@@ -286,66 +755,100 @@ int config_load(const char* path, didactyl_config_t* config) {
config->tools.shell.max_output_bytes = 65536;
strcpy(config->tools.shell.working_directory, ".");
char* json_buf = NULL;
size_t json_len = 0;
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
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;
config->api.enabled = 0;
config->api.port = 8484;
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.1");
char* raw_buf = NULL;
size_t raw_len = 0;
if (read_file_to_buffer(path, &raw_buf, &raw_len) != 0) {
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
return -1;
}
/* Strip JSONC comments before parsing */
char* json_buf = jsonc_strip_comments(raw_buf, raw_len);
free(raw_buf);
if (!json_buf) {
config_set_error("failed to allocate memory for JSONC comment stripping");
return -1;
}
size_t json_len = strlen(json_buf);
cJSON* root = cJSON_ParseWithLength(json_buf, json_len);
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') {
@@ -353,12 +856,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;
}
@@ -368,19 +874,43 @@ int config_load(const char* path, didactyl_config_t* config) {
config->llm.max_tokens = (max_tokens && cJSON_IsNumber(max_tokens)) ? (int)max_tokens->valuedouble : 512;
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
if (parse_tools_config(root, config) != 0) {
if (parse_dm_protocol_config(root, config) != 0) {
config_set_error("invalid dm_protocol configuration (expected 'nip04', 'nip17', or 'both')");
goto cleanup;
}
if (parse_startup_events(root, config) != 0) {
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 (parse_api_config(root, config) != 0) {
config_set_error("invalid api 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;
}

View File

@@ -9,13 +9,7 @@
#define OW_MAX_KEY_LEN 256
#define OW_MAX_MODEL_LEN 128
typedef struct {
char name[OW_MAX_NAME_LEN];
char display_name[OW_MAX_NAME_LEN];
char about[OW_MAX_ABOUT_LEN];
char picture[OW_MAX_URL_LEN];
char nip05[OW_MAX_URL_LEN];
} agent_profile_t;
#define OW_MAX_STRANGER_RESPONSE_LEN 512
typedef struct {
char nsec[OW_MAX_KEY_LEN];
@@ -24,6 +18,12 @@ typedef struct {
char public_key_hex[65];
} agent_keys_t;
typedef enum {
DM_PROTOCOL_NIP04 = 0,
DM_PROTOCOL_NIP17 = 1,
DM_PROTOCOL_BOTH = 2
} dm_protocol_t;
typedef struct {
char pubkey[65];
} admin_config_t;
@@ -57,18 +57,63 @@ typedef struct {
} startup_event_t;
typedef struct {
agent_profile_t profile;
int enabled;
int tools_enabled;
} security_tier_config_t;
typedef struct {
int verify_signatures;
char stranger_response[OW_MAX_STRANGER_RESPONSE_LEN];
security_tier_config_t admin;
security_tier_config_t wot;
security_tier_config_t stranger;
} security_config_t;
typedef struct {
int enabled;
int track_kind_0;
int track_kind_3;
int track_kind_10002;
int track_kind_1;
int kind_1_limit;
} admin_context_config_t;
typedef struct {
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 {
int enabled;
int port;
char bind_address[OW_MAX_URL_LEN];
} api_config_t;
typedef struct {
agent_keys_t keys;
admin_config_t admin;
dm_protocol_t dm_protocol;
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;
api_config_t api;
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);
/* Strip JSONC single-line and block comments, returning malloc'd pure JSON. */
char* jsonc_strip_comments(const char* src, size_t src_len);
#endif

1281
src/http_api.c Normal file

File diff suppressed because it is too large Load Diff

19
src/http_api.h Normal file
View File

@@ -0,0 +1,19 @@
#ifndef DIDACTYL_HTTP_API_H
#define DIDACTYL_HTTP_API_H
#include "config.h"
#include "tools.h"
struct trigger_manager;
typedef struct {
didactyl_config_t* cfg;
tools_context_t* tools_ctx;
struct trigger_manager* trigger_manager;
} http_api_context_t;
int http_api_init(const http_api_context_t* ctx);
int http_api_poll(int timeout_ms);
void http_api_cleanup(void);
#endif

126
src/llm.c
View File

@@ -3,6 +3,7 @@
#include "llm.h"
#include <curl/curl.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -64,15 +65,42 @@ static const char* detect_ca_bundle_path(void) {
return NULL;
}
static char* perform_chat_request(const char* body) {
static int url_looks_like_websocket(const char* url) {
if (!url) return 0;
return (strncmp(url, "ws://", 5) == 0) || (strncmp(url, "wss://", 6) == 0);
}
static int json_string_is_blank(const cJSON* item) {
if (!item || !cJSON_IsString(item) || !item->valuestring) {
return 0;
}
const unsigned char* p = (const unsigned char*)item->valuestring;
while (*p) {
if (!isspace(*p)) {
return 0;
}
p++;
}
return 1;
}
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);
if (url_looks_like_websocket(url)) {
fprintf(stderr,
"[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s\n",
url);
fprintf(stderr,
"[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1\n");
curl_easy_cleanup(curl);
return NULL;
}
response_buffer_t rb = {0};
struct curl_slist* headers = NULL;
@@ -83,8 +111,11 @@ 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);
@@ -95,6 +126,19 @@ static char* perform_chat_request(const char* body) {
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
}
if (is_post) {
size_t body_len = body ? strlen(body) : 0U;
size_t body_preview_len = body_len > 4000U ? 4000U : body_len;
fprintf(stderr,
"[didactyl] llm request: method=POST url=%s body_bytes=%zu body_preview=%.4000s%s\n",
url,
body_len,
body ? body : "",
body_len > body_preview_len ? "..." : "");
} else {
fprintf(stderr, "[didactyl] llm request: method=GET url=%s\n", url);
}
CURLcode res = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
@@ -115,6 +159,10 @@ static char* perform_chat_request(const char* body) {
if (status < 200 || status >= 300) {
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
if (status == 101) {
fprintf(stderr,
"[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API\n");
}
if (rb.data && rb.len > 0) {
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
rb.data,
@@ -132,6 +180,12 @@ static char* perform_chat_request(const char* body) {
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();
@@ -314,6 +368,36 @@ int llm_chat_with_tools_messages(const char* messages_json,
cJSON_Delete(root);
return -1;
}
int filtered_count = 0;
for (int i = cJSON_GetArraySize(messages) - 1; i >= 0; i--) {
cJSON* msg = cJSON_GetArrayItem(messages, i);
if (!msg || !cJSON_IsObject(msg)) {
continue;
}
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
cJSON* tool_calls = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
int role_is_textual = (role && cJSON_IsString(role) && role->valuestring &&
(strcmp(role->valuestring, "system") == 0 ||
strcmp(role->valuestring, "user") == 0 ||
strcmp(role->valuestring, "assistant") == 0));
int has_tool_calls = (tool_calls && cJSON_IsArray(tool_calls) && cJSON_GetArraySize(tool_calls) > 0);
if (role_is_textual && !has_tool_calls && json_string_is_blank(content)) {
cJSON_DeleteItemFromArray(messages, i);
filtered_count++;
}
}
if (filtered_count > 0) {
fprintf(stderr,
"[didactyl] llm request sanitizer: removed %d empty text message(s) before provider request\n",
filtered_count);
}
cJSON_AddItemToObject(root, "messages", messages);
if (tools_json) {
@@ -388,6 +472,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;

View File

@@ -26,6 +26,9 @@ int llm_chat_with_tools_messages(const char* messages_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

View File

@@ -4,6 +4,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#include "main.h"
@@ -12,6 +13,10 @@
#include "llm.h"
#include "nostr_handler.h"
#include "debug.h"
#include "trigger_manager.h"
#include "tools.h"
#include "cjson/cJSON.h"
#include "http_api.h"
static volatile sig_atomic_t g_running = 1;
@@ -20,17 +25,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* config_path = "./config.jsonc";
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], "--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>] [--debug <0-5>]\n", argv[0]);
print_usage(argv[0]);
return 1;
}
}
@@ -47,10 +109,84 @@ 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;
}
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 exit_code;
}
if (llm_init(&cfg.llm) != 0) {
fprintf(stderr, "Failed to initialize llm client\n");
config_free(&cfg);
@@ -84,6 +220,57 @@ int main(int argc, char** argv) {
return 1;
}
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");
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache begin");
if (nostr_handler_subscribe_self_skills() != 0) {
DEBUG_WARN("[didactyl] startup phase: subscribe self skill cache failed (continuing)");
}
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache end");
char startup_dm[768];
const char* startup_name = nostr_handler_get_startup_display_name();
int connected_relays = nostr_handler_connected_relay_count();
time_t startup_now = time(NULL);
struct tm startup_tm;
char startup_time_str[32] = {0};
if (localtime_r(&startup_now, &startup_tm)) {
(void)strftime(startup_time_str, sizeof(startup_time_str), "%Y-%m-%d %H:%M:%S", &startup_tm);
} else {
snprintf(startup_time_str, sizeof(startup_time_str), "%ld", (long)startup_now);
}
snprintf(startup_dm,
sizeof(startup_dm),
"%s has started up and is online at %s (version %s, connected relays: %d/%d).",
(startup_name && startup_name[0] != '\0') ? startup_name : "Didactyl",
startup_time_str,
DIDACTYL_VERSION,
connected_relays,
cfg.relay_count);
if (nostr_handler_send_dm_auto(cfg.admin.pubkey, startup_dm) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to send startup status DM to admin");
}
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
DEBUG_ERROR("[didactyl] startup phase: subscribe DMs failed");
@@ -91,6 +278,7 @@ int main(int argc, char** argv) {
agent_cleanup();
nostr_handler_cleanup();
llm_cleanup();
trigger_manager_cleanup(&trigger_manager);
config_free(&cfg);
nostr_cleanup();
return 1;
@@ -100,21 +288,67 @@ int main(int argc, char** argv) {
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
signal(SIGPIPE, SIG_IGN);
int http_api_started = 0;
if (cfg.api.enabled) {
http_api_context_t http_ctx;
memset(&http_ctx, 0, sizeof(http_ctx));
http_ctx.cfg = &cfg;
http_ctx.tools_ctx = agent_tools_context();
http_ctx.trigger_manager = &trigger_manager;
if (http_api_init(&http_ctx) != 0) {
fprintf(stderr,
"Failed to initialize HTTP API on %s:%d\n",
cfg.api.bind_address,
cfg.api.port);
agent_cleanup();
nostr_handler_cleanup();
llm_cleanup();
trigger_manager_cleanup(&trigger_manager);
config_free(&cfg);
nostr_cleanup();
return 1;
}
http_api_started = 1;
DEBUG_INFO("[didactyl] HTTP API listening at http://%s:%d",
cfg.api.bind_address[0] ? cfg.api.bind_address : "127.0.0.1",
cfg.api.port > 0 ? cfg.api.port : 8484);
DEBUG_INFO("[didactyl] HTTP API endpoints: http://%s:%d/api/context/current http://%s:%d/api/context/parts",
cfg.api.bind_address[0] ? cfg.api.bind_address : "127.0.0.1",
cfg.api.port > 0 ? cfg.api.port : 8484,
cfg.api.bind_address[0] ? cfg.api.bind_address : "127.0.0.1",
cfg.api.port > 0 ? cfg.api.port : 8484);
} else {
DEBUG_INFO("[didactyl] HTTP API disabled (set api.enabled=true in config to enable)");
}
nostr_handler_refresh_relay_statuses();
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);
if (http_api_started) {
(void)http_api_poll(0);
}
struct timespec ts = {0, 10 * 1000 * 1000};
nanosleep(&ts, NULL);
}
DEBUG_INFO("[didactyl] shutting down");
if (http_api_started) {
http_api_cleanup();
}
agent_cleanup();
nostr_handler_cleanup();
llm_cleanup();
trigger_manager_cleanup(&trigger_manager);
config_free(&cfg);
nostr_cleanup();

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 5
#define DIDACTYL_VERSION "v0.0.5"
#define DIDACTYL_VERSION_PATCH 50
#define DIDACTYL_VERSION "v0.0.50"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

28193
src/mongoose.c Normal file

File diff suppressed because it is too large Load Diff

4038
src/mongoose.h Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,54 @@
#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_self_skills(void);
char* nostr_handler_get_self_events_by_kind_json(int kind);
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_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
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);
void nostr_handler_refresh_relay_statuses(void);
const char* nostr_handler_get_system_context(void);
char* nostr_handler_get_self_skill_events_json(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_kind3_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

557
src/prompt_template.c Normal file
View File

@@ -0,0 +1,557 @@
#define _POSIX_C_SOURCE 200809L
#include "prompt_template.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char* dup_range(const char* s, size_t n) {
char* out = (char*)malloc(n + 1U);
if (!out) return NULL;
if (n > 0) {
memcpy(out, s, n);
}
out[n] = '\0';
return out;
}
static char* ltrim_inplace(char* s) {
if (!s) return s;
while (*s && isspace((unsigned char)*s)) s++;
return s;
}
static void rtrim_inplace(char* s) {
if (!s) return;
size_t n = strlen(s);
while (n > 0 && isspace((unsigned char)s[n - 1])) {
s[n - 1] = '\0';
n--;
}
}
static int starts_with(const char* s, const char* prefix) {
if (!s || !prefix) return 0;
size_t n = strlen(prefix);
return strncmp(s, prefix, n) == 0;
}
static int add_line(char*** lines, int* count, int* cap, char* line) {
if (!lines || !count || !cap) return -1;
if (*count >= *cap) {
int next = (*cap == 0) ? 64 : (*cap * 2);
char** grown = (char**)realloc(*lines, (size_t)next * sizeof(char*));
if (!grown) return -1;
*lines = grown;
*cap = next;
}
(*lines)[*count] = line;
(*count)++;
return 0;
}
static int split_lines_inplace(char* s, char*** out_lines, int* out_count) {
if (!s || !out_lines || !out_count) return -1;
char** lines = NULL;
int count = 0;
int cap = 0;
char* p = s;
while (*p) {
char* start = p;
while (*p && *p != '\n') p++;
if (*p == '\n') {
*p = '\0';
p++;
}
if (add_line(&lines, &count, &cap, start) != 0) {
free(lines);
return -1;
}
}
*out_lines = lines;
*out_count = count;
return 0;
}
static int append_text(char** buf, size_t* cap, size_t* used, const char* s) {
if (!buf || !cap || !used || !s) return -1;
size_t n = strlen(s);
if (*used + n + 1U > *cap) {
size_t next = *cap;
while (*used + n + 1U > next) {
next = (next == 0U) ? 256U : (next * 2U);
}
char* grown = (char*)realloc(*buf, next);
if (!grown) return -1;
*buf = grown;
*cap = next;
}
memcpy(*buf + *used, s, n);
*used += n;
(*buf)[*used] = '\0';
return 0;
}
static void init_section_defaults(prompt_template_section_t* sec) {
if (!sec) return;
memset(sec->name, 0, sizeof(sec->name));
memset(sec->role, 0, sizeof(sec->role));
snprintf(sec->role, sizeof(sec->role), "system");
sec->content_template = NULL;
sec->tool_name = NULL;
sec->tool_args = NULL;
sec->result_field = NULL;
sec->limit = 0;
sec->skip_if_empty = 0;
sec->provider_name = NULL;
sec->provider_content_template = NULL;
}
static int parse_int_or_zero(const char* s) {
if (!s) return 0;
while (*s && isspace((unsigned char)*s)) s++;
return atoi(s);
}
int prompt_template_parse(const char* soul_content, prompt_template_t* out_template) {
if (!soul_content || !out_template) {
return -1;
}
memset(out_template, 0, sizeof(*out_template));
const char* marker = strstr(soul_content, PROMPT_TEMPLATE_MARKER);
if (!marker) {
return -1;
}
size_t personality_len = (size_t)(marker - soul_content);
out_template->personality = dup_range(soul_content, personality_len);
if (!out_template->personality) {
return -1;
}
rtrim_inplace(out_template->personality);
const char* after = marker + strlen(PROMPT_TEMPLATE_MARKER);
while (*after == '\r' || *after == '\n') after++;
char* tpl = strdup(after);
if (!tpl) {
prompt_template_free(out_template);
return -1;
}
char** lines = NULL;
int line_count = 0;
if (split_lines_inplace(tpl, &lines, &line_count) != 0) {
free(tpl);
prompt_template_free(out_template);
return -1;
}
int current = -1;
int i = 0;
while (i < line_count) {
char* raw = lines[i];
rtrim_inplace(raw);
char* line = ltrim_inplace(raw);
if (*line == '\0') {
i++;
continue;
}
if (starts_with(line, "- section:")) {
if (out_template->section_count >= PROMPT_TEMPLATE_MAX_SECTIONS) {
break;
}
current = out_template->section_count;
init_section_defaults(&out_template->sections[current]);
out_template->section_count++;
char* name = line + strlen("- section:");
name = ltrim_inplace(name);
rtrim_inplace(name);
snprintf(out_template->sections[current].name,
sizeof(out_template->sections[current].name),
"%s",
name);
i++;
continue;
}
if (current < 0) {
i++;
continue;
}
if (starts_with(line, "role:")) {
char* role = line + strlen("role:");
role = ltrim_inplace(role);
rtrim_inplace(role);
snprintf(out_template->sections[current].role,
sizeof(out_template->sections[current].role),
"%s",
(*role) ? role : "system");
i++;
continue;
}
if (starts_with(line, "limit:")) {
char* lim = line + strlen("limit:");
out_template->sections[current].limit = parse_int_or_zero(lim);
i++;
continue;
}
if (starts_with(line, "skip_if_empty:")) {
char* flag = line + strlen("skip_if_empty:");
flag = ltrim_inplace(flag);
rtrim_inplace(flag);
out_template->sections[current].skip_if_empty =
(strcmp(flag, "true") == 0 || strcmp(flag, "1") == 0) ? 1 : 0;
i++;
continue;
}
if (starts_with(line, "tool:")) {
char* val = line + strlen("tool:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].tool_name);
out_template->sections[current].tool_name = strdup(val);
i++;
continue;
}
if (starts_with(line, "args:")) {
char* val = line + strlen("args:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].tool_args);
out_template->sections[current].tool_args = strdup((*val) ? val : "{}");
i++;
continue;
}
if (starts_with(line, "result_field:")) {
char* val = line + strlen("result_field:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].result_field);
out_template->sections[current].result_field = strdup(val);
i++;
continue;
}
if (starts_with(line, "content:")) {
char* val = line + strlen("content:");
val = ltrim_inplace(val);
free(out_template->sections[current].content_template);
out_template->sections[current].content_template = NULL;
if (strcmp(val, "|") == 0) {
char* acc = strdup("");
size_t cap = acc ? 1U : 0U;
size_t used = 0U;
if (!acc) {
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
while (i < line_count) {
char* next_raw = lines[i];
char* next_ltrim = ltrim_inplace(next_raw);
if (starts_with(next_ltrim, "- section:") ||
starts_with(next_ltrim, "role:") ||
starts_with(next_ltrim, "limit:") ||
starts_with(next_ltrim, "skip_if_empty:") ||
starts_with(next_ltrim, "tool:") ||
starts_with(next_ltrim, "args:") ||
starts_with(next_ltrim, "result_field:") ||
starts_with(next_ltrim, "content:") ||
starts_with(next_ltrim, "provider:")) {
break;
}
char* piece = next_raw;
if (strncmp(piece, " ", 4) == 0) piece += 4;
else if (strncmp(piece, " ", 2) == 0) piece += 2;
rtrim_inplace(piece);
if (append_text(&acc, &cap, &used, piece) != 0 ||
append_text(&acc, &cap, &used, "\n") != 0) {
free(acc);
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
}
rtrim_inplace(acc);
out_template->sections[current].content_template = acc;
continue;
}
rtrim_inplace(val);
out_template->sections[current].content_template = strdup(val);
i++;
continue;
}
if (starts_with(line, "provider:")) {
i++;
if (i >= line_count) continue;
char* provider_line = ltrim_inplace(lines[i]);
rtrim_inplace(provider_line);
char* colon = strchr(provider_line, ':');
if (!colon) {
continue;
}
*colon = '\0';
char* provider_name = ltrim_inplace(provider_line);
rtrim_inplace(provider_name);
char* provider_val = ltrim_inplace(colon + 1);
rtrim_inplace(provider_val);
free(out_template->sections[current].provider_name);
out_template->sections[current].provider_name = strdup(provider_name);
free(out_template->sections[current].provider_content_template);
out_template->sections[current].provider_content_template = NULL;
if (strcmp(provider_val, "|") == 0) {
char* acc = strdup("");
size_t cap = acc ? 1U : 0U;
size_t used = 0U;
if (!acc) {
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
while (i < line_count) {
char* next = ltrim_inplace(lines[i]);
if (starts_with(next, "- section:") ||
starts_with(next, "role:") ||
starts_with(next, "limit:") ||
starts_with(next, "skip_if_empty:") ||
starts_with(next, "tool:") ||
starts_with(next, "args:") ||
starts_with(next, "result_field:") ||
starts_with(next, "content:") ||
starts_with(next, "provider:")) {
break;
}
char* piece = lines[i];
if (strncmp(piece, " ", 4) == 0) piece += 4;
else if (strncmp(piece, " ", 2) == 0) piece += 2;
rtrim_inplace(piece);
if (append_text(&acc, &cap, &used, piece) != 0 ||
append_text(&acc, &cap, &used, "\n") != 0) {
free(acc);
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
}
rtrim_inplace(acc);
out_template->sections[current].provider_content_template = acc;
continue;
}
out_template->sections[current].provider_content_template = strdup(provider_val);
i++;
continue;
}
i++;
}
free(lines);
free(tpl);
return 0;
}
static int append_message_object(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 char* extract_tool_result_content(const prompt_template_section_t* sec, const char* tool_result_json) {
if (!tool_result_json) return strdup("");
cJSON* root = cJSON_Parse(tool_result_json);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return strdup(tool_result_json);
}
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
if (success && cJSON_IsBool(success) && !cJSON_IsTrue(success)) {
cJSON_Delete(root);
return strdup("");
}
const char* field_name = (sec && sec->result_field && sec->result_field[0] != '\0')
? sec->result_field
: "content";
cJSON* field = cJSON_GetObjectItemCaseSensitive(root, field_name);
if (!field) {
field = cJSON_GetObjectItemCaseSensitive(root, "content");
}
char* out = NULL;
if (field && cJSON_IsString(field) && field->valuestring) {
out = strdup(field->valuestring);
} else if (field) {
out = cJSON_PrintUnformatted(field);
} else {
out = cJSON_PrintUnformatted(root);
}
cJSON_Delete(root);
return out ? out : strdup("");
}
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,
tools_context_t* tools_ctx,
cJSON* dm_history_messages,
int dm_history_default_limit,
prompt_template_emit_hook_fn emit_hook,
void* emit_hook_user_data) {
if (!tmpl) return NULL;
cJSON* out = cJSON_CreateArray();
if (!out) return NULL;
int out_idx = 0;
if (tmpl->personality && tmpl->personality[0] != '\0') {
if (append_message_object(out, "system", tmpl->personality) != 0) {
cJSON_Delete(out);
return NULL;
}
if (emit_hook) emit_hook("system_prompt", out_idx, emit_hook_user_data);
out_idx++;
}
for (int i = 0; i < tmpl->section_count; i++) {
const prompt_template_section_t* sec = &tmpl->sections[i];
const char* role = sec->role[0] ? sec->role : "system";
if (strcmp(role, "expand") == 0) {
if (!dm_history_messages || !cJSON_IsArray(dm_history_messages)) {
continue;
}
int total = cJSON_GetArraySize(dm_history_messages);
int lim = sec->limit > 0 ? sec->limit : dm_history_default_limit;
int start = (lim > 0 && total > lim) ? (total - lim) : 0;
for (int j = start; j < total; j++) {
cJSON* item = cJSON_GetArrayItem(dm_history_messages, j);
if (!item || !cJSON_IsObject(item)) continue;
cJSON* dup = cJSON_Duplicate(item, 1);
if (!dup) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToArray(out, dup);
if (emit_hook) emit_hook(sec->name[0] ? sec->name : "context_part", out_idx, emit_hook_user_data);
out_idx++;
}
continue;
}
char* resolved = NULL;
if (sec->tool_name && sec->tool_name[0] != '\0' && tools_ctx) {
const char* args_json = (sec->tool_args && sec->tool_args[0] != '\0') ? sec->tool_args : "{}";
char* tool_result = tools_execute(tools_ctx, sec->tool_name, args_json);
resolved = extract_tool_result_content(sec, tool_result);
free(tool_result);
} else {
const char* tpl = sec->content_template;
if (provider_name && sec->provider_name && sec->provider_content_template &&
strcmp(provider_name, sec->provider_name) == 0) {
tpl = sec->provider_content_template;
}
resolved = strdup(tpl ? tpl : "");
}
if (!resolved) {
cJSON_Delete(out);
return NULL;
}
if (sec->skip_if_empty) {
char* chk = ltrim_inplace(resolved);
if (chk && *chk == '\0') {
free(resolved);
continue;
}
}
if (append_message_object(out, role, resolved) != 0) {
free(resolved);
cJSON_Delete(out);
return NULL;
}
if (emit_hook) emit_hook(sec->name[0] ? sec->name : "context_part", out_idx, emit_hook_user_data);
out_idx++;
free(resolved);
}
return out;
}
void prompt_template_free(prompt_template_t* tmpl) {
if (!tmpl) return;
free(tmpl->personality);
tmpl->personality = NULL;
for (int i = 0; i < tmpl->section_count; i++) {
free(tmpl->sections[i].content_template);
tmpl->sections[i].content_template = NULL;
free(tmpl->sections[i].tool_name);
tmpl->sections[i].tool_name = NULL;
free(tmpl->sections[i].tool_args);
tmpl->sections[i].tool_args = NULL;
free(tmpl->sections[i].result_field);
tmpl->sections[i].result_field = NULL;
free(tmpl->sections[i].provider_name);
tmpl->sections[i].provider_name = NULL;
free(tmpl->sections[i].provider_content_template);
tmpl->sections[i].provider_content_template = NULL;
tmpl->sections[i].name[0] = '\0';
tmpl->sections[i].role[0] = '\0';
tmpl->sections[i].limit = 0;
tmpl->sections[i].skip_if_empty = 0;
}
tmpl->section_count = 0;
}

47
src/prompt_template.h Normal file
View File

@@ -0,0 +1,47 @@
#ifndef DIDACTYL_PROMPT_TEMPLATE_H
#define DIDACTYL_PROMPT_TEMPLATE_H
#include "cjson/cJSON.h"
#include "tools.h"
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
#define PROMPT_TEMPLATE_MAX_ROLE_LEN 16
#define PROMPT_TEMPLATE_MARKER "---template---"
typedef struct {
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
char* content_template;
char* tool_name;
char* tool_args;
char* result_field;
int limit;
int skip_if_empty;
char* provider_name;
char* provider_content_template;
} prompt_template_section_t;
typedef struct {
char* personality;
prompt_template_section_t sections[PROMPT_TEMPLATE_MAX_SECTIONS];
int section_count;
} prompt_template_t;
typedef void (*prompt_template_emit_hook_fn)(const char* section_name,
int message_index,
void* user_data);
int prompt_template_parse(const char* soul_content, prompt_template_t* out_template);
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,
tools_context_t* tools_ctx,
cJSON* dm_history_messages,
int dm_history_default_limit,
prompt_template_emit_hook_fn emit_hook,
void* emit_hook_user_data);
void prompt_template_free(prompt_template_t* tmpl);
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,13 @@
#include "config.h"
struct trigger_manager;
typedef struct {
didactyl_config_t* cfg;
struct trigger_manager* trigger_manager;
int template_sender_tier;
const char* template_current_user_message;
} tools_context_t;
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);

663
src/trigger_manager.c Normal file
View File

@@ -0,0 +1,663 @@
#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_auto(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_auto(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;
}
char* adoption_json = nostr_handler_get_self_events_by_kind_json(10123);
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_events = NULL;
if (strcmp(pubkey, mgr->cfg->keys.public_key_hex) == 0) {
char* skill_json = nostr_handler_get_self_events_by_kind_json(kind);
if (skill_json) {
cJSON* all_events = cJSON_Parse(skill_json);
free(skill_json);
if (all_events && cJSON_IsArray(all_events)) {
skill_events = cJSON_CreateArray();
if (skill_events) {
int all_n = cJSON_GetArraySize(all_events);
for (int ai = 0; ai < all_n; ai++) {
cJSON* ev = cJSON_GetArrayItem(all_events, ai);
cJSON* ev_pubkey = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL;
cJSON* ev_tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
cJSON* ev_d = find_tag_value_string(ev_tags, "d");
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
strcmp(ev_pubkey->valuestring, pubkey) != 0 ||
!ev_d || !cJSON_IsString(ev_d) || !ev_d->valuestring ||
strcmp(ev_d->valuestring, slug) != 0) {
continue;
}
cJSON* dup = cJSON_Duplicate(ev, 1);
if (dup) {
cJSON_AddItemToArray(skill_events, dup);
}
}
}
}
cJSON_Delete(all_events);
}
} else {
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, 2000);
cJSON_Delete(skill_filter);
if (skill_json) {
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) < 10) {
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, 1200);
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");
}

58
src/trigger_manager.h Normal file
View File

@@ -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

1
tasks.json Normal file
View File

@@ -0,0 +1 @@
{"tasks":[],"next_id":9}

28193
vendor/mongoose.c vendored Normal file

File diff suppressed because it is too large Load Diff

4038
vendor/mongoose.h vendored Normal file

File diff suppressed because it is too large Load Diff