Compare commits

...
7 Commits
43 changed files with 83597 additions and 335 deletions
+5
View File
@@ -6,6 +6,11 @@
/nips/
/config.json
test_keys.txt
/mongoose/
# Build artifacts
/build/
/didactyl
+3 -1
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/trigger_manager.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 \
+5 -2
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
@@ -13,6 +13,9 @@ SRCS = \
$(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 = \
@@ -28,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)
+78 -20
View File
@@ -51,11 +51,11 @@ Agents learn capabilities through skills — Nostr events that any agent can di
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.25
## Current Status — v0.0.32
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.25Add model_get/model_set/model_list tools with persisted LLM config updates and model discovery
> Last release update: v0.0.32 — Prevent NIP-17 old message reprocessing by guarding rumor created_at against startup time
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -63,10 +63,13 @@ Didactyl will support local inference, which is very privacy preserving. Remote
- Verifies Nostr event signatures before processing inbound messages
- Applies privilege tiers: ADMIN (tools), WoT (chat-only), STRANGER (configurable canned reply or ignore)
- Subscribes to admin context kinds (`0`,`3`,`10002`,`1`) for WoT + contextual awareness
- Builds LLM context from system prompt + admin identity (kind 0/10002) + startup events + admin DM history + admin recent notes
- 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
- 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
@@ -199,6 +202,31 @@ CLI debugger notes:
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
```
@@ -274,13 +302,12 @@ On boot, Didactyl attempts startup publishes to each relay as that relay transit
Didactyl builds tier-aware context:
- **ADMIN** request context order:
1. Soul message from kind `31120` (or fallback default)
2. Admin identity context — admin pubkey hex, kind `0` profile, kind `10002` relay list
3. Startup events memory block (`kinds/content/tags` snapshot)
4. Last 12 decrypted DM turns between admin and agent
5. Recent admin kind `1` notes (from configured admin-context subscription)
6. Current user message
- **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 — e.g. `admin_identity`, `admin_profile`, `admin_relay_list`, `startup_events`, `adopted_skills`, `dm_history` (expand), `admin_notes`
3. Each section resolves `{{variable}}` placeholders from live data at call time
4. Provider-specific content overrides per section (e.g. XML tags for Anthropic)
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
@@ -323,9 +350,31 @@ Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](s
- `http_fetch`
- Agent metadata:
- `my_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.json) |
| `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
```
@@ -335,18 +384,22 @@ Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
├── Makefile # Build system
├── build_static.sh # Preferred final build validation
├── src/
│ ├── main.c / .h # Entry point, args (--config/--debug), lifecycle, version
│ ├── config.c / .h # JSON config parsing, key decode, startup events
│ ├── context.c / .h # File loader utility (reads file into malloc'd string)
│ ├── agent.c / .h # Context assembly, tool loop, DM response flow
│ ├── tools.c / .h # LLM tool schema and tool execution
│ ├── llm.c / .h # LLM HTTP API client (OpenAI-compatible)
│ ├── 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
── debug.c / .h # Runtime log levels/macros
── 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_AND_SKILLS.md # Tool and skill system documentation
├── plans/ # Architecture and planning documents
│ ├── didactyl_mvp.md
│ ├── didactyl_agentic.md
│ └── security_and_admin_context.md
└── README.md
```
@@ -377,6 +430,11 @@ All dependencies are statically linked into the binary at build time. No system
- [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.json)
- [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
-102
View File
@@ -1,102 +0,0 @@
==========================================
Didactyl MUSL Static Binary Builder (PRODUCTION MODE)
==========================================
Project directory: /home/teknari/lt_gitea/didactyl
Output directory: /home/teknari/lt_gitea/didactyl
Debug build: false
✓ Docker is available and running
Building for platform: linux/amd64
Output binary: didactyl_static_x86_64
Checking for cached Alpine Docker image...
✓ Alpine 3.19 image found in cache
==========================================
Step 1: Building Alpine Docker image
==========================================
This will:
- Use Alpine Linux (native MUSL)
- Build all dependencies statically
- Compile didactyl with full static linking
#0 building with "default" instance using docker driver
#1 [internal] load build definition from Dockerfile.alpine-musl
#1 transferring dockerfile: 3.81kB done
#1 DONE 0.0s
#2 [internal] load metadata for docker.io/library/alpine:3.19
#2 DONE 0.0s
#3 [internal] load .dockerignore
#3 transferring context: 2B done
#3 DONE 0.0s
#4 [builder 1/10] FROM docker.io/library/alpine:3.19
#4 DONE 0.0s
#5 [internal] load build context
#5 transferring context: 10.34kB done
#5 DONE 0.0s
#6 [builder 9/10] RUN if [ "false" = "true" ]; then CFLAGS="-g -O2 -DDEBUG"; STRIP_CMD="echo 'Keeping debug symbols'"; echo "Building with DEBUG symbols enabled (optimized with -O2)"; else CFLAGS="-O2"; STRIP_CMD="strip /build/didactyl_static"; echo "Building optimized production binary (symbols stripped)"; fi && CURL_LIBS="$(pkg-config --static --libs libcurl)" && OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && gcc -static $CFLAGS -Wall -Wextra -std=c99 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket src/main.c src/config.c src/context.c src/llm.c src/nostr_handler.c src/agent.c src/tools.c src/debug.c -o /build/didactyl_static nostr_core_lib/libnostr_core_x64.a -lsecp256k1 $OPENSSL_LIBS $CURL_LIBS -lpthread -lm -ldl && eval "$STRIP_CMD"
#6 CACHED
#7 [builder 10/10] RUN echo "=== Binary Information ===" && file /build/didactyl_static && ls -lh /build/didactyl_static && echo "=== Checking for dynamic dependencies ===" && (ldd /build/didactyl_static 2>&1 || echo "Binary is static") && echo "=== Build complete ==="
#7 CACHED
#8 [builder 8/10] COPY Makefile /build/Makefile
#8 CACHED
#9 [builder 2/10] RUN apk add --no-cache build-base musl-dev git cmake pkgconfig autoconf automake libtool openssl-dev openssl-libs-static zlib-dev zlib-static curl-dev curl-static nghttp2-dev nghttp2-static c-ares-dev c-ares-static libpsl-dev libpsl-static libidn2-dev libidn2-static libunistring-dev libunistring-static brotli-dev brotli-static zstd-dev zstd-static sqlite-dev sqlite-static linux-headers wget bash
#9 CACHED
#10 [builder 3/10] WORKDIR /build
#10 CACHED
#11 [builder 4/10] RUN cd /tmp && git clone https://github.com/bitcoin-core/secp256k1.git && cd secp256k1 && ./autogen.sh && ./configure --enable-static --disable-shared --prefix=/usr CFLAGS="-fPIC" && make -j$(nproc) && make install && rm -rf /tmp/secp256k1
#11 CACHED
#12 [builder 7/10] COPY src/ /build/src/
#12 CACHED
#13 [builder 5/10] COPY nostr_core_lib /build/nostr_core_lib/
#13 CACHED
#14 [builder 6/10] RUN cd nostr_core_lib && chmod +x build.sh && sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && rm -f *.o *.a 2>/dev/null || true && ./build.sh --nips=all
#14 CACHED
#15 [output 1/1] COPY --from=builder /build/didactyl_static /didactyl_static
#15 CACHED
#16 exporting to image
#16 exporting layers done
#16 writing image sha256:1e0a868f1e0957613b56504ecee24b1032f765254e070aad5725dd9af34df56a done
#16 naming to docker.io/library/didactyl-musl-builder:latest done
#16 DONE 0.0s
✓ Docker image built successfully
==========================================
Step 2: Extracting static binary
==========================================
✓ Binary extracted to: /home/teknari/lt_gitea/didactyl/didactyl_static_x86_64
==========================================
Step 3: Verifying static binary
==========================================
Checking for dynamic dependencies:
✓ Binary is statically linked (verified with file command)
==========================================
Build Summary
==========================================
Binary: /home/teknari/lt_gitea/didactyl/didactyl_static_x86_64
Size: 6.8M
Static: true
Debug: false
Platform: linux/amd64
==========================================
+150
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);
});
+22 -5
View File
@@ -8,6 +8,7 @@
"admin": {
"pubkey": "admin pubkey"
},
"dm_protocol": "nip04",
"llm": {
"provider": "",
"api_key": "",
@@ -42,6 +43,11 @@
],
"kind_1_limit": 10
},
"api": {
"enabled": true,
"port": 8484,
"bind_address": "127.0.0.1"
},
"startup_events": [
{
"kind": 0,
@@ -58,8 +64,6 @@
"kind": 10002,
"content": "",
"tags": [
[
"r",
"wss://relay.damus.io"
@@ -76,7 +80,20 @@
"r",
"ws://127.0.0.1:7777"
]
]
},
{
"kind": 10050,
"content": "",
"tags": [
[
"relay",
"wss://relay.damus.io"
],
[
"relay",
"wss://nos.lol"
]
]
},
{
@@ -100,7 +117,7 @@
},
{
"kind": 31120,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.",
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## 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_relay_list\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}\n\n- section: startup_events\n role: system\n content: |\n ## Startup Events Memory (source: config.startup_events)\n\n Startup events memory (kinds/content/tags): {{startup_events_json}}\n\n- section: adopted_skills\n role: system\n content: |\n {{adopted_skills_content}}\n\n- section: agent_tasks\n role: system\n content: |\n {{tasks_content}}\n\n- section: dm_history\n role: expand\n limit: 12\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n {{admin_notes_content}}",
"tags": [
[
"d",
@@ -251,4 +268,4 @@
]
}
]
}
}
+9134
View File
File diff suppressed because one or more lines are too long
+57
View File
@@ -0,0 +1,57 @@
# Context Template
```yaml
- section: admin_identity
role: system
content: |
## Administrator Identity (source: config.admin.pubkey)
This is your administrator! Admin pubkey (hex): {{admin_pubkey}}
- section: admin_profile
role: system
content: |
## Administrator Kind 0 Profile (source: nostr kind 0)
Administrator kind 0 profile content (JSON): {{admin_kind0_json}}
provider:
anthropic: |
<admin_kind0_profile source="nostr_kind_0">
{{admin_kind0_json}}
</admin_kind0_profile>
- section: admin_relay_list
role: system
content: |
## Administrator Relay List (source: nostr kind 10002)
Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}
- section: startup_events
role: system
content: |
## Startup Events Memory (source: config.startup_events)
Startup events memory (kinds/content/tags): {{startup_events_json}}
- section: adopted_skills
role: system
content: |
{{adopted_skills_content}}
- section: agent_tasks
role: system
content: |
{{tasks_content}}
- section: dm_history
role: expand
limit: 12
- section: admin_notes
role: system
content: |
## Administrator Recent Notes (source: nostr kind 1)
{{admin_notes_content}}
```
+1272
View File
File diff suppressed because it is too large Load Diff
+493
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.json`:
```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 |
+49 -23
View File
@@ -44,43 +44,67 @@ sequenceDiagram
### Tool Categories
#### Nostr Core Tools
#### Nostr Event & Messaging Tools
| Tool | Description |
|---|---|
| `nostr_post` | Publish any kind event to relays |
| `nostr_query` | Query relays with filters, return matching events |
| `nostr_dm` | Send a DM via NIP-04 |
| `nostr_dm_nip17` | Send a DM via NIP-17 gift wrap |
| `nostr_profile` | Update the agent's kind 0 metadata |
| `nostr_list_manage` | Add/remove items from replaceable list events |
| `nostr_relay_status` | Get connection status of all relays |
| `nostr_relay_info` | Get NIP-11 relay information document |
| `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 |
#### Identity Tools
#### Nostr Identity & Utility Tools
| Tool | Description |
|---|---|
| `nostr_resolve_identifier` | Resolve NIP-05, npub, nprofile, or note identifiers |
| `nostr_verify_nip05` | Verify a NIP-05 identifier |
| `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) |
#### Skill Management Tools
#### Skills & Trigger Tools
| Tool | Description |
|---|---|
| `skill_create` | Create or update a skill definition |
| `skill_list` | List the agent's published skills |
| `skill_adopt` | Add a skill to the adoption list |
| `skill_remove` | Remove a skill from the adoption list |
| `skill_search` | Search for skills across the Web of Trust |
| `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 |
#### System Tools
#### LLM / Model Management Tools
| Tool | Description |
|---|---|
| `shell_exec` | Execute a shell command with sandboxing |
| `http_request` | Make an HTTP request |
| `get_time` | Get the current UTC time |
| `model_get` | Get current active LLM runtime configuration (excluding API key) |
| `model_set` | Update active LLM configuration and persist it to `config.json` |
| `model_list` | List available model IDs using provider OpenAI-compatible `/models` endpoint |
#### System & Runtime Tools
| Tool | Description |
|---|---|
| `my_version` | Return current Didactyl version and metadata from build macros |
| `http_fetch` | Fetch HTTP(S) resources with optional method, headers, timeout, and body |
| `shell_exec` | Execute a shell command and return stdout/stderr |
| `file_read` | Read a local file as text from the configured working directory |
| `file_write` | Write text content to a local file in the configured working directory |
| `tool_list` | List available tools with name, description, and JSON parameter schema |
#### Content Publishing Conveniences
| Tool | Description |
|---|---|
| `nostr_post_readme` | Publish `README.md` as kind `30023` with deterministic d-tag `readme.md` |
| `nostr_file_md_to_longform_post` | Read a markdown file and publish it as kind `30023` longform post (defaults d-tag to lowercase filename) |
### Security Model
@@ -134,7 +158,9 @@ flowchart LR
### How Skills Are Used Today
Currently, skills are **passive knowledge**. They exist on Nostr and are loaded into the LLM context when relevant. The admin might say "use your summarize-thread skill" and the LLM retrieves and follows the skill's instructions.
Adopted skills are now **always-on contextual knowledge** for admin DM handling: the agent resolves the local adoption list (kind `10123`), caches referenced skills, and injects their instructions into the LLM system context each turn.
To keep context stable and safe, skill injection is bounded by hard caps (per-skill truncation and total skill-context budget), and excess skills are omitted with an explicit budget notice.
---
+71
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
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
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
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 |
+139
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)
+167
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
+194
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
+398
View File
@@ -0,0 +1,398 @@
# 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` |
| `content` | no | Content template with `{{variable}}` placeholders |
| `limit` | no | Integer limit for variable-length sections like DM history or notes |
| `provider` | no | Provider-specific override — see below |
### Variable Placeholders
| Variable | Source | Description |
|---|---|---|
| `{{admin_pubkey}}` | `config.admin.pubkey` | Admin hex pubkey |
| `{{admin_kind0_json}}` | `nostr_handler_get_admin_kind0_context()` | Admin profile JSON |
| `{{admin_kind10002_json}}` | `nostr_handler_get_admin_kind10002_context()` | Admin relay list JSON |
| `{{startup_events_json}}` | Serialized startup events | Startup events array |
| `{{adopted_skills_content}}` | Adoption list cache | Formatted skill instructions |
| `{{admin_notes_content}}` | `nostr_handler_get_admin_kind1_notes_context()` | Recent kind 1 notes |
| `{{agent_pubkey}}` | `config.keys.public_key_hex` | Agent own pubkey |
| `{{agent_npub}}` | Derived from pubkey | Agent npub |
### 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 --> RESOLVE[Resolve {{variables}} from live data]
RESOLVE --> CHECK_PROVIDER{Provider override?}
CHECK_PROVIDER -->|Yes| USE_OVERRIDE[Use provider-specific content]
CHECK_PROVIDER -->|No| USE_DEFAULT[Use default content]
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
+395
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
+360
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.
```
/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):**
- `shell_exec` — arbitrary command execution
- `file_read`, `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: shell_exec
args:
command: "make build TARGET={{target}}"
- tool: 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: 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: 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 |
| `shell_exec` | destructive | Arbitrary command execution |
| `file_read` | destructive | Filesystem access |
| `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
+158
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.
+1967 -77
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -4,6 +4,7 @@
#include "config.h"
#include "nostr_handler.h"
#include "cjson/cJSON.h"
#include "tools.h"
struct trigger_manager;
@@ -17,6 +18,12 @@ 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
+77
View File
@@ -315,6 +315,67 @@ static int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
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;
@@ -603,6 +664,8 @@ int config_load(const char* path, didactyl_config_t* config) {
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;
@@ -632,6 +695,10 @@ int config_load(const char* path, didactyl_config_t* config) {
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* json_buf = NULL;
size_t json_len = 0;
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
@@ -716,6 +783,11 @@ 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_dm_protocol_config(root, config) != 0) {
config_set_error("invalid dm_protocol configuration (expected 'nip04', 'nip17', or 'both')");
goto cleanup;
}
if (parse_tools_config(root, config) != 0) {
config_set_error("invalid tools configuration");
goto cleanup;
@@ -736,6 +808,11 @@ int config_load(const char* path, didactyl_config_t* config) {
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;
+14
View File
@@ -18,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;
@@ -80,9 +86,16 @@ typedef struct {
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;
@@ -90,6 +103,7 @@ typedef struct {
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];
+1281
View File
File diff suppressed because it is too large Load Diff
+19
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
+19
View File
@@ -64,6 +64,11 @@ static const char* detect_ca_bundle_path(void) {
return NULL;
}
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 char* perform_http_request(const char* url, const char* body, int is_post) {
CURL* curl = curl_easy_init();
if (!curl || !url) {
@@ -71,6 +76,16 @@ static char* perform_http_request(const char* url, const char* body, int is_post
return NULL;
}
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;
@@ -116,6 +131,10 @@ static char* perform_http_request(const char* url, const char* body, int is_post
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,
+43 -1
View File
@@ -16,6 +16,7 @@
#include "trigger_manager.h"
#include "tools.h"
#include "cjson/cJSON.h"
#include "http_api.h"
static volatile sig_atomic_t g_running = 1;
@@ -251,7 +252,7 @@ int main(int argc, char** argv) {
DIDACTYL_VERSION,
connected_relays,
cfg.relay_count);
if (nostr_handler_send_dm(cfg.admin.pubkey, startup_dm) != 0) {
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");
}
@@ -274,18 +275,59 @@ int main(int argc, char** argv) {
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)");
}
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();
+2 -2
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 25
#define DIDACTYL_VERSION "v0.0.25"
#define DIDACTYL_VERSION_PATCH 32
#define DIDACTYL_VERSION "v0.0.32"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+28193
View File
File diff suppressed because it is too large Load Diff
+4038
View File
File diff suppressed because it is too large Load Diff
+423 -100
View File
@@ -51,6 +51,17 @@ static int g_seen_dm_next = 0;
static pthread_mutex_t g_dm_dedup_mutex = PTHREAD_MUTEX_INITIALIZER;
#define SENDER_PROTOCOL_CACHE_SIZE 128
typedef struct {
char pubkey_hex[65];
dm_protocol_t protocol;
time_t seen_at;
} sender_protocol_entry_t;
static sender_protocol_entry_t g_sender_protocol_cache[SENDER_PROTOCOL_CACHE_SIZE];
static pthread_mutex_t g_sender_protocol_mutex = PTHREAD_MUTEX_INITIALIZER;
static int dm_id_seen_or_remember(const char* event_id_hex) {
if (!event_id_hex || strlen(event_id_hex) != 64U) {
return 0;
@@ -84,6 +95,88 @@ static int dm_id_seen_or_remember(const char* event_id_hex) {
return seen;
}
static didactyl_sender_tier_t sender_tier_from_pubkey(const char* sender_pubkey_hex) {
if (!g_cfg || !sender_pubkey_hex) {
return DIDACTYL_SENDER_STRANGER;
}
if (strcmp(sender_pubkey_hex, g_cfg->admin.pubkey) == 0) {
return DIDACTYL_SENDER_ADMIN;
}
if (g_cfg->security.wot.enabled && nostr_handler_is_wot_contact(sender_pubkey_hex)) {
return DIDACTYL_SENDER_WOT;
}
return DIDACTYL_SENDER_STRANGER;
}
static void sender_protocol_remember(const char* sender_pubkey_hex, dm_protocol_t protocol) {
if (!sender_pubkey_hex || strlen(sender_pubkey_hex) != 64U) {
return;
}
if (protocol != DM_PROTOCOL_NIP04 && protocol != DM_PROTOCOL_NIP17) {
return;
}
pthread_mutex_lock(&g_sender_protocol_mutex);
int slot = -1;
time_t oldest_time = 0;
int oldest_idx = 0;
for (int i = 0; i < SENDER_PROTOCOL_CACHE_SIZE; i++) {
if (g_sender_protocol_cache[i].pubkey_hex[0] == '\0') {
slot = i;
break;
}
if (strncmp(g_sender_protocol_cache[i].pubkey_hex, sender_pubkey_hex, 64U) == 0) {
slot = i;
break;
}
if (i == 0 || g_sender_protocol_cache[i].seen_at < oldest_time) {
oldest_time = g_sender_protocol_cache[i].seen_at;
oldest_idx = i;
}
}
if (slot < 0) {
slot = oldest_idx;
}
memcpy(g_sender_protocol_cache[slot].pubkey_hex, sender_pubkey_hex, 64U);
g_sender_protocol_cache[slot].pubkey_hex[64] = '\0';
g_sender_protocol_cache[slot].protocol = protocol;
g_sender_protocol_cache[slot].seen_at = time(NULL);
pthread_mutex_unlock(&g_sender_protocol_mutex);
}
static dm_protocol_t sender_protocol_lookup(const char* sender_pubkey_hex) {
if (!sender_pubkey_hex || strlen(sender_pubkey_hex) != 64U) {
return DM_PROTOCOL_NIP04;
}
dm_protocol_t out = DM_PROTOCOL_NIP04;
pthread_mutex_lock(&g_sender_protocol_mutex);
for (int i = 0; i < SENDER_PROTOCOL_CACHE_SIZE; i++) {
if (g_sender_protocol_cache[i].pubkey_hex[0] == '\0') {
continue;
}
if (strncmp(g_sender_protocol_cache[i].pubkey_hex, sender_pubkey_hex, 64U) == 0) {
out = g_sender_protocol_cache[i].protocol;
break;
}
}
pthread_mutex_unlock(&g_sender_protocol_mutex);
return out;
}
static const char* relay_status_str(nostr_pool_relay_status_t status) {
switch (status) {
case NOSTR_POOL_RELAY_DISCONNECTED:
@@ -109,6 +202,7 @@ static int publish_kind_event_to_relays(int kind,
nostr_publish_result_t* out_result);
static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data);
static int parse_kind3_wot_contacts(cJSON* tags);
static int parse_kind10002_relays(cJSON* tags);
static void upsert_kind1_note(time_t created_at, const char* content);
static int startup_self_kind1_exists(void);
static void load_startup_display_name(void);
@@ -461,93 +555,173 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
const char* event_id_hex = (id && cJSON_IsString(id) && id->valuestring && strlen(id->valuestring) == 64U)
? id->valuestring
: NULL;
int kind_val = (int)kind->valuedouble;
DEBUG_TRACE("[didactyl] DEBUG on_event: kind=%d id=%.16s... from=%.16s... via %s",
(int)kind->valuedouble,
kind_val,
event_id_hex ? event_id_hex : "<no-id>",
pubkey->valuestring ? pubkey->valuestring : "<no-pk>",
relay_url ? relay_url : "unknown");
if ((int)kind->valuedouble != 4) {
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring non-kind4 (kind=%d)", (int)kind->valuedouble);
return;
}
char sender_pubkey_hex[65] = {0};
char* decrypted = NULL;
const char* dedup_id_hex = event_id_hex;
dm_protocol_t received_protocol = DM_PROTOCOL_NIP04;
char recipient_pubkey_hex[65] = {0};
if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) {
DEBUG_TRACE("[didactyl] DEBUG on_event: no p-tag found in kind4 event %.16s...",
event_id_hex ? event_id_hex : "<no-id>");
return;
}
if (strcmp(recipient_pubkey_hex, g_cfg->keys.public_key_hex) != 0) {
DEBUG_TRACE("[didactyl] DEBUG on_event: p-tag mismatch (got=%.16s... want=%.16s...)",
recipient_pubkey_hex, g_cfg->keys.public_key_hex);
return;
}
didactyl_sender_tier_t tier = DIDACTYL_SENDER_STRANGER;
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0) {
tier = DIDACTYL_SENDER_ADMIN;
} else if (g_cfg->security.wot.enabled && nostr_handler_is_wot_contact(pubkey->valuestring)) {
tier = DIDACTYL_SENDER_WOT;
}
DEBUG_TRACE("[didactyl] DEBUG on_event: sender=%.16s... tier=%d (admin=%.16s...)",
pubkey->valuestring, (int)tier, g_cfg->admin.pubkey);
if (tier == DIDACTYL_SENDER_STRANGER) {
if (!g_cfg->security.stranger.enabled) {
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s",
pubkey->valuestring,
relay_url ? relay_url : "unknown relay");
if (kind_val == 4) {
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP17) {
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring kind4 in dm_protocol=nip17 mode");
return;
}
if (g_cfg->security.stranger_response[0] != '\0') {
(void)nostr_handler_send_dm(pubkey->valuestring, g_cfg->security.stranger_response);
char recipient_pubkey_hex[65] = {0};
if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) {
DEBUG_TRACE("[didactyl] DEBUG on_event: no p-tag found in kind4 event %.16s...",
event_id_hex ? event_id_hex : "<no-id>");
return;
}
if (strcmp(recipient_pubkey_hex, g_cfg->keys.public_key_hex) != 0) {
DEBUG_TRACE("[didactyl] DEBUG on_event: p-tag mismatch (got=%.16s... want=%.16s...)",
recipient_pubkey_hex, g_cfg->keys.public_key_hex);
return;
}
memcpy(sender_pubkey_hex, pubkey->valuestring, 65U);
unsigned char sender_pubkey[32];
if (hex_to_pubkey(sender_pubkey_hex, sender_pubkey) != 0) {
return;
}
decrypted = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
if (!decrypted) {
fprintf(stderr, "[didactyl] failed to allocate DM decrypt buffer\n");
return;
}
decrypted[0] = '\0';
trace_event_json("received encrypted DM event:", event);
if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", sender_pubkey_hex);
free(decrypted);
return;
}
trace_plaintext_dm("received decrypted DM content:", decrypted);
received_protocol = DM_PROTOCOL_NIP04;
} else if (kind_val == 1059) {
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP04) {
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring kind1059 in dm_protocol=nip04 mode");
return;
}
cJSON* rumor = nostr_nip17_receive_dm(event, g_cfg->keys.private_key);
if (!rumor) {
DEBUG_TRACE("[didactyl] DEBUG on_event: failed to unwrap/decrypt NIP-17 gift wrap %.16s...",
event_id_hex ? event_id_hex : "<no-id>");
return;
}
cJSON* rumor_id = cJSON_GetObjectItemCaseSensitive(rumor, "id");
cJSON* rumor_kind = cJSON_GetObjectItemCaseSensitive(rumor, "kind");
cJSON* rumor_pubkey = cJSON_GetObjectItemCaseSensitive(rumor, "pubkey");
cJSON* rumor_content = cJSON_GetObjectItemCaseSensitive(rumor, "content");
cJSON* rumor_created_at = cJSON_GetObjectItemCaseSensitive(rumor, "created_at");
if (!rumor_kind || !rumor_pubkey || !rumor_content || !rumor_created_at ||
!cJSON_IsNumber(rumor_kind) || !cJSON_IsString(rumor_pubkey) || !cJSON_IsString(rumor_content) ||
!cJSON_IsNumber(rumor_created_at) ||
!rumor_pubkey->valuestring || strlen(rumor_pubkey->valuestring) != 64U) {
cJSON_Delete(rumor);
return;
}
time_t rumor_created_at_ts = (time_t)rumor_created_at->valuedouble;
if (rumor_created_at_ts < g_start_time) {
DEBUG_TRACE("[didactyl] DEBUG on_event: skipping old NIP-17 rumor created_at=%ld start=%ld",
(long)rumor_created_at_ts,
(long)g_start_time);
cJSON_Delete(rumor);
return;
}
int rumor_kind_val = (int)rumor_kind->valuedouble;
if (rumor_kind_val != 14 && rumor_kind_val != 15 && rumor_kind_val != 7) {
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring NIP-17 rumor kind=%d", rumor_kind_val);
cJSON_Delete(rumor);
return;
}
memcpy(sender_pubkey_hex, rumor_pubkey->valuestring, 65U);
const char* rumor_id_hex = (rumor_id && cJSON_IsString(rumor_id) && rumor_id->valuestring && strlen(rumor_id->valuestring) == 64U)
? rumor_id->valuestring
: NULL;
if (rumor_id_hex) {
dedup_id_hex = rumor_id_hex;
}
decrypted = strdup(rumor_content->valuestring ? rumor_content->valuestring : "");
cJSON_Delete(rumor);
if (!decrypted) {
return;
}
trace_plaintext_dm("received NIP-17 DM content:", decrypted);
received_protocol = DM_PROTOCOL_NIP17;
} else {
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring unsupported kind=%d", kind_val);
return;
}
unsigned char sender_pubkey[32];
if (hex_to_pubkey(pubkey->valuestring, sender_pubkey) != 0) {
return;
}
char* decrypted = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
if (!decrypted) {
fprintf(stderr, "[didactyl] failed to allocate DM decrypt buffer\n");
return;
}
decrypted[0] = '\0';
trace_event_json("received encrypted DM event:", event);
if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", pubkey->valuestring);
if (!sender_pubkey_hex[0] || strlen(sender_pubkey_hex) != 64U) {
free(decrypted);
return;
}
trace_plaintext_dm("received decrypted DM content:", decrypted);
didactyl_sender_tier_t tier = sender_tier_from_pubkey(sender_pubkey_hex);
if (event_id_hex && dm_id_seen_or_remember(event_id_hex)) {
DEBUG_TRACE("[didactyl] DEBUG on_event: sender=%.16s... tier=%d (admin=%.16s...)",
sender_pubkey_hex, (int)tier, g_cfg->admin.pubkey);
sender_protocol_remember(sender_pubkey_hex, received_protocol);
if (tier == DIDACTYL_SENDER_STRANGER) {
if (!g_cfg->security.stranger.enabled) {
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s",
sender_pubkey_hex,
relay_url ? relay_url : "unknown relay");
free(decrypted);
return;
}
if (g_cfg->security.stranger_response[0] != '\0') {
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, g_cfg->security.stranger_response);
}
free(decrypted);
return;
}
if (dedup_id_hex && dm_id_seen_or_remember(dedup_id_hex)) {
DEBUG_LOG("[didactyl] skipped duplicate DM event %.16s... from %.16s... via %s",
event_id_hex,
pubkey->valuestring,
dedup_id_hex,
sender_pubkey_hex,
relay_url ? relay_url : "unknown relay");
free(decrypted);
return;
}
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s tier=%d",
(int)kind->valuedouble,
event_id_hex ? event_id_hex : "<no-id>",
pubkey->valuestring,
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s tier=%d protocol=%s",
kind_val,
dedup_id_hex ? dedup_id_hex : "<no-id>",
sender_pubkey_hex,
relay_url ? relay_url : "unknown relay",
(int)tier);
g_dm_callback(pubkey->valuestring, decrypted, tier, g_dm_user_data);
(int)tier,
received_protocol == DM_PROTOCOL_NIP17 ? "nip17" : "nip04");
g_dm_callback(sender_pubkey_hex, decrypted, tier, g_dm_user_data);
free(decrypted);
}
@@ -631,6 +805,49 @@ static int parse_kind3_wot_contacts(cJSON* tags) {
return 0;
}
static int parse_kind10002_relays(cJSON* tags) {
if (!tags || !cJSON_IsArray(tags)) {
free(g_admin_kind10002_json);
g_admin_kind10002_json = strdup("[]");
return g_admin_kind10002_json ? 0 : -1;
}
cJSON* relays = cJSON_CreateArray();
if (!relays) {
return -1;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "r") != 0 || val->valuestring[0] == '\0') {
continue;
}
cJSON_AddItemToArray(relays, cJSON_CreateString(val->valuestring));
}
char* relays_json = cJSON_PrintUnformatted(relays);
cJSON_Delete(relays);
if (!relays_json) {
return -1;
}
free(g_admin_kind10002_json);
g_admin_kind10002_json = relays_json;
return 0;
}
static void upsert_kind1_note(time_t created_at, const char* content) {
if (!content) {
return;
@@ -702,9 +919,8 @@ static void on_admin_context_event(cJSON* event, const char* relay_url, void* us
g_admin_kind0_json = strdup(content->valuestring);
} else if (k == 3 && g_cfg->admin_context.track_kind_3 && tags && cJSON_IsArray(tags)) {
(void)parse_kind3_wot_contacts(tags);
} else if (k == 10002 && g_cfg->admin_context.track_kind_10002 && content && cJSON_IsString(content) && content->valuestring) {
free(g_admin_kind10002_json);
g_admin_kind10002_json = strdup(content->valuestring);
} else if (k == 10002 && g_cfg->admin_context.track_kind_10002 && tags && cJSON_IsArray(tags)) {
(void)parse_kind10002_relays(tags);
} else if (k == 1 && g_cfg->admin_context.track_kind_1 && content && cJSON_IsString(content) && content->valuestring) {
time_t ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : time(NULL);
upsert_kind1_note(ts, content->valuestring);
@@ -872,53 +1088,126 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
g_dm_callback = callback;
g_dm_user_data = user_data;
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* p_values = cJSON_CreateArray();
if (!filter || !kinds || !p_values) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(p_values);
const int need_kind4 = (g_cfg->dm_protocol == DM_PROTOCOL_NIP04 || g_cfg->dm_protocol == DM_PROTOCOL_BOTH) ? 1 : 0;
const int need_kind1059 = (g_cfg->dm_protocol == DM_PROTOCOL_NIP17 || g_cfg->dm_protocol == DM_PROTOCOL_BOTH) ? 1 : 0;
if (!need_kind4 && !need_kind1059) {
DEBUG_WARN("[didactyl] DM subscription skipped: no protocol selected");
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(p_values, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "#p", p_values);
cJSON_AddNumberToObject(filter, "since", (double)g_start_time);
cJSON_AddNumberToObject(filter, "limit", 100);
int subscribed_any = 0;
{
char* filter_str = cJSON_PrintUnformatted(filter);
DEBUG_TRACE("[didactyl] DEBUG DM subscription filter: %s", filter_str ? filter_str : "<null>");
DEBUG_TRACE("[didactyl] DEBUG DM subscription g_start_time=%ld now=%ld delta=%ld relay_count=%d",
(long)g_start_time, (long)time(NULL), (long)(time(NULL) - g_start_time), g_cfg->relay_count);
free(filter_str);
if (need_kind4) {
cJSON* filter4 = cJSON_CreateObject();
cJSON* kinds4 = cJSON_CreateArray();
cJSON* p_values4 = cJSON_CreateArray();
if (!filter4 || !kinds4 || !p_values4) {
cJSON_Delete(filter4);
cJSON_Delete(kinds4);
cJSON_Delete(p_values4);
return -1;
}
cJSON_AddItemToArray(kinds4, cJSON_CreateNumber(4));
cJSON_AddItemToObject(filter4, "kinds", kinds4);
cJSON_AddItemToArray(p_values4, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter4, "#p", p_values4);
cJSON_AddNumberToObject(filter4, "since", (double)g_start_time);
cJSON_AddNumberToObject(filter4, "limit", 100);
{
char* filter_str = cJSON_PrintUnformatted(filter4);
DEBUG_TRACE("[didactyl] DEBUG DM subscription filter kind4: %s", filter_str ? filter_str : "<null>");
free(filter_str);
}
nostr_pool_subscription_t* sub4 = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter4,
on_event,
on_eose,
NULL,
0,
1,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(filter4);
if (!sub4) {
fprintf(stderr, "[didactyl] kind4 DM subscription failed\n");
return -1;
}
subscribed_any = 1;
DEBUG_TRACE("[didactyl] DEBUG kind4 DM subscription sub=%p", (void*)sub4);
}
nostr_pool_subscription_t* sub = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter,
on_event,
on_eose,
NULL,
0,
1,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
if (need_kind1059) {
const time_t lookback_secs = 2 * 24 * 60 * 60;
time_t since_1059 = g_start_time - lookback_secs;
if (since_1059 < 0) {
since_1059 = 0;
}
cJSON_Delete(filter);
if (!sub) {
fprintf(stderr, "[didactyl] DM subscription failed\n");
cJSON* filter1059 = cJSON_CreateObject();
cJSON* kinds1059 = cJSON_CreateArray();
cJSON* p_values1059 = cJSON_CreateArray();
if (!filter1059 || !kinds1059 || !p_values1059) {
cJSON_Delete(filter1059);
cJSON_Delete(kinds1059);
cJSON_Delete(p_values1059);
return -1;
}
cJSON_AddItemToArray(kinds1059, cJSON_CreateNumber(1059));
cJSON_AddItemToObject(filter1059, "kinds", kinds1059);
cJSON_AddItemToArray(p_values1059, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter1059, "#p", p_values1059);
cJSON_AddNumberToObject(filter1059, "since", (double)since_1059);
cJSON_AddNumberToObject(filter1059, "limit", 400);
{
char* filter_str = cJSON_PrintUnformatted(filter1059);
DEBUG_TRACE("[didactyl] DEBUG DM subscription filter kind1059: %s", filter_str ? filter_str : "<null>");
DEBUG_TRACE("[didactyl] DEBUG kind1059 since=%ld start=%ld", (long)since_1059, (long)g_start_time);
free(filter_str);
}
nostr_pool_subscription_t* sub1059 = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter1059,
on_event,
on_eose,
NULL,
0,
1,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(filter1059);
if (!sub1059) {
fprintf(stderr, "[didactyl] kind1059 DM subscription failed\n");
return -1;
}
subscribed_any = 1;
DEBUG_TRACE("[didactyl] DEBUG kind1059 DM subscription sub=%p", (void*)sub1059);
}
if (!subscribed_any) {
return -1;
}
DEBUG_INFO("[didactyl] DM subscription active for pubkey %.16s...", g_cfg->keys.public_key_hex);
DEBUG_TRACE("[didactyl] DEBUG DM subscription sub=%p close_on_eose=0 dedup=1", (void*)sub);
DEBUG_TRACE("[didactyl] DEBUG DM subscription g_start_time=%ld now=%ld delta=%ld relay_count=%d",
(long)g_start_time, (long)time(NULL), (long)(time(NULL) - g_start_time), g_cfg->relay_count);
return 0;
}
@@ -1064,6 +1353,36 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
return sent > 0 ? 0 : -1;
}
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message) {
if (!recipient_pubkey_hex || !message) {
return -1;
}
if (!g_cfg) {
return nostr_handler_send_dm(recipient_pubkey_hex, message);
}
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP04) {
return nostr_handler_send_dm(recipient_pubkey_hex, message);
}
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP17) {
return nostr_handler_send_dm_nip17(recipient_pubkey_hex, message, NULL);
}
dm_protocol_t target = sender_protocol_lookup(recipient_pubkey_hex);
if (target == DM_PROTOCOL_NIP17) {
int rc17 = nostr_handler_send_dm_nip17(recipient_pubkey_hex, message, NULL);
if (rc17 == 0) {
return 0;
}
DEBUG_WARN("[didactyl] auto DM fallback to NIP-04 for %.16s... after NIP-17 send failure",
recipient_pubkey_hex);
}
return nostr_handler_send_dm(recipient_pubkey_hex, message);
}
static int publish_kind_event_to_relays(int kind,
const char* content,
cJSON* tags,
@@ -1789,6 +2108,10 @@ void nostr_handler_cleanup(void) {
g_seen_dm_count = 0;
g_seen_dm_next = 0;
pthread_mutex_lock(&g_sender_protocol_mutex);
memset(g_sender_protocol_cache, 0, sizeof(g_sender_protocol_cache));
pthread_mutex_unlock(&g_sender_protocol_mutex);
pthread_mutex_lock(&g_admin_ctx_mutex);
free_admin_context_locked();
pthread_mutex_unlock(&g_admin_ctx_mutex);
+1
View File
@@ -31,6 +31,7 @@ int nostr_handler_init(didactyl_config_t* config);
int nostr_handler_subscribe_admin_context(void);
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_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);
+543
View File
@@ -0,0 +1,543 @@
#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 char* resolve_placeholders(const char* tpl,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data) {
if (!tpl) return strdup("");
size_t cap = strlen(tpl) + 64U;
char* out = (char*)malloc(cap ? cap : 128U);
if (!out) return NULL;
out[0] = '\0';
size_t used = 0;
const char* p = tpl;
while (*p) {
const char* open = strstr(p, "{{");
if (!open) {
if (append_text(&out, &cap, &used, p) != 0) {
free(out);
return NULL;
}
break;
}
if (open > p) {
char* literal = dup_range(p, (size_t)(open - p));
if (!literal) {
free(out);
return NULL;
}
int rc = append_text(&out, &cap, &used, literal);
free(literal);
if (rc != 0) {
free(out);
return NULL;
}
}
const char* close = strstr(open + 2, "}}");
if (!close) {
if (append_text(&out, &cap, &used, open) != 0) {
free(out);
return NULL;
}
break;
}
char* name = dup_range(open + 2, (size_t)(close - (open + 2)));
if (!name) {
free(out);
return NULL;
}
char* name_trim = ltrim_inplace(name);
rtrim_inplace(name_trim);
char* val = resolver_fn ? resolver_fn(name_trim, resolver_user_data) : NULL;
if (!val) {
val = strdup("");
}
int rc = 0;
if (val) {
rc = append_text(&out, &cap, &used, val);
}
free(val);
free(name);
if (rc != 0) {
free(out);
return NULL;
}
p = close + 2;
}
return out;
}
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->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, "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, "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, "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;
}
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data,
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;
}
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;
}
char* resolved = resolve_placeholders(tpl ? tpl : "", resolver_fn, resolver_user_data);
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].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;
}
+45
View File
@@ -0,0 +1,45 @@
#ifndef DIDACTYL_PROMPT_TEMPLATE_H
#define DIDACTYL_PROMPT_TEMPLATE_H
#include "cjson/cJSON.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;
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 char* (*prompt_var_resolver_fn)(const char* var_name, void* user_data);
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,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data,
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
+555
View File
@@ -596,6 +596,120 @@ static cJSON* parse_tool_args_json(const char* args_json) {
return args;
}
static cJSON* tasks_create_empty_root(void) {
cJSON* root = cJSON_CreateObject();
cJSON* tasks = cJSON_CreateArray();
if (!root || !tasks) {
cJSON_Delete(root);
cJSON_Delete(tasks);
return NULL;
}
cJSON_AddItemToObject(root, "tasks", tasks);
cJSON_AddNumberToObject(root, "next_id", 1);
return root;
}
static const char* normalize_task_status(const char* status) {
if (!status) return NULL;
if (strcmp(status, "pending") == 0) return "pending";
if (strcmp(status, "active") == 0) return "active";
if (strcmp(status, "done") == 0) return "done";
return NULL;
}
static cJSON* tasks_load_root(const char* path) {
if (!path) return NULL;
FILE* fp = fopen(path, "rb");
if (!fp) {
if (access(path, F_OK) == 0) {
return NULL;
}
return tasks_create_empty_root();
}
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
long len = ftell(fp);
if (len < 0) {
fclose(fp);
return NULL;
}
if (fseek(fp, 0, SEEK_SET) != 0) {
fclose(fp);
return NULL;
}
char* buf = (char*)malloc((size_t)len + 1U);
if (!buf) {
fclose(fp);
return NULL;
}
size_t n = fread(buf, 1, (size_t)len, fp);
fclose(fp);
if (n != (size_t)len) {
free(buf);
return NULL;
}
buf[len] = '\0';
cJSON* root = cJSON_Parse(buf);
free(buf);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return NULL;
}
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
if (!tasks || !cJSON_IsArray(tasks)) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "tasks");
cJSON_AddItemToObject(root, "tasks", cJSON_CreateArray());
}
cJSON* next_id = cJSON_GetObjectItemCaseSensitive(root, "next_id");
if (!next_id || !cJSON_IsNumber(next_id) || next_id->valuedouble < 1) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
cJSON_AddNumberToObject(root, "next_id", 1);
}
return root;
}
static int tasks_save_root(const char* path, cJSON* root) {
if (!path || !root) return -1;
char* raw = cJSON_PrintUnformatted(root);
if (!raw) return -1;
FILE* fp = fopen(path, "wb");
if (!fp) {
free(raw);
return -1;
}
size_t len = strlen(raw);
size_t n = fwrite(raw, 1, len, fp);
fclose(fp);
free(raw);
return (n == len) ? 0 : -1;
}
static cJSON* task_find_by_id(cJSON* tasks, int id, int* out_index) {
if (!tasks || !cJSON_IsArray(tasks) || id <= 0) return NULL;
int n = cJSON_GetArraySize(tasks);
for (int i = 0; i < n; i++) {
cJSON* task = cJSON_GetArrayItem(tasks, i);
cJSON* tid = task ? cJSON_GetObjectItemCaseSensitive(task, "id") : NULL;
if (tid && cJSON_IsNumber(tid) && (int)tid->valuedouble == id) {
if (out_index) *out_index = i;
return task;
}
}
return NULL;
}
typedef struct {
char* data;
size_t len;
@@ -1781,6 +1895,121 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON_AddItemToObject(t32, "function", t32_fn);
cJSON_AddItemToArray(tools, t32);
cJSON* t33 = cJSON_CreateObject();
cJSON* t33_fn = cJSON_CreateObject();
cJSON* t33_params = cJSON_CreateObject();
cJSON* t33_props = cJSON_CreateObject();
cJSON_AddStringToObject(t33, "type", "function");
cJSON_AddStringToObject(t33_fn, "name", "nostr_pubkey");
cJSON_AddStringToObject(t33_fn, "description", "Return this agent's pubkey in hex format");
cJSON_AddStringToObject(t33_params, "type", "object");
cJSON_AddItemToObject(t33_params, "properties", t33_props);
cJSON_AddItemToObject(t33_fn, "parameters", t33_params);
cJSON_AddItemToObject(t33, "function", t33_fn);
cJSON_AddItemToArray(tools, t33);
cJSON* t34 = cJSON_CreateObject();
cJSON* t34_fn = cJSON_CreateObject();
cJSON* t34_params = cJSON_CreateObject();
cJSON* t34_props = cJSON_CreateObject();
cJSON_AddStringToObject(t34, "type", "function");
cJSON_AddStringToObject(t34_fn, "name", "nostr_npub");
cJSON_AddStringToObject(t34_fn, "description", "Return this agent's pubkey encoded as npub bech32");
cJSON_AddStringToObject(t34_params, "type", "object");
cJSON_AddItemToObject(t34_params, "properties", t34_props);
cJSON_AddItemToObject(t34_fn, "parameters", t34_params);
cJSON_AddItemToObject(t34, "function", t34_fn);
cJSON_AddItemToArray(tools, t34);
cJSON* t35 = cJSON_CreateObject();
cJSON* t35_fn = cJSON_CreateObject();
cJSON* t35_params = cJSON_CreateObject();
cJSON* t35_props = cJSON_CreateObject();
cJSON_AddStringToObject(t35, "type", "function");
cJSON_AddStringToObject(t35_fn, "name", "my_pubkey");
cJSON_AddStringToObject(t35_fn, "description", "Alias for nostr_pubkey: return this agent's pubkey in hex format");
cJSON_AddStringToObject(t35_params, "type", "object");
cJSON_AddItemToObject(t35_params, "properties", t35_props);
cJSON_AddItemToObject(t35_fn, "parameters", t35_params);
cJSON_AddItemToObject(t35, "function", t35_fn);
cJSON_AddItemToArray(tools, t35);
cJSON* t36 = cJSON_CreateObject();
cJSON* t36_fn = cJSON_CreateObject();
cJSON* t36_params = cJSON_CreateObject();
cJSON* t36_props = cJSON_CreateObject();
cJSON_AddStringToObject(t36, "type", "function");
cJSON_AddStringToObject(t36_fn, "name", "my_npub");
cJSON_AddStringToObject(t36_fn, "description", "Alias for nostr_npub: return this agent's pubkey encoded as npub bech32");
cJSON_AddStringToObject(t36_params, "type", "object");
cJSON_AddItemToObject(t36_params, "properties", t36_props);
cJSON_AddItemToObject(t36_fn, "parameters", t36_params);
cJSON_AddItemToObject(t36, "function", t36_fn);
cJSON_AddItemToArray(tools, t36);
cJSON* t37 = cJSON_CreateObject();
cJSON* t37_fn = cJSON_CreateObject();
cJSON* t37_params = cJSON_CreateObject();
cJSON* t37_props = cJSON_CreateObject();
cJSON* t37_required = cJSON_CreateArray();
cJSON_AddStringToObject(t37, "type", "function");
cJSON_AddStringToObject(t37_fn, "name", "task_manage");
cJSON_AddStringToObject(t37_fn, "description", "Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)");
cJSON_AddStringToObject(t37_params, "type", "object");
cJSON_AddItemToObject(t37_params, "properties", t37_props);
cJSON_AddItemToObject(t37_params, "required", t37_required);
cJSON* p_task_action = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_action, "type", "string");
cJSON* p_task_action_enum = cJSON_CreateArray();
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("list"));
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("add"));
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("update"));
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("remove"));
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("clear"));
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("replace"));
cJSON_AddItemToObject(p_task_action, "enum", p_task_action_enum);
cJSON_AddItemToObject(t37_props, "action", p_task_action);
cJSON* p_task_text = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_text, "type", "string");
cJSON_AddItemToObject(t37_props, "text", p_task_text);
cJSON* p_task_id = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_id, "type", "integer");
cJSON_AddItemToObject(t37_props, "id", p_task_id);
cJSON* p_task_status = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_status, "type", "string");
cJSON* p_task_status_enum = cJSON_CreateArray();
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("pending"));
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("active"));
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("done"));
cJSON_AddItemToObject(p_task_status, "enum", p_task_status_enum);
cJSON_AddItemToObject(t37_props, "status", p_task_status);
cJSON* p_task_tasks = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_tasks, "type", "array");
cJSON* p_task_tasks_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_tasks_item, "type", "string");
cJSON_AddItemToObject(p_task_tasks, "items", p_task_tasks_item);
cJSON_AddItemToObject(t37_props, "tasks", p_task_tasks);
cJSON_AddItemToArray(t37_required, cJSON_CreateString("action"));
cJSON_AddItemToObject(t37_fn, "parameters", t37_params);
cJSON_AddItemToObject(t37, "function", t37_fn);
cJSON_AddItemToArray(tools, t37);
char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools);
return out;
@@ -3900,6 +4129,47 @@ static char* execute_nostr_query(const char* args_json) {
return json;
}
static char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "pubkey", ctx->cfg->keys.public_key_hex);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
static char* execute_nostr_npub(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
char npub[256] = {0};
if (nostr_key_to_bech32(ctx->cfg->keys.public_key, "npub", npub) != NOSTR_SUCCESS) {
return json_error("failed to encode npub");
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "npub", npub);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
static char* execute_my_version(const char* args_json) {
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
@@ -4267,6 +4537,276 @@ static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
return json;
}
static char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
if (!action || !cJSON_IsString(action) || !action->valuestring || action->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error("task_manage requires string action");
}
char tasks_path[PATH_MAX];
if (build_tool_path(ctx, "tasks.json", tasks_path, sizeof(tasks_path)) != 0) {
cJSON_Delete(args);
return json_error("failed to resolve tasks file path");
}
cJSON* root = tasks_load_root(tasks_path);
if (!root) {
cJSON_Delete(args);
return json_error("failed to load tasks file");
}
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
if (!tasks || !cJSON_IsArray(tasks)) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "tasks");
tasks = cJSON_CreateArray();
cJSON_AddItemToObject(root, "tasks", tasks);
}
cJSON* next_id_item = cJSON_GetObjectItemCaseSensitive(root, "next_id");
int next_id = (next_id_item && cJSON_IsNumber(next_id_item) && next_id_item->valuedouble >= 1)
? (int)next_id_item->valuedouble
: 1;
const char* action_s = action->valuestring;
int mutated = 0;
if (strcmp(action_s, "list") == 0) {
/* no-op */
} else if (strcmp(action_s, "add") == 0) {
cJSON* text = cJSON_GetObjectItemCaseSensitive(args, "text");
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage add requires non-empty string text");
}
const char* normalized_status = "pending";
if (status && !cJSON_IsNull(status)) {
if (!cJSON_IsString(status) || !status->valuestring) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage add status must be string when provided");
}
normalized_status = normalize_task_status(status->valuestring);
if (!normalized_status) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage add status must be pending, active, or done");
}
}
cJSON* task = cJSON_CreateObject();
if (!task) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("allocation failure");
}
time_t now = time(NULL);
cJSON_AddNumberToObject(task, "id", next_id++);
cJSON_AddStringToObject(task, "text", text->valuestring);
cJSON_AddStringToObject(task, "status", normalized_status);
cJSON_AddNumberToObject(task, "created_at", (double)now);
cJSON_AddNumberToObject(task, "updated_at", (double)now);
cJSON_AddItemToArray(tasks, task);
mutated = 1;
} else if (strcmp(action_s, "update") == 0) {
cJSON* id = cJSON_GetObjectItemCaseSensitive(args, "id");
cJSON* text = cJSON_GetObjectItemCaseSensitive(args, "text");
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
if (!id || !cJSON_IsNumber(id) || id->valuedouble < 1) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage update requires integer id");
}
if ((!text || cJSON_IsNull(text)) && (!status || cJSON_IsNull(status))) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage update requires text and/or status");
}
cJSON* task = task_find_by_id(tasks, (int)id->valuedouble, NULL);
if (!task) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task not found");
}
if (text && !cJSON_IsNull(text)) {
if (!cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage update text must be non-empty string when provided");
}
cJSON_DeleteItemFromObjectCaseSensitive(task, "text");
cJSON_AddStringToObject(task, "text", text->valuestring);
mutated = 1;
}
if (status && !cJSON_IsNull(status)) {
if (!cJSON_IsString(status) || !status->valuestring) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage update status must be string when provided");
}
const char* normalized_status = normalize_task_status(status->valuestring);
if (!normalized_status) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage update status must be pending, active, or done");
}
cJSON_DeleteItemFromObjectCaseSensitive(task, "status");
cJSON_AddStringToObject(task, "status", normalized_status);
mutated = 1;
}
if (mutated) {
time_t now = time(NULL);
cJSON_DeleteItemFromObjectCaseSensitive(task, "updated_at");
cJSON_AddNumberToObject(task, "updated_at", (double)now);
}
} else if (strcmp(action_s, "remove") == 0) {
cJSON* id = cJSON_GetObjectItemCaseSensitive(args, "id");
if (!id || !cJSON_IsNumber(id) || id->valuedouble < 1) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage remove requires integer id");
}
int idx = -1;
cJSON* task = task_find_by_id(tasks, (int)id->valuedouble, &idx);
if (!task || idx < 0) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task not found");
}
cJSON_DeleteItemFromArray(tasks, idx);
mutated = 1;
} else if (strcmp(action_s, "clear") == 0) {
cJSON* status = cJSON_GetObjectItemCaseSensitive(args, "status");
if (!status || cJSON_IsNull(status)) {
while (cJSON_GetArraySize(tasks) > 0) {
cJSON_DeleteItemFromArray(tasks, 0);
}
mutated = 1;
} else {
if (!cJSON_IsString(status) || !status->valuestring) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage clear status must be string when provided");
}
const char* normalized_status = normalize_task_status(status->valuestring);
if (!normalized_status) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage clear status must be pending, active, or done");
}
int i = 0;
while (i < cJSON_GetArraySize(tasks)) {
cJSON* task = cJSON_GetArrayItem(tasks, i);
cJSON* task_status = task ? cJSON_GetObjectItemCaseSensitive(task, "status") : NULL;
const char* task_status_s = (task_status && cJSON_IsString(task_status) && task_status->valuestring)
? task_status->valuestring
: "pending";
if (strcmp(task_status_s, normalized_status) == 0) {
cJSON_DeleteItemFromArray(tasks, i);
mutated = 1;
} else {
i++;
}
}
}
} else if (strcmp(action_s, "replace") == 0) {
cJSON* tasks_in = cJSON_GetObjectItemCaseSensitive(args, "tasks");
if (!tasks_in || !cJSON_IsArray(tasks_in)) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage replace requires array tasks");
}
while (cJSON_GetArraySize(tasks) > 0) {
cJSON_DeleteItemFromArray(tasks, 0);
}
int n = cJSON_GetArraySize(tasks_in);
time_t now = time(NULL);
for (int i = 0; i < n; i++) {
cJSON* text = cJSON_GetArrayItem(tasks_in, i);
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
continue;
}
cJSON* task = cJSON_CreateObject();
if (!task) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("allocation failure");
}
cJSON_AddNumberToObject(task, "id", next_id++);
cJSON_AddStringToObject(task, "text", text->valuestring);
cJSON_AddStringToObject(task, "status", "pending");
cJSON_AddNumberToObject(task, "created_at", (double)now);
cJSON_AddNumberToObject(task, "updated_at", (double)now);
cJSON_AddItemToArray(tasks, task);
}
mutated = 1;
} else {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("task_manage action must be one of: list, add, update, remove, clear, replace");
}
if (mutated) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
cJSON_AddNumberToObject(root, "next_id", next_id);
if (tasks_save_root(tasks_path, root) != 0) {
cJSON_Delete(args);
cJSON_Delete(root);
return json_error("failed to save tasks file");
}
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(args);
cJSON_Delete(root);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "action", action_s);
cJSON_AddStringToObject(out, "path", tasks_path);
cJSON_AddBoolToObject(out, "mutated", mutated ? 1 : 0);
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(tasks));
cJSON* tasks_dup = cJSON_Duplicate(tasks, 1);
if (!tasks_dup) {
cJSON_Delete(args);
cJSON_Delete(root);
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToObject(out, "tasks", tasks_dup);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(args);
cJSON_Delete(root);
return json;
}
static char* execute_tool_list(tools_context_t* ctx, const char* args_json) {
(void)args_json;
if (!ctx) {
@@ -4653,6 +5193,18 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
if (strcmp(tool_name, "my_version") == 0) {
return execute_my_version(args_json);
}
if (strcmp(tool_name, "nostr_pubkey") == 0) {
return execute_nostr_pubkey(ctx, args_json);
}
if (strcmp(tool_name, "nostr_npub") == 0) {
return execute_nostr_npub(ctx, args_json);
}
if (strcmp(tool_name, "my_pubkey") == 0) {
return execute_nostr_pubkey(ctx, args_json);
}
if (strcmp(tool_name, "my_npub") == 0) {
return execute_nostr_npub(ctx, args_json);
}
if (strcmp(tool_name, "http_fetch") == 0) {
return execute_http_fetch(ctx, args_json);
}
@@ -4702,6 +5254,9 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
if (strcmp(tool_name, "nostr_file_md_to_longform_post") == 0) {
return execute_nostr_file_md_to_longform_post(ctx, args_json);
}
if (strcmp(tool_name, "task_manage") == 0) {
return execute_task_manage(ctx, args_json);
}
return json_error("unknown tool");
}
+2 -2
View File
@@ -160,7 +160,7 @@ static void execute_template_action(trigger_manager_t* mgr,
if (strncmp(rendered, "DM admin:", 9) == 0) {
const char* body = rendered + 9;
while (*body == ' ') body++;
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, body);
(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++;
@@ -170,7 +170,7 @@ static void execute_template_action(trigger_manager_t* mgr,
while (*body == ' ') body++;
DEBUG_INFO("[didactyl] trigger template log (%s): %s", t->skill_slug, body);
} else {
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, rendered);
(void)nostr_handler_send_dm_auto(mgr->cfg->admin.pubkey, rendered);
}
free(rendered);
+1
View File
@@ -0,0 +1 @@
{"tasks":[{"id":5,"text":"Tweet 3: Gets Smarter Over Time - \"Didactyl starts as an AI that explores and learns. Over time, the best workflows get locked in as reliable, fast processes. It evolves from experimental to hardened—from thinking to doing. An agent that improves itself. #nostr #agents\"","status":"pending","created_at":1772625247,"updated_at":1772625247}],"next_id":6}
+28193
View File
File diff suppressed because it is too large Load Diff
+4038
View File
File diff suppressed because it is too large Load Diff