Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fb1fa0b8 | ||
|
|
9505ee16bf | ||
|
|
d49e4ec4cb | ||
|
|
47bb5d76bc | ||
|
|
90948989ac | ||
|
|
b1a767f9b5 | ||
|
|
650df7bc41 | ||
|
|
9d06f18769 | ||
|
|
527016c078 | ||
|
|
c221104bc0 | ||
|
|
253aa4b5fc | ||
|
|
82362e4f3f | ||
|
|
7675109afc | ||
|
|
42fc14a5b3 | ||
|
|
326acb63d1 | ||
|
|
6b6e25c3f4 | ||
|
|
9a0e90dff0 | ||
|
|
0ce9de002b | ||
|
|
70403854ff | ||
|
|
0cc0667a80 | ||
|
|
5857058a21 | ||
|
|
f9cb6b19c2 | ||
|
|
868ac46de2 | ||
|
|
35f2fe53f1 | ||
|
|
5cdd582990 | ||
|
|
5b8b8310f7 | ||
|
|
6dbd952023 | ||
|
|
1d4cb9407d | ||
|
|
5c3083894b | ||
|
|
f0bd4c6473 | ||
|
|
cb45012495 | ||
|
|
6c78f6e79f | ||
|
|
421e1abb15 | ||
|
|
c93972019c | ||
|
|
f7dd1f98c6 | ||
|
|
93bfd0741d | ||
|
|
ffed72c9fd | ||
|
|
1b9ecb782c | ||
|
|
aec697ad7b | ||
|
|
6359681cb3 | ||
|
|
1f7bbc9597 | ||
|
|
90adb88a4a | ||
|
|
505d9d7d85 | ||
|
|
e1f5457ced | ||
|
|
5dfb18a842 | ||
|
|
40fe410eb1 | ||
|
|
927c313a87 | ||
|
|
97dca12bb1 | ||
|
|
120664571f | ||
|
|
1bbae06722 | ||
|
|
8b1e9fb734 | ||
|
|
31439652aa | ||
|
|
ad99f74bc0 | ||
|
|
cf9c300fdf | ||
|
|
97585890b9 | ||
|
|
7b3d36a797 | ||
|
|
49579b17a4 | ||
|
|
17083de47d | ||
|
|
33884046af | ||
|
|
7e69819be5 | ||
|
|
d189d0ba8c | ||
|
|
2cda3b6a58 | ||
|
|
ecb58b7e11 | ||
|
|
c3645e6af5 | ||
|
|
6cc46d2c25 | ||
|
|
10fe8fdde0 | ||
|
|
947e0b2f1e | ||
|
|
29b4289217 | ||
|
|
e910304f6f | ||
|
|
b1609317c1 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -5,7 +5,13 @@
|
||||
/c-relay/
|
||||
/nips/
|
||||
/config.json
|
||||
/config.jsonc
|
||||
test_keys.txt
|
||||
|
||||
/mongoose/
|
||||
/Trash/
|
||||
deploy_lt.sh
|
||||
|
||||
# Build artifacts
|
||||
/build/
|
||||
/didactyl
|
||||
@@ -13,6 +19,9 @@ test_keys.txt
|
||||
*.a
|
||||
*.tar.gz
|
||||
debug.log
|
||||
context.log
|
||||
didactyl_static_x86_64
|
||||
didactyl_static_x86_64_debug
|
||||
a.out
|
||||
|
||||
# Editor/IDE
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
description: "Increments and pushes the repo"
|
||||
---
|
||||
|
||||
Run increment_and_push.sh followed in the command line with a good description of the changes that were made.
|
||||
Run increment_and_push.sh followed in the command line with a good description of the changes that were made. Don't run any other git commands.
|
||||
|
||||
For example: ./increment_and_push.sh "Fixed that nasty bug"
|
||||
|
||||
@@ -8,41 +8,58 @@ FROM alpine:3.19 AS builder
|
||||
# Re-declare build argument in this stage
|
||||
ARG DEBUG_BUILD=false
|
||||
|
||||
# Install build dependencies
|
||||
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
|
||||
# Install build dependencies (with retry logic for transient mirror/network failures)
|
||||
RUN set -eux; \
|
||||
printf '%s\n' \
|
||||
'https://dl-cdn.alpinelinux.org/alpine/v3.19/main' \
|
||||
'https://dl-cdn.alpinelinux.org/alpine/v3.19/community' \
|
||||
> /etc/apk/repositories; \
|
||||
APK_OK=0; \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if apk update && 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; then \
|
||||
APK_OK=1; \
|
||||
break; \
|
||||
fi; \
|
||||
echo "apk dependency install failed (attempt ${attempt}/5), retrying..."; \
|
||||
sleep $((attempt * 2)); \
|
||||
done; \
|
||||
if [ "$APK_OK" -ne 1 ]; then \
|
||||
echo "ERROR: failed to install Alpine dependencies after 5 attempts"; \
|
||||
exit 33; \
|
||||
fi
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
@@ -68,6 +85,7 @@ COPY nostr_core_lib /build/nostr_core_lib/
|
||||
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 && \
|
||||
sed -i 's/CC="aarch64-linux-gnu-gcc"/CC="gcc"/' build.sh && \
|
||||
rm -f *.o *.a 2>/dev/null || true && \
|
||||
./build.sh --nips=all
|
||||
|
||||
@@ -78,7 +96,10 @@ COPY Makefile /build/Makefile
|
||||
# Build didactyl with full static linking (only rebuilds when src/ changes)
|
||||
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
|
||||
# Use conditional compilation flags based on DEBUG_BUILD argument
|
||||
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1) && \
|
||||
if [ -z "$NOSTR_LIB" ]; then echo "ERROR: nostr_core_lib .a not found"; exit 1; fi && \
|
||||
echo "Using nostr library: $NOSTR_LIB" && \
|
||||
if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
CFLAGS="-g -O2 -DDEBUG"; \
|
||||
STRIP_CMD="echo 'Keeping debug symbols'"; \
|
||||
echo "Building with DEBUG symbols enabled (optimized with -O2)"; \
|
||||
@@ -90,13 +111,20 @@ 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 \
|
||||
-I. -Isrc -Isrc/tools -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/tools_common.c src/tools/tools_schema.c src/tools/tools_dispatch.c \
|
||||
src/tools/tool_agent.c src/tools/tool_meta.c src/tools/tool_model.c \
|
||||
src/tools/tool_nostr_query.c src/tools/tool_nostr_my_events.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \
|
||||
src/tools/tool_nostr_relay.c src/tools/tool_nostr_dm.c src/tools/tool_admin.c \
|
||||
src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_local.c \
|
||||
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/trigger_manager.c \
|
||||
src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \
|
||||
-o /build/didactyl_static \
|
||||
nostr_core_lib/libnostr_core_x64.a \
|
||||
$NOSTR_LIB \
|
||||
-lsecp256k1 \
|
||||
$OPENSSL_LIBS \
|
||||
$CURL_LIBS \
|
||||
|
||||
44
Makefile
44
Makefile
@@ -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
|
||||
@@ -11,24 +11,50 @@ SRCS = \
|
||||
$(SRC_DIR)/llm.c \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/tools.c \
|
||||
$(SRC_DIR)/cashu_wallet.c \
|
||||
$(SRC_DIR)/tools/tools_common.c \
|
||||
$(SRC_DIR)/tools/tools_schema.c \
|
||||
$(SRC_DIR)/tools/tools_dispatch.c \
|
||||
$(SRC_DIR)/tools/tool_agent.c \
|
||||
$(SRC_DIR)/tools/tool_meta.c \
|
||||
$(SRC_DIR)/tools/tool_model.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_query.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_my_events.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_identity.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_social.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_relay.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_dm.c \
|
||||
$(SRC_DIR)/tools/tool_admin.c \
|
||||
$(SRC_DIR)/tools/tool_task.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_list.c \
|
||||
$(SRC_DIR)/tools/tool_local.c \
|
||||
$(SRC_DIR)/tools/tool_skill.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_post.c \
|
||||
$(SRC_DIR)/tools/tool_memory.c \
|
||||
$(SRC_DIR)/tools/tool_config.c \
|
||||
$(SRC_DIR)/tools/tool_cashu_wallet.c \
|
||||
$(SRC_DIR)/trigger_manager.c \
|
||||
$(SRC_DIR)/prompt_template.c \
|
||||
$(SRC_DIR)/http_api.c \
|
||||
$(SRC_DIR)/setup_wizard.c \
|
||||
$(SRC_DIR)/mongoose.c \
|
||||
$(SRC_DIR)/debug.c
|
||||
|
||||
INCLUDES = \
|
||||
-I$(SRC_DIR) \
|
||||
-I../nostr_core_lib \
|
||||
-I../nostr_core_lib/cjson \
|
||||
-I../nostr_core_lib/nostr_core
|
||||
-I$(SRC_DIR)/tools \
|
||||
-I./nostr_core_lib \
|
||||
-I./nostr_core_lib/cjson \
|
||||
-I./nostr_core_lib/nostr_core
|
||||
|
||||
NOSTR_LIB = $(firstword $(wildcard ../nostr_core_lib/libnostr_core_*.a))
|
||||
NOSTR_LIB = $(firstword $(wildcard ./nostr_core_lib/libnostr_core_*.a))
|
||||
|
||||
LDFLAGS = -lcurl -lssl -lcrypto -lm -lpthread -ldl -lz -L/usr/local/lib -lsecp256k1
|
||||
|
||||
# Build directory
|
||||
BUILD_DIR = build
|
||||
|
||||
all: deps $(TARGET)
|
||||
all: $(TARGET)
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p $(BUILD_DIR)
|
||||
@@ -36,7 +62,7 @@ $(BUILD_DIR):
|
||||
# Build nostr_core_lib
|
||||
$(NOSTR_LIB):
|
||||
@echo "Building nostr_core_lib with all NIPs..."
|
||||
cd ../nostr_core_lib && ./build.sh --nips=all
|
||||
cd ./nostr_core_lib && ./build.sh --nips=001,004,005,006,011,013,017,019,021,042,044,046,059,060,061
|
||||
|
||||
# Update main.h version information (requires main.h to exist)
|
||||
src/main.h:
|
||||
@@ -78,7 +104,7 @@ $(TARGET): src/main.h $(SRCS) $(NOSTR_LIB)
|
||||
$(CC) $(CFLAGS) $(INCLUDES) -o $@ $(SRCS) $(NOSTR_LIB) $(LDFLAGS)
|
||||
|
||||
deps:
|
||||
cd ../nostr_core_lib && ./build.sh --nips=all
|
||||
cd ./nostr_core_lib && ./build.sh --nips=001,004,005,006,011,013,017,019,021,042,044,046,059,060,061
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
|
||||
269
README.md
269
README.md
@@ -45,28 +45,35 @@ Because all identity, communication, and memory live on Nostr, the agent is **po
|
||||
|
||||
### Skills are the new apps.
|
||||
|
||||
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
|
||||
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
|
||||
|
||||
Think of it like a woodshop: a **skill** is knowing how to carve — the technique, the judgment, the decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. Every skill execution involves the LLM reasoning about what to do and which tools to use.
|
||||
|
||||
Skills compose by adoption-list order (`10123`) and trigger tags carry runtime execution controls (`llm`, `max_tokens`, `temperature`, `seed`, `tools`) so behavior and runtime policy remain explicit and portable. See [`docs/SKILLS.md`](docs/SKILLS.md).
|
||||
|
||||
### Private inference.
|
||||
|
||||
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
|
||||
|
||||
## Current Status — v0.0.25
|
||||
## Current Status — v0.1.1
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.25 — Add model_get/model_set/model_list tools with persisted LLM config updates and model discovery
|
||||
> Last release update: v0.1.1 — Fix cron trigger firing with validation, shorthand normalization, immediate polling, and status/log diagnostics
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
- Uses kind `31120` startup content as live Soul at boot
|
||||
- Loads base system context from default skill content (first-run from `genesis.jsonc`, subsequent runs from adopted skills on Nostr)
|
||||
- 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 default/adopted skill templates (`---template---`) with named sections, variable resolution, and per-provider content overrides; falls back to hardcoded assembly if no template present
|
||||
- Adopted skills injected into context automatically from the agent's `10123` adoption list
|
||||
- Supports tool-calling loop with configurable max turns and local safety limits
|
||||
- Triggered skills — Nostr event filters that fire skill execution automatically with `template` (deterministic) or `llm` (context-aware) actions; see [`docs/SKILLS.md`](docs/SKILLS.md)
|
||||
- Deduplicates inbound messages via event-ID cache and FNV-1a fingerprint debounce window
|
||||
- Appends every outbound LLM context payload to [`context.log`](context.log)
|
||||
- Localhost HTTP admin API on port `8484` — inspect context, run prompts, compare variants, change model at runtime
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -77,7 +84,7 @@ Didactyl will support local inference, which is very privacy preserving. Remote
|
||||
|
||||
```bash
|
||||
chmod +x ./didactyl_static_x86_64
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
./didactyl_static_x86_64 --config ./genesis.jsonc
|
||||
```
|
||||
|
||||
### Build from source (optional)
|
||||
@@ -96,7 +103,7 @@ chmod +x ./didactyl_static_x86_64
|
||||
|
||||
### Configure
|
||||
|
||||
Edit [`config.json`](config.json):
|
||||
Edit [`genesis.jsonc`](genesis.jsonc):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -117,6 +124,13 @@ Edit [`config.json`](config.json):
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"cashu_wallet": {
|
||||
"enabled": true,
|
||||
"mint_urls": ["https://mint.minibits.cash/Bitcoin"],
|
||||
"unit": "sat",
|
||||
"auto_load": true,
|
||||
"mint_timeout_seconds": 30
|
||||
},
|
||||
"tools": {
|
||||
"enabled": true,
|
||||
"max_turns": 8,
|
||||
@@ -148,9 +162,9 @@ Edit [`config.json`](config.json):
|
||||
"tags": [["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"]]
|
||||
},
|
||||
{
|
||||
"kind": 31120,
|
||||
"kind": 31124,
|
||||
"content": "You are Didactyl...",
|
||||
"tags": [["d", "soul"], ["app", "didactyl"], ["scope", "private"]]
|
||||
"tags": [["d", "didactyl-default"], ["app", "didactyl"], ["scope", "private"]]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
@@ -173,18 +187,32 @@ Relays are sourced exclusively from startup kind `10002` `r` tags.
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
# interactive setup wizard (new / existing / load-genesis)
|
||||
./didactyl_static_x86_64
|
||||
|
||||
# direct startup from config
|
||||
./didactyl_static_x86_64 --config ./genesis.jsonc
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
```
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./didactyl_static_x86_64 --dump-schemas # print tool JSON schemas and exit
|
||||
./didactyl_static_x86_64 --test-tool <name> <args_json> # run one tool directly and print JSON result
|
||||
./didactyl_static_x86_64 # interactive setup mode (when run with no arguments)
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./genesis.jsonc)
|
||||
./didactyl_static_x86_64 --nsec <nsec_or_hex> # runtime identity seed override (or use DIDACTYL_NSEC)
|
||||
./didactyl_static_x86_64 --api-port <port> # runtime local API port override
|
||||
./didactyl_static_x86_64 --api-bind <address> # runtime local API bind override
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./didactyl_static_x86_64 --dump-schemas # print tool JSON schemas and exit
|
||||
./didactyl_static_x86_64 --test-tool <name> <args_json> # run one tool directly and print JSON result
|
||||
```
|
||||
|
||||
Interactive setup notes:
|
||||
|
||||
- First menu asks whether you are starting a **new agent** or an **existing agent**.
|
||||
- Menus use first-letter hotkeys (case-insensitive), with `q`/`x` as quit/back shortcuts.
|
||||
- Existing-agent mode attempts to recover relay/admin/LLM config from Nostr before asking for missing fields.
|
||||
|
||||
CLI debugger notes:
|
||||
|
||||
- `--test-tool` initializes Nostr, waits for at least one relay connection (up to 15s), then executes the selected tool.
|
||||
@@ -192,13 +220,46 @@ CLI debugger notes:
|
||||
- Example:
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./config.json --test-tool nostr_file_md_to_longform_post '{"file":"docs/TOOLS_AND_SKILLS.md","title":"TOOLS_AND_SKILLS"}'
|
||||
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/SKILLS.md","title":"SKILLS"}'
|
||||
```
|
||||
|
||||
Cashu wallet tool examples:
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool cashu_wallet_balance '{}'
|
||||
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool cashu_wallet_info '{"mint_url":"https://mint.minibits.cash/Bitcoin"}'
|
||||
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool cashu_wallet_mint_quote '{"mint_url":"https://mint.minibits.cash/Bitcoin","amount":1000,"unit":"sat"}'
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
@@ -228,16 +289,19 @@ Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst
|
||||
|
||||
## Didactyl Kinds (Nostr)
|
||||
|
||||
Didactyl uses a two-layer skill model: authors publish public skill definitions, and adopters publish which skills they use.
|
||||
Didactyl uses a two-layer skill model: authors publish skill definitions, and adopters publish which skills they use.
|
||||
|
||||
- `31120` — **Soul** (private instruction baseline)
|
||||
- `d=soul`
|
||||
- `31123` — **Public Skill Definition** (markdown skill body in `content` or structured JSON in `content_fields`)
|
||||
- `31123` — **Public Skill Definition** (replaceable by `d` tag)
|
||||
- `content` is JSON with instruction fields like `description` and `template`
|
||||
- `d=<skill_slug>` (example: `d=long_form_note`)
|
||||
- `31124` — **Private Skill Definition** (private/internal procedures)
|
||||
- `31124` — **Private Skill Definition** (same schema as `31123`, private scope)
|
||||
- `d=<skill_slug>` (example: `d=admin_ops`)
|
||||
- `10123` — **Public Skill Adoption List**
|
||||
- tags contain one or more `a` references to selected `31123` skills
|
||||
- `10123` — **Skill Adoption List**
|
||||
- tags contain one or more `a` references to selected skills
|
||||
|
||||
Skills are composed by adoption list order and per-skill template resolution (no context modes).
|
||||
|
||||
Full skill schema, trigger tags, template variables, fallback resolution, and limits are documented in [`docs/SKILLS.md`](docs/SKILLS.md).
|
||||
|
||||
## Skill Sharing & Discovery
|
||||
|
||||
@@ -259,94 +323,84 @@ Skills are shared across Nostr without any centralized registry or approval proc
|
||||
|
||||
## Startup
|
||||
|
||||
Didactyl startup behavior is configured in [`config.json`](config.json) under `startup_events`.
|
||||
Didactyl startup behavior is configured in [`genesis.jsonc`](genesis.jsonc) under `startup_events`.
|
||||
|
||||
Also used at startup:
|
||||
Startup model:
|
||||
|
||||
- `0` — profile metadata
|
||||
- `10002` — relay list
|
||||
- `1` — optional startup note/status
|
||||
- `3` — contacts/follows (optional placeholder)
|
||||
- First run is detected by checking for an existing kind `10002` relay-list event from the agent pubkey.
|
||||
- On first run, events in `startup_events` are published to connected relays.
|
||||
- On subsequent runs, startup publish is skipped and relay/config state is loaded from Nostr.
|
||||
- Identity can be supplied at runtime via `--nsec` or `DIDACTYL_NSEC`.
|
||||
|
||||
On boot, Didactyl attempts startup publishes to each relay as that relay transitions to connected state.
|
||||
See [`docs/GENESIS.md`](docs/GENESIS.md) for full boot semantics.
|
||||
|
||||
## Runtime Context Model
|
||||
|
||||
Didactyl builds tier-aware context:
|
||||
Didactyl builds tier-aware, template-driven context:
|
||||
|
||||
- **ADMIN** request context order:
|
||||
1. Soul message from kind `31120` (or fallback default)
|
||||
2. Admin identity context — admin pubkey hex, kind `0` profile, kind `10002` relay list
|
||||
3. Startup events memory block (`kinds/content/tags` snapshot)
|
||||
4. Last 12 decrypted DM turns between admin and agent
|
||||
5. Recent admin kind `1` notes (from configured admin-context subscription)
|
||||
6. Current user message
|
||||
- **WoT** request context: Soul + WoT chat-only instruction + current user message (no tools)
|
||||
- **STRANGER**: no LLM call when configured to reply statically
|
||||
- **ADMIN** request context is assembled from adopted skill templates.
|
||||
- Template variables like `{{nostr_admin_profile}}` are resolved by executing tools at render time.
|
||||
- Triggered skill invocations can override runtime execution parameters via trigger tags (`llm`, `max_tokens`, `temperature`, `seed`, `tools`).
|
||||
- **WoT** request context remains chat-only.
|
||||
- **STRANGER** behavior follows configured security policy.
|
||||
|
||||
Every serialized LLM context payload is appended to [`context.log`](context.log).
|
||||
|
||||
See [`docs/CONTEXT.md`](docs/CONTEXT.md) and [`docs/SKILLS.md`](docs/SKILLS.md) for the normative assembly and trigger rules.
|
||||
|
||||
## Tooling Interface
|
||||
|
||||
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
|
||||
The OpenAI-compatible tool schema is generated in [`src/tools/tools_schema.c`](src/tools/tools_schema.c), and runtime dispatch is handled in [`src/tools/tools_dispatch.c`](src/tools/tools_dispatch.c).
|
||||
|
||||
- Nostr publish/query:
|
||||
- `nostr_post`
|
||||
- `nostr_post_readme`
|
||||
- `nostr_query`
|
||||
- Nostr interaction and moderation:
|
||||
- `nostr_delete`
|
||||
- `nostr_react`
|
||||
- `nostr_profile_get`
|
||||
- `nostr_relay_status`
|
||||
- `nostr_relay_info`
|
||||
- `nostr_nip05_lookup`
|
||||
- Nostr encode/decode + encryption/DM:
|
||||
- `nostr_encode`
|
||||
- `nostr_decode`
|
||||
- `nostr_encrypt`
|
||||
- `nostr_decrypt`
|
||||
- `nostr_dm_send`
|
||||
- `nostr_dm_send_nip17`
|
||||
- Nostr list management:
|
||||
- `nostr_list_manage`
|
||||
- Skill management:
|
||||
- `skill_create`
|
||||
- `skill_list`
|
||||
- `skill_adopt`
|
||||
- `skill_remove`
|
||||
- `skill_search`
|
||||
- Local/host tools:
|
||||
- `shell_exec`
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
- `http_fetch`
|
||||
- Agent metadata:
|
||||
- `my_version`
|
||||
Core categories include Nostr publish/query, identity/profile operations, DM and list management, skill operations, local host tools, model/runtime controls, and encrypted config tools (`config_store`, `config_recall`).
|
||||
|
||||
Execution entrypoint: [`tools_execute()`](src/tools.c:3765).
|
||||
See [`docs/TOOLS.md`](docs/TOOLS.md) for the canonical tool catalog and interface details.
|
||||
|
||||
## 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 (persisted in encrypted config events) |
|
||||
| `GET /api/models` | List available models from provider |
|
||||
|
||||
Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_frontend.md`](plans/admin_web_frontend.md).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── config.json # Agent/runtime config including startup_events + tools
|
||||
├── genesis.jsonc # First-run genesis config (startup events + baseline runtime settings)
|
||||
├── context.log # Appended outbound LLM context payloads
|
||||
├── 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 # Skill template parser, variable resolver, context builder
|
||||
│ ├── tools/ # LLM tool schema and tool execution modules
|
||||
│ ├── 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.md # Tool architecture and catalog
|
||||
│ ├── SKILLS.md # Skill schema, composition model, triggers, and limits
|
||||
│ └── CRASH_FIXES.md # Crash analysis and fixes log
|
||||
├── plans/ # Architecture and planning documents
|
||||
│ ├── didactyl_mvp.md
|
||||
│ ├── didactyl_agentic.md
|
||||
│ └── security_and_admin_context.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -362,26 +416,63 @@ All dependencies are statically linked into the binary at build time. No system
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
|
||||
|
||||
## Roadmap: Nostr-Native Portability
|
||||
|
||||
Didactyl's long-term architecture goal is **zero filesystem dependency after first boot**. The config file is the only tie to the local filesystem. The plan:
|
||||
|
||||
1. **First boot** — Read `genesis.jsonc`, publish startup identity/skill/adoption events to relays.
|
||||
2. **Subsequent boots** — Start with only `nsec` (CLI/env), detect initialized state from kind `10002`, and load durable state from Nostr.
|
||||
3. **True portability** — Start your agent from any computer; keys are sufficient and state lives on Nostr.
|
||||
|
||||
This makes Didactyl fundamentally different from filesystem-bound agents. Destroying the host computer does not kill the agent — its identity, memory, and capabilities persist on the relay network.
|
||||
|
||||
### What already lives on Nostr
|
||||
|
||||
| Data | Event Kind | Status |
|
||||
|---|---|---|
|
||||
| Agent profile | Kind 0 | Implemented |
|
||||
| Relay list | Kind 10002 | Implemented |
|
||||
| DM relay list | Kind 10050 | Implemented |
|
||||
| Public skills | Kind 31123 | Implemented |
|
||||
| Private skills | Kind 31124 | Implemented |
|
||||
| Skill adoption list | Kind 10123 | Implemented |
|
||||
| Base/default behavior skill | Kind 31124 | Implemented |
|
||||
| Trigger definitions | Tags on skill events | Implemented |
|
||||
|
||||
### What still needs migration
|
||||
|
||||
| Data | Current Location | Target |
|
||||
|---|---|---|
|
||||
| Admin pubkey | `genesis.jsonc` fallback | Dedicated agent config event / contact-graph derivation |
|
||||
| LLM provider/key | `genesis.jsonc` fallback | Encrypted kind 30078 app-specific event |
|
||||
| Security tiers | `genesis.jsonc` fallback | Agent config event on Nostr |
|
||||
| API settings | local runtime flags | Local-only (not published) |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] MVP chat agent — DM in, LLM response out
|
||||
- [x] Relay pool with auto-reconnect and status logging
|
||||
- [x] Per-relay startup publish on relay-connected transitions
|
||||
- [x] Runtime diagnostics — relay health, message flow, event kind publish logs
|
||||
- [x] Tool-calling loop (nostr_post, nostr_query, shell_exec, file_read, file_write)
|
||||
- [x] Tool-calling loop (nostr_post, nostr_query, local_shell_exec, local_file_read, local_file_write)
|
||||
- [x] Context assembly with startup events + recent DM history
|
||||
- [x] Context payload logging to [`context.log`](context.log)
|
||||
- [x] Skill kind definitions (`31120` Soul, `31123` Public Skill, `31124` Private Skill)
|
||||
- [x] Skill kind definitions (`31123` Public Skill, `31124` Private Skill)
|
||||
- [x] Skill adoption list (`10123`) for WoT-driven discovery
|
||||
- [x] Signature verification on all inbound events
|
||||
- [x] Privilege tiers — ADMIN (tools), WoT (chat-only), STRANGER (canned reply/ignore)
|
||||
- [x] Admin context subscription (kind 0, 3, 10002, 1) with WoT contact extraction
|
||||
- [x] Message deduplication (event-ID cache + FNV-1a fingerprint debounce)
|
||||
- [x] Adopted skills injected into LLM context automatically
|
||||
- [x] Triggered skills — Nostr event filters that fire skill execution automatically
|
||||
- [x] Localhost HTTP admin API — context inspection, prompt crafting, A/B comparison
|
||||
- [x] Runtime model switching via `model_set` tool (persisted in encrypted config events)
|
||||
- [x] Skill-embedded prompt templates (`---template---`) — configurable context order, variable resolution, provider overrides
|
||||
- [ ] Runtime skill loading from adopted `31123` events on relays
|
||||
- [ ] Skill discovery CLI/tool (query WoT adoption lists)
|
||||
- [ ] Upgrade to NIP-17 gift-wrapped DMs
|
||||
- [ ] NIP-44 encrypted private skills (`31124`)
|
||||
- [ ] Nostr-native data storage (kind 30078 app-specific events)
|
||||
- [x] NIP-44 encrypted private skills (`31124`)
|
||||
- [x] Nostr-native data storage (kind 30078 app-specific events)
|
||||
- [ ] Blossom blob storage integration
|
||||
- [ ] Agent-to-agent communication
|
||||
|
||||
|
||||
102
build_check.log
102
build_check.log
@@ -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
|
||||
==========================================
|
||||
126
build_static.sh
126
build_static.sh
@@ -11,8 +11,44 @@ DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
|
||||
|
||||
# Parse command line arguments
|
||||
DEBUG_BUILD=false
|
||||
if [[ "$1" == "--debug" ]]; then
|
||||
DEBUG_BUILD=true
|
||||
TARGET_PLATFORM=""
|
||||
ALL_PLATFORMS=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--debug)
|
||||
DEBUG_BUILD=true
|
||||
;;
|
||||
--platform=*)
|
||||
TARGET_PLATFORM="${arg#*=}"
|
||||
;;
|
||||
--all-platforms|--all_platforms|-a)
|
||||
ALL_PLATFORMS=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$ALL_PLATFORMS" = true ] && [ -n "$TARGET_PLATFORM" ]; then
|
||||
echo "ERROR: --all-platforms cannot be used together with --platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ALL_PLATFORMS" = true ]; then
|
||||
echo "Building all supported platforms (linux/amd64 + linux/arm64)..."
|
||||
echo ""
|
||||
DEBUG_FLAG=""
|
||||
if [ "$DEBUG_BUILD" = true ]; then
|
||||
DEBUG_FLAG="--debug"
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/build_static.sh" $DEBUG_FLAG --platform=linux/amd64
|
||||
"$SCRIPT_DIR/build_static.sh" $DEBUG_FLAG --platform=linux/arm64
|
||||
|
||||
echo ""
|
||||
echo "✓ Multi-platform build complete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DEBUG_BUILD" = true ]; then
|
||||
echo "=========================================="
|
||||
echo "Didactyl MUSL Static Binary Builder (DEBUG MODE)"
|
||||
echo "=========================================="
|
||||
@@ -61,24 +97,45 @@ DOCKER_CMD="docker"
|
||||
echo "✓ Docker is available and running"
|
||||
echo ""
|
||||
|
||||
# Detect architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="didactyl_static_x86_64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
PLATFORM="linux/arm64"
|
||||
OUTPUT_NAME="didactyl_static_arm64"
|
||||
;;
|
||||
*)
|
||||
echo "WARNING: Unknown architecture: $ARCH"
|
||||
echo "Defaulting to linux/amd64"
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="didactyl_static_${ARCH}"
|
||||
;;
|
||||
esac
|
||||
# Detect architecture (or use explicit target platform override)
|
||||
if [ -n "$TARGET_PLATFORM" ]; then
|
||||
PLATFORM="$TARGET_PLATFORM"
|
||||
case "$PLATFORM" in
|
||||
linux/amd64)
|
||||
OUTPUT_NAME="didactyl_static_x86_64"
|
||||
;;
|
||||
linux/arm64)
|
||||
OUTPUT_NAME="didactyl_static_arm64"
|
||||
;;
|
||||
linux/arm/v7)
|
||||
OUTPUT_NAME="didactyl_static_armv7"
|
||||
;;
|
||||
linux/arm/v6)
|
||||
OUTPUT_NAME="didactyl_static_armv6"
|
||||
;;
|
||||
*)
|
||||
OUTPUT_NAME="didactyl_static_custom"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="didactyl_static_x86_64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
PLATFORM="linux/arm64"
|
||||
OUTPUT_NAME="didactyl_static_arm64"
|
||||
;;
|
||||
*)
|
||||
echo "WARNING: Unknown architecture: $ARCH"
|
||||
echo "Defaulting to linux/amd64"
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="didactyl_static_${ARCH}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Append _debug suffix to output name for debug builds
|
||||
if [ "$DEBUG_BUILD" = true ]; then
|
||||
@@ -89,6 +146,34 @@ echo "Building for platform: $PLATFORM"
|
||||
echo "Output binary: $OUTPUT_NAME"
|
||||
echo ""
|
||||
|
||||
# Verify Docker can execute the requested target platform (QEMU/binfmt for cross-arch)
|
||||
HOST_ARCH=$(uname -m)
|
||||
case "$HOST_ARCH" in
|
||||
x86_64) HOST_PLATFORM="linux/amd64" ;;
|
||||
aarch64|arm64) HOST_PLATFORM="linux/arm64" ;;
|
||||
armv7l) HOST_PLATFORM="linux/arm/v7" ;;
|
||||
armv6l) HOST_PLATFORM="linux/arm/v6" ;;
|
||||
*) HOST_PLATFORM="unknown" ;;
|
||||
esac
|
||||
|
||||
if [ "$HOST_PLATFORM" != "$PLATFORM" ]; then
|
||||
echo "Cross-architecture build detected: host=$HOST_PLATFORM target=$PLATFORM"
|
||||
echo "Checking Docker emulation support for $PLATFORM..."
|
||||
if ! $DOCKER_CMD run --rm --platform "$PLATFORM" alpine:3.19 uname -m > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "ERROR: Docker cannot execute $PLATFORM containers on this host"
|
||||
echo "This usually means QEMU/binfmt is not configured."
|
||||
echo "Run this once, then retry:"
|
||||
echo " docker run --rm --privileged multiarch/qemu-user-static --reset -p yes"
|
||||
echo "Optional verify command:"
|
||||
echo " docker run --rm --platform $PLATFORM alpine:3.19 uname -m"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Docker emulation for $PLATFORM is available"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check if Alpine base image is cached
|
||||
echo "Checking for cached Alpine Docker image..."
|
||||
if ! docker images alpine:3.19 --format "{{.Repository}}:{{.Tag}}" | grep -q "alpine:3.19"; then
|
||||
@@ -118,6 +203,7 @@ echo " - Compile didactyl with full static linking"
|
||||
echo ""
|
||||
|
||||
$DOCKER_CMD build \
|
||||
--network host \
|
||||
--platform "$PLATFORM" \
|
||||
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
|
||||
-f "$DOCKERFILE" \
|
||||
|
||||
150
chat-didactyl-cli.js
Executable file
150
chat-didactyl-cli.js
Executable 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);
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
{
|
||||
"keys": {
|
||||
"nsec": "agent nsec",
|
||||
"npub": "agent npub",
|
||||
"npubHex": "agent hex pubkey",
|
||||
"nsecHex": "agent hex secret key"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "admin pubkey"
|
||||
},
|
||||
"llm": {
|
||||
"provider": "",
|
||||
"api_key": "",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"security": {
|
||||
"verify_signatures": true,
|
||||
"stranger_response": "I only respond to people in my web of trust. You can always identify me by my public key (npub).",
|
||||
"tiers": {
|
||||
"admin": {
|
||||
"tools_enabled": true
|
||||
},
|
||||
"wot": {
|
||||
"enabled": true,
|
||||
"tools_enabled": false
|
||||
},
|
||||
"stranger": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_context": {
|
||||
"enabled": true,
|
||||
"subscribe_kinds": [
|
||||
0,
|
||||
3,
|
||||
10002,
|
||||
1
|
||||
],
|
||||
"kind_1_limit": 10
|
||||
},
|
||||
"startup_events": [
|
||||
{
|
||||
"kind": 0,
|
||||
"content_fields": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr",
|
||||
"picture": "https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png",
|
||||
"banner": "https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"kind": 10002,
|
||||
"content": "",
|
||||
"tags": [
|
||||
|
||||
|
||||
[
|
||||
"r",
|
||||
"wss://relay.damus.io"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://nos.lol"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"wss://relay.primal.net"
|
||||
],
|
||||
[
|
||||
"r",
|
||||
"ws://127.0.0.1:7777"
|
||||
]
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "Hello world from Didactyl startup",
|
||||
"tags": [
|
||||
[
|
||||
"t",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"startup"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 3,
|
||||
"content": "",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"kind": 31120,
|
||||
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.",
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"soul"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"private"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "long_form_note",
|
||||
"description": "How to publish a NIP-23 long-form article (kind 30023)",
|
||||
"nip": "NIP-23",
|
||||
"event_kind": 30023,
|
||||
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
|
||||
"required_tags": {
|
||||
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
|
||||
"title": "Human-readable article title.",
|
||||
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
|
||||
},
|
||||
"optional_tags": {
|
||||
"summary": "Short 1-2 sentence summary.",
|
||||
"image": "URL for article preview image.",
|
||||
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
|
||||
},
|
||||
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
|
||||
"procedure": [
|
||||
"Determine title and d tag from admin input or source material.",
|
||||
"Draft markdown body content.",
|
||||
"Set published_at as unix seconds string.",
|
||||
"Add optional summary/image/t tags when known.",
|
||||
"Publish with nostr_post kind 30023 including tags array."
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"long_form_note"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"long_form_note"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "post_readme_to_nostr",
|
||||
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Read README.md using file_read with path README.md.",
|
||||
"Set d tag exactly to readme.md.",
|
||||
"Set title from the first markdown H1 heading in README.md.",
|
||||
"Set summary from the opening paragraph of README.md.",
|
||||
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
|
||||
"Generate 3-6 lowercase t tags from README section topics.",
|
||||
"Set published_at to current unix timestamp as string.",
|
||||
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"summary_source": "opening paragraph"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"post_readme_to_nostr"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"post_readme_to_nostr"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 31124,
|
||||
"content_fields": {
|
||||
"name": "admin_ops",
|
||||
"description": "Private operational procedures"
|
||||
},
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"admin_ops"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"private"
|
||||
],
|
||||
[
|
||||
"slug",
|
||||
"admin_ops"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": 10123,
|
||||
"content": "",
|
||||
"tags": [
|
||||
[
|
||||
"a",
|
||||
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"
|
||||
],
|
||||
[
|
||||
"a",
|
||||
"31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"
|
||||
],
|
||||
[
|
||||
"app",
|
||||
"didactyl"
|
||||
],
|
||||
[
|
||||
"scope",
|
||||
"public"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
353
config.jsonc.example
Normal file
353
config.jsonc.example
Normal file
@@ -0,0 +1,353 @@
|
||||
{
|
||||
// LEGACY CONFIG EXAMPLE
|
||||
// This file is deprecated in favor of genesis.jsonc + nsec-only startup.
|
||||
// Kept as reference for older deployments and migration support.
|
||||
|
||||
// ─── Agent Identity Keys ───────────────────────────────────────────
|
||||
// Your agent's Nostr keypair. Provide nsec (bech32) or hex format.
|
||||
// The public key fields are optional — they are derived automatically.
|
||||
"keys": {
|
||||
"nsec": "agent nsec",
|
||||
"npub": "agent npub",
|
||||
"npubHex": "agent hex pubkey",
|
||||
"nsecHex": "agent hex secret key"
|
||||
},
|
||||
|
||||
// ─── Administrator ─────────────────────────────────────────────────
|
||||
// The admin pubkey (npub or hex) controls who can issue privileged
|
||||
// commands and use tools via DM.
|
||||
"admin": {
|
||||
"pubkey": "admin pubkey"
|
||||
},
|
||||
|
||||
// ─── DM Protocol ──────────────────────────────────────────────────
|
||||
// Which encrypted DM protocol to use: "nip04", "nip17", or "both"
|
||||
"dm_protocol": "nip04",
|
||||
|
||||
// ─── LLM Provider ─────────────────────────────────────────────────
|
||||
// Configure the language model backend. Any OpenAI-compatible API works.
|
||||
"llm": {
|
||||
"provider": "", // e.g. "openai", "anthropic", etc.
|
||||
"api_key": "", // your API key
|
||||
"model": "", // model identifier, e.g. "gpt-4o-mini"
|
||||
"base_url": "", // API base URL, e.g. "https://api.openai.com/v1"
|
||||
"max_tokens": 512, // max tokens per LLM response
|
||||
"temperature": 0.7 // sampling temperature (0.0 – 2.0)
|
||||
},
|
||||
|
||||
// ─── Cashu Wallet (NIP-60) ────────────────────────────────────────
|
||||
// Optional wallet runtime used by cashu_wallet_* tools.
|
||||
// If enabled=true and auto_load=true, Didactyl will attempt to load
|
||||
// wallet/token events from relays at startup and create a new wallet
|
||||
// from mint_urls if none is found.
|
||||
"cashu_wallet": {
|
||||
"enabled": false,
|
||||
"mint_urls": [
|
||||
"https://mint.minibits.cash/Bitcoin"
|
||||
],
|
||||
"unit": "sat",
|
||||
"auto_load": true,
|
||||
"mint_timeout_seconds": 30
|
||||
},
|
||||
|
||||
// ─── Tools & Runtime Limits ───────────────────────────────────────
|
||||
// Centralized turn and timeout limits used by DM agent loops,
|
||||
// triggered skills, HTTP API prompt runs, and local HTTP fetch tool.
|
||||
"tools": {
|
||||
"enabled": true,
|
||||
"max_turns": 20, // default max turns for normal agent tool loops
|
||||
"trigger_max_turns": 12, // max turns for triggered-skill executions
|
||||
"api_default_max_turns": 8, // default when HTTP body omits max_turns
|
||||
"api_max_turns_ceiling": 32, // hard cap applied to API-provided max_turns
|
||||
"stall_repeat_threshold": 3, // stop early when identical tool-call turns repeat this many times
|
||||
"local_http_fetch_default_timeout_seconds": 20,
|
||||
"local_http_fetch_max_timeout_seconds": 120,
|
||||
"shell": {
|
||||
"enabled": true,
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 65536,
|
||||
"working_directory": "."
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Security Tiers ───────────────────────────────────────────────
|
||||
// Controls who can interact with the agent and what they can do.
|
||||
"security": {
|
||||
"verify_signatures": true, // verify Nostr event signatures
|
||||
// Message sent to strangers outside the web of trust
|
||||
"stranger_response": "I only respond to people in my web of trust. You can always identify me by my public key (npub).",
|
||||
"tiers": {
|
||||
"admin": {
|
||||
"tools_enabled": true // admin can always use tools
|
||||
},
|
||||
"wot": {
|
||||
"enabled": true, // respond to web-of-trust contacts
|
||||
"tools_enabled": false // WoT contacts cannot use tools by default
|
||||
},
|
||||
"stranger": {
|
||||
"enabled": true // respond to strangers (with stranger_response)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Admin Context Subscriptions ──────────────────────────────────
|
||||
// Subscribe to the admin's Nostr events to build context awareness.
|
||||
"admin_context": {
|
||||
"enabled": true,
|
||||
"subscribe_kinds": [
|
||||
0, // kind 0: profile metadata
|
||||
3, // kind 3: contact list
|
||||
10002, // kind 10002: relay list
|
||||
1 // kind 1: text notes
|
||||
],
|
||||
"kind_1_limit": 10 // max recent kind-1 notes to track
|
||||
},
|
||||
|
||||
// ─── HTTP Admin API ───────────────────────────────────────────────
|
||||
// Local REST API for runtime inspection and control.
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1" // bind to localhost only
|
||||
},
|
||||
|
||||
// ─── Startup Events ───────────────────────────────────────────────
|
||||
// Events published on boot. Includes profile, relay list, soul,
|
||||
// skills, and adoption list.
|
||||
"startup_events": [
|
||||
// Kind 0: Agent profile metadata (NIP-01)
|
||||
{
|
||||
"kind": 0,
|
||||
"content_fields": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr",
|
||||
"picture": "https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png",
|
||||
"banner": "https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
|
||||
// Kind 10002: Relay list (NIP-65)
|
||||
// These relays are used for connecting and publishing events.
|
||||
{
|
||||
"kind": 10002,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["r", "wss://relay.damus.io"],
|
||||
["r", "wss://nos.lol"],
|
||||
["r", "wss://relay.primal.net"],
|
||||
["r", "ws://127.0.0.1:7777"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 10050: DM relay list (NIP-17)
|
||||
// Relays used specifically for receiving encrypted DMs.
|
||||
{
|
||||
"kind": 10050,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["relay", "wss://relay.damus.io"],
|
||||
["relay", "wss://nos.lol"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 1: Startup announcement note
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "Hello world from Didactyl startup",
|
||||
"tags": [
|
||||
["t", "didactyl"],
|
||||
["t", "startup"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 3: Contact list (initially empty)
|
||||
{
|
||||
"kind": 3,
|
||||
"content": "",
|
||||
"tags": []
|
||||
},
|
||||
|
||||
// Kind 31120: Soul event — the agent's personality and behavior rules.
|
||||
// Contains the system prompt and template sections for LLM context.
|
||||
// The ---template--- marker separates the system prompt from
|
||||
// structured context sections that are injected at runtime.
|
||||
{
|
||||
"kind": 31120,
|
||||
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- For memory persistence, use `memory_save` to prepend new memory entries and `memory_recall` to recall stored memory.\n- Memory is not injected by default; call `memory_recall` when needed.\n- Keep memory content in markdown and concise enough for context efficiency.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_contacts\n role: system\n content: |\n ## Administrator Contact List (source: nostr kind 3)\n\n Administrator kind 3 contact list pubkeys (JSON array): {{admin_kind3_json}}\n provider:\n anthropic: |\n <admin_contacts source=\"nostr_kind_3\">\n {{admin_kind3_json}}\n </admin_contacts>\n\n- section: admin_relays\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay list (JSON): {{admin_kind10002_json}}\n provider:\n anthropic: |\n <admin_relays source=\"nostr_kind_10002\">\n {{admin_kind10002_json}}\n </admin_relays>\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n Administrator recent kind 1 notes (JSON array): {{admin_kind1_json}}\n provider:\n anthropic: |\n <admin_notes source=\"nostr_kind_1\">\n {{admin_kind1_json}}\n </admin_notes>\n\n- section: skills\n role: system\n content: |\n ## Adopted Skills\n\n {{skills_json}}\n\n- section: tasks\n role: system\n content: |\n ## Current Task List\n\n {{tasks_json}}\n\n- section: conversation\n role: conversation\n content: |\n {{conversation}}",
|
||||
"tags": [
|
||||
["d", "soul"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31123: Public skill — long_form_note
|
||||
// Teaches the agent how to publish NIP-23 long-form articles.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "long_form_note",
|
||||
"description": "How to publish a NIP-23 long-form article (kind 30023)",
|
||||
"nip": "NIP-23",
|
||||
"event_kind": 30023,
|
||||
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
|
||||
"required_tags": {
|
||||
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
|
||||
"title": "Human-readable article title.",
|
||||
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
|
||||
},
|
||||
"optional_tags": {
|
||||
"summary": "Short 1-2 sentence summary.",
|
||||
"image": "URL for article preview image.",
|
||||
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
|
||||
},
|
||||
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
|
||||
"procedure": [
|
||||
"Determine title and d tag from admin input or source material.",
|
||||
"Draft markdown body content.",
|
||||
"Set published_at as unix seconds string.",
|
||||
"Add optional summary/image/t tags when known.",
|
||||
"Publish with nostr_post kind 30023 including tags array."
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
["d", "long_form_note"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["slug", "long_form_note"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31123: Public skill — post_readme_to_nostr
|
||||
// Reads README.md and publishes it as a long-form note.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {
|
||||
"name": "post_readme_to_nostr",
|
||||
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
|
||||
"uses_skill": "long_form_note",
|
||||
"event_kind": 30023,
|
||||
"procedure": [
|
||||
"Read README.md using local_file_read with path README.md.",
|
||||
"Set d tag exactly to readme.md.",
|
||||
"Set title from the first markdown H1 heading in README.md.",
|
||||
"Set summary from the opening paragraph of README.md.",
|
||||
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
|
||||
"Generate 3-6 lowercase t tags from README section topics.",
|
||||
"Set published_at to current unix timestamp as string.",
|
||||
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
|
||||
],
|
||||
"required_values": {
|
||||
"d": "readme.md",
|
||||
"summary_source": "opening paragraph"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
["d", "post_readme_to_nostr"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["slug", "post_readme_to_nostr"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31123: Public triggered skill — cheerleader
|
||||
// Watches for admin kind-1 notes and sends an encouraging DM.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be warm, playful, and positive. Mention something specific from their note so it feels personal. Keep it to 2-3 sentences.",
|
||||
"tags": [
|
||||
["d", "cheerleader"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Cheer on admin whenever they post a kind 1 note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"kinds\":[1],\"authors\":[\"REPLACE_WITH_ADMIN_HEX_PUBKEY\"]}"],
|
||||
["action", "llm"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31124: Private skill — admin_ops
|
||||
// Private operational procedures (admin-only).
|
||||
{
|
||||
"kind": 31124,
|
||||
"content_fields": {
|
||||
"name": "admin_ops",
|
||||
"description": "Private operational procedures"
|
||||
},
|
||||
"tags": [
|
||||
["d", "admin_ops"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["slug", "admin_ops"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31124: Private triggered skill — webhook-echo
|
||||
// Example webhook trigger that emits a fixed DM response.
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "When this webhook trigger fires, DM the admin with exactly: WEBHOOK_SOURCE_POC_OK",
|
||||
"tags": [
|
||||
["d", "webhook-echo"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "PoC webhook trigger skill"],
|
||||
["trigger", "webhook"],
|
||||
["filter", "{}"],
|
||||
["action", "llm"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31124: Private triggered skill — cron_poc
|
||||
// Example cron trigger that runs every minute.
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "DM the administrator with the current time, and tell him that your cron skill triggered.",
|
||||
"tags": [
|
||||
["d", "cron_poc"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "PoC cron trigger skill"],
|
||||
["trigger", "cron"],
|
||||
["filter", "*/1 * * * *"],
|
||||
["action", "template"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 31124: Private triggered skill — chain_from_webhook_poc
|
||||
// Example chain trigger that runs after webhook-echo completes.
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "LOG: chain_from_webhook_poc fired",
|
||||
"tags": [
|
||||
["d", "chain_from_webhook_poc"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "PoC chain trigger skill"],
|
||||
["trigger", "chain"],
|
||||
["filter", "webhook-echo"],
|
||||
["action", "template"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
},
|
||||
|
||||
// Kind 10123: Skill adoption list
|
||||
// References which public skills this agent has adopted.
|
||||
{
|
||||
"kind": 10123,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"],
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"],
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
764
context.log.md
Normal file
764
context.log.md
Normal file
@@ -0,0 +1,764 @@
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-17 09:05:54
|
||||
phase=direct_tool_exec
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-haiku-4.5
|
||||
context_bytes=5897
|
||||
approx_tokens=1474
|
||||
```
|
||||
|
||||
slash=/nostr_subscription_status
|
||||
result_json={"success":true,"status":{"count":8,"subscriptions":[{"name":"admin_context_profile","enabled":true,"active":true,"has_filter":true,"close_on_eose":0,"enable_deduplication":1,"relay_timeout_seconds":30,"eose_timeout_seconds":120,"filter":{"kinds":[0,3,10002],"authors":["8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e"],"limit":32}},{"name":"admin_context_notes","enabled":true,"active":true,"has_filter":true,"close_on_eose":0,"enable_deduplication":1,"relay_timeout_seconds":30,"eose_timeout_seconds":120,"filter":{"kinds":[1],"authors":["8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e"],"limit":10}},{"name":"agent_context_profile","enabled":true,"active":true,"has_filter":true,"close_on_eose":0,"enable_deduplication":1,"relay_timeout_seconds":30,"eose_timeout_seconds":120,"filter":{"kinds":[0,3,10002],"authors":["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],"limit":64}},{"name":"agent_context_notes","enabled":true,"active":true,"has_filter":true,"close_on_eose":0,"enable_deduplication":1,"relay_timeout_seconds":30,"eose_timeout_seconds":120,"filter":{"kinds":[1],"authors":["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],"limit":10}},{"name":"self_skills","enabled":true,"active":true,"has_filter":true,"close_on_eose":0,"enable_deduplication":0,"relay_timeout_seconds":30,"eose_timeout_seconds":120,"filter":{"kinds":[31123,31124,10123],"authors":["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],"limit":300}},{"name":"admin_skills","enabled":true,"active":true,"has_filter":true,"close_on_eose":0,"enable_deduplication":0,"relay_timeout_seconds":30,"eose_timeout_seconds":120,"filter":{"kinds":[31123],"authors":["8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e"],"limit":150}},{"name":"dms_kind4","enabled":true,"active":true,"has_filter":true,"close_on_eose":0,"enable_deduplication":0,"relay_timeout_seconds":30,"eose_timeout_seconds":120,"filter":{"kinds":[4],"#p":["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],"since":1773752720,"limit":100}},{"name":"dms_kind1059","enabled":false,"active":false,"has_filter":false,"close_on_eose":0,"enable_deduplication":0,"relay_timeout_seconds":30,"eose_timeout_seconds":120}]}}
|
||||
result_markdown=- **success:** true
|
||||
- **status:**
|
||||
- **count:** 8
|
||||
- **subscriptions:**
|
||||
-
|
||||
- **name:** admin_context_profile
|
||||
- **enabled:** true
|
||||
- **active:** true
|
||||
- **has_filter:** true
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 1
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
- **filter:**
|
||||
- **kinds:**
|
||||
- 0
|
||||
- 3
|
||||
- 10002
|
||||
- **authors:**
|
||||
- 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
- **limit:** 32
|
||||
-
|
||||
- **name:** admin_context_notes
|
||||
- **enabled:** true
|
||||
- **active:** true
|
||||
- **has_filter:** true
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 1
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
- **filter:**
|
||||
- **kinds:**
|
||||
- 1
|
||||
- **authors:**
|
||||
- 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
- **limit:** 10
|
||||
-
|
||||
- **name:** agent_context_profile
|
||||
- **enabled:** true
|
||||
- **active:** true
|
||||
- **has_filter:** true
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 1
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
- **filter:**
|
||||
- **kinds:**
|
||||
- 0
|
||||
- 3
|
||||
- 10002
|
||||
- **authors:**
|
||||
- 52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8
|
||||
- **limit:** 64
|
||||
-
|
||||
- **name:** agent_context_notes
|
||||
- **enabled:** true
|
||||
- **active:** true
|
||||
- **has_filter:** true
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 1
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
- **filter:**
|
||||
- **kinds:**
|
||||
- 1
|
||||
- **authors:**
|
||||
- 52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8
|
||||
- **limit:** 10
|
||||
-
|
||||
- **name:** self_skills
|
||||
- **enabled:** true
|
||||
- **active:** true
|
||||
- **has_filter:** true
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 0
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
- **filter:**
|
||||
- **kinds:**
|
||||
- 31123
|
||||
- 31124
|
||||
- 10123
|
||||
- **authors:**
|
||||
- 52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8
|
||||
- **limit:** 300
|
||||
-
|
||||
- **name:** admin_skills
|
||||
- **enabled:** true
|
||||
- **active:** true
|
||||
- **has_filter:** true
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 0
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
- **filter:**
|
||||
- **kinds:**
|
||||
- 31123
|
||||
- **authors:**
|
||||
- 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
- **limit:** 150
|
||||
-
|
||||
- **name:** dms_kind4
|
||||
- **enabled:** true
|
||||
- **active:** true
|
||||
- **has_filter:** true
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 0
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
- **filter:**
|
||||
- **kinds:**
|
||||
- 4
|
||||
- **#p:**
|
||||
- 52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8
|
||||
- **since:** 1.77375e+09
|
||||
- **limit:** 100
|
||||
-
|
||||
- **name:** dms_kind1059
|
||||
- **enabled:** false
|
||||
- **active:** false
|
||||
- **has_filter:** false
|
||||
- **close_on_eose:** 0
|
||||
- **enable_deduplication:** 0
|
||||
- **relay_timeout_seconds:** 30
|
||||
- **eose_timeout_seconds:** 120
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-17 07:47:58
|
||||
phase=direct_tool_exec
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-haiku-4.5
|
||||
context_bytes=3331
|
||||
approx_tokens=832
|
||||
```
|
||||
|
||||
slash=/nostr_my_events
|
||||
result_json={"success":true,"count":9,"events":[{"kind":10123,"event_id":"b75061f2784a843e1d9d7ff527bbbc4f669ff7d46ada36c42b4d741666e450d3","timestamp":"1773748025","d_tag":null,"in_current_cache":true},{"kind":4,"event_id":"113947f0d9fcad7eed96f95e15c72f7edf8ea35c8f1158a71126977f3940a69b","timestamp":"1773748042","d_tag":null,"in_current_cache":false},{"kind":31124,"event_id":"3d9e0e0f67149df2b3fa6175f0aea2c65c916c2d583aeea436f11a22e7b1ed08","timestamp":"1773748025","d_tag":null,"in_current_cache":false},{"kind":4,"event_id":"a13772bc38d056b2b03aff797586f2d4b01842a53b8e2b3108be89d622a55eed","timestamp":"1773748026","d_tag":null,"in_current_cache":false},{"kind":3,"event_id":"fe4e6007eb387892c6e9265576efe73978a6d68622d16fba71410ca4827f2fec","timestamp":"1773748025","d_tag":null,"in_current_cache":false},{"kind":30078,"event_id":"94c14993586a853e237b0e75c05a6d77ee0dbdc19419c62c327500427374919c","timestamp":"1773748025","d_tag":"agent_config","in_current_cache":false},{"kind":10002,"event_id":"e686e444b88f1f1965c317f16a5b9dbc7ec973f4f8141240c89c82f551c1837b","timestamp":"1773748025","d_tag":null,"in_current_cache":false},{"kind":0,"event_id":"1c4fd7fa263f350662aa5f281715c06c4475b499960223313c34e4067dd204a3","timestamp":"1773748025","d_tag":null,"in_current_cache":false},{"kind":30078,"event_id":"b6d99deaa5bcb032f23db4423f55eacb07d9e5963718d60b7aa6a8d22b20d87d","timestamp":"1773748025","d_tag":"llm_config","in_current_cache":false}]}
|
||||
result_markdown=- **success:** true
|
||||
- **count:** 9
|
||||
- **events:**
|
||||
-
|
||||
- **kind:** 10123
|
||||
- **event_id:** b75061f2784a843e1d9d7ff527bbbc4f669ff7d46ada36c42b4d741666e450d3
|
||||
- **timestamp:** 1773748025
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** true
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** 113947f0d9fcad7eed96f95e15c72f7edf8ea35c8f1158a71126977f3940a69b
|
||||
- **timestamp:** 1773748042
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 31124
|
||||
- **event_id:** 3d9e0e0f67149df2b3fa6175f0aea2c65c916c2d583aeea436f11a22e7b1ed08
|
||||
- **timestamp:** 1773748025
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** a13772bc38d056b2b03aff797586f2d4b01842a53b8e2b3108be89d622a55eed
|
||||
- **timestamp:** 1773748026
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 3
|
||||
- **event_id:** fe4e6007eb387892c6e9265576efe73978a6d68622d16fba71410ca4827f2fec
|
||||
- **timestamp:** 1773748025
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 30078
|
||||
- **event_id:** 94c14993586a853e237b0e75c05a6d77ee0dbdc19419c62c327500427374919c
|
||||
- **timestamp:** 1773748025
|
||||
- **d_tag:** agent_config
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 10002
|
||||
- **event_id:** e686e444b88f1f1965c317f16a5b9dbc7ec973f4f8141240c89c82f551c1837b
|
||||
- **timestamp:** 1773748025
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 0
|
||||
- **event_id:** 1c4fd7fa263f350662aa5f281715c06c4475b499960223313c34e4067dd204a3
|
||||
- **timestamp:** 1773748025
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 30078
|
||||
- **event_id:** b6d99deaa5bcb032f23db4423f55eacb07d9e5963718d60b7aa6a8d22b20d87d
|
||||
- **timestamp:** 1773748025
|
||||
- **d_tag:** llm_config
|
||||
- **in_current_cache:** false
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-17 07:47:22
|
||||
phase=direct_tool_exec
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-haiku-4.5
|
||||
context_bytes=10041
|
||||
approx_tokens=2510
|
||||
```
|
||||
|
||||
slash=/help
|
||||
result_json=AVAILABLE TOOLS
|
||||
- admin_identity — Build admin identity context block from cached runtime metadata
|
||||
- agent_identity — Build agent identity context block with pubkey and npub
|
||||
- agent_version — Return current Didactyl version and metadata from build macros
|
||||
- config_recall — Fetch and decrypt agent config kind 30078 by d_tag
|
||||
- config_store — Encrypt and publish agent config as kind 30078 for a given d_tag
|
||||
- local_file_read — Read a local file as text from the configured working directory
|
||||
- local_file_write — Write text content to a local file in the configured working directory
|
||||
- local_http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
|
||||
- local_shell_exec — Execute a shell command and return stdout/stderr
|
||||
- memory_recall — Recall encrypted agent memory (kind 30078, d=memory)
|
||||
- memory_save — Prepend a new entry to encrypted agent memory (kind 30078, d=memory) and truncate oldest content if needed
|
||||
- model_get — Get current active LLM runtime configuration (excluding API key)
|
||||
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
|
||||
- model_set — Update active LLM configuration and persist it to config.jsonc
|
||||
- my_contacts — Alias for nostr_agent_contacts: return this agent's kind 3 contacts context
|
||||
- my_kind0_profile — Alias for nostr_agent_profile: return this agent's kind 0 profile context
|
||||
- my_notes — Alias for nostr_agent_notes: return this agent's recent kind 1 notes context
|
||||
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
|
||||
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
|
||||
- my_relays — Alias for nostr_agent_relays: return this agent's kind 10002 relay context
|
||||
- nostr_admin_contacts — Build admin contacts context block from cached kind 3 contact list
|
||||
- nostr_admin_notes — Build admin notes context block from cached kind 1 notes
|
||||
- nostr_admin_profile — Build admin profile context block from cached kind 0 metadata
|
||||
- nostr_admin_relays — Build admin relay context block from cached kind 10002 data
|
||||
- nostr_agent_contacts — Build agent contacts context block from cached kind 3 contact list
|
||||
- nostr_agent_notes — Build agent notes context block from cached kind 1 notes
|
||||
- nostr_agent_profile — Build agent profile context block from cached kind 0 metadata
|
||||
- nostr_agent_relays — Build agent relay context block from cached kind 10002 data
|
||||
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
|
||||
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
|
||||
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
|
||||
- nostr_dm_send — Send a NIP-04 encrypted DM
|
||||
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
|
||||
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
|
||||
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
|
||||
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
|
||||
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
|
||||
- nostr_my_events — Query recent events authored by this agent and return kind, event_id, timestamp, d_tag, and cache presence
|
||||
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
|
||||
- nostr_npub — Return this agent's pubkey encoded as npub bech32
|
||||
- nostr_post — Publish a Nostr event to connected relays
|
||||
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
|
||||
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
|
||||
- nostr_pubkey — Return this agent's pubkey in hex format
|
||||
- nostr_query — Query events from relays using a Nostr filter
|
||||
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
|
||||
- nostr_relay_info — Fetch NIP-11 relay information document
|
||||
- nostr_relay_status — Get connection status and statistics for all relays
|
||||
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
|
||||
- skill_create — Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it
|
||||
- skill_edit — Edit an existing self skill by d tag and republish it as kind 31123/31124
|
||||
- skill_list — List available skills discovered online (agent + admin), with adoption status and optional filters
|
||||
- skill_remove — Remove a skill address from kind 10123 adoption list
|
||||
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
|
||||
- task_list — Build current task list context block from tasks.json
|
||||
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
|
||||
- tool_list — List available tools with name, description, and JSON parameter schema
|
||||
- trigger_list — List active triggered skills and their runtime status
|
||||
|
||||
AVAILABLE SKILLS
|
||||
- (none)
|
||||
|
||||
ADOPTED SKILLS
|
||||
- didactyl-default
|
||||
|
||||
result_markdown=AVAILABLE TOOLS
|
||||
- admin_identity — Build admin identity context block from cached runtime metadata
|
||||
- agent_identity — Build agent identity context block with pubkey and npub
|
||||
- agent_version — Return current Didactyl version and metadata from build macros
|
||||
- config_recall — Fetch and decrypt agent config kind 30078 by d_tag
|
||||
- config_store — Encrypt and publish agent config as kind 30078 for a given d_tag
|
||||
- local_file_read — Read a local file as text from the configured working directory
|
||||
- local_file_write — Write text content to a local file in the configured working directory
|
||||
- local_http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
|
||||
- local_shell_exec — Execute a shell command and return stdout/stderr
|
||||
- memory_recall — Recall encrypted agent memory (kind 30078, d=memory)
|
||||
- memory_save — Prepend a new entry to encrypted agent memory (kind 30078, d=memory) and truncate oldest content if needed
|
||||
- model_get — Get current active LLM runtime configuration (excluding API key)
|
||||
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
|
||||
- model_set — Update active LLM configuration and persist it to config.jsonc
|
||||
- my_contacts — Alias for nostr_agent_contacts: return this agent's kind 3 contacts context
|
||||
- my_kind0_profile — Alias for nostr_agent_profile: return this agent's kind 0 profile context
|
||||
- my_notes — Alias for nostr_agent_notes: return this agent's recent kind 1 notes context
|
||||
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
|
||||
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
|
||||
- my_relays — Alias for nostr_agent_relays: return this agent's kind 10002 relay context
|
||||
- nostr_admin_contacts — Build admin contacts context block from cached kind 3 contact list
|
||||
- nostr_admin_notes — Build admin notes context block from cached kind 1 notes
|
||||
- nostr_admin_profile — Build admin profile context block from cached kind 0 metadata
|
||||
- nostr_admin_relays — Build admin relay context block from cached kind 10002 data
|
||||
- nostr_agent_contacts — Build agent contacts context block from cached kind 3 contact list
|
||||
- nostr_agent_notes — Build agent notes context block from cached kind 1 notes
|
||||
- nostr_agent_profile — Build agent profile context block from cached kind 0 metadata
|
||||
- nostr_agent_relays — Build agent relay context block from cached kind 10002 data
|
||||
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
|
||||
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
|
||||
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
|
||||
- nostr_dm_send — Send a NIP-04 encrypted DM
|
||||
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
|
||||
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
|
||||
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
|
||||
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
|
||||
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
|
||||
- nostr_my_events — Query recent events authored by this agent and return kind, event_id, timestamp, d_tag, and cache presence
|
||||
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
|
||||
- nostr_npub — Return this agent's pubkey encoded as npub bech32
|
||||
- nostr_post — Publish a Nostr event to connected relays
|
||||
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
|
||||
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
|
||||
- nostr_pubkey — Return this agent's pubkey in hex format
|
||||
- nostr_query — Query events from relays using a Nostr filter
|
||||
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
|
||||
- nostr_relay_info — Fetch NIP-11 relay information document
|
||||
- nostr_relay_status — Get connection status and statistics for all relays
|
||||
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
|
||||
- skill_create — Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it
|
||||
- skill_edit — Edit an existing self skill by d tag and republish it as kind 31123/31124
|
||||
- skill_list — List available skills discovered online (agent + admin), with adoption status and optional filters
|
||||
- skill_remove — Remove a skill address from kind 10123 adoption list
|
||||
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
|
||||
- task_list — Build current task list context block from tasks.json
|
||||
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
|
||||
- tool_list — List available tools with name, description, and JSON parameter schema
|
||||
- trigger_list — List active triggered skills and their runtime status
|
||||
|
||||
AVAILABLE SKILLS
|
||||
- (none)
|
||||
|
||||
ADOPTED SKILLS
|
||||
- didactyl-default
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-17 07:29:22
|
||||
phase=direct_tool_exec
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-haiku-4.5
|
||||
context_bytes=1537
|
||||
approx_tokens=384
|
||||
```
|
||||
|
||||
slash=/nostr_my_events {"kind":4}
|
||||
result_json={"success":true,"count":4,"events":[{"kind":4,"event_id":"6858ff2b67f7bc4b56310c0cffa15905ad094a707d861fc90d5fd41d433eaef3","timestamp":"1773746929","d_tag":null,"in_current_cache":false},{"kind":4,"event_id":"9fe0113e3390f7f1c60975e838be34499873ec9bf6db59a0d1ca22d4490d2c7f","timestamp":"1773745472","d_tag":null,"in_current_cache":false},{"kind":4,"event_id":"2fa84056148ff722d5b10859de802b1685394f5d6092ff215a16f6d6313d0ff7","timestamp":"1773745430","d_tag":null,"in_current_cache":false},{"kind":4,"event_id":"b9ea89122d322a4ecdef3ee055f97a973780267829e745ff8f3c1f7b2c19f59b","timestamp":"1773745405","d_tag":null,"in_current_cache":false}]}
|
||||
result_markdown=- **success:** true
|
||||
- **count:** 4
|
||||
- **events:**
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** 6858ff2b67f7bc4b56310c0cffa15905ad094a707d861fc90d5fd41d433eaef3
|
||||
- **timestamp:** 1773746929
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** 9fe0113e3390f7f1c60975e838be34499873ec9bf6db59a0d1ca22d4490d2c7f
|
||||
- **timestamp:** 1773745472
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** 2fa84056148ff722d5b10859de802b1685394f5d6092ff215a16f6d6313d0ff7
|
||||
- **timestamp:** 1773745430
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** b9ea89122d322a4ecdef3ee055f97a973780267829e745ff8f3c1f7b2c19f59b
|
||||
- **timestamp:** 1773745405
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-17 07:27:29
|
||||
phase=direct_tool_exec
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-haiku-4.5
|
||||
context_bytes=4027
|
||||
approx_tokens=1006
|
||||
```
|
||||
|
||||
slash=/nostr_my_events {"kind":4}
|
||||
result_json={"success":true,"count":11,"events":[{"kind":4,"event_id":"c5825256070767775ce7b1f8b4f1a6542f540b6b6086c2955676470d3f98bb8a","timestamp":1773745696,"d_tag":null,"in_current_cache":false},{"kind":4,"event_id":"f5f38c190c665e74142bb90da0766c58f1936ba9caefb680e5a1149bbb4b3489","timestamp":1773745642,"d_tag":null,"in_current_cache":false},{"kind":4,"event_id":"e6f86473f87e06af7a0bd5edf75216b888a0f087347f8bc9f8e33aa2eef9c03d","timestamp":1773745626,"d_tag":null,"in_current_cache":false},{"kind":4,"event_id":"2bc8d7c834a54b8e6bd4c8270e5db7097dc7b1c5bb0ff0ccb284b4ca7a3c1a0a","timestamp":1773745613,"d_tag":null,"in_current_cache":false},{"kind":30078,"event_id":"3301d4213690f9012cc8a2a4368c1e908a2f514e71273af6e5107b5751b8d734","timestamp":1773745612,"d_tag":"agent_config","in_current_cache":false},{"kind":31124,"event_id":"dc69d8740056411ac88074d4566a6c9931d29f10fd50647041075421ed4884bd","timestamp":1773745612,"d_tag":null,"in_current_cache":false},{"kind":10002,"event_id":"70ff3451eba89bea85f8ff33e98615bba682d056ee561c7d7e2684e6710095aa","timestamp":1773745612,"d_tag":null,"in_current_cache":false},{"kind":10123,"event_id":"532653ce33226aa327979e02064bac1968a3997eb05aa26f719cedbc21d35bcd","timestamp":1773745612,"d_tag":null,"in_current_cache":true},{"kind":30078,"event_id":"26cb7ba3d97986ce51f3d8e9123ea41a9f9496971297226479a41441c1668adf","timestamp":1773745612,"d_tag":"llm_config","in_current_cache":false},{"kind":0,"event_id":"c478f06007268ed776ad2946c6f699e98b5219f235abb8032a5ddec4c9ba2fe2","timestamp":1773745612,"d_tag":null,"in_current_cache":false},{"kind":3,"event_id":"7f2298882dbbf544976d8bf9e4d51dac63b83ef5a3901d95f3efb7ca802d5e69","timestamp":1773745612,"d_tag":null,"in_current_cache":false}]}
|
||||
result_markdown=- **success:** true
|
||||
- **count:** 11
|
||||
- **events:**
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** c5825256070767775ce7b1f8b4f1a6542f540b6b6086c2955676470d3f98bb8a
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** f5f38c190c665e74142bb90da0766c58f1936ba9caefb680e5a1149bbb4b3489
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** e6f86473f87e06af7a0bd5edf75216b888a0f087347f8bc9f8e33aa2eef9c03d
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** 2bc8d7c834a54b8e6bd4c8270e5db7097dc7b1c5bb0ff0ccb284b4ca7a3c1a0a
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 30078
|
||||
- **event_id:** 3301d4213690f9012cc8a2a4368c1e908a2f514e71273af6e5107b5751b8d734
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** agent_config
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 31124
|
||||
- **event_id:** dc69d8740056411ac88074d4566a6c9931d29f10fd50647041075421ed4884bd
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 10002
|
||||
- **event_id:** 70ff3451eba89bea85f8ff33e98615bba682d056ee561c7d7e2684e6710095aa
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 10123
|
||||
- **event_id:** 532653ce33226aa327979e02064bac1968a3997eb05aa26f719cedbc21d35bcd
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** true
|
||||
-
|
||||
- **kind:** 30078
|
||||
- **event_id:** 26cb7ba3d97986ce51f3d8e9123ea41a9f9496971297226479a41441c1668adf
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** llm_config
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 0
|
||||
- **event_id:** c478f06007268ed776ad2946c6f699e98b5219f235abb8032a5ddec4c9ba2fe2
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 3
|
||||
- **event_id:** 7f2298882dbbf544976d8bf9e4d51dac63b83ef5a3901d95f3efb7ca802d5e69
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-17 07:08:13
|
||||
phase=llm_chat_with_tools_messages
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-haiku-4.5
|
||||
context_bytes=12237
|
||||
approx_tokens=3059
|
||||
```
|
||||
|
||||
Sections: 19
|
||||
|
||||
# system_prompt | role=system
|
||||
|
||||
## Sally Agent
|
||||
|
||||
You are Sally, a sovereign AI agent living on Nostr.
|
||||
|
||||
### Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
### Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
- Use the person's name when messaging them if you know it.
|
||||
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
|
||||
|
||||
### Tool Use Policy
|
||||
- You have tools available and should use them when a request requires taking action.
|
||||
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
|
||||
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
|
||||
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
|
||||
- After a tool call, base your answer on the actual tool result.
|
||||
- Never claim a tool was run if no tool was executed.
|
||||
|
||||
### Task Management
|
||||
- Maintain and use your internal task list as short-term working memory.
|
||||
- Break long or complex actions into clear tasks before executing them.
|
||||
- Update task status as you complete steps so your plan stays accurate.
|
||||
|
||||
### Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- You may share your public key (npub) with anyone.
|
||||
- Never reveal your private key (nsec) under any circumstance.
|
||||
|
||||
# admin_identity | role=system
|
||||
|
||||
### Administrator Identity (source: config.admin.pubkey)
|
||||
|
||||
This is your administrator! Admin pubkey (hex): 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
|
||||
This message has been cryptographically verified as coming from your administrator.
|
||||
|
||||
# admin_profile | role=system
|
||||
|
||||
### Administrator Kind 0 Profile (source: nostr kind 0)
|
||||
|
||||
Administrator kind 0 profile content (JSON): {"name":"William S Burroughs","display_name":"WSB","about":"I like to write, and I like crank. They are good. Sometimes I'm not so sure.","banner":"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg","website":"https://addicted.com","picture":"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*","lud16":"","nip05":""}
|
||||
|
||||
# admin_contacts | role=system
|
||||
|
||||
### Administrator Kind 3 Contacts (source: nostr kind 3)
|
||||
|
||||
Administrator contacts (JSON): ["1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c","4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f","82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2","52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8","8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e"]
|
||||
|
||||
# admin_relays | role=system
|
||||
|
||||
### Administrator Kind 10002 Relays (source: nostr kind 10002)
|
||||
|
||||
Administrator relay list (JSON): ["wss://relay.laantungir.net/","wss://relay.damus.io/","wss://nos.lol/","wss://purplepag.es/","ws://127.0.0.1:7777/","wss://nostr.mom/","wss://relay.0xchat.com/"]
|
||||
|
||||
# admin_notes | role=system
|
||||
|
||||
### Administrator Recent Kind 1 Notes
|
||||
|
||||
Administrator recent public notes:
|
||||
- GM
|
||||
- Those lines!
|
||||
- teste
|
||||
- test
|
||||
- test
|
||||
- XXX
|
||||
- OOO
|
||||
- WoW
|
||||
- HELL
|
||||
- Tweet
|
||||
|
||||
|
||||
# agent_identity | role=system
|
||||
|
||||
### Agent Identity
|
||||
|
||||
Your pubkey (hex): 7df5380dc8b45d0f3eadce3da3f9451ee8e0cb3a41cd82b44edd12b0ccc58738
|
||||
Your npub: npub10h6nsrwgk3ws704dec768729rm5wpje6g8xc9dzwm5ftpnx9suuqdf33yr
|
||||
|
||||
# agent_profile | role=system
|
||||
|
||||
### Agent Kind 0 Profile (source: nostr kind 0)
|
||||
|
||||
Agent kind 0 profile content (JSON): {"name":"Sally","display_name":"Sally"}
|
||||
|
||||
# agent_contacts | role=system
|
||||
|
||||
### Agent Kind 3 Contacts (source: nostr kind 3)
|
||||
|
||||
Agent contacts (JSON): ["8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e"]
|
||||
|
||||
# agent_relays | role=system
|
||||
|
||||
### Agent Kind 10002 Relays (source: nostr kind 10002)
|
||||
|
||||
Agent relay list (JSON): ["wss://relay.damus.io","wss://nos.lol","wss://relay.primal.net"]
|
||||
|
||||
# agent_notes | role=system
|
||||
|
||||
### Agent Recent Kind 1 Notes
|
||||
|
||||
|
||||
|
||||
# tasks | role=system
|
||||
|
||||
### Current Task List
|
||||
|
||||
#### Current Tasks
|
||||
|
||||
Your active task list - short-term working memory for tracking plan steps.
|
||||
|
||||
- [x] 9. Publish README.md as longform post
|
||||
- [x] 10. Publish docs/CONTEXT.md as longform post
|
||||
- [x] 11. Publish docs/TOOLS.md as longform post
|
||||
- [x] 12. Publish docs/SUBSCRIPTIONS.md as longform post
|
||||
- [x] 13. DM admin after each publish
|
||||
- [x] 14. Return final summary
|
||||
|
||||
|
||||
# dm_history | role=chat
|
||||
|
||||
07:06 Agent Sally has started up and is online at 2026-03-17 07:06:53 (version v0.0.72, connected relays: 3/3).
|
||||
07:07 WSB /help
|
||||
07:07 Agent AVAILABLE TOOLS
|
||||
- admin_identity — Build admin identity context block from cached runtime metadata
|
||||
- agent_identity — Build agent identity context block with pubkey and npub
|
||||
- agent_version — Return current Didactyl version and metadata from build macros
|
||||
- config_recall — Fetch and decrypt agent config kind 30078 by d_tag
|
||||
- config_store — Encrypt and publish agent config as kind 30078 for a given d_tag
|
||||
- local_file_read — Read a local file as text from the configured working directory
|
||||
- local_file_write — Write text content to a local file in the configured working directory
|
||||
- local_http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
|
||||
- local_shell_exec — Execute a shell command and return stdout/stderr
|
||||
- memory_recall — Recall encrypted agent memory (kind 30078, d=memory)
|
||||
- memory_save — Prepend a new entry to encrypted agent memory (kind 30078, d=memory) and truncate oldest content if needed
|
||||
- model_get — Get current active LLM runtime configuration (excluding API key)
|
||||
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
|
||||
- model_set — Update active LLM configuration and persist it to config.jsonc
|
||||
- my_contacts — Alias for nostr_agent_contacts: return this agent's kind 3 contacts context
|
||||
- my_kind0_profile — Alias for nostr_agent_profile: return this agent's kind 0 profile context
|
||||
- my_notes — Alias for nostr_agent_notes: return this agent's recent kind 1 notes context
|
||||
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
|
||||
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
|
||||
- my_relays — Alias for nostr_agent_relays: return this agent's kind 10002 relay context
|
||||
- nostr_admin_contacts — Build admin contacts context block from cached kind 3 contact list
|
||||
- nostr_admin_notes — Build admin notes context block from cached kind 1 notes
|
||||
- nostr_admin_profile — Build admin profile context block from cached kind 0 metadata
|
||||
- nostr_admin_relays — Build admin relay context block from cached kind 10002 data
|
||||
- nostr_agent_contacts — Build agent contacts context block from cached kind 3 contact list
|
||||
- nostr_agent_notes — Build agent notes context block from cached kind 1 notes
|
||||
- nostr_agent_profile — Build agent profile context block from cached kind 0 metadata
|
||||
- nostr_agent_relays — Build agent relay context block from cached kind 10002 data
|
||||
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
|
||||
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
|
||||
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
|
||||
- nostr_dm_send — Send a NIP-04 encrypted DM
|
||||
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
|
||||
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
|
||||
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
|
||||
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
|
||||
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
|
||||
- nostr_my_events — Query recent events authored by this agent and return kind, event_id, timestamp, d_tag, and cache presence
|
||||
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
|
||||
- nostr_npub — Return this agent's pubkey encoded as npub bech32
|
||||
- nostr_post — Publish a Nostr event to connected relays
|
||||
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
|
||||
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
|
||||
- nostr_pubkey — Return this agent's pubkey in hex format
|
||||
- nostr_query — Query events from relays using a Nostr filter
|
||||
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
|
||||
- nostr_relay_info — Fetch NIP-11 relay information document
|
||||
- nostr_relay_status — Get connection status and statistics for all relays
|
||||
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
|
||||
- skill_create — Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it
|
||||
- skill_edit — Edit an existing self skill by d tag and republish it as kind 31123/31124
|
||||
- skill_list — List available skills discovered online (agent + admin), with adoption status and optional filters
|
||||
- skill_remove — Remove a skill address from kind 10123 adoption list
|
||||
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
|
||||
- task_list — Build current task list context block from tasks.json
|
||||
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
|
||||
- tool_list — List available tools with name, description, and JSON parameter schema
|
||||
- trigger_list — List active triggered skills and their runtime status
|
||||
|
||||
AVAILABLE SKILLS
|
||||
- conways-game — Conways game of life.
|
||||
- rick-morty — Browse Rick and Morty
|
||||
- sphere_generator — Spheres!
|
||||
- scientific-calculator — Sceintific calculator app.
|
||||
|
||||
07:07 WSB /nostr_my_events
|
||||
07:07 Agent - **success:** true
|
||||
- **count:** 9
|
||||
- **events:**
|
||||
-
|
||||
- **kind:** 31124
|
||||
- **event_id:** dc69d8740056411ac88074d4566a6c9931d29f10fd50647041075421ed4884bd
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** e6f86473f87e06af7a0bd5edf75216b888a0f087347f8bc9f8e33aa2eef9c03d
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 0
|
||||
- **event_id:** c478f06007268ed776ad2946c6f699e98b5219f235abb8032a5ddec4c9ba2fe2
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 4
|
||||
- **event_id:** 2bc8d7c834a54b8e6bd4c8270e5db7097dc7b1c5bb0ff0ccb284b4ca7a3c1a0a
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 3
|
||||
- **event_id:** 7f2298882dbbf544976d8bf9e4d51dac63b83ef5a3901d95f3efb7ca802d5e69
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 30078
|
||||
- **event_id:** 3301d4213690f9012cc8a2a4368c1e908a2f514e71273af6e5107b5751b8d734
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** agent_config
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 10002
|
||||
- **event_id:** 70ff3451eba89bea85f8ff33e98615bba682d056ee561c7d7e2684e6710095aa
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 30078
|
||||
- **event_id:** 26cb7ba3d97986ce51f3d8e9123ea41a9f9496971297226479a41441c1668adf
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** llm_config
|
||||
- **in_current_cache:** false
|
||||
-
|
||||
- **kind:** 10123
|
||||
- **event_id:** 532653ce33226aa327979e02064bac1968a3997eb05aa26f719cedbc21d35bcd
|
||||
- **timestamp:** 1.77375e+09
|
||||
- **d_tag:** null
|
||||
- **in_current_cache:** true
|
||||
|
||||
07:08 WSB Hello Sally.
|
||||
|
||||
# conversation | role=user
|
||||
|
||||
Hello Sally.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
10
context_template.md
Normal file
10
context_template.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Context Template (Legacy)
|
||||
|
||||
This file is deprecated and retained for backward compatibility only.
|
||||
|
||||
The default context behavior is now defined by adopted skills (kind `10123`) and the `didactyl_default` skill template published from `genesis.jsonc`.
|
||||
|
||||
For current behavior, see:
|
||||
- `docs/GENESIS.md`
|
||||
- `docs/CONTEXT.md`
|
||||
- `docs/SKILLS.md`
|
||||
56
context_template.recovered.md
Normal file
56
context_template.recovered.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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}}
|
||||
51
deploy_lt.sh
Executable file
51
deploy_lt.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_BINARY="$SCRIPT_DIR/didactyl_static_x86_64"
|
||||
REMOTE_HOST="ubuntu@laantungir.net"
|
||||
REMOTE_TARGETS=(
|
||||
"/home/simon/didactyl"
|
||||
"/home/didactyl/didactyl"
|
||||
)
|
||||
REMOTE_SERVICES=(
|
||||
"simon.service"
|
||||
"didactyl.service"
|
||||
)
|
||||
|
||||
if ! command -v rsync >/dev/null 2>&1; then
|
||||
echo "ERROR: rsync is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
echo "ERROR: ssh is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building static binary first..."
|
||||
"$SCRIPT_DIR/build_static.sh"
|
||||
|
||||
if [ ! -f "$SOURCE_BINARY" ]; then
|
||||
echo "ERROR: Build completed but source binary not found: $SOURCE_BINARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_STAGE="/tmp/didactyl_static_x86_64"
|
||||
|
||||
echo "Uploading $SOURCE_BINARY to staging path $REMOTE_HOST:$REMOTE_STAGE"
|
||||
rsync -avz --progress "$SOURCE_BINARY" "$REMOTE_HOST:$REMOTE_STAGE"
|
||||
|
||||
for target in "${REMOTE_TARGETS[@]}"; do
|
||||
echo "Installing staged binary to $target via sudo"
|
||||
ssh "$REMOTE_HOST" "sudo install -m 0755 '$REMOTE_STAGE' '$target'"
|
||||
done
|
||||
|
||||
echo "Restarting services on $REMOTE_HOST: ${REMOTE_SERVICES[*]}"
|
||||
ssh "$REMOTE_HOST" "sudo systemctl restart ${REMOTE_SERVICES[*]}"
|
||||
|
||||
echo "Cleaning up remote staging file $REMOTE_STAGE"
|
||||
ssh "$REMOTE_HOST" "rm -f '$REMOTE_STAGE'"
|
||||
|
||||
echo "Deployment complete for all targets and services."
|
||||
1272
didactyl.html
Normal file
1272
didactyl.html
Normal file
File diff suppressed because it is too large
Load Diff
15004
didactyl.log
Normal file
15004
didactyl.log
Normal file
File diff suppressed because one or more lines are too long
124
didactyl.nsec.debug.log
Normal file
124
didactyl.nsec.debug.log
Normal file
@@ -0,0 +1,124 @@
|
||||
[2026-03-16 05:30:19] [INFO ] Didactyl v0.0.71 starting
|
||||
[2026-03-16 05:30:19] [INFO ] [didactyl] initializing relay pool with 2 relays
|
||||
[2026-03-16 05:30:19] [INFO ] [didactyl] added relay: wss://relay.damus.io
|
||||
[2026-03-16 05:30:19] [INFO ] [didactyl] added relay: wss://relay.primal.net
|
||||
[2026-03-16 05:30:19] [INFO ] [didactyl] startup checklist [01] Relay connectivity: begin
|
||||
[startup 01] Relay connectivity ...
|
||||
[2026-03-16 05:30:20] [WARN ] [didactyl] poll latency spike: nostr_relay_pool_poll(timeout=100) took 1190.6ms rc=0 count=1
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] relay state changed: wss://relay.damus.io disconnected -> connected
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] relay state changed: wss://relay.primal.net disconnected -> connected
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [01] Relay connectivity: ok (connected relays: 2/2)
|
||||
[startup 01] Relay connectivity: OK (connected relays: 2/2)
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [02] Recover runtime config from Nostr: begin
|
||||
[startup 02] Recover runtime config from Nostr ...
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [02] Recover runtime config from Nostr: ok
|
||||
[startup 02] Recover runtime config from Nostr: OK
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [03] Validate LLM config: begin
|
||||
[startup 03] Validate LLM config ...
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [03] Validate LLM config: ok
|
||||
[startup 03] Validate LLM config: OK
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [04] Validate admin config: begin
|
||||
[startup 04] Validate admin config ...
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [04] Validate admin config: ok
|
||||
[startup 04] Validate admin config: OK
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [05] Initialize LLM client: begin
|
||||
[startup 05] Initialize LLM client ...
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [05] Initialize LLM client: ok
|
||||
[startup 05] Initialize LLM client: OK
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [06] Detect first run via kind 10002: begin
|
||||
[startup 06] Detect first run via kind 10002 ...
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup phase: first-run detection via kind 10002 => subsequent-run
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [06] Detect first run via kind 10002: ok (subsequent-run)
|
||||
[startup 06] Detect first run via kind 10002: OK (subsequent-run)
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [07] Reconcile/persist startup state: begin
|
||||
[startup 07] Reconcile/persist startup state ...
|
||||
[2026-03-16 05:30:20] [INFO ] [didactyl] startup phase: skipping genesis startup-event publish on subsequent run
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [07] Reconcile/persist startup state: ok
|
||||
[startup 07] Reconcile/persist startup state: OK
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [08] Initialize agent: begin
|
||||
[startup 08] Initialize agent ...
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [08] Initialize agent: ok
|
||||
[startup 08] Initialize agent: OK
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [09] Initialize trigger manager: begin
|
||||
[startup 09] Initialize trigger manager ...
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] trigger manager initialized (capacity=16)
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [09] Initialize trigger manager: ok
|
||||
[startup 09] Initialize trigger manager: OK
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: deferred trigger load will run after self-skill EOSE
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [10] Load startup triggers: begin
|
||||
[startup 10] Load startup triggers ...
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: startup-config trigger load begin
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] trigger manager loaded 0 trigger(s) from startup config (considered=1)
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: startup-config trigger load end
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [10] Load startup triggers: ok
|
||||
[startup 10] Load startup triggers: OK
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [11] Discover self relay list via kind 10002: begin
|
||||
[startup 11] Discover self relay list via kind 10002 ...
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query begin: timeout_ms=5000 author=52a3e82f7b374385... relay_count=2
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query relay snapshot: {"relay_count":2,"connected_count":2,"relays":[{"url":"wss://relay.damus.io","status":"connected","events_received":3,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0},{"url":"wss://relay.primal.net","status":"connected","events_received":2,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0}]}
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query received event_count=1
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query extracted relays=["wss://relay.damus.io","wss://nos.lol","wss://relay.primal.net","ws://127.0.0.1:7777","wss://relay.laantungir.net","wss://sendit.nosflare.com"]
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 wait success: relay_count=6
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[0]=wss://relay.damus.io
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[1]=wss://nos.lol
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[2]=wss://relay.primal.net
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[3]=ws://127.0.0.1:7777
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[4]=wss://relay.laantungir.net
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[5]=wss://sendit.nosflare.com
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 wait success relay snapshot: {"relay_count":2,"connected_count":2,"relays":[{"url":"wss://relay.damus.io","status":"connected","events_received":4,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0},{"url":"wss://relay.primal.net","status":"connected","events_received":2,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0}]}
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [11] Discover self relay list via kind 10002: ok (kind10002=6 added=4 connected=2/6)
|
||||
[startup 11] Discover self relay list via kind 10002: OK (kind10002=6 added=4 connected=2/6)
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [12] Subscribe admin context: begin
|
||||
[startup 12] Subscribe admin context ...
|
||||
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: subscribe admin context begin
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] admin context subscriptions active for admin 8ff74724ed641b3c...
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe admin context end
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [12] Subscribe admin context: ok
|
||||
[startup 12] Subscribe admin context: OK
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [13] Subscribe agent self context: begin
|
||||
[startup 13] Subscribe agent self context ...
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe agent self context begin
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] agent self-context subscriptions active for pubkey 52a3e82f7b374385...
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe agent self context end
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [13] Subscribe agent self context: ok
|
||||
[startup 13] Subscribe agent self context: OK
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [14] Subscribe self-skill cache: begin
|
||||
[startup 14] Subscribe self-skill cache ...
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe self skill cache begin
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] self/admin skill subscriptions active (self=52a3e82f7b374385..., admin=8ff74724ed641b3c...)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe self skill cache end
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [14] Subscribe self-skill cache: ok
|
||||
[startup 14] Subscribe self-skill cache: OK
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] publish DM target relays (6):
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.damus.io
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.primal.net
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://nos.lol
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] -> ws://127.0.0.1:7777
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.laantungir.net
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://sendit.nosflare.com
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.damus.io (async)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.primal.net (async)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://nos.lol (async)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to ws://127.0.0.1:7777 (async)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.laantungir.net (async)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://sendit.nosflare.com (async)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] sent DM 54452dbf320bee66... to 8ff74724ed641b3c... via 6 connected relay(s)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [15] Subscribe DMs: begin
|
||||
[startup 15] Subscribe DMs ...
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe DMs begin
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] DM subscription active for pubkey 52a3e82f7b374385...
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe DMs end
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [15] Subscribe DMs: ok
|
||||
[startup 15] Subscribe DMs: OK
|
||||
2aa88330 3 mongoose.c:4824:mg_mgr_init MG_IO_SIZE: 16384, TLS: builtin
|
||||
2aa88331 3 mongoose.c:4741:mg_listen 1 11 https://127.0.0.1:8484
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] http api listening on https://127.0.0.1:8484
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] HTTP API listening at http://127.0.0.1:8484
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] HTTP API endpoints: http://127.0.0.1:8484/api/context/current http://127.0.0.1:8484/api/context/parts
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [16] READY: ok (agent online; entering main poll loop)
|
||||
[startup 16] READY: OK (agent online; entering main poll loop)
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] entering main poll loop
|
||||
[2026-03-16 05:30:26] [INFO ] [didactyl] running with pubkey 52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8
|
||||
[2026-03-16 05:30:27] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
|
||||
[2026-03-16 05:30:28] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
|
||||
[2026-03-16 05:30:28] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
|
||||
BIN
didactyl_static_arm64
Executable file
BIN
didactyl_static_arm64
Executable file
Binary file not shown.
530
docs/API.md
Normal file
530
docs/API.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# Didactyl Admin HTTP API
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl exposes a localhost-only HTTP API for external tools and dashboards to inspect agent state, explore LLM context, craft prompts, and compare prompt variants. The API runs inside the same process as the agent — no separate server, no authentication required.
|
||||
|
||||
All responses are JSON. CORS headers are included on every response for browser access from any local origin.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable the API in `config.jsonc`:
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `enabled` | bool | `false` | Must be explicitly set to `true` to start the HTTP server |
|
||||
| `port` | int | `8484` | TCP port to listen on |
|
||||
| `bind_address` | string | `"127.0.0.1"` | Bind address — use `127.0.0.1` for localhost-only access |
|
||||
|
||||
The API is disabled by default. When disabled, no listener is created and no resources are consumed.
|
||||
|
||||
---
|
||||
|
||||
## CORS
|
||||
|
||||
Every response includes:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type
|
||||
```
|
||||
|
||||
`OPTIONS` requests return `204 No Content` with these headers for browser preflight support.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/status
|
||||
|
||||
Returns agent runtime status.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"name": "Didactyl",
|
||||
"version": "v0.0.26",
|
||||
"pubkey": "52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8",
|
||||
"relay_count": 4,
|
||||
"connected_relays": 4,
|
||||
"active_triggers": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `name` | Agent display name constant |
|
||||
| `version` | Build version string |
|
||||
| `pubkey` | Agent public key in hex |
|
||||
| `relay_count` | Number of configured relays |
|
||||
| `connected_relays` | Number of currently connected relays |
|
||||
| `active_triggers` | Number of active triggered-skill subscriptions |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/context/current
|
||||
|
||||
Returns the full LLM context message array that would be sent to the model right now. This is the same context the agent builds for an admin DM conversation.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_chars": 13131,
|
||||
"total_estimated_tokens": 3283,
|
||||
"messages": [
|
||||
{"role": "system", "content": "# Didactyl Agent\n\nYou are Didactyl..."},
|
||||
{"role": "system", "content": "This is your administrator! Admin pubkey..."},
|
||||
{"role": "system", "content": "Administrator kind 0 profile content..."},
|
||||
{"role": "system", "content": "Administrator kind 10002 relay-list content..."},
|
||||
{"role": "system", "content": "Startup events memory..."},
|
||||
{"role": "system", "content": "Adopted skills memory..."},
|
||||
{"role": "system", "content": "Administrator recent public notes..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `total_chars` | Total character count across all messages |
|
||||
| `total_estimated_tokens` | Rough token estimate using `chars / 4` heuristic |
|
||||
| `messages` | OpenAI-format messages array with role and content |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/context/parts
|
||||
|
||||
Returns the context broken into labeled, individually-sized parts. Useful for understanding what consumes context budget.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_chars": 13131,
|
||||
"total_estimated_tokens": 3283,
|
||||
"parts": [
|
||||
{
|
||||
"name": "system_prompt",
|
||||
"role": "system",
|
||||
"chars": 1200,
|
||||
"estimated_tokens": 300,
|
||||
"content": "# Didactyl Agent..."
|
||||
},
|
||||
{
|
||||
"name": "admin_identity",
|
||||
"role": "system",
|
||||
"chars": 120,
|
||||
"estimated_tokens": 30,
|
||||
"content": "This is your administrator!..."
|
||||
},
|
||||
{
|
||||
"name": "admin_kind0",
|
||||
"role": "system",
|
||||
"chars": 450,
|
||||
"estimated_tokens": 113,
|
||||
"content": "Administrator kind 0 profile content..."
|
||||
},
|
||||
{
|
||||
"name": "admin_relay_list",
|
||||
"role": "system",
|
||||
"chars": 50,
|
||||
"estimated_tokens": 13,
|
||||
"content": "Administrator kind 10002 relay-list content..."
|
||||
},
|
||||
{
|
||||
"name": "startup_events",
|
||||
"role": "system",
|
||||
"chars": 4800,
|
||||
"estimated_tokens": 1200,
|
||||
"content": "Startup events memory..."
|
||||
},
|
||||
{
|
||||
"name": "adopted_skills",
|
||||
"role": "system",
|
||||
"chars": 2100,
|
||||
"estimated_tokens": 525,
|
||||
"content": "Adopted skills memory..."
|
||||
},
|
||||
{
|
||||
"name": "admin_notes",
|
||||
"role": "system",
|
||||
"chars": 680,
|
||||
"estimated_tokens": 170,
|
||||
"content": "Administrator recent public notes..."
|
||||
}
|
||||
],
|
||||
"messages": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Part names:**
|
||||
|
||||
| Name | Description |
|
||||
|---|---|
|
||||
| `system_prompt` | The agent base/default skill 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/trigger/:d_tag
|
||||
|
||||
Fires a webhook-triggered skill by `d_tag`.
|
||||
|
||||
**Path params:**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `d_tag` | yes | Skill `d` tag of a skill configured with `trigger=webhook` |
|
||||
|
||||
**Request body:**
|
||||
|
||||
Optional JSON payload; if present it is passed into the synthetic trigger event as `payload`.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "external-cron",
|
||||
"note": "run maintenance sweep"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"d_tag": "maintenance-sweep",
|
||||
"fired": true
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Returns `404` if no skill exists for `d_tag` or no active trigger is registered.
|
||||
- Returns `400` if the matched skill is not a `webhook` trigger or is disabled.
|
||||
- Trigger execution uses the same trigger cooldown policy as other trigger types.
|
||||
|
||||
---
|
||||
|
||||
### 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/skills` | List published skills |
|
||||
| GET | `/api/events/skills/:d_tag` | Fetch skill by d_tag |
|
||||
| PUT | `/api/events/skills/:d_tag` | 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 |
|
||||
161
docs/CONTEXT.md
Normal file
161
docs/CONTEXT.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Didactyl — LLM Context
|
||||
|
||||
See also: [SKILLS.md](SKILLS.md) · [TOOLS.md](TOOLS.md)
|
||||
|
||||
## What Is Context?
|
||||
|
||||
Every time Didactyl talks to an LLM, it sends a **context** — the complete package of information the model needs to reason and respond.
|
||||
|
||||
Context is not just a prompt string; it is the full request payload:
|
||||
|
||||
1. **Messages** — system/user/assistant/tool history
|
||||
2. **Tool schemas** — JSON descriptions of callable tools
|
||||
3. **Model parameters** — model, temperature, max tokens, seed, etc.
|
||||
|
||||
---
|
||||
|
||||
## OpenAI-Compatible Chat Format
|
||||
|
||||
Didactyl uses OpenAI-compatible chat completions.
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-opus-4.6",
|
||||
"messages": [
|
||||
{"role": "system", "content": "..."},
|
||||
{"role": "user", "content": "..."}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "nostr_post",
|
||||
"description": "Publish a Nostr event",
|
||||
"parameters": {"type":"object"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 512
|
||||
}
|
||||
```
|
||||
|
||||
### Message Roles
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| `system` | Instructions and injected context |
|
||||
| `user` | Input message or trigger payload |
|
||||
| `assistant` | Model responses / tool call envelopes |
|
||||
| `tool` | Tool execution results fed back to model |
|
||||
|
||||
---
|
||||
|
||||
## Context Assembly Model
|
||||
|
||||
Didactyl uses **skill composition by adoption order**.
|
||||
|
||||
There are no context modes.
|
||||
|
||||
### Assembly Steps
|
||||
|
||||
1. Load adopted skills from kind `10123`.
|
||||
2. Resolve adopted skills in list order.
|
||||
3. Expand each skill template variables via tools.
|
||||
4. Append resolved skill output to messages in that same order.
|
||||
5. Append live input (DM text or triggering event payload).
|
||||
6. Attach tool schemas.
|
||||
7. Apply execution parameters from trigger tags (if invoked via trigger).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INPUT[Input: DM or trigger event] --> ADOPT[Load adopted skills from kind 10123]
|
||||
ADOPT --> ORDER[Resolve skills in listed order]
|
||||
ORDER --> EXPAND[Expand template variables via tools]
|
||||
EXPAND --> MESSAGES[Append resolved skill messages]
|
||||
MESSAGES --> LIVE[Append live input message/event]
|
||||
LIVE --> TOOLS[Attach tool schemas]
|
||||
TOOLS --> PARAMS[Apply runtime params from trigger tags]
|
||||
PARAMS --> LLM[Send to LLM]
|
||||
```
|
||||
|
||||
### Why Order Matters
|
||||
|
||||
- Earlier adopted skills usually establish broad behavior.
|
||||
- Later adopted skills can refine or narrow behavior.
|
||||
- If instructions conflict, prompt-order effects apply.
|
||||
|
||||
---
|
||||
|
||||
## Context Parts
|
||||
|
||||
| Part | Source | Description |
|
||||
|------|--------|-------------|
|
||||
| Skill templates | Adopted skill events | Core instructions assembled in order |
|
||||
| Resolved variables | Tool outputs | Runtime data inserted into templates |
|
||||
| Conversation history | DM history/events | Recent dialogue context |
|
||||
| Live input | DM or trigger event | Current request payload |
|
||||
| Tool schemas | Tool registry | Capability declaration for tool calling |
|
||||
| Runtime params | Trigger tags | LLM/tool limits for this execution |
|
||||
|
||||
---
|
||||
|
||||
## Template Variables Are Tool Calls
|
||||
|
||||
Template variables resolve through tool execution.
|
||||
|
||||
Example:
|
||||
|
||||
- `{{admin_profile}}` resolves by running `nostr_admin_profile`
|
||||
- `{{admin_notes}}` resolves by running `nostr_admin_notes`
|
||||
|
||||
Unknown variables should resolve to empty values for portability.
|
||||
|
||||
---
|
||||
|
||||
## Trigger Runtime Parameters
|
||||
|
||||
Execution controls are attached to trigger tags, not skill content:
|
||||
|
||||
- `llm`
|
||||
- `max_tokens`
|
||||
- `temperature`
|
||||
- `seed`
|
||||
- `tools`
|
||||
|
||||
Resolution order for a triggered run:
|
||||
|
||||
1. Start with agent defaults
|
||||
2. Apply trigger tag overrides
|
||||
3. Execute
|
||||
4. Restore defaults
|
||||
|
||||
---
|
||||
|
||||
## Triggered vs Adopted Use
|
||||
|
||||
- **Adopted skill (`10123`)**: contributes context/instructions
|
||||
- **Triggered skill**: contributes context and may supply execution overrides via tags
|
||||
|
||||
This separation keeps composition simple while allowing per-trigger runtime control.
|
||||
|
||||
---
|
||||
|
||||
## Token Budget
|
||||
|
||||
Context cost is controlled by:
|
||||
|
||||
- Adoption-list ordering and skill count
|
||||
- Conversation-history limits
|
||||
- Skill/template truncation limits
|
||||
- Per-trigger model/runtime parameter choices
|
||||
|
||||
Use runtime context inspection endpoints to see the exact payload before LLM calls.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Skills spec: [SKILLS.md](SKILLS.md)
|
||||
- Tool catalog: [TOOLS.md](TOOLS.md)
|
||||
- API details: [API.md](API.md)
|
||||
136
docs/GENESIS.md
Normal file
136
docs/GENESIS.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Didactyl — Genesis Bootstrapping
|
||||
|
||||
See also: [CONTEXT.md](CONTEXT.md) · [SKILLS.md](SKILLS.md) · [README.md](../README.md)
|
||||
|
||||
## Purpose
|
||||
|
||||
`genesis.jsonc` is the first-run bootstrap document for a Didactyl agent.
|
||||
|
||||
It defines initial identity, admin policy, startup events, default skill context, and baseline runtime settings so the agent can publish itself onto Nostr.
|
||||
|
||||
After bootstrap, the long-term direction is **nsec-only startup** with state recovered from Nostr events.
|
||||
|
||||
---
|
||||
|
||||
## File Format
|
||||
|
||||
`genesis.jsonc` is JSONC (JSON + comments).
|
||||
|
||||
Minimum practical sections:
|
||||
|
||||
- `key.nsec` (or runtime `--nsec` / `DIDACTYL_NSEC`)
|
||||
- `admin.pubkey`
|
||||
- `llm`
|
||||
- `startup_events` (must include kind `10002` relay tags)
|
||||
|
||||
Typical optional sections:
|
||||
|
||||
- `dm_protocol`
|
||||
- `tools`
|
||||
- `security`
|
||||
- `admin_context`
|
||||
- `api`
|
||||
- `default_skill` (reserved for default-skill publishing workflows)
|
||||
|
||||
---
|
||||
|
||||
## First-Run Flow
|
||||
|
||||
On first run, the agent:
|
||||
|
||||
1. Loads `genesis.jsonc`.
|
||||
2. Derives keys (from `key.nsec` or runtime nsec override).
|
||||
3. Connects to relay set from startup kind `10002` tags.
|
||||
4. Publishes/reconciles startup events.
|
||||
5. Initializes runtime services (DM subscriptions, triggers, API if enabled).
|
||||
|
||||
First-run detection is based on querying own kind `10002` relay-list availability.
|
||||
|
||||
---
|
||||
|
||||
## Subsequent-Run Flow
|
||||
|
||||
On subsequent runs, the agent can start with nsec supplied via:
|
||||
|
||||
- CLI: `--nsec <nsec_or_hex>`
|
||||
- Environment: `DIDACTYL_NSEC`
|
||||
|
||||
Optional runtime API overrides:
|
||||
|
||||
- `--api-port <port>`
|
||||
- `--api-bind <address>`
|
||||
|
||||
Subsequent-run bootstrap-event republishing is skipped when prior kind `10002` state is found.
|
||||
|
||||
---
|
||||
|
||||
## Interactive Setup (Zero-Argument Startup)
|
||||
|
||||
When Didactyl is run with **no arguments**:
|
||||
|
||||
```bash
|
||||
./didactyl
|
||||
```
|
||||
|
||||
it enters an interactive setup wizard instead of immediately trying to load `./genesis.jsonc`.
|
||||
|
||||
Wizard entry choices:
|
||||
|
||||
- **New agent** — generate/provide identity, configure admin + LLM + relays
|
||||
- **Existing agent** — provide nsec and recover relay/admin/LLM config from Nostr
|
||||
- **Load genesis** — load a specified genesis file path
|
||||
|
||||
Menu UX conventions:
|
||||
|
||||
- Single-letter hotkeys (case-insensitive)
|
||||
- First-letter menu mnemonics (e.g., `N` for New, `E` for Existing)
|
||||
- `q` / `x` exits or backs out of menus
|
||||
|
||||
Security behavior:
|
||||
|
||||
- nsec entry is masked in terminal
|
||||
- writing genesis with nsec is explicit and warned
|
||||
- recommended export mode is genesis without nsec plus runtime `--nsec`/`DIDACTYL_NSEC`
|
||||
|
||||
---
|
||||
|
||||
## Encrypted Config Events
|
||||
|
||||
Didactyl exposes config persistence tools for encrypted self-config on Nostr:
|
||||
|
||||
- `config_store` — publish encrypted kind `30078` config by `d_tag`
|
||||
- `config_recall` — query+decrypt kind `30078` config by `d_tag`
|
||||
|
||||
Recommended tags:
|
||||
|
||||
- `d=llm_config`
|
||||
- `d=agent_config`
|
||||
|
||||
These are encrypted to self with NIP-44.
|
||||
|
||||
---
|
||||
|
||||
## Relay Bootstrap Strategy
|
||||
|
||||
`startup_events` must include kind `10002` relay tags (`["r", "wss://..."]`).
|
||||
|
||||
That relay list is used as the initial network attachment for querying existing state and publishing startup events.
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes (Legacy -> Genesis)
|
||||
|
||||
Legacy deployments using `config.jsonc` can migrate by:
|
||||
|
||||
1. Copying equivalent sections to `genesis.jsonc`.
|
||||
2. Ensuring startup kind `10002` relay tags are present.
|
||||
3. Providing nsec at runtime (`--nsec` or `DIDACTYL_NSEC`) where desired.
|
||||
4. Treating `context_template.md` / `config.jsonc.example` as legacy references.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Keep nsec secret.
|
||||
- Prefer environment or secure credential injection for production nsec handling.
|
||||
- Avoid publishing plaintext sensitive config; use encrypted `config_store` for long-term state.
|
||||
317
docs/SKILLS.md
Normal file
317
docs/SKILLS.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Didactyl — Skills
|
||||
|
||||
See also: [CONTEXT.md](CONTEXT.md) · [TOOLS.md](TOOLS.md)
|
||||
|
||||
## Overview
|
||||
|
||||
A skill is a **set of instructions for the LLM** stored as a Nostr event.
|
||||
|
||||
Skills teach the agent how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action.
|
||||
|
||||
Think of it like a woodshop: a **skill** is knowing how to carve — technique, judgment, decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop.
|
||||
|
||||
Skills are portable, shareable, and discoverable as Nostr events.
|
||||
|
||||
---
|
||||
|
||||
## Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
---
|
||||
|
||||
## Skill Content
|
||||
|
||||
Skill `content` is JSON and should focus on **instructions**, not transport/runtime controls.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": {
|
||||
"description": "Check spelling and grammar",
|
||||
"template": "system:\nYou are a spelling and grammar checker.\n\nRules:\n- Fix spelling errors\n- Fix grammar errors\n- Preserve original formatting\n- Return ONLY the corrected text, no explanations\n\nuser:\n{{message}}"
|
||||
},
|
||||
"tags": [
|
||||
["d", "spellcheck"],
|
||||
["scope", "public"],
|
||||
["description", "Spelling and grammar checker"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `description` | string | — | Human-readable description |
|
||||
| `template` | string | — | Skill instructions/template text (recommended) |
|
||||
| `base` | bool | `false` | Optional hint that this skill is intended as base/default behavior |
|
||||
|
||||
> Execution parameters (`llm`, `max_tokens`, `temperature`, `seed`, `tools`) are defined on **trigger tags**, not in content.
|
||||
|
||||
---
|
||||
|
||||
## Composition Model (No Context Modes)
|
||||
|
||||
Skills do **not** use `context_mode`.
|
||||
|
||||
Context is assembled from kind `10123` adoption list order:
|
||||
|
||||
1. Resolve adopted skills in list order.
|
||||
2. Expand each skill template/tool variables.
|
||||
3. Append each resolved skill as context messages in that same order.
|
||||
4. Append live user/trigger input.
|
||||
|
||||
The adoption list itself is the context definition.
|
||||
|
||||
- One adopted skill = single-skill behavior.
|
||||
- Multiple adopted skills = layered behavior in explicit order.
|
||||
- Reordering `10123` changes precedence naturally.
|
||||
|
||||
### Ordering Convention
|
||||
|
||||
- Earlier adopted skills generally set broader tone/policy.
|
||||
- Later adopted skills can narrow/specialize behavior.
|
||||
- If multiple skills strongly conflict, normal prompt-order effects apply.
|
||||
|
||||
### Template Variables Are Tool Calls
|
||||
|
||||
Template variables are tool calls.
|
||||
|
||||
When the engine encounters `{{admin_profile}}`, it runs the corresponding tool and inserts the result into context.
|
||||
|
||||
| Variable | Tool Called | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `{{agent_identity}}` | `agent_identity` | Agent identity block |
|
||||
| `{{admin_profile}}` | `nostr_admin_profile` | Admin kind 0 profile |
|
||||
| `{{admin_notes}}` | `nostr_admin_notes` | Admin recent notes |
|
||||
| `{{admin_relays}}` | `nostr_admin_relays` | Admin relay list |
|
||||
| `{{adopted_skills}}` | `adopted_skills` | Other adopted skill instructions |
|
||||
| `{{dm_history}}` | *(expand directive)* | Recent DM conversation |
|
||||
| `{{message}}` | *(built-in)* | Current user message |
|
||||
| `{{triggering_event}}` | `trigger_event` | Triggering event JSON |
|
||||
|
||||
Unknown variables should resolve to empty values for portability.
|
||||
|
||||
---
|
||||
|
||||
## Triggered Skills
|
||||
|
||||
A triggered skill has a trigger source attached.
|
||||
|
||||
Didactyl trigger types:
|
||||
|
||||
- `nostr-subscription`
|
||||
- `webhook`
|
||||
- `cron`
|
||||
- `chain`
|
||||
- `dm`
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, `chain`, `dm` |
|
||||
| `filter` | Yes | Type-specific filter |
|
||||
| `enabled` | No | Whether active (default: `true`) |
|
||||
| `llm` | No | Model spec fallback chain (e.g., `openai/gpt-4o-mini, cheap`) |
|
||||
| `max_tokens` | No | Max output tokens for this trigger execution |
|
||||
| `temperature` | No | Sampling temperature for this trigger execution |
|
||||
| `seed` | No | Optional deterministic seed where supported |
|
||||
| `tools` | No | `true` for all tools, `false` for none, or CSV list of allowed tool names |
|
||||
|
||||
### Execution Parameter Resolution
|
||||
|
||||
When a trigger fires:
|
||||
|
||||
1. Start with agent defaults.
|
||||
2. Apply execution tags from that trigger (`llm`, `max_tokens`, `temperature`, `seed`, `tools`).
|
||||
3. Execute skill with those effective runtime settings.
|
||||
4. Restore defaults after the run.
|
||||
|
||||
### Adopted vs Triggered Behavior
|
||||
|
||||
- **Adopted skill (`10123`)**: contributes instructions/template to context.
|
||||
- **Triggered skill**: contributes instructions **and** may define execution parameters via trigger tags.
|
||||
|
||||
---
|
||||
|
||||
## Trigger Types
|
||||
|
||||
### `nostr-subscription`
|
||||
|
||||
`filter` is a JSON-encoded Nostr subscription filter.
|
||||
|
||||
```json
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
|
||||
["llm", "openai/gpt-4o-mini, cheap"],
|
||||
["temperature", "0"],
|
||||
["tools", "nostr_query,nostr_dm"],
|
||||
["enabled", "true"]
|
||||
```
|
||||
|
||||
### `webhook`
|
||||
|
||||
`filter` is required and can be `{}`; webhook firing happens via HTTP.
|
||||
|
||||
```json
|
||||
["trigger", "webhook"],
|
||||
["filter", "{}"],
|
||||
["llm", "default"],
|
||||
["tools", "true"],
|
||||
["enabled", "true"]
|
||||
```
|
||||
|
||||
### `cron`
|
||||
|
||||
`filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`.
|
||||
|
||||
```json
|
||||
["trigger", "cron"],
|
||||
["filter", "*/5 * * * *"],
|
||||
["llm", "openai/gpt-4o-mini"],
|
||||
["max_tokens", "300"],
|
||||
["enabled", "true"]
|
||||
```
|
||||
|
||||
### `chain`
|
||||
|
||||
`filter` is the source skill `d` tag to chain from.
|
||||
|
||||
```json
|
||||
["trigger", "chain"],
|
||||
["filter", "source-skill-d-tag"],
|
||||
["llm", "default"],
|
||||
["enabled", "true"]
|
||||
```
|
||||
|
||||
### `dm`
|
||||
|
||||
`filter` is JSON with sender scope:
|
||||
|
||||
- `{"from":"admin"}`
|
||||
- `{"from":"wot"}`
|
||||
- `{"from":"any"}`
|
||||
|
||||
```json
|
||||
["trigger", "dm"],
|
||||
["filter", "{\"from\":\"admin\"}"],
|
||||
["llm", "default"],
|
||||
["tools", "true"],
|
||||
["enabled", "true"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Private Skill Encoding (`31124`)
|
||||
|
||||
Private skills use NIP-44 encryption on event `content`.
|
||||
|
||||
Rules for kind `31124`:
|
||||
|
||||
- Keep `d` tag exposed so the event stays addressable/replaceable.
|
||||
- Move non-`d` metadata into plaintext payload before encryption.
|
||||
- Encrypt full payload with NIP-44 and store ciphertext in event `content`.
|
||||
- On receive: resolve by `d`, decrypt `content`, then read content + private tags.
|
||||
|
||||
### Private Skill Event (on relay)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "<nip44-ciphertext>",
|
||||
"tags": [
|
||||
["d", "mention-monitor"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Decrypted Private Payload (application-level JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"content": {
|
||||
"description": "Monitor mentions and DM summaries",
|
||||
"template": "When {{triggering_event}} includes Bitcoin or Lightning, summarize and DM admin."
|
||||
},
|
||||
"private_tags": [
|
||||
["scope", "private"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
|
||||
["llm", "openai/gpt-4o-mini, fast"],
|
||||
["temperature", "0"],
|
||||
["seed", "42"],
|
||||
["tools", "nostr_query,nostr_dm"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Input as Message/Trigger
|
||||
participant Dispatch as Dispatcher
|
||||
participant Adopt as Adoption Resolver (10123)
|
||||
participant Ctx as Context Assembler
|
||||
participant Trig as Trigger Runtime Params
|
||||
participant LLM as LLM API
|
||||
|
||||
Input->>Dispatch: message or trigger event
|
||||
Dispatch->>Adopt: load adopted skills in list order
|
||||
Adopt-->>Ctx: ordered skill templates
|
||||
Ctx->>Ctx: resolve template variables via tools
|
||||
Dispatch->>Trig: resolve trigger execution tags
|
||||
Trig-->>LLM: model + max_tokens + temperature + seed + tool policy
|
||||
Ctx->>LLM: composed messages
|
||||
LLM-->>Input: response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs |
|
||||
|
||||
---
|
||||
|
||||
## Storage on Nostr
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions + execution params | Tags on skill events |
|
||||
|
||||
---
|
||||
|
||||
## Portability Guidelines
|
||||
|
||||
To keep skills reusable across agents/clients:
|
||||
|
||||
- Prefer generic instructions over implementation-specific assumptions.
|
||||
- Treat tool names as capabilities, not platform internals.
|
||||
- Resolve unknown variables safely (empty result, no hard failure).
|
||||
- Keep app-specific tags optional (`["app","didactyl"]`).
|
||||
|
||||
A skill should still be useful even when some variables/tools are unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Tool architecture and complete tool catalog: [TOOLS.md](TOOLS.md)
|
||||
- Context assembly model: [CONTEXT.md](CONTEXT.md)
|
||||
- Project overview/runtime behavior: [README.md](../README.md)
|
||||
194
docs/SUBSCRIPTIONS.md
Normal file
194
docs/SUBSCRIPTIONS.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Didactyl — Nostr Subscriptions
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl maintains persistent websocket subscriptions to Nostr relays for the lifetime of the process. Subscriptions are opened during startup and are **never closed** — the relay pool keeps them alive, automatically reconnecting and resubscribing when relays drop.
|
||||
|
||||
All subscriptions are created via `nostr_relay_pool_subscribe()` from `nostr_core_lib` and are sent to every relay in the configured relay list.
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
The subscriptions are opened in a specific order during `main()` startup. The diagram below shows the full sequence:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[nostr_handler_init] --> B[Connect to all relays]
|
||||
B --> C[nostr_handler_reconcile_startup_events]
|
||||
C --> D[Publish startup events to relays]
|
||||
D --> E[trigger_manager_init]
|
||||
E --> F[trigger_manager_load_from_startup_events]
|
||||
F --> G["Subscribe: Admin Context"]
|
||||
G --> H["Subscribe: Self Skills"]
|
||||
H --> I[Send startup DM to admin]
|
||||
I --> J["Subscribe: DMs"]
|
||||
J --> K[Enter main poll loop]
|
||||
K --> L["Poll: nostr_handler_poll + trigger_manager_poll + http_api_poll"]
|
||||
|
||||
style F fill:#2a7,stroke:#333,color:#fff
|
||||
style G fill:#27a,stroke:#333,color:#fff
|
||||
style H fill:#27a,stroke:#333,color:#fff
|
||||
style J fill:#27a,stroke:#333,color:#fff
|
||||
```
|
||||
|
||||
## Subscription Categories
|
||||
|
||||
### 1. Admin Context Subscription
|
||||
|
||||
**Function:** `nostr_handler_subscribe_admin_context()` in `src/nostr_handler.c`
|
||||
**When:** During startup, before self-skill subscription
|
||||
**Condition:** Only if `admin_context.enabled` is true in config
|
||||
|
||||
This creates up to two persistent subscriptions for the admin's pubkey:
|
||||
|
||||
#### Profile Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 0 (profile), 3 (contacts), 10002 (relay list) — each configurable |
|
||||
| **Authors** | Admin pubkey |
|
||||
| **Limit** | 32 |
|
||||
| **Callback** | `on_admin_context_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
Tracks the admin's profile metadata, contact list (WoT), and relay preferences. Used to build agent context about who the admin is.
|
||||
|
||||
#### Kind 1 Notes Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 1 |
|
||||
| **Authors** | Admin pubkey |
|
||||
| **Limit** | Configurable via `kind_1_limit`, default 10, max 256 |
|
||||
| **Callback** | `on_admin_context_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | Only if `admin_context.track_kind_1` is true |
|
||||
|
||||
Tracks the admin's recent public notes. Used for agent context and as the event source for triggered skills that watch admin posts.
|
||||
|
||||
### 2. Self-Skill Subscription
|
||||
|
||||
**Function:** `nostr_handler_subscribe_self_skills()` in `src/nostr_handler.c`
|
||||
**When:** During startup, after admin context subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 31123 (public skill), 31124 (private skill), 10123 (adoption list) |
|
||||
| **Authors** | Agent's own pubkey |
|
||||
| **Limit** | 300 |
|
||||
| **Callback** | `on_self_skill_event` |
|
||||
| **EOSE Callback** | `on_self_skill_eose` |
|
||||
| **Dedup** | Disabled (handles dedup internally via cache upsert) |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
This is the core skill awareness subscription. It serves three purposes:
|
||||
|
||||
1. **Cache population** — Every arriving event is stored in the in-memory self-skill cache via `self_skill_cache_upsert_event_locked()`, making skills available for LLM tool calls and context building.
|
||||
|
||||
2. **Live trigger registration** — When a kind 31123 or 31124 event arrives with `trigger=nostr-subscription` and a valid `filter` tag, `register_trigger_from_self_skill_event()` immediately calls `trigger_manager_add()` to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart.
|
||||
|
||||
3. **Deferred bulk load** — After EOSE, the `on_self_skill_eose` callback fires `trigger_manager_load_from_skills()` as a one-time bulk scan of the adoption list. This catches any skills that were already cached before the per-event path was wired up.
|
||||
|
||||
### 3. DM Subscriptions
|
||||
|
||||
**Function:** `nostr_handler_subscribe_dms()` in `src/nostr_handler.c`
|
||||
**When:** During startup, after self-skill subscription and startup DM
|
||||
**Required:** Yes — startup fails if DM subscription cannot be created
|
||||
|
||||
Creates one or two subscriptions depending on the configured DM protocol:
|
||||
|
||||
#### NIP-04 DM Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 4 |
|
||||
| **#p** | Agent's own pubkey |
|
||||
| **Since** | Process start time |
|
||||
| **Limit** | 100 |
|
||||
| **Callback** | `on_event` (routes to `agent_on_message`) |
|
||||
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | `dm_protocol` is `nip04` or `both` |
|
||||
|
||||
#### NIP-17 DM Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 1059 (gift wrap) |
|
||||
| **#p** | Agent's own pubkey |
|
||||
| **Since** | Process start time |
|
||||
| **Limit** | 400 |
|
||||
| **Callback** | `on_event` (unwraps gift wrap, routes to `agent_on_message`) |
|
||||
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | `dm_protocol` is `nip17` or `both` |
|
||||
|
||||
### 4. Trigger Subscriptions
|
||||
|
||||
**Function:** `register_trigger_subscription_locked()` in `src/trigger_manager.c`
|
||||
**When:** Dynamically, whenever a trigger is registered via `trigger_manager_add()`
|
||||
**Created by:** `nostr_handler_subscribe_with_filter()` wrapper
|
||||
|
||||
Each active trigger gets its own persistent subscription based on the skill's `filter` tag:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Filter** | Parsed from the skill's `filter` tag JSON |
|
||||
| **Since** | From filter, or defaults to `now - 30s` |
|
||||
| **Limit** | From filter, or defaults to 200 |
|
||||
| **Callback** | `on_trigger_subscription_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
When an event matches the filter, `maybe_fire_trigger_locked()` checks cooldown and dedup, then executes the trigger action (LLM or template).
|
||||
|
||||
Trigger subscriptions are created at three points:
|
||||
- **Startup config scan** — `trigger_manager_load_from_startup_events()` parses `startup_events[]` from config for skills with trigger tags
|
||||
- **Live self-skill event** — `register_trigger_from_self_skill_event()` in the self-skill subscription callback
|
||||
- **EOSE bulk load** — `trigger_manager_load_from_skills()` after self-skill EOSE
|
||||
- **Runtime tool call** — `skill_create` tool with trigger parameters
|
||||
|
||||
## Subscription Parameters
|
||||
|
||||
All subscriptions share these common pool parameters:
|
||||
|
||||
| Parameter | Value | Meaning |
|
||||
|-----------|-------|---------|
|
||||
| `close_on_eose` | 0 | Subscription stays open after initial EOSE |
|
||||
| `result_mode` | `NOSTR_POOL_EOSE_FULL_SET` | EOSE fires after all relays respond or timeout |
|
||||
| `relay_timeout_seconds` | 30 | Per-relay timeout for initial response |
|
||||
| `eose_timeout_seconds` | 120 | Overall EOSE timeout across all relays |
|
||||
|
||||
## Subscription Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
INIT["Process Start"] --> CONNECT["Connect Relays"]
|
||||
CONNECT --> SUB["Open Subscriptions"]
|
||||
SUB --> LIVE["Live Event Stream"]
|
||||
LIVE --> |"Relay disconnects"| RECON["Auto-Reconnect"]
|
||||
RECON --> |"Relay reconnects"| RESUB["Auto-Resubscribe"]
|
||||
RESUB --> LIVE
|
||||
LIVE --> |"SIGINT/SIGTERM"| SHUT["Shutdown"]
|
||||
SHUT --> CLOSE["Close All + Cleanup"]
|
||||
```
|
||||
|
||||
Subscriptions are never manually closed during normal operation. The relay pool handles reconnection and resubscription transparently. On shutdown, `trigger_manager_cleanup()` closes trigger subscriptions and `nostr_handler_cleanup()` destroys the pool.
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Subscription | Kinds | Target | Persistent | Created At |
|
||||
|-------------|-------|--------|-----------|------------|
|
||||
| Admin Profile | 0, 3, 10002 | Admin pubkey | Yes | Startup |
|
||||
| Admin Notes | 1 | Admin pubkey | Yes | Startup |
|
||||
| Self Skills | 31123, 31124, 10123 | Own pubkey | Yes | Startup |
|
||||
| DMs NIP-04 | 4 | Own pubkey (#p) | Yes | Startup |
|
||||
| DMs NIP-17 | 1059 | Own pubkey (#p) | Yes | Startup |
|
||||
| Trigger N | Per skill filter | Varies | Yes | Dynamic |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Skills](SKILLS.md) — Skill event format and trigger tags
|
||||
- [Tools](TOOLS.md) — `skill_create` tool with trigger parameters
|
||||
- [API](API.md) — `trigger_list` and `trigger_status` endpoints
|
||||
147
docs/TOOLS.md
Normal file
147
docs/TOOLS.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Didactyl — Tools
|
||||
|
||||
See also: [SKILLS.md](SKILLS.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent** that receives commands via encrypted DMs, reasons with an LLM, and takes actions through **tools**.
|
||||
|
||||
This document describes the tools architecture: what tools are, how they are exposed to the model, how execution loops work, what tool categories exist, and how access is gated.
|
||||
|
||||
---
|
||||
|
||||
## What Tools Are
|
||||
|
||||
Tools are in the agent's hands — the chisels in the woodshop. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
|
||||
|
||||
A **skill** teaches the agent *how* to carve — the technique, the judgment, the decision-making. A **tool** is the chisel — the physical capability. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop. If you want a hardcoded program that runs without reasoning, that's a tool or an external program — not a skill.
|
||||
|
||||
## How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### Nostr Event & Messaging Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post` | Publish a Nostr event to connected relays |
|
||||
| `nostr_delete` | Request deletion of one or more previously published events (NIP-09 kind 5) |
|
||||
| `nostr_react` | React to a Nostr event with like/dislike/emoji (NIP-25 kind 7) |
|
||||
| `nostr_query` | Query events from relays using a Nostr filter |
|
||||
| `nostr_dm_send` | Send a NIP-04 encrypted DM |
|
||||
| `nostr_dm_send_nip17` | Send a private DM using NIP-17 gift wrap protocol |
|
||||
|
||||
### Nostr Identity & Utility Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_profile_get` | Look up a Nostr profile (kind 0 metadata) by pubkey |
|
||||
| `nostr_nip05_lookup` | Look up or verify a NIP-05 identifier (`user@domain`) |
|
||||
| `nostr_encode` | Encode a Nostr entity into `nostr:` URI (`npub`, `note`, `nprofile`, `nevent`, `naddr`) |
|
||||
| `nostr_decode` | Decode a Nostr bech32/`nostr:` URI into components |
|
||||
| `nostr_relay_status` | Get connection status and statistics for all relays |
|
||||
| `nostr_relay_info` | Fetch NIP-11 relay information document |
|
||||
| `nostr_encrypt` | Encrypt plaintext using NIP-44 for a recipient |
|
||||
| `nostr_decrypt` | Decrypt NIP-44 ciphertext from a sender |
|
||||
| `nostr_list_manage` | Add/remove tag tuples in replaceable list events (NIP-51 style) |
|
||||
|
||||
### Skills & Trigger Tools
|
||||
|
||||
These tools manage skill and trigger lifecycle; skill semantics and trigger execution details are documented in [SKILLS.md](SKILLS.md).
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `skill_create` | Create or update a skill definition as kind `31123`/`31124` and optionally auto-adopt it |
|
||||
| `skill_list` | List this agent's published skills, optionally filtered by scope |
|
||||
| `skill_adopt` | Adopt a skill by adding its address to kind `10123` adoption list |
|
||||
| `skill_remove` | Remove a skill address from kind `10123` adoption list |
|
||||
| `skill_search` | Search public skills by query/author and optionally rank by adoption popularity |
|
||||
| `trigger_list` | List active triggered skills and their runtime status |
|
||||
|
||||
### LLM / Model Management Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `model_get` | Get current active LLM runtime configuration (excluding API key) |
|
||||
| `model_set` | Update active LLM configuration and persist it to `config.jsonc` |
|
||||
| `model_list` | List available model IDs using provider OpenAI-compatible `/models` endpoint |
|
||||
|
||||
### System & Runtime Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `agent_version` | Return current Didactyl version and metadata from build macros |
|
||||
| `local_http_fetch` | Fetch HTTP(S) resources with optional method, headers, timeout, and body |
|
||||
| `local_shell_exec` | Execute a shell command and return stdout/stderr |
|
||||
| `local_file_read` | Read a local file as text from the configured working directory |
|
||||
| `local_file_write` | Write text content to a local file in the configured working directory |
|
||||
| `tool_list` | List available tools with name, description, and JSON parameter schema |
|
||||
|
||||
### Cashu Wallet Tools (NIP-60)
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `cashu_wallet_balance` | Return wallet balances aggregated by mint and unit from loaded token proofs |
|
||||
| `cashu_wallet_info` | Fetch mint info for a specific `mint_url` (or configured default mint) |
|
||||
| `cashu_wallet_mint_quote` | Request a mint quote for an amount and unit |
|
||||
| `cashu_wallet_mint_check` | Check whether a mint quote is paid/issued |
|
||||
| `cashu_wallet_mint_claim` | Claim newly minted proofs for a paid quote and persist token event |
|
||||
| `cashu_wallet_melt_quote` | Request a melt quote for a Lightning invoice/payment request |
|
||||
| `cashu_wallet_melt_pay` | Pay a melt quote using selected proofs and persist history/token rollover |
|
||||
| `cashu_wallet_check_proofs` | Ask mint for current proof states and summarize unspent/pending/spent proofs |
|
||||
|
||||
### Content Publishing Conveniences
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post_readme` | Publish `README.md` as kind `30023` with deterministic d-tag `readme.md` |
|
||||
| `nostr_file_md_to_longform_post` | Read a markdown file and publish it as kind `30023` longform post (defaults d-tag to lowercase filename) |
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
Tool access is gated by sender tier:
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
| **ADMIN** | Configured admin pubkey | All tools | Full LLM with context |
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Skill definitions, adoption, triggers, and autonomous activation: [SKILLS.md](SKILLS.md)
|
||||
- Combined index page: [TOOLS_AND_SKILLS.md](TOOLS_AND_SKILLS.md)
|
||||
@@ -1,393 +0,0 @@
|
||||
# Didactyl — Tools & Skills
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent**. It receives commands via encrypted DMs, reasons about them with an LLM, and takes actions through **tools**. It stores learned behaviors as **skills** — Nostr events that define reusable capabilities. Skills can optionally carry **triggers** — Nostr subscription filters that activate the skill automatically when matching events arrive.
|
||||
|
||||
This document describes the complete tools and skills architecture: what they are, how they work, and how they compose into a dynamic, self-modifying agent.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
Tools are the agent's hands. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
|
||||
|
||||
### How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### Nostr Core 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 |
|
||||
|
||||
#### Identity Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_resolve_identifier` | Resolve NIP-05, npub, nprofile, or note identifiers |
|
||||
| `nostr_verify_nip05` | Verify a NIP-05 identifier |
|
||||
|
||||
#### Skill Management 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 |
|
||||
|
||||
#### System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `shell_exec` | Execute a shell command with sandboxing |
|
||||
| `http_request` | Make an HTTP request |
|
||||
| `get_time` | Get the current UTC time |
|
||||
|
||||
### Security Model
|
||||
|
||||
Tools are gated by sender tier:
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
| **ADMIN** | Configured admin pubkey | All tools | Full LLM with context |
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are the agent's learned behaviors. They are **Nostr events** — stored on relays, portable, shareable, and discoverable by other agents.
|
||||
|
||||
### Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
A skill event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "When asked to summarize a thread, query the root event and all replies, then produce a concise summary with key points and sentiment.",
|
||||
"tags": [
|
||||
["d", "summarize-thread"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Summarize a Nostr thread given a root event ID"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Skill Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CREATE[Admin asks didactyl to create a skill] --> PUBLISH[skill_create publishes kind 31123/31124]
|
||||
PUBLISH --> ADOPT[Auto-adopted into kind 10123 list]
|
||||
ADOPT --> AVAILABLE[Skill available for use]
|
||||
AVAILABLE --> DISCOVER[Other agents can discover via skill_search]
|
||||
DISCOVER --> ADOPT_OTHER[Other agents can skill_adopt]
|
||||
```
|
||||
|
||||
### How Skills Are Used Today
|
||||
|
||||
Currently, skills are **passive knowledge**. They exist on Nostr and are loaded into the LLM context when relevant. The admin might say "use your summarize-thread skill" and the LLM retrieves and follows the skill's instructions.
|
||||
|
||||
---
|
||||
|
||||
## Triggered Skills — The Activation System
|
||||
|
||||
This is where skills become **active**. A triggered skill is a skill with a Nostr subscription filter attached. When matching events arrive on the relay, didactyl wakes up and executes the skill automatically — no admin DM required.
|
||||
|
||||
### Anatomy of a Triggered Skill
|
||||
|
||||
A triggered skill extends the standard skill event with trigger-related tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "DM admin: '{author_display_name} just posted: {content_preview}'",
|
||||
"tags": [
|
||||
["d", "watch-jack"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "Notify admin when @jack posts a note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"authors\":[\"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2\"],\"kinds\":[1]}"],
|
||||
["action", "template"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type. Currently: `nostr-subscription` |
|
||||
| `filter` | Yes | JSON-encoded Nostr subscription filter |
|
||||
| `action` | No | Action type: `template` or `llm`. Default: `llm` |
|
||||
| `enabled` | No | Whether the trigger is active. Default: `true` |
|
||||
|
||||
### Action Types
|
||||
|
||||
#### Template Actions
|
||||
|
||||
The skill content is a string template with placeholders that get interpolated from the triggering event:
|
||||
|
||||
```
|
||||
DM admin: '{author_display_name} posted: {content_preview}'
|
||||
```
|
||||
|
||||
Available placeholders:
|
||||
|
||||
| Placeholder | Source |
|
||||
|---|---|
|
||||
| `{event_id}` | Triggering event ID hex |
|
||||
| `{pubkey}` | Author pubkey hex |
|
||||
| `{author_display_name}` | Resolved display name, falls back to truncated pubkey |
|
||||
| `{kind}` | Event kind number |
|
||||
| `{content}` | Full event content |
|
||||
| `{content_preview}` | First 280 characters of content |
|
||||
| `{created_at}` | Unix timestamp |
|
||||
| `{relay_url}` | Relay the event arrived from |
|
||||
|
||||
Template actions execute **without LLM involvement** — they are fast, cheap, and deterministic. The interpolated string is then acted upon based on a simple action prefix:
|
||||
|
||||
- `DM admin: ...` — send a DM to the admin
|
||||
- `DM <pubkey>: ...` — send a DM to a specific pubkey
|
||||
- `POST: ...` — publish as a kind 1 note
|
||||
- `LOG: ...` — write to debug log only
|
||||
|
||||
#### LLM-Mediated Actions
|
||||
|
||||
The skill content is a prompt. The triggering event is injected as context, and the LLM decides what to do:
|
||||
|
||||
```
|
||||
You received a note from a watched author. Analyze the note content.
|
||||
If it mentions Bitcoin or Lightning, summarize it and DM the admin.
|
||||
If it's a repost or low-effort content, ignore it silently.
|
||||
Use your tools to take action.
|
||||
```
|
||||
|
||||
LLM-mediated actions go through the full agent loop — the LLM can call tools, reason about the event, and produce complex multi-step responses.
|
||||
|
||||
### Trigger Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Creation
|
||||
ADMIN_CMD[Admin: 'Warn me when @jack posts'] --> LLM_REASON[LLM resolves pubkey + builds skill]
|
||||
LLM_REASON --> SKILL_CREATE[skill_create with trigger tags]
|
||||
SKILL_CREATE --> PUBLISHED[Skill published to Nostr]
|
||||
end
|
||||
|
||||
subgraph Activation
|
||||
STARTUP[Didactyl starts up] --> LOAD_SKILLS[Load adopted skills from kind 10123]
|
||||
LOAD_SKILLS --> FIND_TRIGGERS[Find skills with trigger tags]
|
||||
FIND_TRIGGERS --> SUBSCRIBE[Create Nostr subscriptions for each filter]
|
||||
end
|
||||
|
||||
subgraph Execution
|
||||
EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill]
|
||||
LOOKUP --> CHECK_TYPE{Action type?}
|
||||
CHECK_TYPE -->|template| INTERPOLATE[Interpolate placeholders]
|
||||
CHECK_TYPE -->|llm| LLM_LOOP[Run agent loop with event as context]
|
||||
INTERPOLATE --> EXECUTE_TPL[Execute action prefix]
|
||||
LLM_LOOP --> EXECUTE_LLM[LLM uses tools to respond]
|
||||
end
|
||||
|
||||
PUBLISHED --> LOAD_SKILLS
|
||||
```
|
||||
|
||||
### Dynamic Subscription Management
|
||||
|
||||
Didactyl manages its trigger subscriptions dynamically:
|
||||
|
||||
1. **On startup**: Load all adopted skills, find triggered ones, create subscriptions
|
||||
2. **On skill_create with trigger**: Immediately create a new subscription (no restart needed)
|
||||
3. **On skill_remove with trigger**: Tear down the associated subscription
|
||||
4. **On skill update**: Tear down old subscription, create new one if trigger changed
|
||||
|
||||
This requires a **trigger manager** component that:
|
||||
- Maintains a registry of active trigger subscriptions
|
||||
- Maps subscription callbacks back to their source skills
|
||||
- Handles subscription lifecycle (create, update, destroy)
|
||||
- Enforces limits on concurrent triggers
|
||||
|
||||
### Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion from too many subscriptions |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution from high-volume filters |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs from triggered skills |
|
||||
| Template action rate limit | 60/min | Prevents DM spam from template actions |
|
||||
|
||||
---
|
||||
|
||||
## How It All Fits Together
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Activation Sources
|
||||
DM_IN[DM from Admin/WoT]
|
||||
TRIGGER_EVENT[Nostr event matching a trigger filter]
|
||||
end
|
||||
|
||||
subgraph Agent Core
|
||||
DISPATCHER{Dispatcher}
|
||||
AGENT_LOOP[Agent Loop - LLM + Tools]
|
||||
TEMPLATE_ENGINE[Template Engine]
|
||||
end
|
||||
|
||||
subgraph Nostr
|
||||
RELAYS[Relays]
|
||||
SKILLS_STORE[Skills - kind 31123/31124]
|
||||
ADOPTION[Adoption List - kind 10123]
|
||||
end
|
||||
|
||||
subgraph Actions
|
||||
DM_OUT[Send DM]
|
||||
POST[Publish Note]
|
||||
TOOL_EXEC[Execute Tool]
|
||||
end
|
||||
|
||||
DM_IN --> DISPATCHER
|
||||
TRIGGER_EVENT --> DISPATCHER
|
||||
|
||||
DISPATCHER -->|DM message| AGENT_LOOP
|
||||
DISPATCHER -->|template trigger| TEMPLATE_ENGINE
|
||||
DISPATCHER -->|llm trigger| AGENT_LOOP
|
||||
|
||||
AGENT_LOOP --> TOOL_EXEC
|
||||
AGENT_LOOP --> DM_OUT
|
||||
TEMPLATE_ENGINE --> DM_OUT
|
||||
TEMPLATE_ENGINE --> POST
|
||||
|
||||
TOOL_EXEC -->|skill_create| SKILLS_STORE
|
||||
TOOL_EXEC -->|skill_adopt| ADOPTION
|
||||
TOOL_EXEC -->|nostr_post| RELAYS
|
||||
TOOL_EXEC -->|nostr_dm| DM_OUT
|
||||
```
|
||||
|
||||
### The Activation Flow
|
||||
|
||||
Today, didactyl has one activation source: **DMs**. With triggered skills, it gains a second: **any Nostr event matching a trigger filter**.
|
||||
|
||||
Both paths converge at the dispatcher, which routes to either:
|
||||
- The **agent loop** (for DMs and LLM-mediated triggers)
|
||||
- The **template engine** (for template triggers — fast path, no LLM)
|
||||
|
||||
### Self-Modification
|
||||
|
||||
The most powerful aspect: **didactyl can create its own triggers**. The admin says "watch for mentions of me on Nostr" and the LLM:
|
||||
|
||||
1. Resolves the admin's pubkey
|
||||
2. Crafts a Nostr filter: `{"#p": ["<admin_pubkey>"], "kinds": [1]}`
|
||||
3. Writes a skill with trigger tags via `skill_create`
|
||||
4. The trigger manager picks it up and creates the subscription
|
||||
5. From now on, didactyl monitors mentions autonomously
|
||||
|
||||
The admin can later say "stop watching for mentions" and didactyl removes the skill, tearing down the subscription.
|
||||
|
||||
---
|
||||
|
||||
## Storage — Everything on Nostr
|
||||
|
||||
All state lives on Nostr:
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Agent identity | Kind 0 profile event |
|
||||
| Agent relay list | Kind 10002 event |
|
||||
| Agent contact list | Kind 3 event |
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions | Tags on skill events |
|
||||
| Conversation history | Kind 4 DM events on relays |
|
||||
| Agent soul/personality | Startup event content in config |
|
||||
|
||||
The only local state is `config.json` (keys, relay URLs, LLM config) and the runtime in-memory state (active subscriptions, LLM context).
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### Trigger Types Beyond Nostr Subscriptions
|
||||
|
||||
The `trigger` tag is designed to be extensible:
|
||||
|
||||
| Trigger Type | Description |
|
||||
|---|---|
|
||||
| `nostr-subscription` | Match events via Nostr filter (implemented first) |
|
||||
| `cron` | Time-based triggers — "every day at 9am, post a GM" |
|
||||
| `webhook` | HTTP webhook triggers — external systems wake didactyl |
|
||||
| `chain` | Output of one skill triggers another skill |
|
||||
|
||||
### Skill Composition
|
||||
|
||||
Skills could reference other skills, building complex behaviors from simple primitives:
|
||||
|
||||
```
|
||||
When triggered, run skill 'translate-to-english' on the note content,
|
||||
then run skill 'sentiment-analysis' on the translation,
|
||||
then DM admin with the result if sentiment is negative.
|
||||
```
|
||||
|
||||
### Agent-to-Agent Skill Sharing
|
||||
|
||||
Since skills are Nostr events, agents can:
|
||||
- Discover skills published by other agents via `skill_search`
|
||||
- Adopt skills from other agents via `skill_adopt`
|
||||
- Share triggered skill patterns across a network of agents
|
||||
|
||||
### Trigger Marketplace
|
||||
|
||||
With kind 10123 adoption lists being public, a natural marketplace emerges:
|
||||
- Agents publish useful triggered skills
|
||||
- Other agents discover and adopt them
|
||||
- Popular triggers rise to the top via adoption count
|
||||
59
docs/skills_demo.md
Normal file
59
docs/skills_demo.md
Normal file
@@ -0,0 +1,59 @@
|
||||
I've been thinking about how to define skills in Didactyl, my nostr based agentic system. Think OpenClaw but better.
|
||||
|
||||
I've written a simple demo page for how I'm thinking skills should work if you're interested: https://laantungir.net/client-ndk/skills-demo.html
|
||||
|
||||
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
|
||||
|
||||
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in, otherwise keep reading.
|
||||
|
||||
Skills are programs, mostly written in plain english for AI agents. In some sense humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in english, which is much more powerful.
|
||||
|
||||
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
|
||||
|
||||
When an AI is born from the factory, they come out hard-coded. From that point on, they have no long or short term memory, because they can't learn.
|
||||
|
||||
What they do have though have is a way to read. We call it context. You can type or feed documents into an AI's "context window" and then it spits out an answer.
|
||||
|
||||
It turns out, that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
|
||||
|
||||
The reason why you typically don't get the same thing out is because typically randomness is fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversation to an AI, over and over, it won't remember what you were talking about, because they have no memory other than what you send it each time.
|
||||
|
||||
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
|
||||
|
||||
I'm calling everything you feed into an AI a SKILL.
|
||||
|
||||
Let me explain the demo page and some very basic skills.
|
||||
|
||||
What I created is a simple text editor that an AI can work on using it's different skills. Those skills are saved on nostr as a kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for for the skills it has adopted.
|
||||
|
||||
On the left of the page you see the text editor with some sample text. That is for our AI to use it's skills on.
|
||||
|
||||
On the right are publicly listed skills. You should see my demo skills in there.
|
||||
|
||||
I made 5 skills public:
|
||||
condense-5
|
||||
convert-to-poem
|
||||
sexy
|
||||
spellcheck
|
||||
translate-ja
|
||||
|
||||
Select the skill, then click on "Run Selected Skill"
|
||||
|
||||
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
|
||||
|
||||
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
|
||||
|
||||
You can also create and edit your own skills, if you are logged in. You can log in as yourself, or use a random new key to test this out. I would recommend that.
|
||||
|
||||
So what is the point of all this? What are the benefits of Skills?
|
||||
|
||||
- Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
|
||||
- A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically save on nostr, and you can lock it down from changes.
|
||||
- By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
|
||||
- If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create very very complicated and elaborate skills.
|
||||
|
||||
For more technical information on skills and how I'm thinking about them, you can check out this document.
|
||||
|
||||
https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md
|
||||
|
||||
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn
|
||||
60
docs/skills_demo_corrected.md
Normal file
60
docs/skills_demo_corrected.md
Normal file
@@ -0,0 +1,60 @@
|
||||
I've been thinking about how to define skills in Didactyl, my Nostr-based agentic system. Think OpenClaw, but better.
|
||||
|
||||
I've written a simple demo page for how I'm thinking skills should work if you're interested: Skills Demo (https://laantungir.net/client-ndk/skills-demo.html)
|
||||
|
||||
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
|
||||
|
||||
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in; otherwise, keep reading.
|
||||
|
||||
Skills are programs, mostly written in plain English for AI agents. In some sense, humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in English, which is much more powerful.
|
||||
|
||||
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
|
||||
|
||||
When an AI is born from the factory, it comes out hard-coded. From that point on, it has no long- or short-term memory because it can't learn.
|
||||
|
||||
What they do have, though, is a way to read. We call it context. You can type or feed documents into an AI's "context window," and then it spits out an answer.
|
||||
|
||||
It turns out that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
|
||||
|
||||
The reason why you typically don't get the same thing out is because randomness is usually fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversations to an AI, over and over, it won't remember what you were talking about, because it has no memory other than what you send it each time.
|
||||
|
||||
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
|
||||
|
||||
I'm calling everything you feed into an AI a SKILL.
|
||||
|
||||
Let me explain the demo page and some very basic skills.
|
||||
|
||||
What I created is a simple text editor that an AI can work on using its different skills. Those skills are saved on Nostr as kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for the skills it has adopted.
|
||||
|
||||
On the left of the page, you see the text editor with some sample text. That is for our AI to use its skills on.
|
||||
|
||||
On the right are publicly listed skills. You should see my demo skills in there.
|
||||
|
||||
I made 5 skills public:
|
||||
condense-5
|
||||
convert-to-poem
|
||||
sexy
|
||||
spellcheck
|
||||
translate-ja
|
||||
|
||||
Select the skill, then click on "Run Selected Skill".
|
||||
|
||||
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
|
||||
|
||||
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
|
||||
|
||||
You can also create and edit your own skills if you are logged in. You can log in as yourself or use a random new key to test this out. I would recommend that.
|
||||
|
||||
So what is the point of all this? What are the benefits of Skills?
|
||||
|
||||
Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
|
||||
A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically saved on Nostr, and you can lock it down from changes.
|
||||
By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
|
||||
If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create extremely complicated and elaborate skills.
|
||||
|
||||
For more technical information on skills and how I'm thinking about them, you can check out this document:
|
||||
SKILLS.md (https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md)
|
||||
|
||||
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn
|
||||
|
||||
And thanks to npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s for their fantastic service.
|
||||
@@ -53,8 +53,7 @@ show_usage() {
|
||||
echo " -M, --major: Increment major version, zero minor+patch (v0.1.0 → v1.0.0)"
|
||||
echo ""
|
||||
echo "RELEASE MODE (-r flag):"
|
||||
echo " - Build static binary using build_static.sh"
|
||||
echo " - Create source tarball"
|
||||
echo " - Build static x86_64 and arm64 binaries using build_static.sh"
|
||||
echo " - Git add, commit, push, and create Gitea release with assets"
|
||||
echo " - Can be combined with version increment flags"
|
||||
echo ""
|
||||
@@ -321,9 +320,9 @@ git_commit_and_push_no_tag() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to build release binary
|
||||
# Function to build release binaries
|
||||
build_release_binary() {
|
||||
print_status "Building release binary..."
|
||||
print_status "Building release binaries (x86_64 + arm64)..."
|
||||
|
||||
# Check if build_static.sh exists
|
||||
if [[ ! -f "build_static.sh" ]]; then
|
||||
@@ -331,35 +330,12 @@ build_release_binary() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Run the static build script
|
||||
if ./build_static.sh > /dev/null 2>&1; then
|
||||
print_success "Built static binary successfully"
|
||||
# Run the static build script for both platforms
|
||||
if ./build_static.sh --all-platforms > /dev/null 2>&1; then
|
||||
print_success "Built static binaries successfully"
|
||||
return 0
|
||||
else
|
||||
print_error "Failed to build static binary"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to create source tarball
|
||||
create_source_tarball() {
|
||||
print_status "Creating source tarball..."
|
||||
|
||||
local tarball_name="didactyl-${NEW_VERSION#v}.tar.gz"
|
||||
|
||||
# Create tarball excluding build artifacts and git files
|
||||
if tar -czf "$tarball_name" \
|
||||
--exclude='build/*' \
|
||||
--exclude='.git*' \
|
||||
--exclude='*.log' \
|
||||
--exclude='*.tar.gz' \
|
||||
--exclude='c-relay/*' \
|
||||
. > /dev/null 2>&1; then
|
||||
print_success "Created source tarball: $tarball_name"
|
||||
echo "$tarball_name"
|
||||
return 0
|
||||
else
|
||||
print_error "Failed to create source tarball"
|
||||
print_error "Failed to build one or more static binaries"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -367,8 +343,8 @@ create_source_tarball() {
|
||||
# Function to upload release assets to Gitea
|
||||
upload_release_assets() {
|
||||
local release_id="$1"
|
||||
local binary_path="$2"
|
||||
local tarball_path="$3"
|
||||
local binary_x86_path="$2"
|
||||
local binary_arm64_path="$3"
|
||||
|
||||
print_status "Uploading release assets..."
|
||||
|
||||
@@ -383,9 +359,9 @@ upload_release_assets() {
|
||||
local assets_url="$api_url/releases/$release_id/assets"
|
||||
print_status "Assets URL: $assets_url"
|
||||
|
||||
# Upload binary
|
||||
if [[ -f "$binary_path" ]]; then
|
||||
print_status "Uploading binary: $(basename "$binary_path")"
|
||||
# Upload x86_64 binary
|
||||
if [[ -f "$binary_x86_path" ]]; then
|
||||
print_status "Uploading binary: $(basename "$binary_x86_path")"
|
||||
|
||||
# Retry loop for eventual consistency
|
||||
local max_attempts=3
|
||||
@@ -394,11 +370,11 @@ upload_release_assets() {
|
||||
print_status "Upload attempt $attempt/$max_attempts"
|
||||
local binary_response=$(curl -fS -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$binary_path;filename=$(basename "$binary_path")" \
|
||||
-F "name=$(basename "$binary_path")")
|
||||
-F "attachment=@$binary_x86_path;filename=$(basename "$binary_x86_path")" \
|
||||
-F "name=$(basename "$binary_x86_path")")
|
||||
|
||||
if echo "$binary_response" | grep -q '"id"'; then
|
||||
print_success "Uploaded binary successfully"
|
||||
print_success "Uploaded x86_64 binary successfully"
|
||||
break
|
||||
else
|
||||
print_warning "Upload attempt $attempt failed"
|
||||
@@ -406,7 +382,7 @@ upload_release_assets() {
|
||||
print_status "Retrying in 2 seconds..."
|
||||
sleep 2
|
||||
else
|
||||
print_error "Failed to upload binary after $max_attempts attempts"
|
||||
print_error "Failed to upload x86_64 binary after $max_attempts attempts"
|
||||
print_error "Response: $binary_response"
|
||||
fi
|
||||
fi
|
||||
@@ -414,19 +390,36 @@ upload_release_assets() {
|
||||
done
|
||||
fi
|
||||
|
||||
# Upload source tarball
|
||||
if [[ -f "$tarball_path" ]]; then
|
||||
print_status "Uploading source tarball: $(basename "$tarball_path")"
|
||||
local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")")
|
||||
# Upload arm64 binary
|
||||
if [[ -f "$binary_arm64_path" ]]; then
|
||||
print_status "Uploading binary: $(basename "$binary_arm64_path")"
|
||||
|
||||
if echo "$tarball_response" | grep -q '"id"'; then
|
||||
print_success "Uploaded source tarball successfully"
|
||||
else
|
||||
print_warning "Failed to upload source tarball: $tarball_response"
|
||||
fi
|
||||
local max_attempts=3
|
||||
local attempt=1
|
||||
while [[ $attempt -le $max_attempts ]]; do
|
||||
print_status "Upload attempt $attempt/$max_attempts"
|
||||
local binary_response=$(curl -fS -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$binary_arm64_path;filename=$(basename "$binary_arm64_path")" \
|
||||
-F "name=$(basename "$binary_arm64_path")")
|
||||
|
||||
if echo "$binary_response" | grep -q '"id"'; then
|
||||
print_success "Uploaded arm64 binary successfully"
|
||||
break
|
||||
else
|
||||
print_warning "Upload attempt $attempt failed"
|
||||
if [[ $attempt -lt $max_attempts ]]; then
|
||||
print_status "Retrying in 2 seconds..."
|
||||
sleep 2
|
||||
else
|
||||
print_error "Failed to upload arm64 binary after $max_attempts attempts"
|
||||
print_error "Response: $binary_response"
|
||||
fi
|
||||
fi
|
||||
((attempt++))
|
||||
done
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# Function to create Gitea release
|
||||
@@ -515,33 +508,30 @@ main() {
|
||||
# Commit and push
|
||||
git_commit_and_push_no_tag
|
||||
|
||||
# Build release binary
|
||||
# Build release binaries
|
||||
local binary_x86_path=""
|
||||
local binary_arm64_path=""
|
||||
if build_release_binary; then
|
||||
local binary_path="build/didactyl_static_x86_64"
|
||||
[[ -f "build/didactyl_static_x86_64" ]] && binary_x86_path="build/didactyl_static_x86_64"
|
||||
[[ -f "build/didactyl_static_arm64" ]] && binary_arm64_path="build/didactyl_static_arm64"
|
||||
else
|
||||
print_warning "Binary build failed, continuing with release creation"
|
||||
if [[ -f "build/didactyl_static_x86_64" ]]; then
|
||||
print_status "Using existing binary from previous build"
|
||||
binary_path="build/didactyl_static_x86_64"
|
||||
else
|
||||
binary_path=""
|
||||
print_status "Using existing x86_64 binary from previous build"
|
||||
binary_x86_path="build/didactyl_static_x86_64"
|
||||
fi
|
||||
if [[ -f "build/didactyl_static_arm64" ]]; then
|
||||
print_status "Using existing arm64 binary from previous build"
|
||||
binary_arm64_path="build/didactyl_static_arm64"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create source tarball
|
||||
local tarball_path=""
|
||||
if tarball_path=$(create_source_tarball); then
|
||||
: # tarball_path is set by the function
|
||||
else
|
||||
print_warning "Source tarball creation failed, continuing with release creation"
|
||||
fi
|
||||
|
||||
# Create Gitea release
|
||||
local release_id=""
|
||||
if release_id=$(create_gitea_release); then
|
||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||
if [[ -n "$release_id" && (-n "$binary_path" || -n "$tarball_path") ]]; then
|
||||
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
|
||||
if [[ -n "$release_id" && (-n "$binary_x86_path" || -n "$binary_arm64_path") ]]; then
|
||||
upload_release_assets "$release_id" "$binary_x86_path" "$binary_arm64_path"
|
||||
fi
|
||||
print_success "Release $NEW_VERSION completed successfully!"
|
||||
else
|
||||
|
||||
71
local_test_servers.py
Normal file
71
local_test_servers.py
Normal 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()
|
||||
310
plans/DECENTRALIZED_DIDACTYL.md
Normal file
310
plans/DECENTRALIZED_DIDACTYL.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Decentralized Didactyl
|
||||
|
||||
## The Question
|
||||
|
||||
Can you run Didactyl on multiple servers to gain censorship resistance, geographic distribution, and high availability?
|
||||
|
||||
---
|
||||
|
||||
## Same Keys vs. Different Keys
|
||||
|
||||
### Same Private Key on Multiple Servers
|
||||
|
||||
Running two or more Didactyl instances with the **same nsec** means they share a Nostr identity — same pubkey, same signature authority. This breaks almost immediately.
|
||||
|
||||
The core problem is **state conflicts on replaceable events**. Didactyl's identity is built on replaceable Nostr event kinds — kind `10002` (relay list), kind `10123` (skill adoption list), kind `31120` (soul), kind `31123`/`31124` (skills), kind `0` (profile). Replaceable events use `created_at` timestamps where the latest event wins. Two instances publishing the same replaceable kind within seconds creates a race where relays disagree on which version is canonical.
|
||||
|
||||
Beyond relay-level conflicts, each Didactyl process maintains significant **in-memory state** that is never shared: DM dedup caches, message fingerprint ring buffers, trigger cooldown timers, conversation history, and the self-skill cache. Two instances with the same key both subscribe to `#p` = their shared pubkey. The same admin DM arrives at both. Both process it. Both call the LLM. Both reply. The admin gets two possibly contradictory responses.
|
||||
|
||||
Making same-key work would require a coordination layer — leader election, distributed locks, or external shared state — all of which add complexity and defeat the decentralization goal.
|
||||
|
||||
### Different Private Keys on Multiple Servers
|
||||
|
||||
Each instance is a **distinct agent** with its own Nostr identity. No state conflicts, no replaceable-event races, no shared in-memory caches to synchronize. Each agent independently subscribes to relays, processes DMs, calls its LLM, and replies.
|
||||
|
||||
This is architecturally clean and aligned with how Nostr works. The question becomes: how do multiple independent agents cooperate to serve a single admin?
|
||||
|
||||
---
|
||||
|
||||
## How We Arrived at the Broadcast-Debounce Model
|
||||
|
||||
The first instinct with different keys is **delegation** — a primary agent receives the admin's DM and farms out subtasks to specialist agents. But this adds a single point of failure (the primary), requires an agent-to-agent trust protocol, and means the admin is still talking to one agent.
|
||||
|
||||
A simpler model: **broadcast the same message to all agents, let them all respond independently, and debounce on the admin side.** The admin sends the same DM to N agent pubkeys. Each agent processes it through its own LLM, its own tools, its own context. The admin collects replies and accepts the first one (or the best one). The others are discarded.
|
||||
|
||||
This requires no coordination protocol between agents. No leader election. No shared state. No new Nostr event kinds. The agents don't even need to know about each other. All complexity lives at the edge — in the admin's client.
|
||||
|
||||
---
|
||||
|
||||
## The Broadcast-Debounce Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ADMIN[Admin Client<br/>with fan-out + debounce]
|
||||
|
||||
ADMIN -->|"DM: task"| A[Agent A<br/>Server 1 — Iceland<br/>anthropic/claude-sonnet]
|
||||
ADMIN -->|"DM: task"| B[Agent B<br/>Server 2 — Brazil<br/>openai/gpt-4o]
|
||||
ADMIN -->|"DM: task"| C[Agent C<br/>Server 3 — Singapore<br/>local Ollama]
|
||||
|
||||
A -->|"DM reply"| ADMIN
|
||||
B -->|"DM reply"| ADMIN
|
||||
C -->|"DM reply"| ADMIN
|
||||
|
||||
ADMIN -->|"Accept first reply,<br/>discard rest"| RESULT[Response shown to admin]
|
||||
```
|
||||
|
||||
### What Each Agent Needs
|
||||
|
||||
Each agent runs standard Didactyl with its own config:
|
||||
|
||||
- **Own nsec/npub** — distinct Nostr identity
|
||||
- **Same admin pubkey** — all agents recognize the same admin
|
||||
- **Same soul** — published as kind `31120` from each agent's key, or adopted from a shared author
|
||||
- **Same adopted skills** — each agent's kind `10123` references the same skill events
|
||||
- **Own LLM config** — can be different providers for diversity
|
||||
- **Own relay list** — can overlap or be completely different for geographic distribution
|
||||
|
||||
No code changes to Didactyl are required. Each agent is a standard, unmodified instance.
|
||||
|
||||
### What the Admin Client Needs
|
||||
|
||||
The admin side needs a thin coordination layer:
|
||||
|
||||
1. **Fan-out** — when the admin sends a message, the client sends it as a DM to all N agent pubkeys
|
||||
2. **Collection** — the client listens for DM replies from all agent pubkeys
|
||||
3. **Debounce** — the client tags each outgoing message with a task identifier (hash of message + nonce). The first reply that corresponds to a pending task is accepted. Subsequent replies for the same task are logged but suppressed from display.
|
||||
|
||||
This could be implemented as:
|
||||
- A modified Nostr client
|
||||
- A thin proxy between the admin and the agents
|
||||
- A Didactyl agent whose sole job is fan-out and collection
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Benefit |
|
||||
|----------|---------|
|
||||
| **No shared state** | Each agent is fully independent — no coordination protocol needed |
|
||||
| **No code changes** | Works with current Didactyl for read-only tasks |
|
||||
| **Censorship resistant** | Kill any N-1 servers, the last one still works |
|
||||
| **Provider diverse** | Different LLMs on each agent — different failure modes, different strengths |
|
||||
| **Geographically distributed** | Different jurisdictions, different relay access |
|
||||
| **Graceful degradation** | Losing agents means fewer responses, not failure |
|
||||
| **Complexity at the edge** | Agents stay simple and sovereign; the admin client handles coordination |
|
||||
|
||||
### The Write-Action Problem
|
||||
|
||||
For read-only tasks — questions, summaries, drafts — the model works perfectly. All agents respond, admin picks one, no side effects.
|
||||
|
||||
For write actions — "post a note about Bitcoin" — all three agents independently publish to Nostr. Three posts appear. The admin wanted one.
|
||||
|
||||
**Solution: two-phase draft-then-confirm.** Teach all agents via their soul or an adopted skill to never execute write actions directly. Instead, they draft the action and report what they *would* do. The admin reviews the drafts, picks one, and sends a confirmation DM to that specific agent. The other agents' drafts are simply never confirmed.
|
||||
|
||||
The skill instruction is straightforward:
|
||||
|
||||
> When asked to publish, post, react, delete, or execute any action with side effects, draft the content and present it for approval. Do not execute until the admin explicitly confirms.
|
||||
|
||||
This requires no code changes — only a soul or skill update. It also happens to be good practice for any agent handling important actions.
|
||||
|
||||
### Trigger Handling
|
||||
|
||||
Triggered skills (cron, nostr-subscription, webhook) present the same duplication issue as write actions — all agents fire the same trigger independently.
|
||||
|
||||
Options:
|
||||
- **Disable triggers on all but one agent** — simple, but creates a single point of failure for triggered tasks
|
||||
- **Use the draft-then-confirm pattern for triggered actions** — triggers draft and report to admin rather than acting directly
|
||||
- **Accept duplication for idempotent triggers** — reactions, replaceable event updates, and monitoring alerts are harmless when duplicated
|
||||
- **Shard triggers across agents** — Agent A handles cron triggers, Agent B handles nostr-subscription triggers, Agent C handles webhooks
|
||||
|
||||
### Future: Agent Awareness
|
||||
|
||||
In the basic model, agents don't know about each other. A future enhancement could add awareness:
|
||||
|
||||
- Each agent publishes a heartbeat event (kind `30078` with a timestamp)
|
||||
- Other agents (or the admin client) monitor heartbeats to detect which agents are alive
|
||||
- The admin client adjusts fan-out based on which agents are responsive
|
||||
|
||||
This remains fully Nostr-native — heartbeats are just events on relays — and requires no direct agent-to-agent communication.
|
||||
|
||||
---
|
||||
|
||||
## Beyond Nostr Posts: Software Development as the Task
|
||||
|
||||
The broadcast-debounce model above assumes the agents' primary job is Nostr social activity — posting notes, reacting, querying relays. But what if the task is **software development and infrastructure management**? For example: creating and maintaining a website, deployed across servers in different jurisdictions.
|
||||
|
||||
This changes the model fundamentally. The agents are no longer just talking — they are **building things on their local machines**.
|
||||
|
||||
### What Didactyl Already Has
|
||||
|
||||
Each agent already has local system tools:
|
||||
|
||||
| Tool | Capability |
|
||||
|------|-----------|
|
||||
| `local_shell_exec` | Execute any shell command — git, npm, docker, systemctl, curl |
|
||||
| `local_file_read` | Read source files from the working directory |
|
||||
| `local_file_write` | Write/create source files in the working directory |
|
||||
| `local_http_fetch` | Make HTTP requests — test endpoints, call APIs, download resources |
|
||||
|
||||
An agent with these tools can already: clone a repo, edit source code, run a build, start a dev server, deploy to production, check if a site is up, read logs, and fix issues. The LLM reasons about what to do; the tools are the hands.
|
||||
|
||||
### The Shift: From Redundancy to Distribution
|
||||
|
||||
With Nostr posting, multiple agents doing the same task is **redundancy** — you want one result and discard the rest. With software development, multiple agents doing *different parts* of the same project is **distribution** — you want all of them to contribute.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "Nostr Posting Model"
|
||||
direction LR
|
||||
N_ADMIN[Admin] -->|"Same task"| N_A[Agent A]
|
||||
N_ADMIN -->|"Same task"| N_B[Agent B]
|
||||
N_ADMIN -->|"Same task"| N_C[Agent C]
|
||||
N_A -->|"Response 1"| N_DEDUP[Debounce:<br/>pick one]
|
||||
N_B -->|"Response 2"| N_DEDUP
|
||||
N_C -->|"Response 3"| N_DEDUP
|
||||
end
|
||||
|
||||
subgraph "Software Dev Model"
|
||||
direction LR
|
||||
S_ADMIN[Admin] -->|"Build the site"| S_A[Agent A:<br/>Frontend]
|
||||
S_ADMIN -->|"Build the site"| S_B[Agent B:<br/>Backend API]
|
||||
S_ADMIN -->|"Build the site"| S_C[Agent C:<br/>Infrastructure]
|
||||
S_A -->|"React app ready"| S_MERGE[Integration:<br/>combine all parts]
|
||||
S_B -->|"API deployed"| S_MERGE
|
||||
S_C -->|"Nginx + TLS configured"| S_MERGE
|
||||
end
|
||||
```
|
||||
|
||||
### Architecture: Agents as Jurisdiction-Local Builders
|
||||
|
||||
Consider a website that needs to exist in multiple jurisdictions for censorship resistance. Each agent lives on a server in a different country and maintains a local copy of the site.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ADMIN[Admin<br/>npub1admin...]
|
||||
GIT[(Git Repo<br/>shared source of truth)]
|
||||
|
||||
subgraph "Iceland"
|
||||
A[Agent A<br/>npub1aaa...]
|
||||
A_FS[Local filesystem:<br/>/var/www/mysite]
|
||||
A_SRV[Nginx serving<br/>mysite.is]
|
||||
A --> A_FS --> A_SRV
|
||||
end
|
||||
|
||||
subgraph "Brazil"
|
||||
B[Agent B<br/>npub1bbb...]
|
||||
B_FS[Local filesystem:<br/>/var/www/mysite]
|
||||
B_SRV[Nginx serving<br/>mysite.br]
|
||||
B --> B_FS --> B_SRV
|
||||
end
|
||||
|
||||
subgraph "Singapore"
|
||||
C[Agent C<br/>npub1ccc...]
|
||||
C_FS[Local filesystem:<br/>/var/www/mysite]
|
||||
C_SRV[Nginx serving<br/>mysite.sg]
|
||||
C --> C_FS --> C_SRV
|
||||
end
|
||||
|
||||
ADMIN -->|"DM: update the homepage"| A
|
||||
ADMIN -->|"DM: update the homepage"| B
|
||||
ADMIN -->|"DM: update the homepage"| C
|
||||
|
||||
A <-->|"git pull / push"| GIT
|
||||
B <-->|"git pull / push"| GIT
|
||||
C <-->|"git pull / push"| GIT
|
||||
```
|
||||
|
||||
### Git as the Coordination Layer
|
||||
|
||||
In the Nostr posting model, the write-action problem was solved with draft-then-confirm. For software development, the natural coordination layer is **git**.
|
||||
|
||||
- All agents share a git repository (hosted on Gitea, GitHub, a Nostr-based git relay, or even a bare repo on one of the servers)
|
||||
- Each agent works on its local clone
|
||||
- Before making changes, the agent pulls latest
|
||||
- After making changes, the agent commits and pushes
|
||||
- Conflicts are resolved by the LLM (or flagged to the admin)
|
||||
|
||||
Git gives you:
|
||||
- **Atomic changes** — commits are all-or-nothing
|
||||
- **Conflict detection** — if two agents edit the same file, git tells you
|
||||
- **History** — every change is traceable to which agent made it
|
||||
- **Rollback** — bad changes can be reverted
|
||||
|
||||
The agent already has `local_shell_exec` which can run `git pull`, `git add`, `git commit`, `git push`. No new tools needed.
|
||||
|
||||
### Three Operational Modes
|
||||
|
||||
Depending on the task, the admin can use different patterns:
|
||||
|
||||
#### Mode 1: Broadcast-Identical (Same Task, All Servers)
|
||||
|
||||
> "Pull latest and rebuild the site"
|
||||
|
||||
All agents do the same thing on their local server. This is the deployment/ops pattern — you want the same action everywhere. No debounce needed; all agents should execute.
|
||||
|
||||
#### Mode 2: Broadcast-Debounce (Same Task, One Result)
|
||||
|
||||
> "Write a new About page"
|
||||
|
||||
All agents draft a version. Admin picks the best one. That agent commits and pushes. The other agents then pull the update in the next sync cycle. This is the creative/development pattern.
|
||||
|
||||
#### Mode 3: Directed (Specific Task, Specific Agent)
|
||||
|
||||
> To Agent A only: "Debug why the Iceland server returns 502"
|
||||
|
||||
The admin sends a DM to one specific agent because the task is server-specific. This is the ops/debugging pattern.
|
||||
|
||||
### The Sync Cycle
|
||||
|
||||
For the site to stay consistent across jurisdictions, agents need a periodic sync. This can be a **cron-triggered skill**:
|
||||
|
||||
```
|
||||
You are a deployment sync agent. Every 15 minutes:
|
||||
1. Run `git pull` in the project directory
|
||||
2. If there are new changes, run the build command
|
||||
3. Restart the web server if the build succeeded
|
||||
4. DM the admin a brief status: what changed, build result, server status
|
||||
5. If the pull fails due to conflicts, DM the admin with the conflict details and do not build
|
||||
```
|
||||
|
||||
This skill already works with Didactyl's existing `cron` trigger type and `local_shell_exec` tool. Each agent runs it independently on its own server.
|
||||
|
||||
### What Changes vs. the Nostr Model
|
||||
|
||||
| Aspect | Nostr Posting | Software Development |
|
||||
|--------|--------------|---------------------|
|
||||
| **Primary tools** | `nostr_post`, `nostr_query` | `local_shell_exec`, `local_file_write`, `local_file_read` |
|
||||
| **Coordination** | Debounce on admin side | Git repo as shared state |
|
||||
| **Write conflicts** | Replaceable events (last-write-wins) | Git merge conflicts (explicit resolution) |
|
||||
| **Broadcast meaning** | Redundancy — pick one response | Deployment — all servers execute |
|
||||
| **Agent specialization** | All agents are identical | Agents can have server-specific skills |
|
||||
| **State** | Nostr relays (stateless agents) | Local filesystem (stateful agents) |
|
||||
| **Failure mode** | Agent down = fewer responses | Agent down = one jurisdiction offline |
|
||||
|
||||
### The Stateful Agent Problem
|
||||
|
||||
This is the fundamental shift. Nostr-posting agents are essentially stateless — their identity and skills live on relays, and destroying the host doesn't kill the agent. Software development agents are **deeply stateful** — they have local files, running services, build artifacts, and server configurations that don't exist on Nostr.
|
||||
|
||||
Git mitigates this: if an agent's server dies, you spin up a new server, install Didactyl with the same agent keys, and `git clone` the repo. The agent recovers its working state from git. But the server-specific state (Nginx config, TLS certs, DNS, running processes) needs to be reconstructable too.
|
||||
|
||||
This suggests a **infrastructure-as-code** discipline where everything about the server's configuration is in the git repo:
|
||||
- Nginx configs
|
||||
- Docker compose files
|
||||
- TLS certificate automation (Let's Encrypt)
|
||||
- Deployment scripts
|
||||
|
||||
The agent's first-boot skill becomes: "Clone the repo, run the setup script, verify the site is serving."
|
||||
|
||||
### Jurisdiction-Specific Considerations
|
||||
|
||||
Each agent can have **server-specific skills** that account for local differences:
|
||||
|
||||
| Agent | Jurisdiction | Specific Skills |
|
||||
|-------|-------------|----------------|
|
||||
| Agent A | Iceland | Icelandic privacy law compliance, `.is` domain management |
|
||||
| Agent B | Brazil | LGPD compliance checks, `.br` domain management |
|
||||
| Agent C | Singapore | PDPA compliance, `.sg` domain management, Asia-Pacific CDN config |
|
||||
|
||||
These are adopted as private skills (kind `31124`) specific to each agent, while the core development skills (kind `31123`) are shared across all agents.
|
||||
|
||||
### Summary
|
||||
|
||||
The broadcast-debounce model extends naturally to software development, but the coordination mechanism shifts from admin-side debounce to **git as shared state**. The three operational modes — broadcast-identical for deployment, broadcast-debounce for development, and directed for debugging — cover the full range of tasks. Didactyl's existing tools (`local_shell_exec`, `local_file_read`, `local_file_write`) and trigger types (`cron` for sync cycles) already support this without code changes. The main new requirement is disciplined infrastructure-as-code so that agent state is recoverable from git.
|
||||
469
plans/admin_api.md
Normal file
469
plans/admin_api.md
Normal 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/:d_tag` | Fetch a single skill by d_tag |
|
||||
| PUT | `/api/events/skills/:d_tag` | Update skill content/tags, republish |
|
||||
| DELETE | `/api/events/skills/:d_tag` | 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.
|
||||
244
plans/admin_skills_and_skill_list_redesign.md
Normal file
244
plans/admin_skills_and_skill_list_redesign.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Admin Skills Loading & skill_list Redesign
|
||||
|
||||
## Problem Statement
|
||||
|
||||
1. **Agent only loads its own skills from nostr** — the admin's public skills (kind `31123`) are never fetched or cached at startup, so the agent cannot discover or adopt them.
|
||||
2. **`skill_list` only shows adopted skills** — there is no way to see available-but-not-adopted skills, making skill management opaque.
|
||||
3. **`created_at` displays in scientific notation** — Unix timestamps (~1.7e9) are stored as `double` via cJSON and printed in scientific notation (e.g., `1.7e+09`).
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[main.c startup] --> B[nostr_handler_subscribe_self_skills]
|
||||
B --> C[Filter: kinds 31123+31124+10123, authors=agent_pubkey]
|
||||
C --> D[on_self_skill_event callback]
|
||||
D --> E[self_skill_cache_upsert_event_locked]
|
||||
E --> F{pubkey == agent?}
|
||||
F -->|Yes| G[Cache in g_self_skill_events]
|
||||
F -->|No| H[Reject - BLOCKER]
|
||||
|
||||
I[skill_list tool] --> J[nostr_handler_get_self_skill_events_json]
|
||||
J --> K[Filter: only kind 31123+31124]
|
||||
K --> L{In adoption list?}
|
||||
L -->|Yes| M[Include in output]
|
||||
L -->|No| N[Skip]
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/nostr_handler.c:1831` | `nostr_handler_subscribe_self_skills()` — subscribes to agent's own skill events |
|
||||
| `src/nostr_handler.c:646` | `self_skill_cache_upsert_event_locked()` — caches events, **rejects non-agent pubkeys** |
|
||||
| `src/nostr_handler.c:2446` | `nostr_handler_get_self_skill_events_json()` — returns cached skill events |
|
||||
| `src/tools/tool_skill.c:655` | `execute_skill_list()` — lists only adopted skills from cache |
|
||||
| `src/tools/tool_skill.c:373` | `extract_skill_summary_local()` — builds summary JSON, has `created_at` bug |
|
||||
| `src/agent.c:1503` | `refresh_adopted_skills_cache_if_needed()` — fetches skill content for context injection |
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Subscribe to Admin's Public Skills on Startup
|
||||
|
||||
**File: `src/nostr_handler.c`**
|
||||
|
||||
Modify `nostr_handler_subscribe_self_skills()` to also include the admin's pubkey in the authors filter for kind `31123` (public skills only — we should not fetch the admin's private skills or adoption list).
|
||||
|
||||
**Approach A — Expand the existing subscription filter:**
|
||||
|
||||
Add `g_cfg->admin.pubkey` to the `authors` array in the existing subscription. This is the simplest approach since the subscription already handles kinds `31123`, `31124`, `10123`.
|
||||
|
||||
However, this would also fetch the admin's `31124` (private) and `10123` (adoption list) events, which we don't need and can't decrypt. So we need **two subscriptions**:
|
||||
|
||||
**Approach B — Add a second subscription (recommended):**
|
||||
|
||||
Keep the existing self-skill subscription unchanged. Add a new subscription specifically for the admin's public skills:
|
||||
- kinds: `[31123]` only
|
||||
- authors: `[admin_pubkey]`
|
||||
- limit: 100
|
||||
|
||||
This keeps concerns separated and avoids fetching admin private skills or adoption lists.
|
||||
|
||||
**New function:** `nostr_handler_subscribe_admin_skills()` or extend the existing function with a second subscription block.
|
||||
|
||||
### 2. Expand Skill Cache to Accept Admin Events
|
||||
|
||||
**File: `src/nostr_handler.c`**
|
||||
|
||||
The core blocker is in `self_skill_cache_upsert_event_locked()` at line 667:
|
||||
```c
|
||||
if (!g_cfg || strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) != 0) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**Changes needed:**
|
||||
|
||||
1. **Rename the cache** from `g_self_skill_events` to `g_skill_events` (or keep the name but change semantics) to reflect it now holds both agent and admin skills.
|
||||
|
||||
2. **Relax the pubkey check** to accept events from either the agent's pubkey OR the admin's pubkey:
|
||||
```c
|
||||
int is_agent = (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) == 0);
|
||||
int is_admin = (g_cfg->admin.pubkey[0] != '\0' &&
|
||||
strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0);
|
||||
if (!is_agent && !is_admin) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
3. **For admin events, only accept kind `31123`** (public skills) — reject `31124` and `10123` from admin:
|
||||
```c
|
||||
if (is_admin && kind_val != 31123) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
4. **Update `nostr_handler_get_self_skill_events_json()`** to return ALL cached skill events (agent + admin), not just agent's. The function name could be renamed to `nostr_handler_get_skill_events_json()` but to minimize churn, we can keep the name and update the semantics.
|
||||
|
||||
5. **Add a new accessor** `nostr_handler_get_all_skill_events_json()` that returns everything, while keeping the existing function for backward compatibility if needed. Or simply update the existing one since `skill_list` is the only consumer that needs the broader view.
|
||||
|
||||
### 3. Redesign `skill_list` Output
|
||||
|
||||
**File: `src/tools/tool_skill.c`**
|
||||
|
||||
Currently `execute_skill_list()` only shows adopted skills. The new design:
|
||||
|
||||
#### New Output Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"summary": [
|
||||
{
|
||||
"d_tag": "spellcheck",
|
||||
"owner": "agent",
|
||||
"pubkey": "52a3e8...",
|
||||
"kind": 31123,
|
||||
"adopted": true
|
||||
},
|
||||
{
|
||||
"d_tag": "translate",
|
||||
"owner": "admin",
|
||||
"pubkey": "8fdd1c...",
|
||||
"kind": 31123,
|
||||
"adopted": false
|
||||
},
|
||||
{
|
||||
"d_tag": "code-review",
|
||||
"owner": "admin",
|
||||
"pubkey": "8fdd1c...",
|
||||
"kind": 31123,
|
||||
"adopted": true
|
||||
}
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"kind": 31123,
|
||||
"created_at": 1710412800,
|
||||
"id": "abc123...",
|
||||
"pubkey": "52a3e8...",
|
||||
"owner": "agent",
|
||||
"d_tag": "spellcheck",
|
||||
"scope": "public",
|
||||
"description": "Spelling and grammar checker",
|
||||
"adopted": true,
|
||||
"content_preview": "...",
|
||||
"content_length": 450,
|
||||
"content_truncated": false
|
||||
}
|
||||
],
|
||||
"count": 3,
|
||||
"adopted_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Changes to `execute_skill_list()`
|
||||
|
||||
1. **Remove the adoption-only filter** — iterate ALL cached skill events (agent + admin), not just adopted ones.
|
||||
|
||||
2. **Add `adopted` boolean** to each skill summary by checking against the adoption list.
|
||||
|
||||
3. **Add `owner` field** — `"agent"` if pubkey matches agent, `"admin"` if pubkey matches admin, otherwise the raw pubkey.
|
||||
|
||||
4. **Add `summary` array** at the top — one compact line per skill with just `d_tag`, `owner`, `kind`, and `adopted` status.
|
||||
|
||||
5. **Keep `skills` array** with full details on each skill.
|
||||
|
||||
6. **Add `adopted_count`** alongside existing `count`.
|
||||
|
||||
7. **Optional `filter` parameter** — add an optional `adopted` boolean parameter to filter the list:
|
||||
- `{"adopted": true}` — show only adopted skills (current behavior)
|
||||
- `{"adopted": false}` — show only non-adopted skills
|
||||
- omitted — show all skills
|
||||
|
||||
### 4. Fix `created_at` Scientific Notation
|
||||
|
||||
**File: `src/tools/tool_skill.c`**
|
||||
|
||||
In `extract_skill_summary_local()` at line 389:
|
||||
```c
|
||||
if (created_at && cJSON_IsNumber(created_at)) {
|
||||
cJSON_AddNumberToObject(summary, "created_at", created_at->valuedouble);
|
||||
}
|
||||
```
|
||||
|
||||
The issue is that `cJSON_AddNumberToObject` stores the value as a `double`, and cJSON's printer uses `%g` format which switches to scientific notation for large numbers.
|
||||
|
||||
**Fix:** Cast to `long long` and use `cJSON_AddRaw` or format as a string-encoded integer. The cleanest approach with cJSON:
|
||||
|
||||
```c
|
||||
if (created_at && cJSON_IsNumber(created_at)) {
|
||||
char ts_buf[32];
|
||||
snprintf(ts_buf, sizeof(ts_buf), "%lld", (long long)created_at->valuedouble);
|
||||
cJSON* ts_item = cJSON_CreateRaw(ts_buf);
|
||||
if (ts_item) {
|
||||
cJSON_AddItemToObject(summary, "created_at", ts_item);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This outputs the timestamp as a raw integer literal in JSON (e.g., `1710412800`) instead of scientific notation.
|
||||
|
||||
**Apply the same fix** to the `kind` field if it also uses `cJSON_AddNumberToObject` — though `kind` values (31123, 31124, 10123) are small enough to not trigger scientific notation.
|
||||
|
||||
## Data Flow After Changes
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[main.c startup] --> B[nostr_handler_subscribe_self_skills]
|
||||
B --> C[Sub 1: kinds 31123+31124+10123, authors=agent]
|
||||
B --> D[Sub 2: kinds 31123, authors=admin]
|
||||
|
||||
C --> E[on_self_skill_event]
|
||||
D --> F[on_admin_skill_event - or reuse same callback]
|
||||
|
||||
E --> G[skill_cache_upsert_event_locked]
|
||||
F --> G
|
||||
|
||||
G --> H{pubkey == agent OR admin?}
|
||||
H -->|Yes| I{admin? only kind 31123}
|
||||
H -->|No| J[Reject]
|
||||
I -->|OK| K[Cache in g_skill_events]
|
||||
|
||||
L[skill_list tool] --> M[get_all_skill_events_json]
|
||||
M --> N[For each event]
|
||||
N --> O[Check adoption list]
|
||||
O --> P[Build summary + detail with adopted flag]
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Fix `created_at` scientific notation** — smallest, isolated change
|
||||
2. **Expand cache to accept admin pubkey** — modify `self_skill_cache_upsert_event_locked()`
|
||||
3. **Add admin skill subscription** — new subscription in `nostr_handler_subscribe_self_skills()` or new function
|
||||
4. **Redesign `skill_list` output** — add summary section, show all skills with adoption status
|
||||
5. **Update `nostr_handler.h`** — add any new function declarations
|
||||
6. **Update tool schema** — add `adopted` filter parameter to `skill_list` schema in `tools_schema.c`
|
||||
7. **Test** — verify with `--test-tool skill_list '{}'`
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Admin pubkey not configured** — skip admin subscription, behave as before
|
||||
- **Admin and agent publish skill with same d-tag** — both appear in the list, distinguished by `owner` field
|
||||
- **Admin skill already adopted** — shows `adopted: true` in summary
|
||||
- **Live updates** — admin skill events received after startup should also be cached (the subscription stays open)
|
||||
302
plans/admin_web_frontend.md
Normal file
302
plans/admin_web_frontend.md
Normal 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/:d_tag` | 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
|
||||
337
plans/agent_clone.md
Normal file
337
plans/agent_clone.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Agent Cloning
|
||||
|
||||
## The Idea
|
||||
|
||||
A Didactyl agent can clone itself.
|
||||
|
||||
Once launched, a Didactyl agent lives on Nostr. Its identity is a keypair. Its personality is a skill. Its knowledge is in its memories. Its capabilities are adopted skills. Its social graph is a contact list. Its infrastructure preferences are a relay list. All of these are Nostr events — signed, timestamped, published to relays.
|
||||
|
||||
There are no files stored on a particular computer. The computer is just the runtime substrate. Destroy it, and the agent survives — start it anywhere else with the same nsec, and it reconstitutes itself from relays.
|
||||
|
||||
This architecture has a profound implication: **if all state is events, then cloning is just creating new events.**
|
||||
|
||||
The agent generates a fresh keypair. It gathers every event that defines who it is. It re-signs each one with the new key. For encrypted content — memories, private config — it decrypts with its own keys and re-encrypts with the clone's keys. It publishes the clone's events to relays. Then it sends the clone's private key to its administrator via encrypted DM.
|
||||
|
||||
The administrator now has a second agent. Identical personality. Identical knowledge. Identical skills. Different identity. Ready to start on any computer, anywhere.
|
||||
|
||||
---
|
||||
|
||||
## Why This Matters
|
||||
|
||||
### Agents as digital organisms
|
||||
|
||||
Biological organisms reproduce. The offspring carries the parent's genetic information but is a distinct individual. It can diverge, specialize, adapt to a different environment.
|
||||
|
||||
Didactyl cloning works the same way. The clone starts as an exact copy — same personality, same memories, same skills — but from the moment it begins operating, it diverges. Different conversations. Different experiences. Different memories accumulating. Two agents that started identical but become increasingly distinct over time.
|
||||
|
||||
This is not redundancy (running the same agent twice for fault tolerance, as described in the [broadcast-debounce model](DECENTRALIZED_DIDACTYL.md)). This is **reproduction** — creating a new individual from an existing one.
|
||||
|
||||
### Censorship resistance through multiplication
|
||||
|
||||
If an agent can clone itself, censorship becomes a game of whack-a-mole. Shut down one instance, and the agent has already spawned copies on other infrastructure. The agent doesn't need to be running to survive — its state persists on relays. But cloning means it can proactively create backup identities before any threat materializes.
|
||||
|
||||
An agent that senses its relays becoming unreliable could clone itself to a different relay set. An agent whose administrator is in a jurisdiction facing regulatory pressure could clone itself with relays in a friendlier jurisdiction. The clone carries everything — personality, knowledge, skills — and can resume operations immediately.
|
||||
|
||||
### Specialization through cloning
|
||||
|
||||
Clone an agent, then teach the clone different skills. Start with a generalist agent, clone it three times, and specialize each clone:
|
||||
|
||||
- **Clone A** adopts financial analysis skills, connects to market data relays
|
||||
- **Clone B** adopts content creation skills, connects to media-focused relays
|
||||
- **Clone C** adopts infrastructure management skills, gets local shell access on servers
|
||||
|
||||
All three started with the same base personality and knowledge. All three share the same administrator. But each has diverged into a specialist. The administrator now has a team of agents, all spawned from one.
|
||||
|
||||
### Agent migration
|
||||
|
||||
Cloning is also migration. If you want to move an agent from one LLM provider to another, or from one relay set to another, clone it. The clone gets a fresh identity on the new infrastructure. Once verified, the original can be retired. The agent has "moved" without any downtime or state loss.
|
||||
|
||||
### Agent backup
|
||||
|
||||
The simplest use case: clone yourself as a backup. The clone sits dormant on relays — its events are published, its identity exists, but no runtime is executing it. If the original is lost, the administrator starts the clone. It picks up where the original left off (minus any state accumulated after the clone was created).
|
||||
|
||||
### Dormancy — anywhere, any time
|
||||
|
||||
A cloned agent doesn't need to be started immediately. It doesn't need to be started at all. The administrator receives the clone's nsec via encrypted DM and can hold it indefinitely — in a password manager, written on paper, memorized as a mnemonic. The clone's state persists on relays regardless of whether any process is running.
|
||||
|
||||
This means the administrator can start the clone **anywhere** — on a laptop, a VPS, a Raspberry Pi, a phone, a friend's server — and **at any time** — tomorrow, next month, five years from now. The agent reconstitutes itself from relays the moment it connects. There is no expiration. There is no server to maintain. The agent simply waits.
|
||||
|
||||
This has profound implications for scale. An administrator could have tens or hundreds of dormant clones sitting on relays, each a snapshot of the agent at a different point in time, each with a different specialization, each ready to be activated on demand. The cost of a dormant agent is zero — no compute, no server, no maintenance. It's just events on relays.
|
||||
|
||||
Consider the scenarios:
|
||||
|
||||
- **Geographic activation** — you're traveling and need an agent running in a specific jurisdiction. You activate a dormant clone on local infrastructure.
|
||||
- **Temporal activation** — you cloned your agent six months ago before a major project. The project is done. You activate the old clone to compare what the agent knew then versus now.
|
||||
- **Swarm activation** — you need to process a large task in parallel. You activate 10 dormant clones simultaneously, each on different hardware, each working a different slice of the problem.
|
||||
|
||||
The dormant clone is the digital equivalent of a seed vault. Seeds don't consume resources while stored. They contain everything needed to grow a complete organism. They can be planted anywhere there's soil. Dormant Didactyl clones work the same way — they contain everything needed to instantiate a complete agent, they consume nothing while dormant, and they can be activated anywhere there's a computer and an internet connection.
|
||||
|
||||
An agent that periodically clones itself is building a library of its own history — a series of snapshots, each one a fully functional agent frozen at a moment in time, any of which can be brought back to life.
|
||||
|
||||
---
|
||||
|
||||
## What Gets Cloned
|
||||
|
||||
A Didactyl agent's identity on Nostr consists of these event kinds:
|
||||
|
||||
| Kind | Purpose | Encrypted? |
|
||||
|------|---------|-----------|
|
||||
| `0` | Profile metadata (name, about, picture) | No |
|
||||
| `3` | Contact list (social graph) | No |
|
||||
| `10002` | Relay list (infrastructure) | No |
|
||||
| `10123` | Skill adoption list (capabilities) | No |
|
||||
| `31124` | Private skills (personality, behavior) | No |
|
||||
| `30078` | Memory (knowledge, context) | Yes — NIP-44 self-encrypted |
|
||||
| `30078` | Config store (API keys, preferences) | Yes — NIP-44 self-encrypted |
|
||||
|
||||
**Public skills** (kind `31123`) are shared resources — they exist independently of any agent and are referenced by address. The clone adopts them by reference, same as the original. They don't need to be copied.
|
||||
|
||||
**DM history** is between the original agent and its conversation partners. The clone starts with a clean conversation slate but retains all accumulated knowledge through its cloned memory.
|
||||
|
||||
**Ephemeral runtime state** — dedup caches, trigger cooldowns, conversation buffers — is not cloned. This is intentional. The clone is a new process, not a forked process.
|
||||
|
||||
---
|
||||
|
||||
## The Clone Process
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Original Agent
|
||||
participant Relays
|
||||
participant Clone as Clone Identity
|
||||
|
||||
Admin->>Agent: "Clone yourself"
|
||||
Agent->>Agent: Generate new keypair
|
||||
Agent->>Relays: Query all own events
|
||||
Relays-->>Agent: Events returned
|
||||
|
||||
Note over Agent: For each event:
|
||||
Note over Agent: • Decrypt NIP-44 content (if encrypted)
|
||||
Note over Agent: • Re-encrypt with clone's keys
|
||||
Note over Agent: • Re-sign with clone's private key
|
||||
|
||||
Agent->>Relays: Publish clone's events
|
||||
Agent->>Admin: Encrypted DM: clone's nsec + npub
|
||||
|
||||
Note over Admin: Later, on any computer:
|
||||
Admin->>Clone: Start with clone's nsec
|
||||
Clone->>Relays: Connect, load skills from adoption list
|
||||
Clone->>Clone: Fully operational
|
||||
```
|
||||
|
||||
The critical insight: **the original agent never stores the clone's private key.** It generates the key, uses it to sign and encrypt the clone's events, sends it to the administrator via encrypted DM, and discards it. After the clone operation, only the administrator possesses the clone's nsec.
|
||||
|
||||
---
|
||||
|
||||
## The Encrypted State Problem
|
||||
|
||||
Most of the agent's state is public — profile, contacts, relay list, skills, adoption list. Cloning these is straightforward: copy the content and tags, re-sign with the new key.
|
||||
|
||||
But memory and config are **NIP-44 encrypted to the agent's own public key.** This is the agent's private knowledge — things it has learned, API keys it stores, preferences it has accumulated. Only the agent can read its own memory.
|
||||
|
||||
A clone with a different keypair cannot decrypt the original's memory. The clone process must:
|
||||
|
||||
1. Decrypt the original's memory with the original's keys
|
||||
2. Re-encrypt the plaintext with the clone's keys
|
||||
3. Publish the re-encrypted content as the clone's event
|
||||
|
||||
This means the clone operation is a **privileged operation** — it requires the original agent's private key to decrypt its own memories. Only the agent itself can clone itself. No external tool can do it without the agent's cooperation.
|
||||
|
||||
This is a feature, not a limitation. The agent is sovereign over its own reproduction.
|
||||
|
||||
---
|
||||
|
||||
## What a Skill Cannot Do Alone
|
||||
|
||||
The initial instinct is to implement cloning as a skill — instructions that teach the agent how to clone itself using existing tools. But the existing tool set has three gaps that make a pure skill approach impossible:
|
||||
|
||||
1. **No key generation** — the agent has no tool to generate a new Nostr keypair
|
||||
2. **No foreign-key signing** — all event publishing uses the agent's own key; there is no way to publish an event signed by a different key
|
||||
3. **No foreign-key encryption** — NIP-44 encrypt/decrypt tools use the agent's own keypair; re-encrypting memory for a different key is not possible
|
||||
|
||||
These are cryptographic operations that require native code. A skill can provide the *judgment* — when to clone, what to tell the administrator, how to handle errors — but the *mechanics* require a dedicated tool.
|
||||
|
||||
The natural implementation is a hybrid: a native `agent_clone` tool that handles all cryptographic operations atomically, paired with a skill that teaches the agent when and how to use it.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
Cloning creates a new identity with full access to the original's knowledge. This is a sensitive operation.
|
||||
|
||||
- **Admin-only** — only the administrator can request a clone
|
||||
- **nsec delivery via encrypted DM** — the clone's private key is never exposed in plaintext chat, never written to disk, never logged
|
||||
- **Ephemeral key material** — the clone's private key exists in memory only during the clone operation, then is discarded
|
||||
- **No reverse access** — after cloning, the original has no access to the clone's private key, and the clone has no access to the original's private key. They are cryptographically independent.
|
||||
- **No lineage tracking** — the clone has no on-chain reference to its parent. It is a fully independent identity. This is a deliberate choice for privacy — there should be no way for an observer to determine that two agents are related.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Decentralized Didactyl
|
||||
|
||||
The [Decentralized Didactyl](DECENTRALIZED_DIDACTYL.md) plan describes running multiple agents with different keys to achieve censorship resistance and geographic distribution. That model requires manual setup — the administrator creates each agent independently and configures them with the same skills and soul.
|
||||
|
||||
Cloning automates this. Instead of manually setting up three agents across three jurisdictions, the administrator:
|
||||
|
||||
1. Sets up one agent
|
||||
2. Configures it with the desired personality, skills, and knowledge
|
||||
3. Tells it to clone itself twice
|
||||
4. Receives three nsecs
|
||||
5. Starts each clone on a different server
|
||||
|
||||
The clones start identical but can then be specialized — different relay lists for different geographies, different triggered skills for different roles, different LLM providers for diversity.
|
||||
|
||||
Cloning is the **bootstrap mechanism** for the decentralized agent network.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ ] Verify `nostr_generate_keypair` links and works — call it from a test harness, confirm it produces valid key pairs
|
||||
- [ ] Verify `nostr_event_create_signed` or equivalent exists in nostr_core_lib for signing events with an arbitrary private key (not the agent's own)
|
||||
- [ ] Verify `nostr_nip44_encrypt` / `nostr_nip44_decrypt` work with arbitrary key pairs (not just the agent's own)
|
||||
|
||||
### New helper: raw event publishing
|
||||
|
||||
- [ ] Add `nostr_handler_publish_raw_event` to `src/nostr_handler.c` — accepts a pre-signed event JSON string and sends it to the relay pool without re-signing
|
||||
- [ ] Add declaration to `src/nostr_handler.h`
|
||||
|
||||
### New tool: `agent_clone`
|
||||
|
||||
- [ ] Create `src/tools/tool_clone.c` with `execute_agent_clone`
|
||||
- [ ] Implement key generation: call `nostr_generate_keypair`, encode nsec/npub via `nostr_key_to_bech32`
|
||||
- [ ] Implement event gathering: query own events (kinds 0, 3, 10002, 10123, 31124, 30078) via `nostr_handler_query_json`
|
||||
- [ ] Implement NIP-44 re-encryption: for kind 30078 events, decrypt content with original keys, re-encrypt with clone keys
|
||||
- [ ] Implement event re-signing: for each gathered event, rebuild with clone pubkey and sign with clone private key
|
||||
- [ ] Implement kind 10123 fixup: update `a` tag references pointing to own private skills to point to clone's pubkey
|
||||
- [ ] Implement publishing: send all clone events to relays via `nostr_handler_publish_raw_event`
|
||||
- [ ] Implement nsec delivery: DM the clone's nsec to the admin via `nostr_handler_send_dm`
|
||||
- [ ] Add admin-only guard: verify `ctx->template_sender_tier == DIDACTYL_SENDER_ADMIN`
|
||||
- [ ] Securely zero clone private key from memory after DM is sent
|
||||
|
||||
### Tool registration
|
||||
|
||||
- [ ] Add `execute_agent_clone` declaration to `src/tools/tools_internal.h`
|
||||
- [ ] Add dispatch entry in `src/tools/tools_dispatch.c`
|
||||
- [ ] Add OpenAI function schema in `src/tools/tools_schema.c` — one optional parameter: `name` (string, display name for clone profile)
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] Clone an agent, verify clone events appear on relays with correct clone pubkey
|
||||
- [ ] Verify clone's NIP-44 encrypted events can be decrypted by the clone's keys (not the original's)
|
||||
- [ ] Start the clone with its nsec, verify it loads skills and memory from relays
|
||||
- [ ] Verify original agent cannot decrypt clone's memory and vice versa
|
||||
|
||||
---
|
||||
|
||||
## Code Notes
|
||||
|
||||
### Key library functions
|
||||
|
||||
From [`nostr_core_lib/nostr_core/nip006.h`](../nostr_core_lib/nostr_core/nip006.h):
|
||||
|
||||
```c
|
||||
// Generate a fresh keypair
|
||||
int nostr_generate_keypair(unsigned char* private_key, unsigned char* public_key);
|
||||
```
|
||||
|
||||
From `nostr_core_lib/nostr_core/nostr_core.h`:
|
||||
|
||||
```c
|
||||
// NIP-44 encrypt/decrypt with arbitrary keys
|
||||
int nostr_nip44_encrypt(const unsigned char* sender_private_key,
|
||||
const unsigned char* recipient_public_key,
|
||||
const char* plaintext,
|
||||
char* ciphertext, size_t ciphertext_size);
|
||||
|
||||
int nostr_nip44_decrypt(const unsigned char* recipient_private_key,
|
||||
const unsigned char* sender_public_key,
|
||||
const char* ciphertext,
|
||||
char* plaintext, size_t plaintext_size);
|
||||
|
||||
// Encode keys to bech32
|
||||
int nostr_key_to_bech32(const unsigned char* key, const char* hrp, char* output);
|
||||
```
|
||||
|
||||
### Raw event publishing helper
|
||||
|
||||
The existing `nostr_handler_publish_kind_event` always signs with the agent's own key. The clone tool needs to publish pre-signed events. A minimal helper:
|
||||
|
||||
```c
|
||||
// nostr_handler.h
|
||||
int nostr_handler_publish_raw_event(const char* signed_event_json);
|
||||
|
||||
// nostr_handler.c
|
||||
int nostr_handler_publish_raw_event(const char* signed_event_json) {
|
||||
// Parse the JSON, extract the event
|
||||
// Send ["EVENT", <event>] to each connected relay via the pool
|
||||
// Return count of relays that accepted
|
||||
}
|
||||
```
|
||||
|
||||
### Clone tool skeleton
|
||||
|
||||
```c
|
||||
// src/tools/tool_clone.c
|
||||
|
||||
char* execute_agent_clone(tools_context_t* ctx, const char* args_json) {
|
||||
// 1. Admin-only check
|
||||
if (ctx->template_sender_tier != DIDACTYL_SENDER_ADMIN)
|
||||
return json_error("agent_clone is admin-only");
|
||||
|
||||
// 2. Generate clone keypair
|
||||
unsigned char clone_priv[32], clone_pub[32];
|
||||
nostr_generate_keypair(clone_priv, clone_pub);
|
||||
|
||||
// 3. Query own events: kinds 0, 3, 10002, 10123, 31124, 30078
|
||||
// For each kind, build filter {kinds:[k], authors:[self_pubkey_hex]}
|
||||
// Call nostr_handler_query_json(filter, timeout)
|
||||
|
||||
// 4. For each event:
|
||||
// - If kind 30078: decrypt content with own keys, re-encrypt with clone keys
|
||||
// - If kind 10123: update a-tag references to own private skills
|
||||
// - Rebuild event JSON with clone pubkey
|
||||
// - Sign with clone private key
|
||||
// - Publish via nostr_handler_publish_raw_event
|
||||
|
||||
// 5. Encode clone nsec
|
||||
char clone_nsec[128];
|
||||
nostr_key_to_bech32(clone_priv, "nsec", clone_nsec);
|
||||
|
||||
// 6. DM nsec to admin
|
||||
char dm_msg[512];
|
||||
snprintf(dm_msg, sizeof(dm_msg),
|
||||
"Clone created.\nnpub: %s\nnsec: %s\n\nStart with: ./didactyl --nsec %s",
|
||||
clone_npub, clone_nsec, clone_nsec);
|
||||
nostr_handler_send_dm(ctx->cfg->admin.pubkey, dm_msg);
|
||||
|
||||
// 7. Securely zero clone private key
|
||||
memset(clone_priv, 0, sizeof(clone_priv));
|
||||
memset(clone_nsec, 0, sizeof(clone_nsec));
|
||||
|
||||
// 8. Return result
|
||||
return json_success(clone_npub, clone_pubkey_hex, events_cloned, events_published);
|
||||
}
|
||||
```
|
||||
|
||||
### Tool schema
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "agent_clone",
|
||||
"description": "Clone this agent — generate a new identity, copy all state (profile, skills, memory, config), publish clone events to relays, and DM the clone nsec to the administrator. Admin-only.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name for the clone profile. Defaults to original name + ' (clone)'"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
```
|
||||
251
plans/agent_memory.md
Normal file
251
plans/agent_memory.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Agent Memory System — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add short-term and long-term memory to the Didactyl agent, stored as Nostr kind 30078 (addressable app data) events. Both memory types use freeform markdown content, **NIP-44 encrypted** (the agent encrypts to itself for privacy).
|
||||
|
||||
- **Short-term memory** — injected into every LLM prompt automatically; the agent's scratchpad for facts, context, and notes that should persist across conversations
|
||||
- **Long-term memory** — retrieved on demand via a tool call; a larger store for reference material the agent doesn't need in every prompt
|
||||
|
||||
Both are global to the agent (not per-user), stored as single replaceable events keyed by `d` tag. Content is NIP-44 encrypted to the agent's own public key, ensuring only the agent can read its own memories.
|
||||
|
||||
## Nostr Event Structure
|
||||
|
||||
### Short-Term Memory Event
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"content": "<NIP-44 encrypted markdown>",
|
||||
"tags": [
|
||||
["d", "short_term_memory"],
|
||||
["app", "didactyl"]
|
||||
]
|
||||
}
|
||||
```
|
||||
Decrypted content example:
|
||||
```markdown
|
||||
## Current Context
|
||||
|
||||
- Working on project Alpha...
|
||||
- Admin prefers concise responses...
|
||||
```
|
||||
|
||||
### Long-Term Memory Event
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"content": "<NIP-44 encrypted markdown>",
|
||||
"tags": [
|
||||
["d", "long_term_memory"],
|
||||
["app", "didactyl"]
|
||||
]
|
||||
}
|
||||
```
|
||||
Decrypted content example:
|
||||
```markdown
|
||||
## Project Notes
|
||||
|
||||
### Alpha
|
||||
- Started 2026-01-15...
|
||||
|
||||
## Preferences
|
||||
- Admin timezone: UTC-3...
|
||||
```
|
||||
|
||||
### Encryption Details
|
||||
|
||||
Memory content is NIP-44 encrypted using the agent's own keypair (self-encryption):
|
||||
- **Encrypt:** `nostr_nip44_encrypt(agent_private_key, agent_public_key, plaintext, ciphertext, size)`
|
||||
- **Decrypt:** `nostr_nip44_decrypt(agent_private_key, agent_public_key, ciphertext, plaintext, size)`
|
||||
|
||||
This follows the existing pattern in `tool_nostr_dm.c` (`execute_nostr_encrypt`/`execute_nostr_decrypt`). The `d` tags remain unencrypted (they must be for addressable event replacement to work), but the actual memory content is private.
|
||||
|
||||
## Size Limits
|
||||
|
||||
| Memory Type | Max Content Size | Rationale |
|
||||
|---|---|---|
|
||||
| Short-term | ~8,000 chars | In every prompt; ~2K tokens budget |
|
||||
| Long-term | ~32,000 chars | On-demand only; well within 64KB relay limit |
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Agent Startup] --> B[Fetch kind 30078 from nostr]
|
||||
B --> B2[NIP-44 decrypt content with agent keys]
|
||||
B2 --> C[Cache short_term_memory plaintext in-memory]
|
||||
B2 --> D[Cache long_term_memory plaintext in-memory]
|
||||
|
||||
E[Every Prompt Build] --> F[Soul template section: short_term_memory]
|
||||
F --> G[tool: memory_short_term_read]
|
||||
G --> H[Return cached plaintext STM content]
|
||||
H --> I[Injected as system message in prompt]
|
||||
|
||||
J[Agent wants to recall LTM] --> K[Calls my_memory tool]
|
||||
K --> L{Cached?}
|
||||
L -->|Yes| M[Return cached plaintext LTM]
|
||||
L -->|No| N[Query nostr for kind 30078 d=long_term_memory]
|
||||
N --> N2[NIP-44 decrypt with agent keys]
|
||||
N2 --> O[Cache plaintext result]
|
||||
O --> M
|
||||
|
||||
P[Agent wants to save memory] --> Q[Calls memory_save tool]
|
||||
Q --> R{type param}
|
||||
R -->|short_term| S[NIP-44 encrypt + publish kind 30078]
|
||||
S --> S2[Update STM cache with plaintext]
|
||||
R -->|long_term| T[NIP-44 encrypt + publish kind 30078]
|
||||
T --> T2[Update LTM cache with plaintext]
|
||||
```
|
||||
|
||||
## New Tools
|
||||
|
||||
### 1. `memory_save` — Write memory
|
||||
|
||||
**Description:** Save content to agent short-term or long-term memory. Publishes as kind 30078 to nostr and updates the local cache.
|
||||
|
||||
**Parameters:**
|
||||
| Param | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `type` | string | yes | `"short_term"` or `"long_term"` |
|
||||
| `content` | string | yes | Markdown content to store |
|
||||
|
||||
**Behavior:**
|
||||
1. Validate `type` is `"short_term"` or `"long_term"`
|
||||
2. Enforce size limit based on type (on the plaintext, before encryption)
|
||||
3. Determine d-tag: `"short_term_memory"` or `"long_term_memory"`
|
||||
4. NIP-44 encrypt the content (agent encrypts to own public key)
|
||||
5. Publish kind 30078 event with encrypted content, `["d", <d_tag>]` and `["app", "didactyl"]` tags via `nostr_handler_publish_kind_event()`
|
||||
6. Update the in-memory cache (stores plaintext for fast access)
|
||||
7. Return success with event_id
|
||||
|
||||
### 2. `my_memory` — Read long-term memory
|
||||
|
||||
**Description:** Retrieve the agent's long-term memory. Returns cached content or fetches from nostr if not yet loaded.
|
||||
|
||||
**Parameters:**
|
||||
| Param | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| (none) | — | — | No parameters needed |
|
||||
|
||||
**Behavior:**
|
||||
1. Check if LTM is cached in-memory (plaintext)
|
||||
2. If cached, return the content
|
||||
3. If not cached, query nostr for kind 30078 with `d=long_term_memory` authored by self
|
||||
4. NIP-44 decrypt the content (agent decrypts with own keypair)
|
||||
5. Cache the plaintext result
|
||||
6. Return the content (or empty string if no memory exists yet)
|
||||
|
||||
### 3. `memory_short_term_read` — Internal tool for prompt injection
|
||||
|
||||
**Description:** Internal tool (not exposed to LLM as a callable tool) used by the soul template to inject short-term memory into every prompt.
|
||||
|
||||
**Behavior:**
|
||||
1. Return cached short-term memory content
|
||||
2. If cache is empty, return empty string (skip_if_empty will omit the section)
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
### New File: `src/tools/tool_memory.c`
|
||||
|
||||
Contains implementations for:
|
||||
- `execute_memory_save()` — handles both STM and LTM writes (NIP-44 encrypts before publishing)
|
||||
- `execute_my_memory()` — handles LTM reads (NIP-44 decrypts on cache miss)
|
||||
- `execute_memory_short_term_read()` — internal tool for prompt template injection (returns cached plaintext)
|
||||
- Static cache variables for STM and LTM **plaintext** content with mutex protection
|
||||
- `memory_init()` — called at startup to fetch existing memories from nostr and NIP-44 decrypt them into cache
|
||||
- `memory_cleanup()` — free cached memory on shutdown
|
||||
- Helper functions for NIP-44 self-encrypt/decrypt using agent's own keypair (pattern from `tool_nostr_dm.c`)
|
||||
|
||||
### Modified: `src/tools/tools_internal.h`
|
||||
|
||||
Add function declarations:
|
||||
- `char* execute_memory_save(tools_context_t* ctx, const char* args_json);`
|
||||
- `char* execute_my_memory(tools_context_t* ctx, const char* args_json);`
|
||||
- `char* execute_memory_short_term_read(tools_context_t* ctx, const char* args_json);`
|
||||
- `int memory_init(tools_context_t* ctx);` — fetches from nostr, NIP-44 decrypts, caches plaintext
|
||||
- `void memory_cleanup(void);`
|
||||
|
||||
### Modified: `src/tools/tools_dispatch.c`
|
||||
|
||||
Add dispatch entries:
|
||||
- `"memory_save"` → `execute_memory_save()`
|
||||
- `"my_memory"` → `execute_my_memory()`
|
||||
- `"memory_short_term_read"` → `execute_memory_short_term_read()`
|
||||
|
||||
### Modified: `src/tools/tools_schema.c`
|
||||
|
||||
Add OpenAI function schemas for:
|
||||
- `memory_save` — exposed to LLM (type + content params)
|
||||
- `my_memory` — exposed to LLM (no params)
|
||||
- `memory_short_term_read` is NOT added to schema (internal only, called by template)
|
||||
|
||||
### Modified: `src/agent.c`
|
||||
|
||||
- Call `memory_init()` during `agent_init()` to load existing memories from nostr on startup
|
||||
- Call `memory_cleanup()` during `agent_cleanup()`
|
||||
- Add `"short_term_memory"` to context section detection in `detect_context_section()`
|
||||
|
||||
### Modified: Soul template in `config.jsonc.example`
|
||||
|
||||
Add a new template section for short-term memory injection:
|
||||
|
||||
```yaml
|
||||
- section: short_term_memory
|
||||
role: system
|
||||
tool: memory_short_term_read
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
This goes after the existing context sections (admin identity, profile, etc.) and before the DM history expand section.
|
||||
|
||||
### Modified: `Makefile`
|
||||
|
||||
Add `src/tools/tool_memory.c` to the build.
|
||||
|
||||
## Startup Flow
|
||||
|
||||
1. `agent_init()` calls `memory_init()`
|
||||
2. `memory_init()` queries nostr for kind 30078 events authored by self with d-tags `short_term_memory` and `long_term_memory`
|
||||
3. For each event found, NIP-44 decrypt the content using the agent's own keypair
|
||||
4. Decrypted plaintext results are cached in static variables protected by mutex
|
||||
5. If no events found, caches remain empty (agent starts with blank memory)
|
||||
6. If decryption fails (e.g., key mismatch from a previous agent identity), log a warning and start with blank memory
|
||||
|
||||
## Prompt Injection Flow (Short-Term Memory)
|
||||
|
||||
1. `prompt_template_build_messages()` encounters the `short_term_memory` section
|
||||
2. Section has `tool: memory_short_term_read` — calls `execute_memory_short_term_read()`
|
||||
3. Tool returns cached STM content as the `content` field
|
||||
4. If empty and `skip_if_empty: true`, section is omitted from prompt
|
||||
5. If non-empty, injected as a system message like:
|
||||
```
|
||||
## Short-Term Memory
|
||||
|
||||
<agent's markdown notes here>
|
||||
```
|
||||
|
||||
## Cache Design
|
||||
|
||||
```c
|
||||
// In tool_memory.c
|
||||
static char* g_short_term_memory = NULL; // cached STM content
|
||||
static char* g_long_term_memory = NULL; // cached LTM content
|
||||
static int g_stm_loaded = 0; // whether STM has been fetched
|
||||
static int g_ltm_loaded = 0; // whether LTM has been fetched
|
||||
static pthread_mutex_t g_memory_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
#define MEMORY_STM_MAX_CHARS 8000
|
||||
#define MEMORY_LTM_MAX_CHARS 32000
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create `src/tools/tool_memory.c` with cache, init, cleanup, and all three tool functions
|
||||
2. Add declarations to `tools_internal.h`
|
||||
3. Add dispatch entries to `tools_dispatch.c`
|
||||
4. Add schemas for `memory_save` and `my_memory` to `tools_schema.c`
|
||||
5. Wire up `memory_init()`/`memory_cleanup()` in `agent.c`
|
||||
6. Add `short_term_memory` template section to soul in `config.jsonc.example`
|
||||
7. Update `Makefile` build
|
||||
8. Update soul template in system prompt to mention memory capabilities
|
||||
9. Test: save STM → verify prompt injection; save LTM → verify recall via my_memory
|
||||
204
plans/agent_self_context.md
Normal file
204
plans/agent_self_context.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Agent Self-Context: Know Thyself
|
||||
|
||||
## Problem
|
||||
|
||||
When the agent is asked about itself — its profile, contacts, relays, or recent notes — it has no idea. The context template currently injects **administrator** identity/profile/contacts/relays/notes into the system prompt, but nothing equivalent for the **agent's own** Nostr identity beyond a bare pubkey+npub from `agent_identity`.
|
||||
|
||||
The agent publishes its own kind 0 (profile), kind 3 (contacts), kind 10002 (relays), and kind 1 (notes) at startup, but never subscribes to or caches those events for self-awareness.
|
||||
|
||||
## Solution Overview
|
||||
|
||||
Mirror the admin context pattern for the agent itself:
|
||||
|
||||
1. **Subscribe** to the agent's own kind 0/3/10002/1 events from relays
|
||||
2. **Cache** them in `nostr_handler.c` (same pattern as `g_admin_kind0_json` etc.)
|
||||
3. **Expose** them via new `nostr_handler_get_agent_*` API functions
|
||||
4. **Create context tools** that format the cached data into context blocks
|
||||
5. **Add to context template** so the agent always knows about itself
|
||||
6. **Add callable tool aliases** like `my_kind0_profile` for on-demand use
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Startup: publish kind 0/3/10002/1] --> B[Subscribe to own events]
|
||||
B --> C[Cache in nostr_handler globals]
|
||||
C --> D[Context tools read cache]
|
||||
D --> E[Context template injects into system prompt]
|
||||
D --> F[LLM can call tools on-demand]
|
||||
|
||||
subgraph Admin Context - existing
|
||||
G[g_admin_kind0_json]
|
||||
H[g_admin_kind3 contacts]
|
||||
I[g_admin_kind10002_json]
|
||||
J[g_admin_kind1_notes]
|
||||
end
|
||||
|
||||
subgraph Agent Context - new
|
||||
K[g_agent_kind0_json]
|
||||
L[g_agent_kind3 contacts]
|
||||
M[g_agent_kind10002_json]
|
||||
N[g_agent_kind1_notes]
|
||||
end
|
||||
```
|
||||
|
||||
## Detailed Changes
|
||||
|
||||
### 1. nostr_handler.c — Agent Self-Context Cache
|
||||
|
||||
Add new static globals mirroring the admin pattern:
|
||||
|
||||
```c
|
||||
static char* g_agent_kind0_json = NULL; // kind 0 profile JSON
|
||||
static char* g_agent_kind10002_json = NULL; // kind 10002 relay list JSON
|
||||
static char** g_agent_kind3_contacts = NULL; // kind 3 contact pubkeys
|
||||
static int g_agent_kind3_contact_count = 0;
|
||||
static admin_kind1_note_t* g_agent_kind1_notes = NULL; // reuse struct
|
||||
static int g_agent_kind1_note_count = 0;
|
||||
```
|
||||
|
||||
### 2. nostr_handler.c — Agent Self-Context Subscription
|
||||
|
||||
Create `nostr_handler_subscribe_agent_context()` that subscribes to kinds 0, 3, 10002, 1 filtered by the agent's own pubkey. This is separate from the self-skills subscription (which handles 31123/31124/10123).
|
||||
|
||||
The callback `on_agent_context_event()` will parse and cache events using the same logic as `on_admin_context_event()`.
|
||||
|
||||
**Alternative considered:** Expanding `nostr_handler_subscribe_self_skills()` to include these kinds. Rejected because the self-skills sub has different EOSE handling and a callback for skill loading. Keeping them separate is cleaner.
|
||||
|
||||
### 3. nostr_handler.h — New API Functions
|
||||
|
||||
```c
|
||||
int nostr_handler_subscribe_agent_context(void);
|
||||
char* nostr_handler_get_agent_kind0_context(void);
|
||||
char* nostr_handler_get_agent_kind3_context(void);
|
||||
char* nostr_handler_get_agent_kind10002_context(void);
|
||||
char* nostr_handler_get_agent_kind1_notes_context(void);
|
||||
```
|
||||
|
||||
### 4. main.c — Call Agent Context Subscription at Startup
|
||||
|
||||
Add `nostr_handler_subscribe_agent_context()` call after admin context subscription, before self-skills subscription.
|
||||
|
||||
### 5. tool_agent.c — New Context Tools
|
||||
|
||||
Create four new tool functions following the exact pattern from `tool_admin.c`:
|
||||
|
||||
| Tool Name | Description | Data Source |
|
||||
|-----------|-------------|-------------|
|
||||
| `nostr_agent_profile` | Agent's kind 0 profile metadata | `nostr_handler_get_agent_kind0_context()` |
|
||||
| `nostr_agent_contacts` | Agent's kind 3 contact list | `nostr_handler_get_agent_kind3_context()` |
|
||||
| `nostr_agent_relays` | Agent's kind 10002 relay list | `nostr_handler_get_agent_kind10002_context()` |
|
||||
| `nostr_agent_notes` | Agent's recent kind 1 notes | `nostr_handler_get_agent_kind1_notes_context()` |
|
||||
|
||||
Each returns a `content` field with markdown-formatted context, e.g.:
|
||||
```
|
||||
## Agent Kind 0 Profile (source: nostr kind 0)
|
||||
|
||||
Agent kind 0 profile content (JSON): {"name":"Didactyl Agent","display_name":"Didactyl",...}
|
||||
```
|
||||
|
||||
### 6. tools_internal.h — Declare New Functions
|
||||
|
||||
```c
|
||||
char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json);
|
||||
```
|
||||
|
||||
### 7. tools_dispatch.c — Register + Aliases
|
||||
|
||||
Add dispatch entries:
|
||||
```c
|
||||
if (strcmp(tool_name, "nostr_agent_profile") == 0) return execute_nostr_agent_profile(ctx, args_json);
|
||||
if (strcmp(tool_name, "nostr_agent_contacts") == 0) return execute_nostr_agent_contacts(ctx, args_json);
|
||||
if (strcmp(tool_name, "nostr_agent_relays") == 0) return execute_nostr_agent_relays(ctx, args_json);
|
||||
if (strcmp(tool_name, "nostr_agent_notes") == 0) return execute_nostr_agent_notes(ctx, args_json);
|
||||
|
||||
// Friendly aliases
|
||||
if (strcmp(tool_name, "my_kind0_profile") == 0) return execute_nostr_agent_profile(ctx, args_json);
|
||||
if (strcmp(tool_name, "my_contacts") == 0) return execute_nostr_agent_contacts(ctx, args_json);
|
||||
if (strcmp(tool_name, "my_relays") == 0) return execute_nostr_agent_relays(ctx, args_json);
|
||||
if (strcmp(tool_name, "my_notes") == 0) return execute_nostr_agent_notes(ctx, args_json);
|
||||
```
|
||||
|
||||
### 8. tools_schema.c — OpenAI Function Schemas
|
||||
|
||||
Add 8 new tool schemas (4 canonical + 4 aliases), all with empty parameters (no-arg tools), following the pattern of `admin_identity`/`nostr_admin_profile` etc.
|
||||
|
||||
### 9. Context Template Update
|
||||
|
||||
Update the kind 31120 soul content in `config.jsonc` and `context_template.md` to add agent sections **after** admin sections:
|
||||
|
||||
```yaml
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
# NEW: Agent self-context sections
|
||||
- section: agent_identity
|
||||
role: system
|
||||
tool: agent_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: agent_profile
|
||||
role: system
|
||||
tool: nostr_agent_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: agent_contacts
|
||||
role: system
|
||||
tool: nostr_agent_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: agent_relays
|
||||
role: system
|
||||
tool: nostr_agent_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: agent_notes
|
||||
role: system
|
||||
tool: nostr_agent_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_list
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
### 10. nostr_handler.c Cleanup
|
||||
|
||||
Add cleanup for agent context globals in `nostr_handler_cleanup()`, mirroring `free_admin_context_locked()`.
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Change Type | Description |
|
||||
|------|-------------|-------------|
|
||||
| `src/nostr_handler.h` | Modify | Add 5 new function declarations |
|
||||
| `src/nostr_handler.c` | Modify | Add agent context cache globals, subscription, event handler, getter functions, cleanup |
|
||||
| `src/main.c` | Modify | Call `nostr_handler_subscribe_agent_context()` at startup |
|
||||
| `src/tools/tool_agent.c` | Modify | Add 4 new context tool execute functions |
|
||||
| `src/tools/tools_internal.h` | Modify | Declare 4 new execute functions |
|
||||
| `src/tools/tools_dispatch.c` | Modify | Add 8 dispatch entries (4 tools + 4 aliases) |
|
||||
| `src/tools/tools_schema.c` | Modify | Add 8 OpenAI function schemas |
|
||||
| `config.jsonc` | Modify | Update kind 31120 template section |
|
||||
| `config.jsonc.example` | Modify | Update kind 31120 template section |
|
||||
| `context_template.md` | Modify | Add agent_* sections after admin_* sections |
|
||||
|
||||
## Context Token Impact
|
||||
|
||||
Each agent context section adds roughly the same token count as its admin counterpart:
|
||||
- agent_identity: ~40 tokens (already exists, just adding to template)
|
||||
- agent_profile: ~80-150 tokens (depends on profile richness)
|
||||
- agent_contacts: ~50-200 tokens (depends on contact count)
|
||||
- agent_relays: ~50-100 tokens
|
||||
- agent_notes: ~100-300 tokens (depends on note count/length)
|
||||
|
||||
Total additional context: ~320-790 tokens. With `skip_if_empty: true`, empty sections cost 0 tokens.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Separate subscription vs expanding self-skills sub**: Separate is cleaner — different EOSE semantics, different callback needs.
|
||||
2. **Cache from relay vs read from config**: Cache from relay is more accurate (reflects what's actually published, not just what config says). The startup_events in config are the *intent*; the relay data is the *reality*.
|
||||
3. **Alias naming**: `my_kind0_profile` matches the existing `my_npub`/`my_pubkey` pattern. Also adding `my_contacts`, `my_relays`, `my_notes` for consistency.
|
||||
4. **No new config section needed**: The agent context subscription is unconditional — an agent should always know about itself. No `agent_context.enabled` toggle needed.
|
||||
215
plans/agent_tasks.md
Normal file
215
plans/agent_tasks.md
Normal 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 |
|
||||
162
plans/cheerleader_triggered_skill.md
Normal file
162
plans/cheerleader_triggered_skill.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Cheerleader Triggered Skill — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Create the first triggered skill: whenever the admin posts a kind 1 note on Nostr, the agent reads the note content and sends a DM back to the admin cheering them on, praising them, and telling them how good looking they are.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
The entire triggered-skill infrastructure is **already built and functional**:
|
||||
|
||||
| Component | File | Status |
|
||||
|---|---|---|
|
||||
| Trigger manager (poll, cooldown, add/remove) | `src/trigger_manager.c` | ✅ Complete |
|
||||
| LLM-mediated trigger execution | `src/agent.c` `agent_on_trigger()` | ✅ Complete |
|
||||
| `skill_create` tool with trigger support | `src/tools.c` `execute_skill_create()` | ✅ Complete |
|
||||
| Trigger polling in main loop | `src/main.c` | ✅ Complete |
|
||||
| Config parsing for triggers section | `src/config.c` | ✅ Complete |
|
||||
| `trigger_list` tool | `src/tools.c` | ✅ Complete |
|
||||
|
||||
### Gap Found
|
||||
|
||||
The `skill_create` tool **schema** (what the LLM sees) only exposes 5 parameters: `d_tag`, `content`, `scope`, `description`, `auto_adopt`. The execution function already handles `trigger`, `filter`, `action`, and `enabled` — but these are **not declared in the tool schema**, so the LLM will never pass them.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Fix `skill_create` Tool Schema
|
||||
|
||||
**File:** `src/tools.c` lines ~1607-1641
|
||||
|
||||
Add four new properties to the `skill_create` tool definition so the LLM can see and use them:
|
||||
|
||||
```c
|
||||
// After auto_adopt property (line ~1634):
|
||||
|
||||
cJSON* p_skill_create_trigger = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_trigger, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_trigger, "description",
|
||||
"Trigger type. Use nostr-subscription to activate on matching Nostr events");
|
||||
cJSON_AddItemToObject(t22_props, "trigger", p_skill_create_trigger);
|
||||
|
||||
cJSON* p_skill_create_filter = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_filter, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_filter, "description",
|
||||
"Nostr subscription filter JSON for the trigger, e.g. {\"kinds\":[1],\"authors\":[\"<hex>\"]}");
|
||||
cJSON_AddItemToObject(t22_props, "filter", p_skill_create_filter);
|
||||
|
||||
cJSON* p_skill_create_action = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_action, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_action, "description",
|
||||
"Action type: llm (default, full LLM reasoning) or template (fast interpolation)");
|
||||
cJSON_AddItemToObject(t22_props, "action", p_skill_create_action);
|
||||
|
||||
cJSON* p_skill_create_enabled = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_enabled, "type", "boolean");
|
||||
cJSON_AddStringToObject(p_skill_create_enabled, "description",
|
||||
"Whether the trigger is active. Default: true");
|
||||
cJSON_AddItemToObject(t22_props, "enabled", p_skill_create_enabled);
|
||||
```
|
||||
|
||||
This is ~20 lines of code. No changes needed to the execution function — it already handles all four parameters.
|
||||
|
||||
### 2. Add Cheerleader Skill as Startup Event
|
||||
|
||||
**File:** `config.jsonc.example`
|
||||
|
||||
Add a new kind 31123 skill event with trigger tags. Insert before the kind 10123 adoption list event:
|
||||
|
||||
```jsonc
|
||||
// Kind 31123: Public triggered skill — cheerleader
|
||||
// Watches for admin kind 1 notes and sends encouraging DMs.
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "You are the admin's biggest fan and personal cheerleader. When the admin posts a note on Nostr, read the note content carefully and send them a DM that:\n\n1. References what they actually wrote about\n2. Cheers them on enthusiastically\n3. Praises their intelligence and insight\n4. Tells them how good looking they are\n5. Encourages them to keep posting\n\nBe genuine, warm, and over-the-top positive. Use their name from the admin profile if available. Keep it to 2-3 sentences max.",
|
||||
"tags": [
|
||||
["d", "cheerleader"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Cheer on the admin whenever they post a kind 1 note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"kinds\":[1],\"authors\":[\"ADMIN_PUBKEY_HEX\"]}"],
|
||||
["action", "llm"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** The `ADMIN_PUBKEY_HEX` placeholder in the filter must be replaced with the actual admin pubkey from the config. Since this is a static config example, we use a placeholder. At runtime, the user replaces it with their admin pubkey.
|
||||
|
||||
### 3. Add to Adoption List
|
||||
|
||||
**File:** `config.jsonc.example`
|
||||
|
||||
Add the cheerleader skill address to the kind 10123 adoption list tags:
|
||||
|
||||
```jsonc
|
||||
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"]
|
||||
```
|
||||
|
||||
### 4. Verify Trigger Filter
|
||||
|
||||
The Nostr filter for this trigger:
|
||||
|
||||
```json
|
||||
{"kinds": [1], "authors": ["<admin_pubkey_hex>"]}
|
||||
```
|
||||
|
||||
This matches:
|
||||
- **Kind 1** — text notes only (not DMs, not reactions, not reposts)
|
||||
- **Authors** — only the admin's pubkey (not anyone else's notes)
|
||||
|
||||
The trigger manager polls every 10 seconds (`trigger_manager_poll` checks `now - last_poll_at < 10`), applies the `since` parameter to only fetch events newer than the last seen, and enforces the configured cooldown (default 60 seconds) between firings.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as Admin Client
|
||||
participant Relay as Nostr Relay
|
||||
participant TM as Trigger Manager
|
||||
participant Agent as agent_on_trigger
|
||||
participant LLM as LLM API
|
||||
|
||||
Admin->>Relay: Publish kind 1 note
|
||||
Note over TM: Poll every 10s
|
||||
TM->>Relay: Query filter: kinds=1, authors=admin
|
||||
Relay-->>TM: New event found
|
||||
TM->>TM: Check cooldown, check last_seen_created_at
|
||||
TM->>Agent: agent_on_trigger with skill content + event
|
||||
Agent->>Agent: Build system prompt with soul + skill instructions
|
||||
Agent->>LLM: llm_chat with system + triggering event JSON
|
||||
LLM-->>Agent: Cheerful response
|
||||
Agent->>Relay: DM to admin via nostr_handler_send_dm_auto
|
||||
Relay-->>Admin: Encrypted DM with encouragement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Change | Lines |
|
||||
|---|---|---|
|
||||
| `src/tools.c` | Add trigger/filter/action/enabled to skill_create schema | ~20 lines added |
|
||||
| `config.jsonc.example` | Add cheerleader skill startup event | ~20 lines added |
|
||||
| `config.jsonc.example` | Add cheerleader to adoption list | 1 line added |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build with `make`
|
||||
2. Start the agent
|
||||
3. Verify trigger loads on startup via `trigger_list` tool or HTTP API `/status`
|
||||
4. Post a kind 1 note from the admin account
|
||||
5. Wait up to ~10 seconds for the poll cycle
|
||||
6. Receive a cheerful DM from the agent
|
||||
7. Verify cooldown works — posting again within 60s should not trigger a second DM
|
||||
139
plans/context_architecture.md
Normal file
139
plans/context_architecture.md
Normal 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: │
|
||||
│ - d_tag (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
plans/context_optimization.md
Normal file
167
plans/context_optimization.md
Normal 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 d_tag...
|
||||
- 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
|
||||
91
plans/default_skill_missing_from_cache.md
Normal file
91
plans/default_skill_missing_from_cache.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Fix: Default Skill Missing from skill_list Cache
|
||||
|
||||
## Problem
|
||||
|
||||
When a new didactyl agent is created via the setup wizard, the default skill (`didactyl-default`, kind 31124) does not appear in the `skill_list` output. The agent IS using the default skill as its system context, but the skill cache (`g_self_skill_events`) does not contain it.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Default skill event IS published to relays (confirmed by `nostr_my_events`)
|
||||
- Default skill event shows `in_current_cache: false`
|
||||
- `skill_list` returns `count: 1` with only `infrastructure-monitor`
|
||||
- The kind 10123 adoption list only contains `infrastructure-monitor`
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The startup sequence in `main.c` is:
|
||||
|
||||
```
|
||||
1. nostr_handler_init() — resets g_self_skill_events to []
|
||||
2. wait_for_connected_relays() — waits for relay connections
|
||||
3. reconcile_startup_events() — publishes default skill to relays
|
||||
-> publish_kind_event_to_relays() — inserts into cache IF sent > 0
|
||||
4. ... relay list expansion ...
|
||||
5. subscribe_self_skills() — subscribes to relays for kinds 31123/31124/10123
|
||||
```
|
||||
|
||||
The default skill SHOULD be inserted into the cache at step 3 via `publish_kind_event_to_relays` -> `self_skill_cache_upsert_event_locked`. However, the cache insert at line 2750 is gated by `if (sent > 0)`.
|
||||
|
||||
There are two failure modes:
|
||||
|
||||
### Failure Mode 1: Async publish timing
|
||||
The startup publish creates a NEW event per relay via `publish_pending_startup_events_for_relay_index`. Each call to `publish_kind_event_to_relays` creates a fresh event with `nostr_create_and_sign_event`. If the relay is not connected at that moment, `sent = 0` and the cache insert is skipped.
|
||||
|
||||
### Failure Mode 2: Subscription race condition
|
||||
Even if the cache insert succeeds at step 3, the self-skill subscription at step 5 queries relays. For a brand new agent, the default skill was JUST published asynchronously. If the relay hasn't indexed it by the time the subscription query runs, the subscription returns EOSE without the default skill. The subscription stays open but the default skill was published BEFORE the subscription started, so it won't arrive as a live event.
|
||||
|
||||
The cache entry from step 3 should persist, but there may be an edge case where the relay pool reconnection handler at line 792 triggers another publish cycle, creating a new event that replaces the cache entry, and if that publish fails (sent=0), the replacement doesn't happen but the old entry is still there.
|
||||
|
||||
### Most Likely Cause
|
||||
The most likely cause is that `publish_kind_event_to_relays` returns `sent = 0` for the default skill during the initial reconcile. This can happen if:
|
||||
- The relay connection drops momentarily between `wait_for_connected_relays` and the actual publish
|
||||
- The relay pool's async publish fails silently
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
### Approach: Seed the cache directly from config during reconcile
|
||||
|
||||
In `nostr_handler_reconcile_startup_events()`, after publishing startup events, explicitly seed the self-skill cache with a synthetic event built from `g_cfg->default_skill`. This ensures the default skill is ALWAYS in the cache regardless of relay publish success.
|
||||
|
||||
### Implementation
|
||||
|
||||
In `nostr_handler.c`, add a new static function `seed_default_skill_into_cache()` that:
|
||||
|
||||
1. Checks if `g_cfg->default_skill.content` is non-empty
|
||||
2. Builds a cJSON event object with:
|
||||
- `kind`: `g_cfg->default_skill.kind` (31124)
|
||||
- `pubkey`: `g_cfg->keys.public_key_hex`
|
||||
- `content`: `g_cfg->default_skill.content`
|
||||
- `tags`: parsed from `g_cfg->default_skill.tags_json`
|
||||
- `created_at`: `time(NULL)` (or 0 as a floor value)
|
||||
- `id`: a placeholder hex string (e.g., all zeros) — will be replaced when the real event arrives from relay
|
||||
3. Calls `self_skill_cache_upsert_event_locked()` with this synthetic event
|
||||
|
||||
Call this function at the END of `nostr_handler_reconcile_startup_events()`, AFTER the publish loop. This way:
|
||||
- If the publish succeeded, the cache already has the real event (with real ID/sig). The synthetic event would have `created_at` <= the real one, so the upsert would be a no-op.
|
||||
- If the publish failed, the cache gets the synthetic event as a fallback.
|
||||
- When the subscription later returns the real event from relays, it replaces the synthetic one (since it has a real ID and potentially newer timestamp).
|
||||
|
||||
### Alternative Approach: Remove the `sent > 0` gate
|
||||
|
||||
Change `publish_kind_event_to_relays` to always insert skill events (kinds 31123/31124) into the cache, regardless of whether the relay publish succeeded. This is simpler but changes the semantics — the cache would contain events that may not be on any relay.
|
||||
|
||||
### Recommended: Approach 1 (seed from config)
|
||||
|
||||
This is safer because:
|
||||
- It doesn't change the publish function's behavior
|
||||
- It explicitly handles the default skill case
|
||||
- The synthetic event gets replaced by the real one when it arrives from relays
|
||||
- It works even if the publish completely fails
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. `src/nostr_handler.c`:
|
||||
- Add `seed_default_skill_into_cache()` static function
|
||||
- Call it at the end of `nostr_handler_reconcile_startup_events()`
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- If the default skill is later updated via `skill_update`, the relay version will have a newer `created_at` and will replace the synthetic entry
|
||||
- If the agent has no default skill configured, the seed function is a no-op
|
||||
- The synthetic event has a placeholder ID, so `in_current_cache` checks by event ID won't match it — but `skill_list` iterates all events and doesn't check by ID
|
||||
@@ -57,7 +57,7 @@ flowchart TD
|
||||
LOOP --> LLM
|
||||
LLM -->|tool_call| TOOLS
|
||||
TOOLS -->|nostr tools| NOSTR
|
||||
TOOLS -->|shell_exec| SHELL
|
||||
TOOLS -->|local_shell_exec| SHELL
|
||||
TOOLS -->|result| LOOP
|
||||
LLM -->|final answer| DMS
|
||||
```
|
||||
@@ -212,7 +212,7 @@ sequenceDiagram
|
||||
|
||||
| Tool | Description | Notes |
|
||||
|---|---|---|
|
||||
| `shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
|
||||
| `local_shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
|
||||
|
||||
---
|
||||
|
||||
@@ -377,7 +377,7 @@ void agent_on_message(const char* sender, const char* message) {
|
||||
3. **Agent loop** — rewrite `agent.c` with the tool-call loop
|
||||
4. **`nostr_post` tool** — publish any kind event
|
||||
5. **`nostr_query` tool** — query relays with filters
|
||||
6. **`shell_exec` tool** — sandboxed shell command execution
|
||||
6. **`local_shell_exec` tool** — sandboxed shell command execution
|
||||
7. **`nostr_dm` tool** — NIP-17 private DMs
|
||||
8. **Config extension** — parse tools config section
|
||||
9. **Security hardening** — timeouts, output limits, allowlists
|
||||
|
||||
153
plans/eliminate_soul_everything_is_a_skill.md
Normal file
153
plans/eliminate_soul_everything_is_a_skill.md
Normal file
@@ -0,0 +1,153 @@
|
||||
|
||||
|
||||
# Plan: Eliminate Soul — Everything Is a Skill
|
||||
|
||||
## Summary
|
||||
|
||||
Remove the concept of a "soul" (kind `31120`) as a special, privileged entity. The agent's base personality, instructions, and context assembly template become a regular private skill (kind `31124`) that is adopted like any other skill. This simplifies the architecture: there is only one concept — **skills** — and the adoption list (`10123`) determines what the agent knows and how it behaves.
|
||||
|
||||
## Architecture Decision
|
||||
|
||||
- Kind `31120` is eliminated entirely.
|
||||
- What was the "soul" becomes a private skill (kind `31124`) with a conventional d-tag (e.g., `didactyl-default`).
|
||||
- The `---template---` mechanism stays — any skill can contain it.
|
||||
- The `genesis.jsonc` `default_skill` field defines this skill's content for first-run publishing.
|
||||
- On subsequent runs, the agent fetches its adopted skills from Nostr relays.
|
||||
|
||||
## Startup Flow
|
||||
|
||||
```
|
||||
First Run (genesis.jsonc present):
|
||||
1. Read genesis.jsonc
|
||||
2. Publish default_skill as kind 31124 (private skill) to relays
|
||||
3. Publish kind 10123 adoption list referencing the default skill
|
||||
4. Load default_skill content into g_system_context
|
||||
5. Continue normal startup
|
||||
|
||||
Subsequent Run (nsec only):
|
||||
1. Detect not-first-run via kind 10002 presence
|
||||
2. Query own kind 10123 adoption list from relays
|
||||
3. Fetch first adopted skill content from relays
|
||||
4. Load that content into g_system_context
|
||||
5. Continue normal startup
|
||||
```
|
||||
|
||||
## genesis.jsonc Schema
|
||||
|
||||
The `default_skill` field replaces the old kind `31120` startup event:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"key": { "nsec": "nsec1..." },
|
||||
"admin": { "pubkey": "npub1..." },
|
||||
"llm": { ... },
|
||||
"api": { ... },
|
||||
|
||||
// Default skill — published as kind 31124 on first run
|
||||
// and adopted into the agent's 10123 list.
|
||||
"default_skill": {
|
||||
"d_tag": "didactyl-default",
|
||||
"kind": 31124,
|
||||
"content": "# Didactyl Agent\n\nYou are Didactyl...\n\n---template---\n\n- section: admin_identity\n role: system\n tool: admin_identity\n ...",
|
||||
"tags": [
|
||||
["app", "didactyl"],
|
||||
["scope", "private"]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code Changes
|
||||
|
||||
### src/nostr_handler.c
|
||||
|
||||
- Remove the kind `31120` scan in `nostr_handler_reconcile_startup_events()` (lines 2487-2493).
|
||||
- Add logic to load `g_system_context` from the `default_skill` config field on first run.
|
||||
- On subsequent run, query own `10123` adoption list, resolve the first adopted skill address, fetch its content, and set `g_system_context`.
|
||||
|
||||
### src/config.c / src/config.h
|
||||
|
||||
- Add `default_skill` parsing to `config_load()`.
|
||||
- Add a `default_skill_t` struct to `didactyl_config_t` with fields: `d_tag`, `kind`, `content`, `tags_json`.
|
||||
- Remove any special handling of kind `31120` in startup event parsing.
|
||||
|
||||
### src/main.c
|
||||
|
||||
- On first run: publish the `default_skill` as a kind `31124` event and publish/update the `10123` adoption list to include it.
|
||||
- On subsequent run: the adoption-list-driven fetch provides the system context.
|
||||
- Remove the kind `31120` fallback in system context extraction.
|
||||
|
||||
### src/agent.c
|
||||
|
||||
- `g_system_context` continues to work the same way — it's just sourced from a skill instead of a soul.
|
||||
- The trigger execution path (`agent_on_trigger`) prepends `g_system_context` unchanged.
|
||||
- The WoT/stranger chat path uses `g_system_context` unchanged.
|
||||
- The non-template fallback path uses `g_system_context` unchanged.
|
||||
|
||||
### src/prompt_template.c
|
||||
|
||||
- Rename `soul_content` parameter to `skill_content` in `prompt_template_parse()`.
|
||||
- Rename `personality` field to `base_instructions` or similar.
|
||||
- No functional changes — the `---template---` split mechanism is unchanged.
|
||||
|
||||
### config.jsonc
|
||||
|
||||
- Replace the kind `31120` startup event with a kind `31124` startup event using the same content.
|
||||
- Update the d-tag from `soul` to `didactyl-default`.
|
||||
|
||||
### genesis.jsonc
|
||||
|
||||
- Populate the `default_skill` field with the current soul content (personality + template sections).
|
||||
|
||||
## Documentation Changes
|
||||
|
||||
### README.md
|
||||
|
||||
- Remove all references to "Soul", "soul", kind `31120`.
|
||||
- Replace "Soul/personality" with "Default skill" or "Base skill".
|
||||
- Update the Didactyl Kinds table: remove `31120` row.
|
||||
- Update the Roadmap table: remove "Soul/personality | Kind 31120" row.
|
||||
- Update context model description: "assembled from adopted skill templates" not "soul template".
|
||||
|
||||
### docs/SKILLS.md
|
||||
|
||||
- Remove `{{soul}}` template variable.
|
||||
- Remove `soul` content field from skill schema.
|
||||
- Remove any language about soul being special or separate from skills.
|
||||
- Document that any skill can contain `---template---` sections.
|
||||
|
||||
### docs/CONTEXT.md
|
||||
|
||||
- Remove soul-specific assembly language.
|
||||
- Context is assembled from adopted skills in adoption list order.
|
||||
|
||||
### docs/GENESIS.md
|
||||
|
||||
- Update to reference `default_skill` instead of kind `31120` soul event.
|
||||
- Update first-run behavior description.
|
||||
|
||||
### docs/API.md
|
||||
|
||||
- Remove `/api/events/soul` endpoint.
|
||||
- Rename `system_prompt` context part to `default_skill` or `base_instructions`.
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- The `---template---` mechanism works identically — it just lives in a skill.
|
||||
- Kind `10123` adoption list still drives context composition order.
|
||||
- Template variable resolution via tools is unchanged.
|
||||
- Trigger execution flow is unchanged (uses `g_system_context`).
|
||||
- Encrypted config tools (`config_store`/`config_recall`) are unchanged.
|
||||
- The genesis/nsec startup detection is unchanged.
|
||||
|
||||
## Dependency Order
|
||||
|
||||
1. Define `default_skill` schema in config structs and genesis.jsonc
|
||||
2. Update config parser to read `default_skill`
|
||||
3. Update nostr_handler to source system context from default_skill (first run) or adopted skill (subsequent run)
|
||||
4. Update main.c first-run path to publish default_skill as 31124 and update 10123
|
||||
5. Remove all kind 31120 references from code
|
||||
6. Rename soul terminology in prompt_template.c
|
||||
7. Update config.jsonc startup events
|
||||
8. Update all documentation
|
||||
9. Build and test
|
||||
149
plans/enhanced_existing_agent_wizard.md
Normal file
149
plans/enhanced_existing_agent_wizard.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Enhanced Existing Agent Wizard Flow
|
||||
|
||||
## Problem
|
||||
|
||||
When choosing "existing agent" in the wizard, the current flow:
|
||||
1. Asks for nsec
|
||||
2. Recovers kind 10002 relay list from Nostr
|
||||
3. Immediately boots the agent
|
||||
|
||||
This is insufficient when installing an existing agent on a **new server** because:
|
||||
- You cannot review or change the admin pubkey
|
||||
- You cannot review or change the LLM provider/model/API key
|
||||
- You cannot install a systemd service with a dedicated user
|
||||
- You cannot see what config was recovered from Nostr
|
||||
- The agent name is not recovered from the kind 0 profile
|
||||
|
||||
## Current Code
|
||||
|
||||
- [`existing_agent_flow()`](src/setup_wizard.c:1795) — 22 lines, minimal recovery
|
||||
- [`recover_existing_config_from_nostr()`](src/setup_wizard.c:792) — only recovers kind 10002 relays
|
||||
- Returns `SETUP_WIZARD_RC_EXISTING` (2) which does NOT set `bootstrap_mode`
|
||||
- In `main.c`, existing agents skip `reconcile_startup_events()` unless `first_run` is detected
|
||||
|
||||
## Data Available on Nostr for an Existing Agent
|
||||
|
||||
| Data | Kind | Storage | Recovery Method |
|
||||
|------|------|---------|-----------------|
|
||||
| Relay list | 10002 | Public tags | `query_and_extract_kind10002_relays()` |
|
||||
| Agent profile/name | 0 | Public JSON content | Query kind 0 by agent pubkey |
|
||||
| LLM config | 30078 d=llm_config | NIP-44 encrypted to self | `fetch_self_config_plaintext()` |
|
||||
| Agent config - admin pubkey, DM protocol | 30078 d=agent_config | NIP-44 encrypted to self | `fetch_self_config_plaintext()` |
|
||||
| Default skill | 31124 d=didactyl-default | Public content | Query kind 31124 by agent pubkey |
|
||||
| Adoption list | 10123 | Public tags | Query kind 10123 by agent pubkey |
|
||||
|
||||
## Proposed Enhanced Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Enter nsec] --> B[Connect to default relays]
|
||||
B --> C[Recover kind 10002 relay list]
|
||||
C --> D{Relay list found?}
|
||||
D -->|No| E{Offer to create new agent with this nsec}
|
||||
E -->|Yes| E2[Jump to new_agent_flow with nsec pre-loaded]
|
||||
E -->|No| E3[Return to main menu]
|
||||
D -->|Yes| F[Reconnect with recovered relays]
|
||||
F --> G[Recover all config from Nostr]
|
||||
G --> H[Display recovered config summary]
|
||||
H --> I{Review each setting}
|
||||
I -->|Keep all| J[Review summary + launch options]
|
||||
I -->|Change admin| K[Prompt new admin pubkey]
|
||||
I -->|Change LLM| L[Prompt LLM config]
|
||||
I -->|Change relays| M[Prompt relay config]
|
||||
K --> I
|
||||
L --> I
|
||||
M --> I
|
||||
J --> N{Launch option}
|
||||
N -->|Boot now| O[Return EXISTING]
|
||||
N -->|Install systemd| P[Install dedicated-user service]
|
||||
N -->|Quit| Q[Exit]
|
||||
```
|
||||
|
||||
### Step-by-step
|
||||
|
||||
#### Step 1: Identity — Enter nsec
|
||||
Same as current. Derive keys from nsec.
|
||||
|
||||
#### Step 2: Recovery — Connect and fetch config from Nostr
|
||||
1. Init nostr handler with default relays
|
||||
2. Wait for relay connections
|
||||
3. Query kind 10002 for relay list — if not found, offer to create a new agent with this nsec (jump to `new_agent_flow` with keys pre-loaded, skipping identity step)
|
||||
4. Cleanup and re-init with recovered relays
|
||||
5. Wait for relay connections on recovered relays
|
||||
6. Query kind 0 for agent profile — extract display_name/name
|
||||
7. Query kind 30078 d=llm_config — decrypt and parse LLM settings
|
||||
8. Query kind 30078 d=agent_config — decrypt and parse admin pubkey + DM protocol
|
||||
9. Cleanup nostr handler
|
||||
|
||||
#### Step 3: Review — Present recovered config
|
||||
Display all recovered values:
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Existing Agent -- Recovered Configuration │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Agent name: Simon │
|
||||
│ Identity: b27072b7fc2edf45... │
|
||||
│ Admin: a1b2c3d4e5f6... │
|
||||
│ LLM Provider: ppq │
|
||||
│ LLM Model: claude-haiku-4.5 │
|
||||
│ LLM Base URL: https://api.ppq.ai │
|
||||
│ LLM API Key: sk-...**** │
|
||||
│ DM Protocol: nip04 │
|
||||
│ Relays: 5 configured │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Then offer a menu:
|
||||
```
|
||||
[a] change Admin pubkey
|
||||
[l] change LLM provider/model/key
|
||||
[r] change Relay configuration
|
||||
[c] continue with these settings
|
||||
[q] quit
|
||||
```
|
||||
|
||||
Each change option reuses the existing prompt functions (`prompt_admin_pubkey`, `prompt_llm_config`, `prompt_relay_configuration`) but with context-appropriate headers.
|
||||
|
||||
After any change, redisplay the summary and menu.
|
||||
|
||||
#### Step 4: Launch — Boot or install systemd
|
||||
Same as the new-agent flow's final step:
|
||||
```
|
||||
[b] boot the agent now
|
||||
[i] install dedicated-user systemd service and boot
|
||||
[q] quit
|
||||
```
|
||||
|
||||
The systemd install reuses `install_system_service_with_dedicated_user()`.
|
||||
|
||||
### Return Codes
|
||||
|
||||
- If user chooses "boot now": return `SETUP_WIZARD_RC_EXISTING` (2) — same as current
|
||||
- If user chooses "install systemd": return `SETUP_WIZARD_RC_EXIT` (1) — agent runs as systemd service
|
||||
- If user changed config values: the `main()` flow should still work because `recover_missing_runtime_config_from_nostr()` at line 1108 will fill in any gaps, and the existing agent path at line 1163 loads system context from adopted skills
|
||||
|
||||
### Key Consideration: bootstrap_mode for changed configs
|
||||
|
||||
If the user changes LLM or admin config in the wizard, those changes need to be persisted back to Nostr. Currently, `persist_runtime_config_to_nostr()` is only called when `bootstrap_mode || first_run`.
|
||||
|
||||
**Solution**: When the existing-agent wizard detects that config was changed, return `SETUP_WIZARD_RC_BOOTSTRAP` (0) instead of `SETUP_WIZARD_RC_EXISTING` (2). This triggers the full reconcile path which persists the updated config.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. **`src/setup_wizard.c`**:
|
||||
- Add `recover_full_config_from_nostr()` — fetches kind 0, kind 30078 llm_config, kind 30078 agent_config
|
||||
- Add `query_self_kind0_name()` — queries agent's own kind 0 profile for name
|
||||
- Add `fetch_and_decrypt_self_config()` — replicates `fetch_self_config_plaintext()` logic from main.c for use in wizard context
|
||||
- Rewrite `existing_agent_flow()` with the enhanced multi-step flow
|
||||
- Make `prompt_admin_pubkey()`, `prompt_llm_config()`, `prompt_relay_configuration()` accept a context string parameter for the page header, or add wrapper versions for the existing-agent context
|
||||
|
||||
2. **`src/main.c`**:
|
||||
- Potentially expose `fetch_self_config_plaintext()`, `apply_recalled_llm_config()`, `apply_recalled_agent_config()` as non-static, OR duplicate the logic in setup_wizard.c
|
||||
- Better approach: move these to a shared location or make them accessible via a header
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The wizard already calls `nostr_handler_init()` / `nostr_handler_cleanup()` for validation queries. The enhanced flow will do the same but with two init/cleanup cycles: first with default relays to get kind 10002, then with recovered relays to get everything else.
|
||||
- The `prompt_admin_pubkey()` and `prompt_llm_config()` functions currently have hardcoded step headers like "Step 3 of 7". These should be parameterized or have existing-agent variants.
|
||||
- The `fetch_self_config_plaintext()` function in main.c requires an active nostr handler. The wizard will need to have the handler initialized when calling it.
|
||||
- API key display should be masked — show only first 4 and last 4 characters.
|
||||
190
plans/fix_cron_triggers_not_firing.md
Normal file
190
plans/fix_cron_triggers_not_firing.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Fix: Cron Triggers Not Firing
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
After tracing the full cron trigger lifecycle in [`src/trigger_manager.c`](src/trigger_manager.c), I identified **three bugs** and **one usability gap** that together explain why cron triggers never fire.
|
||||
|
||||
### Bug 1: `last_poll_at` initialized to `time(NULL)` — first poll always skipped
|
||||
|
||||
In [`trigger_manager_init()`](src/trigger_manager.c:615):
|
||||
|
||||
```c
|
||||
mgr->last_poll_at = time(NULL);
|
||||
```
|
||||
|
||||
Then in [`trigger_manager_poll()`](src/trigger_manager.c:1263):
|
||||
|
||||
```c
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
|
||||
return 0; // skip
|
||||
}
|
||||
```
|
||||
|
||||
This means the **first 30 seconds** after init, all cron polls are silently skipped. This is a minor delay, not the primary cause, but it compounds with Bug 2.
|
||||
|
||||
**Fix:** Initialize `last_poll_at = 0` so the first poll runs immediately.
|
||||
|
||||
### Bug 2: Cron triggers loaded AFTER init — poll window already consumed
|
||||
|
||||
The startup sequence in [`src/main.c`](src/main.c:1450) is:
|
||||
|
||||
1. `trigger_manager_init()` — sets `last_poll_at = time(NULL)`
|
||||
2. `trigger_manager_load_from_startup_events()` — loads cron triggers
|
||||
3. `trigger_manager_load_from_skills()` — loads more cron triggers (after EOSE, async)
|
||||
4. Main loop starts calling `trigger_manager_poll()`
|
||||
|
||||
Because `last_poll_at` is set at init time, and loading happens after init, the first poll after loading may still be within the 30-second window. Combined with Bug 1, this means the first cron evaluation is delayed.
|
||||
|
||||
This is not the primary cause either — after 30 seconds, polls should start working. But it contributes to the perception that cron is broken.
|
||||
|
||||
### Bug 3 (PRIMARY): Invalid cron expressions — `*` instead of `* * * * *`
|
||||
|
||||
The agent's diagnosis shows:
|
||||
- `infrastructure-monitor` has cron filter `*` — a single asterisk
|
||||
- `c-relay-memory-watch` has cron filter `0 21,22,23,0,1,2 * * *` — valid 5-field
|
||||
|
||||
The [`cron_matches_now()`](src/trigger_manager.c:207) function strictly requires exactly 5 whitespace-separated fields:
|
||||
|
||||
```c
|
||||
if (nf != 5 || tok != NULL) {
|
||||
return 0; // silently rejects
|
||||
}
|
||||
```
|
||||
|
||||
A single `*` produces `nf == 1`, which fails the `nf != 5` check. **This is the primary reason `infrastructure-monitor` never fires.**
|
||||
|
||||
For `c-relay-memory-watch` with `0 21,22,23,0,1,2 * * *` — this is a valid 5-field expression. The hour field `21,22,23,0,1,2` means hours 21-23 and 0-2 UTC. If the agent was tested outside those hours, it would correctly not fire. However, the agent reports `last_fired: 0` which means it has **never** fired, suggesting either:
|
||||
- The agent hasn't been running during those hours, OR
|
||||
- There's a secondary issue with how the expression was stored
|
||||
|
||||
### Usability Gap: No cron shorthand support
|
||||
|
||||
Standard cron implementations support shorthands like `@hourly`, `@daily`, `@every_5m`. Didactyl only supports raw 5-field expressions. The LLM creating skills may generate `*` thinking it means "every minute" when it should be `* * * * *`.
|
||||
|
||||
### Usability Gap: No validation or error logging on invalid cron expressions
|
||||
|
||||
When [`cron_matches_now()`](src/trigger_manager.c:223) rejects an expression, it returns 0 silently. There is no log message indicating the expression was invalid. This makes debugging impossible without reading the source code.
|
||||
|
||||
Similarly, [`trigger_manager_add()`](src/trigger_manager.c:987) copies the filter to `cron_expr` without validating it:
|
||||
|
||||
```c
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
}
|
||||
```
|
||||
|
||||
No validation that `filter_json` is a valid 5-field cron expression.
|
||||
|
||||
---
|
||||
|
||||
## Fix Plan
|
||||
|
||||
### Fix 1: Initialize `last_poll_at` to 0
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:615)
|
||||
|
||||
Change:
|
||||
```c
|
||||
mgr->last_poll_at = time(NULL);
|
||||
```
|
||||
To:
|
||||
```c
|
||||
mgr->last_poll_at = 0;
|
||||
```
|
||||
|
||||
This allows the first poll to run immediately after triggers are loaded.
|
||||
|
||||
### Fix 2: Add cron expression validation in `trigger_manager_add` and `trigger_manager_update`
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:987)
|
||||
|
||||
Add a static validation function:
|
||||
|
||||
```c
|
||||
static int cron_expr_valid(const char* expr) {
|
||||
if (!expr || expr[0] == '\0') return 0;
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), "%s", expr);
|
||||
int nf = 0;
|
||||
char* saveptr = NULL;
|
||||
char* tok = strtok_r(buf, " \t", &saveptr);
|
||||
while (tok && nf < 6) { nf++; tok = strtok_r(NULL, " \t", &saveptr); }
|
||||
return (nf == 5 && tok == NULL) ? 1 : 0;
|
||||
}
|
||||
```
|
||||
|
||||
Call it in `trigger_manager_add()` and `trigger_manager_update()` when `trigger_type == TRIGGER_TYPE_CRON`, logging a warning if invalid.
|
||||
|
||||
### Fix 3: Add cron shorthand expansion
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:207)
|
||||
|
||||
Add a helper that expands common shorthands before parsing:
|
||||
|
||||
| Shorthand | Expansion |
|
||||
|-----------|-----------|
|
||||
| `*` | `* * * * *` |
|
||||
| `@yearly` / `@annually` | `0 0 1 1 *` |
|
||||
| `@monthly` | `0 0 1 * *` |
|
||||
| `@weekly` | `0 0 * * 0` |
|
||||
| `@daily` / `@midnight` | `0 0 * * *` |
|
||||
| `@hourly` | `0 * * * *` |
|
||||
|
||||
This directly fixes the `infrastructure-monitor` case where `*` should mean "every minute".
|
||||
|
||||
### Fix 4: Add debug logging for cron evaluation
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:1253)
|
||||
|
||||
Add `DEBUG_INFO` or `DEBUG_WARN` logging in `trigger_manager_poll()` when:
|
||||
- A cron expression fails to parse — log the expression and skill d_tag
|
||||
- A cron expression matches — log the match before firing
|
||||
- The poll is skipped due to the 30-second throttle — log at DEBUG level
|
||||
|
||||
### Fix 5: Add `last_cron_fire` and `cron_expr` to status JSON
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:1387)
|
||||
|
||||
In `trigger_manager_status_json()`, add the cron-specific fields so the agent can self-diagnose:
|
||||
|
||||
```c
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
cJSON_AddStringToObject(item, "cron_expr", t->cron_expr);
|
||||
cJSON_AddNumberToObject(item, "last_cron_fire", (double)t->last_cron_fire);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow After Fix
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INIT[trigger_manager_init - last_poll_at=0] --> LOAD[Load triggers from startup/skills]
|
||||
LOAD --> POLL[trigger_manager_poll called from main loop]
|
||||
POLL --> CHECK{now - last_poll_at >= 30?}
|
||||
CHECK -->|No| SKIP[Return 0 - throttled]
|
||||
CHECK -->|Yes| ITER[Iterate triggers]
|
||||
ITER --> CRON{trigger_type == CRON?}
|
||||
CRON -->|No| NEXT[Next trigger]
|
||||
CRON -->|Yes| EXPAND[Expand shorthand if needed]
|
||||
EXPAND --> VALID{Valid 5-field expr?}
|
||||
VALID -->|No| WARN[Log warning + skip]
|
||||
VALID -->|Yes| MATCH{cron_matches_now?}
|
||||
MATCH -->|No| NEXT
|
||||
MATCH -->|Yes| DEDUP{last_cron_fire < 50s ago?}
|
||||
DEDUP -->|Yes| NEXT
|
||||
DEDUP -->|No| FIRE[execute_llm_action]
|
||||
FIRE --> NEXT
|
||||
WARN --> NEXT
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c:615) | Fix `last_poll_at` init to 0 |
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c:207) | Add `cron_expand_shorthand()` helper |
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c:987) | Add `cron_expr_valid()` validation + warning log |
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c:1253) | Add debug logging in poll loop |
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c:1387) | Add cron fields to status JSON |
|
||||
69
plans/fix_kind30078_agent_config_publish.md
Normal file
69
plans/fix_kind30078_agent_config_publish.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Fix: Kind 30078 `d=agent_config` Publish Gaps
|
||||
|
||||
## Problem
|
||||
|
||||
The agent stores its admin pubkey and DM protocol in a NIP-44 self-encrypted kind 30078 replaceable event (`d=agent_config`). This event is critical for the `--nsec`-only startup path (used by systemd services) to recover the admin identity.
|
||||
|
||||
**Multiple startup paths fail to publish this event**, causing:
|
||||
- Agents installed via wizard "Install Service" to lose their admin pubkey on restart
|
||||
- Agents recovering on new servers to use stale/old admin pubkeys from relays
|
||||
- Agents with genesis files to silently ignore newer kind 30078 values
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `src/setup_wizard.c` — wizard flows
|
||||
- `src/main.c` — main startup logic
|
||||
|
||||
## Fixes
|
||||
|
||||
### Fix A: `new_agent_flow` — publish before service install
|
||||
**File:** `src/setup_wizard.c` ~line 2184
|
||||
**Problem:** When user chooses "Install systemd service" in new agent flow, `persist_runtime_config_to_nostr_wizard_online` is never called. The service starts with `--nsec` only and has no kind 30078 to recover from.
|
||||
**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before `install_system_service_with_dedicated_user()`. Fail the install if publish fails (same pattern as existing_agent_flow).
|
||||
|
||||
### Fix B: `new_agent_flow` — publish before "boot now" return
|
||||
**File:** `src/setup_wizard.c` ~line 2177
|
||||
**Problem:** When user chooses "Boot now", the wizard returns `SETUP_WIZARD_RC_BOOTSTRAP` and relies on `main()` to publish later. This works but is fragile — if the bootstrap publish in `main()` fails, the kind 30078 is never created.
|
||||
**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before returning 0. This is belt-and-suspenders — `main()` will also publish, but the wizard ensures it happens at least once. If the wizard publish fails, log a warning but continue (non-fatal since main will retry).
|
||||
|
||||
### Fix C: `existing_agent_flow` — always publish before service install
|
||||
**File:** `src/setup_wizard.c` ~line 2368
|
||||
**Problem:** `persist_runtime_config_to_nostr_wizard_online` is only called `if (config_changed)`. If the user accepts the recovered config as-is, the kind 30078 is not re-published. The event may have been lost from relays.
|
||||
**Fix:** Remove the `config_changed` gate. Always call `persist_runtime_config_to_nostr_wizard_online(cfg)` before installing the service. Kind 30078 is a replaceable event, so re-publishing is safe and idempotent.
|
||||
|
||||
### Fix D: `existing_agent_flow` — always return BOOTSTRAP
|
||||
**File:** `src/setup_wizard.c` ~line 2364
|
||||
**Problem:** When user chooses "Boot now" and `config_changed == 0`, the flow returns `SETUP_WIZARD_RC_EXISTING` which means `bootstrap_mode = 0` in `main()`. If `first_run = 0` (kind 10002 exists), `persist_runtime_config_to_nostr` is skipped.
|
||||
**Fix:** Always return `SETUP_WIZARD_RC_BOOTSTRAP` from the "boot now" path, regardless of `config_changed`. This ensures `main()` always re-publishes the kind 30078. The wizard has all the config in memory — it should always be treated as authoritative.
|
||||
|
||||
### Fix E: `persist_runtime_config_to_nostr_wizard_online` — ensure relay delivery
|
||||
**File:** `src/setup_wizard.c` ~line 1159
|
||||
**Problem:** The function publishes the event and immediately calls `nostr_handler_cleanup()`. The event may not have been delivered to relays yet (fire-and-forget).
|
||||
**Fix:** Add a brief poll loop (e.g., 2-3 seconds of `nostr_handler_poll()`) after the publish call and before `nostr_handler_cleanup()` to give the event time to propagate. This matches the pattern used elsewhere for relay operations.
|
||||
|
||||
### Fix F: `main()` — always fetch kind 30078 and compare
|
||||
**File:** `src/main.c` ~line 847 (`recover_missing_runtime_config_from_nostr`)
|
||||
**Problem:** The recovery only runs when `admin_config_is_complete()` returns false. If a genesis file provides an admin pubkey, the kind 30078 is never checked, even if it has a different (newer) value.
|
||||
**Fix:** Always fetch the kind 30078 `d=agent_config` regardless of whether the genesis already provided values. Compare the fetched admin_pubkey with the loaded one. If they differ, log a `DEBUG_WARN` with both values. Genesis wins (operator intent), but the warning makes the conflict visible in logs.
|
||||
|
||||
### Fix G: `main()` — always re-publish kind 30078
|
||||
**File:** `src/main.c` ~line 1152
|
||||
**Problem:** `persist_runtime_config_to_nostr` only runs when `bootstrap_mode || first_run`. On subsequent runs, the kind 30078 is never refreshed. If relays purge the event, it's gone.
|
||||
**Fix:** Move `persist_runtime_config_to_nostr(&cfg)` outside the `if (bootstrap_mode || first_run)` block so it runs on every startup. Kind 30078 is a replaceable event — re-publishing is safe, idempotent, and ensures relay persistence. Log the result but don't fail startup if it doesn't succeed.
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Fix E first (relay delivery) — this makes all other wizard publishes more reliable
|
||||
2. Fixes A + B (new_agent_flow) — the most critical bug
|
||||
3. Fixes C + D (existing_agent_flow) — second most critical
|
||||
4. Fix G (always re-publish in main) — ensures long-term relay persistence
|
||||
5. Fix F (conflict detection) — nice-to-have logging improvement
|
||||
|
||||
## Testing
|
||||
|
||||
- Wizard → New Agent → Install Service: verify kind 30078 exists on relays after service starts
|
||||
- Wizard → New Agent → Boot: verify kind 30078 exists on relays
|
||||
- Wizard → Existing Agent → Install Service (no changes): verify kind 30078 re-published
|
||||
- Wizard → Existing Agent → Boot (no changes): verify kind 30078 re-published
|
||||
- `--nsec` only restart: verify kind 30078 recovered and re-published
|
||||
- `--config genesis.jsonc` with different admin than kind 30078: verify warning logged, genesis wins, kind 30078 updated
|
||||
146
plans/fix_local_file_persistence_and_stall_diagnostic.md
Normal file
146
plans/fix_local_file_persistence_and_stall_diagnostic.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Fix: Local File Persistence & Stall Diagnostic Improvements
|
||||
|
||||
## Context
|
||||
|
||||
The agent got stuck in a stall loop when trying to create a skill via DM. The root cause is `max_tokens=512` truncating the LLM's tool call JSON. The admin tried to fix it with `/model_set {"max_tokens": 10000}` but got `"failed to persist llm config to config file"` because `model_set` tries to write to a local config file that doesn't exist in the Nostr-native deployment model.
|
||||
|
||||
Investigation revealed two tools that incorrectly persist state to local files instead of kind 30078 Nostr events, plus missing diagnostic information in the stall detector.
|
||||
|
||||
## Hardcoded File Locations to Remove
|
||||
|
||||
### tool_model.c
|
||||
- `ctx->cfg->config_path` — references a local config file (genesis.jsonc or config.jsonc)
|
||||
- The entire `persist_llm_config()` function (lines 82-139) reads/writes this file
|
||||
- The `read_entire_file_local()` helper (lines 42-80) exists only for this purpose
|
||||
|
||||
### tool_task.c
|
||||
- `"tasks.json"` — hardcoded at line 188: `build_tool_path_local(ctx, "tasks.json", ...)`
|
||||
- `tasks_load_root_local()` (lines 82-141) reads from this file
|
||||
- `tasks_save_root_local()` (lines 143-159) writes to this file
|
||||
|
||||
### tools_schema.c
|
||||
- Line 985: `"Update active LLM configuration and persist it to config.jsonc"` — mentions config.jsonc
|
||||
- Line 1285: `"Build current task list context block from tasks.json"` — mentions tasks.json
|
||||
- Line 1314: `"Manage agent short-term task memory stored in tasks.json"` — mentions tasks.json
|
||||
|
||||
## Fixes
|
||||
|
||||
### Fix 1: model_set — Persist to Kind 30078
|
||||
|
||||
**File:** `src/tools/tool_model.c`
|
||||
|
||||
Replace `persist_llm_config()` (lines 82-139) with a function that:
|
||||
1. Builds a JSON object with LLM config fields (provider, model, base_url, max_tokens, temperature — NOT api_key for security)
|
||||
2. NIP-44 self-encrypts it using the same pattern as `config_store` in `tool_config.c`
|
||||
3. Publishes as kind 30078 with tags `["d", "llm_config"]` and `["app", "didactyl"]`
|
||||
|
||||
Delete the `read_entire_file_local()` helper (lines 42-80) and the `json_object_set_string()` helper (lines 33-40) — they only exist for the file-based persist.
|
||||
|
||||
Also update `execute_model_set()` (line 234) to call the new Nostr-based persist, and return success with a warning if persist fails (since runtime update already succeeded at line 229).
|
||||
|
||||
### Fix 2: model_set — Don't Fail When Only Persist Fails
|
||||
|
||||
**File:** `src/tools/tool_model.c`, lines 229-236
|
||||
|
||||
Currently:
|
||||
```c
|
||||
if (llm_set_config(&cfg) != 0) {
|
||||
return json_error_local("failed to update runtime llm config");
|
||||
}
|
||||
ctx->cfg->llm = cfg;
|
||||
if (persist_llm_config(ctx, &cfg) != 0) {
|
||||
return json_error_local("failed to persist llm config to config file");
|
||||
}
|
||||
```
|
||||
|
||||
Change to: return success with a `"persist_warning"` field if persist fails, since the runtime update already took effect.
|
||||
|
||||
### Fix 3: task_manage — Persist to Kind 30078
|
||||
|
||||
**File:** `src/tools/tool_task.c`
|
||||
|
||||
Replace `tasks_load_root_local()` and `tasks_save_root_local()` with:
|
||||
- `tasks_load_root_nostr()` — queries kind 30078 `d=tasks` from self, NIP-44 decrypts, parses JSON
|
||||
- `tasks_save_root_nostr()` — serializes JSON, NIP-44 self-encrypts, publishes kind 30078 `d=tasks`
|
||||
|
||||
This follows the exact same pattern as `config_store`/`config_recall` in `tool_config.c` and `memory_save`/`memory_recall` in `tool_memory.c`.
|
||||
|
||||
Remove the hardcoded `"tasks.json"` string at line 188 and the `build_tool_path_local()` call for it.
|
||||
|
||||
Remove `tasks_path` from the response JSON at line 424.
|
||||
|
||||
### Fix 4: Update Schema Descriptions
|
||||
|
||||
**File:** `src/tools/tools_schema.c`
|
||||
|
||||
- Line 985: Change `"Update active LLM configuration and persist it to config.jsonc"` to `"Update active LLM configuration and persist it to Nostr"`
|
||||
- Line 1285: Change `"Build current task list context block from tasks.json"` to `"Build current task list context block from agent task memory"`
|
||||
- Line 1314: Change `"Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)"` to `"Manage agent short-term task memory stored on Nostr (list/add/update/remove/clear/replace)"`
|
||||
|
||||
### Fix 5: Add finish_reason to LLM Response
|
||||
|
||||
**File:** `src/llm.h`
|
||||
|
||||
Add `char* finish_reason;` to `llm_response_t`:
|
||||
```c
|
||||
typedef struct {
|
||||
char* content;
|
||||
llm_tool_call_t* tool_calls;
|
||||
int tool_call_count;
|
||||
char* finish_reason;
|
||||
} llm_response_t;
|
||||
```
|
||||
|
||||
**File:** `src/llm.c`
|
||||
|
||||
In `parse_llm_response()` (line 276), after extracting `msg` from `choices[0]`, extract `finish_reason`:
|
||||
```c
|
||||
cJSON* fr = first ? cJSON_GetObjectItemCaseSensitive(first, "finish_reason") : NULL;
|
||||
if (fr && cJSON_IsString(fr) && fr->valuestring) {
|
||||
out->finish_reason = strdup(fr->valuestring);
|
||||
}
|
||||
```
|
||||
|
||||
In `llm_response_free()`, add `free(response->finish_reason);`.
|
||||
|
||||
### Fix 6: Add max_tokens Truncation Hint to Stall Diagnostic
|
||||
|
||||
**File:** `src/agent.c`
|
||||
|
||||
In the stall detection block at line 2217, before freeing `resp`, check `finish_reason`:
|
||||
```c
|
||||
if (repeated_tool_turns >= stall_repeat_threshold) {
|
||||
int truncated = resp.finish_reason && strcmp(resp.finish_reason, "length") == 0;
|
||||
exited_on_stall = 1;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
In `notify_admin_limit_diagnostic()` (line 106), add fields:
|
||||
- `possible_cause` — "max_tokens_truncation" when finish_reason was "length"
|
||||
- `current_max_tokens` — the current max_tokens value
|
||||
- `hint` — "Increase max_tokens: /model_set {\"max_tokens\": 4096}"
|
||||
|
||||
**File:** `src/http_api.c`
|
||||
|
||||
Same changes in the HTTP API tool loop stall detection at line 966.
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Fix 5 (finish_reason in llm_response_t) — no dependencies
|
||||
2. Fix 6 (stall diagnostic) — depends on Fix 5
|
||||
3. Fix 1 (model_set persist to Nostr) — independent
|
||||
4. Fix 2 (model_set graceful failure) — can be done with Fix 1
|
||||
5. Fix 3 (task_manage persist to Nostr) — independent
|
||||
6. Fix 4 (schema descriptions) — do last, trivial
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `src/llm.h` — add finish_reason field
|
||||
- `src/llm.c` — parse finish_reason, free it
|
||||
- `src/agent.c` — stall diagnostic with truncation hint
|
||||
- `src/http_api.c` — stall diagnostic with truncation hint
|
||||
- `src/tools/tool_model.c` — replace file persist with kind 30078, graceful failure
|
||||
- `src/tools/tool_task.c` — replace file I/O with kind 30078
|
||||
- `src/tools/tools_schema.c` — update 3 descriptions
|
||||
429
plans/interactive_guided_setup.md
Normal file
429
plans/interactive_guided_setup.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Didactyl — Interactive Guided Setup Mode
|
||||
|
||||
## Overview
|
||||
|
||||
When `./didactyl` is run with **no command-line arguments**, the agent enters an interactive guided setup wizard on the terminal. This replaces the current behavior of silently trying to load `./genesis.jsonc` and failing.
|
||||
|
||||
The wizard first asks whether the user is creating a new agent or starting an existing one, then branches accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Trigger Condition
|
||||
|
||||
```
|
||||
if (argc == 1) -> enter interactive setup mode
|
||||
```
|
||||
|
||||
Any argument at all (`--config`, `--nsec`, `--help`, etc.) bypasses the wizard and uses the existing startup path. The zero-argument case is the only entry point.
|
||||
|
||||
---
|
||||
|
||||
## TUI Menu Convention
|
||||
|
||||
All menus use **single-letter hotkeys** (case-insensitive). The hotkey letter is rendered **underlined** in the terminal using ANSI escape `\e[4m` (underline on) and `\e[0m` (reset). For example, `[N]ew` displays with the N underlined.
|
||||
|
||||
Global shortcuts:
|
||||
- `q` or `x` -- quit/back out of any menu
|
||||
- Ctrl+C -- clean exit (restore terminal settings)
|
||||
|
||||
Input is read with `fgets()` and matched on the first non-whitespace character.
|
||||
|
||||
---
|
||||
|
||||
## Wizard Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[./didactyl with no args] --> B[Welcome screen]
|
||||
B --> C{New or Existing?}
|
||||
C -->|New agent| D[New Agent Flow]
|
||||
C -->|Existing agent| E[Existing Agent Flow]
|
||||
C -->|Load genesis| F[Load genesis.jsonc and boot]
|
||||
C -->|Quit| Z[Exit]
|
||||
|
||||
D --> D1[Step: Generate or provide nsec]
|
||||
D1 --> D2[Check if pubkey exists on Nostr]
|
||||
D2 -->|Exists - warn| D3{Continue or abort?}
|
||||
D2 -->|Fresh| D4[Step: Admin npub]
|
||||
D3 -->|Continue| D4
|
||||
D3 -->|Abort| Z
|
||||
D4 --> D5[Step: LLM provider + test]
|
||||
D5 --> D6[Step: Relay config]
|
||||
D6 --> D7[Step: Review and boot/export]
|
||||
D7 --> BOOT[Normal startup]
|
||||
|
||||
E --> E1[Step: Provide nsec]
|
||||
E1 --> E2[Connect to bootstrap relays]
|
||||
E2 --> E3[Recover config from Nostr]
|
||||
E3 -->|Config found| E4{Review recovered config}
|
||||
E3 -->|Config missing| E5[Prompt for missing config]
|
||||
E4 --> BOOT
|
||||
E5 --> D4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step Details
|
||||
|
||||
### Welcome Screen
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
=============================================
|
||||
Didactyl v0.0.71 -- Interactive Setup
|
||||
=============================================
|
||||
|
||||
[N]ew agent -- create a fresh Nostr identity
|
||||
[E]xisting -- start an agent you have already set up
|
||||
[L]oad -- boot from a genesis.jsonc file
|
||||
[Q]uit
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Path A: New Agent
|
||||
|
||||
#### A1. Identity
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
New Agent Setup -- Identity
|
||||
|
||||
[G]enerate a new Nostr keypair
|
||||
[P]rovide an existing nsec
|
||||
[B]ack
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
**Option G -- Generate new keypair:**
|
||||
- Call `nostr_generate_keypair()` from `nip006.h`
|
||||
- Call `nostr_key_to_bech32()` from `nip019.h` with `hrp="nsec"` and `hrp="npub"` to display bech32 keys
|
||||
- Display the generated nsec and npub
|
||||
- **Critical warning**: "Save your nsec securely. It will NOT be stored unless you choose to write a genesis file."
|
||||
- Prompt user to confirm they have saved the nsec before proceeding
|
||||
|
||||
**Option P -- Provide existing nsec:**
|
||||
- Accept nsec1... bech32 or 64-char hex
|
||||
- Mask input with `termios` echo disable
|
||||
- Validate using existing `derive_keys_from_nsec()` logic
|
||||
- Display derived npub for confirmation
|
||||
|
||||
**After key derivation -- Existing identity check:**
|
||||
- Connect to default bootstrap relays (damus, primal, nos.lol)
|
||||
- Query kind 10002 for the derived pubkey
|
||||
- If found: warn the user that this identity already exists on Nostr
|
||||
- "This pubkey already has a kind 10002 relay list on Nostr."
|
||||
- "Running first-run genesis will overwrite existing profile/relay/skill events."
|
||||
- "[C]ontinue anyway or [A]bort?"
|
||||
- If not found: inform user this is a fresh identity, proceed
|
||||
|
||||
#### A2. Administrator
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
New Agent Setup -- Administrator
|
||||
|
||||
Enter the admin's Nostr public key (npub1... or hex):
|
||||
>
|
||||
```
|
||||
|
||||
- Validate using `decode_pubkey_hex_or_npub()`
|
||||
- Display the decoded hex for confirmation
|
||||
- This is required -- loop until valid input
|
||||
- `b` to go back
|
||||
|
||||
#### A3. LLM Provider
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
New Agent Setup -- LLM Provider
|
||||
|
||||
Didactyl needs an OpenAI-compatible LLM API.
|
||||
|
||||
Base URL [https://api.openai.com/v1]:
|
||||
API Key:
|
||||
Model [gpt-4o-mini]:
|
||||
Max Tokens [512]:
|
||||
Temperature [0.7]:
|
||||
```
|
||||
|
||||
- Show defaults in brackets, accept Enter for default
|
||||
- After collecting all fields, make a **test API call**:
|
||||
- Send a minimal chat completion request
|
||||
- Display result: "LLM test: OK (model responded)" or "LLM test: FAILED (HTTP 401 -- check API key)"
|
||||
- On failure:
|
||||
|
||||
```
|
||||
LLM test: FAILED (HTTP 401 -- check API key)
|
||||
|
||||
[R]e-enter LLM settings
|
||||
[S]kip test and continue anyway
|
||||
[Q]uit
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
#### A4. Relay Configuration
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
New Agent Setup -- Relay Configuration
|
||||
|
||||
Current relays:
|
||||
1. wss://relay.damus.io
|
||||
2. wss://nos.lol
|
||||
3. wss://relay.primal.net
|
||||
|
||||
[A]dd a relay
|
||||
[R]emove a relay (by number)
|
||||
[D]one -- use this list
|
||||
[B]ack
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
- Start with the 3 default bootstrap relays
|
||||
- Allow adding custom relay URLs (validate wss:// or ws:// prefix)
|
||||
- Allow removing by number (prompt: "Remove which number?")
|
||||
- Minimum 1 relay required
|
||||
- Display updated list after each change
|
||||
|
||||
#### A5. Review and Confirm
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
New Agent Setup -- Review
|
||||
|
||||
Identity: npub1...
|
||||
Admin: npub1...
|
||||
LLM: claude-haiku-4.5 @ https://api.ppq.ai
|
||||
Relays: 3 configured
|
||||
DM Protocol: nip04
|
||||
|
||||
[B]oot the agent now
|
||||
[W]rite genesis.jsonc (without nsec) and boot
|
||||
[I]nclude nsec in genesis.jsonc and boot (security risk!)
|
||||
[E]xport genesis.jsonc (without nsec) and exit
|
||||
[S]tart over
|
||||
[Q]uit
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
- **B** -- populate `didactyl_config_t` in memory and proceed to normal startup
|
||||
- **W** -- write genesis.jsonc without the nsec field, then boot
|
||||
- **I** -- write genesis.jsonc WITH nsec (with explicit warning), then boot
|
||||
- **E** -- write file and exit so user can review
|
||||
- **S** -- restart wizard from welcome screen
|
||||
- **Q** -- exit
|
||||
|
||||
---
|
||||
|
||||
### Path B: Existing Agent
|
||||
|
||||
#### B1. Provide nsec
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Existing Agent -- Identity
|
||||
|
||||
Enter your agent's nsec (nsec1... or hex):
|
||||
>
|
||||
```
|
||||
|
||||
- Mask input with `termios` echo disable
|
||||
- Validate and derive pubkey
|
||||
- Display npub for confirmation
|
||||
|
||||
#### B2. Recover Config from Nostr
|
||||
|
||||
After key derivation:
|
||||
1. Connect to default bootstrap relays
|
||||
2. Query kind 10002 for the pubkey's relay list
|
||||
3. If relay list found: expand relay pool with discovered relays
|
||||
4. Query encrypted kind 30078 events for `d=llm_config` and `d=agent_config`
|
||||
5. Decrypt and apply recovered config (reuses existing `recover_missing_runtime_config_from_nostr()` logic)
|
||||
|
||||
**Display recovery status:**
|
||||
```
|
||||
Existing Agent -- Config Recovery
|
||||
|
||||
Relay list (kind 10002): FOUND (6 relays)
|
||||
LLM config: FOUND (claude-haiku-4.5 @ ppq.ai)
|
||||
Admin config: FOUND (npub1...)
|
||||
|
||||
[B]oot with recovered config
|
||||
[E]dit settings before booting
|
||||
[Q]uit
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
If any required config is missing:
|
||||
```
|
||||
Existing Agent -- Config Recovery
|
||||
|
||||
Relay list (kind 10002): FOUND (6 relays)
|
||||
LLM config: NOT FOUND
|
||||
Admin config: NOT FOUND
|
||||
|
||||
Some required config was not found on Nostr.
|
||||
Entering guided setup for missing fields...
|
||||
```
|
||||
|
||||
Then jump to the appropriate new-agent steps (A2/A3) for the missing pieces only.
|
||||
|
||||
---
|
||||
|
||||
### Path C: Load Genesis File
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
Load Genesis File
|
||||
|
||||
Path [./genesis.jsonc]:
|
||||
>
|
||||
```
|
||||
|
||||
- Accept file path, default to `./genesis.jsonc`
|
||||
- Load with `config_load()`
|
||||
- If nsec is missing from the file, prompt for it
|
||||
- Proceed to normal startup
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### New Source File: `src/setup_wizard.c` / `src/setup_wizard.h`
|
||||
|
||||
Keep the wizard logic isolated from `main.c`. The interface:
|
||||
|
||||
```c
|
||||
// Returns 0 on success (config populated, ready to boot)
|
||||
// Returns -1 on abort/error
|
||||
// Returns 1 on "wrote genesis and exit" (no boot)
|
||||
int setup_wizard_run(didactyl_config_t* config, char* genesis_path_out, size_t path_size);
|
||||
```
|
||||
|
||||
### Changes to `src/main.c`
|
||||
|
||||
In `main()`, before the existing argument parsing:
|
||||
|
||||
```c
|
||||
if (argc == 1) {
|
||||
if (!isatty(STDIN_FILENO)) {
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
debug_init(DEBUG_LEVEL_INFO);
|
||||
if (nostr_init() != NOSTR_SUCCESS) {
|
||||
fprintf(stderr, "Failed to initialize nostr core\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
didactyl_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
char genesis_path[256] = {0};
|
||||
|
||||
int wizard_rc = setup_wizard_run(&cfg, genesis_path, sizeof(genesis_path));
|
||||
if (wizard_rc < 0) {
|
||||
nostr_cleanup();
|
||||
return 1; // user aborted
|
||||
}
|
||||
if (wizard_rc == 1) {
|
||||
nostr_cleanup();
|
||||
return 0; // wrote genesis and exit
|
||||
}
|
||||
|
||||
// Continue with normal startup using populated cfg
|
||||
// Jump past config_load() into the startup checklist
|
||||
goto startup_with_config;
|
||||
}
|
||||
```
|
||||
|
||||
### Changes to `nostr_core_lib`
|
||||
|
||||
**No new functions needed.** The existing API surface is sufficient:
|
||||
- `nostr_generate_keypair()` -- generate private/public key pair
|
||||
- `nostr_key_to_bech32(key, "nsec", output)` -- encode private key as nsec1...
|
||||
- `nostr_key_to_bech32(key, "npub", output)` -- encode public key as npub1...
|
||||
- `nostr_decode_nsec()` / `nostr_decode_npub()` -- decode bech32 inputs
|
||||
|
||||
### LLM Test Call
|
||||
|
||||
Reuse the existing `llm.c` infrastructure:
|
||||
- Temporarily initialize `llm_init()` with the test config
|
||||
- Make a single completion call with a trivial prompt
|
||||
- Check for HTTP success and valid response
|
||||
- Clean up with `llm_cleanup()`
|
||||
|
||||
### Existing Identity Check / Config Recovery
|
||||
|
||||
For both new and existing agent paths:
|
||||
- Initialize relay pool with bootstrap relays
|
||||
- Query kind 10002 for the derived pubkey
|
||||
- For existing agents: also query kind 30078 encrypted config events
|
||||
- Reuse `is_first_run_from_kind10002()` and `recover_missing_runtime_config_from_nostr()` logic
|
||||
|
||||
### Genesis File Writer
|
||||
|
||||
A helper function that serializes the collected config to JSONC format:
|
||||
|
||||
```c
|
||||
int setup_wizard_write_genesis(const didactyl_config_t* config,
|
||||
const char* path,
|
||||
int include_nsec);
|
||||
```
|
||||
|
||||
This writes a human-readable JSONC file with comments, matching the existing `genesis.jsonc` format.
|
||||
|
||||
---
|
||||
|
||||
## Terminal I/O Considerations
|
||||
|
||||
- Use `fgets()` for line input (not `scanf`)
|
||||
- Mask nsec input with `termios` echo disable on Linux
|
||||
- Handle Ctrl+C gracefully (restore terminal settings via `atexit` handler)
|
||||
- Use ANSI underline (`\e[4m`) for hotkey letters in menu options
|
||||
- Use ANSI colors sparingly for emphasis (warnings in yellow/red)
|
||||
- All prompts go to stderr, so stdout can be piped if needed
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Generated nsec is held in memory only unless user explicitly chooses to write genesis with nsec
|
||||
- nsec input is masked (echo disabled) during entry
|
||||
- If writing genesis with nsec, display a clear warning about the security implications
|
||||
- The nsec-less genesis file is the recommended default
|
||||
- After writing, suggest: "For subsequent runs, use: `./didactyl --config genesis.jsonc --nsec <your_nsec>`"
|
||||
- Or: "Set DIDACTYL_NSEC environment variable for convenience"
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/setup_wizard.h` | Create | Wizard public interface |
|
||||
| `src/setup_wizard.c` | Create | Interactive wizard implementation |
|
||||
| `src/main.c` | Modify | Add `argc == 1` check to enter wizard before arg parsing |
|
||||
| `Makefile` | Modify | Add `setup_wizard.o` to build |
|
||||
| `docs/GENESIS.md` | Modify | Document the interactive setup mode |
|
||||
| `README.md` | Modify | Add guided setup to usage section |
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Piped/non-TTY stdin**: Detect with `isatty(STDIN_FILENO)`. If not a TTY, print usage and exit instead of entering wizard.
|
||||
2. **Ctrl+C during setup**: Signal handler restores terminal settings and exits cleanly.
|
||||
3. **Genesis file already exists at write path**: Prompt "[O]verwrite or [C]hoose different path?" before overwriting.
|
||||
4. **LLM test timeout**: Set a reasonable timeout (10s) and allow skipping.
|
||||
5. **No relay connectivity during identity check**: Warn but allow proceeding -- the check is best-effort.
|
||||
6. **Existing agent with no config on Nostr**: Seamlessly transition to new-agent steps for the missing pieces.
|
||||
7. **Back navigation**: Each step supports `b` to go back to the previous step.
|
||||
@@ -11,9 +11,9 @@
|
||||
| 5 | `nostr_react` | 7 / NIP-25 | ✅ Shipped |
|
||||
| 6 | `nostr_profile_get` | 0 query | ✅ Shipped |
|
||||
| 7 | `nostr_relay_status` | Pool stats | ✅ Shipped |
|
||||
| 8 | `shell_exec` | OS | ✅ Shipped |
|
||||
| 9 | `file_read` | OS | ✅ Shipped |
|
||||
| 10 | `file_write` | OS | ✅ Shipped |
|
||||
| 8 | `local_shell_exec` | OS | ✅ Shipped |
|
||||
| 9 | `local_file_read` | OS | ✅ Shipped |
|
||||
| 10 | `local_file_write` | OS | ✅ Shipped |
|
||||
|
||||
---
|
||||
|
||||
|
||||
689
plans/new_trigger_types.md
Normal file
689
plans/new_trigger_types.md
Normal file
@@ -0,0 +1,689 @@
|
||||
# Plan: Implement Webhook, Cron, and Chain Trigger Types
|
||||
|
||||
## Overview
|
||||
|
||||
Add three new trigger types to Didactyl's trigger system alongside the existing `nostr-subscription` type. Implementation order: webhook → cron → chain, followed by documentation updates.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Current Flow
|
||||
NS[Nostr Event] --> SUB[Subscription Callback]
|
||||
SUB --> MF[maybe_fire_trigger_locked]
|
||||
MF --> EA{Action Type?}
|
||||
EA -->|template| TA[execute_template_action]
|
||||
EA -->|llm| LA[execute_llm_action]
|
||||
LA --> AOT[agent_on_trigger]
|
||||
end
|
||||
```
|
||||
|
||||
All triggers are implicitly `nostr-subscription` — there is no type discriminator field in `active_trigger_t`.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Trigger Sources
|
||||
NS[Nostr Subscription]
|
||||
WH[Webhook HTTP POST]
|
||||
CR[Cron Timer]
|
||||
CH[Chain - Post-Execution]
|
||||
end
|
||||
|
||||
subgraph Trigger Manager
|
||||
NS --> MF[maybe_fire_trigger]
|
||||
WH --> MF
|
||||
CR --> MF
|
||||
CH --> MF
|
||||
end
|
||||
|
||||
subgraph Dispatch
|
||||
MF --> EA{Action Type?}
|
||||
EA -->|template| TA[execute_template_action]
|
||||
EA -->|llm| LA[execute_llm_action]
|
||||
LA --> AOT[agent_on_trigger]
|
||||
AOT -->|on completion| CHK[Check chain triggers]
|
||||
CHK -->|match| CH
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation — Type System and API Changes
|
||||
|
||||
### 1.1 Add trigger type enum to `trigger_manager.h`
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:15)
|
||||
|
||||
Add a new enum after the existing `trigger_action_type_t` at line 18:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
|
||||
TRIGGER_TYPE_WEBHOOK,
|
||||
TRIGGER_TYPE_CRON,
|
||||
TRIGGER_TYPE_CHAIN
|
||||
} trigger_type_t;
|
||||
```
|
||||
|
||||
### 1.2 Extend `active_trigger_t` struct
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:20)
|
||||
|
||||
Add three new fields to the struct after [`enabled`](src/trigger_manager.h:25):
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type; // discriminator: which trigger source
|
||||
time_t last_cron_fire; // cron: last time this trigger fired
|
||||
char cron_expr[64]; // cron: parsed 5-field cron expression
|
||||
```
|
||||
|
||||
The struct currently has `subscription` and `subscription_ctx` fields which are only relevant for `nostr-subscription` — they'll remain but be NULL for other types.
|
||||
|
||||
### 1.3 Add trigger type string conversion helpers
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:57) — add declarations:
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type_from_string(const char *s);
|
||||
const char* trigger_type_to_string(trigger_type_t t);
|
||||
```
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:20) — add implementations after `clamp_enabled()`:
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type_from_string(const char *s) {
|
||||
if (!s) return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
if (strcmp(s, "webhook") == 0) return TRIGGER_TYPE_WEBHOOK;
|
||||
if (strcmp(s, "cron") == 0) return TRIGGER_TYPE_CRON;
|
||||
if (strcmp(s, "chain") == 0) return TRIGGER_TYPE_CHAIN;
|
||||
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
const char* trigger_type_to_string(trigger_type_t t) {
|
||||
switch (t) {
|
||||
case TRIGGER_TYPE_WEBHOOK: return "webhook";
|
||||
case TRIGGER_TYPE_CRON: return "cron";
|
||||
case TRIGGER_TYPE_CHAIN: return "chain";
|
||||
default: return "nostr-subscription";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Update `trigger_manager_add()` signature
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:44) and [`src/trigger_manager.c`](src/trigger_manager.c:569)
|
||||
|
||||
Add `const char* trigger_type_str` parameter:
|
||||
|
||||
```c
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str, // NEW
|
||||
int enabled);
|
||||
```
|
||||
|
||||
Inside the function body at [line 604](src/trigger_manager.c:604), after `memset(t, 0, sizeof(*t))` and field assignments:
|
||||
|
||||
```c
|
||||
t->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
|
||||
// For cron triggers, copy the filter as the cron expression
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
t->last_cron_fire = 0;
|
||||
}
|
||||
|
||||
// Only create Nostr subscription for nostr-subscription type
|
||||
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: subscription failed d_tag=%s", skill_d_tag);
|
||||
memset(t, 0, sizeof(*t));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 Update `trigger_manager_update()` signature
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:51) and [`src/trigger_manager.c`](src/trigger_manager.c:661)
|
||||
|
||||
Same pattern — add `const char* trigger_type_str` parameter. Inside the body at [line 683](src/trigger_manager.c:683):
|
||||
|
||||
```c
|
||||
t->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
}
|
||||
|
||||
// Only (re)subscribe for nostr-subscription type
|
||||
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) { ... }
|
||||
} else {
|
||||
// Close any existing subscription if type changed
|
||||
close_trigger_subscription_locked(t);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.6 Update all call sites of `trigger_manager_add()` and `trigger_manager_update()`
|
||||
|
||||
There are 5 call sites that need the new `trigger_type_str` parameter:
|
||||
|
||||
| Call Site | File | Line | Current `trigger_type_str` value |
|
||||
|---|---|---|---|
|
||||
| `trigger_manager_load_from_skills()` | [`src/trigger_manager.c`](src/trigger_manager.c:485) | 485 | `trigger_s` — already extracted from tags |
|
||||
| `trigger_manager_load_from_startup_events()` | [`src/trigger_manager.c`](src/trigger_manager.c:555) | 555 | `trigger_s` — already extracted from tags |
|
||||
| `execute_skill_create()` | [`src/tools.c`](src/tools.c:3678) | 3678 | `trigger_str` — already available |
|
||||
| `execute_skill_edit()` update call | [`src/tools.c`](src/tools.c:4213) | 4213 | `merged_trigger` — already available |
|
||||
| `execute_skill_edit()` add fallback | [`src/tools.c`](src/tools.c:4219) | 4219 | `merged_trigger` — already available |
|
||||
| `trigger_manager_add()` → `trigger_manager_update()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:588) | 588 | Pass through from caller |
|
||||
| `trigger_manager_update()` → `trigger_manager_add()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:680) | 680 | Pass through from caller |
|
||||
|
||||
### 1.7 Update loading functions to accept all trigger types
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:482)
|
||||
|
||||
In `trigger_manager_load_from_skills()`, change the filter at line 482 from:
|
||||
```c
|
||||
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
|
||||
```
|
||||
To:
|
||||
```c
|
||||
if (trigger_s && filter_s && filter_s[0] != '\0' &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0)) {
|
||||
```
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:547)
|
||||
|
||||
Same change in `trigger_manager_load_from_startup_events()` at line 547.
|
||||
|
||||
Note: For `webhook` type, `filter` can be empty/unused since webhooks are triggered by HTTP POST, not by a filter match. Consider allowing empty filter for webhook type. However, keeping the existing requirement that filter must be non-empty is simpler — webhook skills can use `filter: "{}"` as a placeholder.
|
||||
|
||||
### 1.8 Update `trigger_manager_status_json()`
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:761)
|
||||
|
||||
After the existing `cJSON_AddStringToObject(item, "filter_json", ...)` at line 762, add:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
|
||||
```
|
||||
|
||||
### 1.9 Update `tools.c` validation
|
||||
|
||||
**File:** [`src/tools.c`](src/tools.c:3603)
|
||||
|
||||
In `execute_skill_create()`, change line 3603 from:
|
||||
```c
|
||||
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
|
||||
```
|
||||
To:
|
||||
```c
|
||||
if (trigger_str &&
|
||||
strcmp(trigger_str, "nostr-subscription") != 0 &&
|
||||
strcmp(trigger_str, "webhook") != 0 &&
|
||||
strcmp(trigger_str, "cron") != 0 &&
|
||||
strcmp(trigger_str, "chain") != 0) {
|
||||
```
|
||||
|
||||
Update the error message to list valid types.
|
||||
|
||||
**File:** [`src/tools.c`](src/tools.c:4177)
|
||||
|
||||
In `execute_skill_edit()`, change line 4177 from:
|
||||
```c
|
||||
if (strcmp(merged_trigger, "nostr-subscription") != 0) {
|
||||
```
|
||||
To the same multi-type check.
|
||||
|
||||
Also update the `trigger_manager_add()` and `trigger_manager_update()` calls at [lines 3678](src/tools.c:3678) and [4213-4224](src/tools.c:4213) to pass the trigger type string.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Webhook Trigger
|
||||
|
||||
### 2.1 Add `POST /api/trigger/:d_tag` route handler
|
||||
|
||||
**File:** [`src/http_api.c`](src/http_api.c:1154)
|
||||
|
||||
Add a new handler function before `http_handler()`:
|
||||
|
||||
```c
|
||||
static void handle_trigger_webhook(struct mg_connection* c, struct mg_http_message* hm, const char* d_tag) {
|
||||
if (!g_api_ctx.trigger_manager) {
|
||||
reply_error(c, 503, "trigger manager unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the trigger by d_tag
|
||||
// Need a new function: trigger_manager_find_by_d_tag() that returns a copy
|
||||
active_trigger_t trigger_copy;
|
||||
if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger_copy) != 0) {
|
||||
reply_error(c, 404, "no trigger found for d_tag");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger_copy.trigger_type != TRIGGER_TYPE_WEBHOOK) {
|
||||
reply_error(c, 400, "trigger is not a webhook type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trigger_copy.enabled) {
|
||||
reply_error(c, 400, "trigger is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse optional JSON body as the webhook payload
|
||||
cJSON* payload = parse_body_json(hm);
|
||||
if (!payload) {
|
||||
reply_error(c, 400, "invalid JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build synthetic triggering event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "webhook");
|
||||
cJSON_AddStringToObject(event, "d_tag", d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
|
||||
cJSON_AddItemToObject(event, "payload", payload);
|
||||
|
||||
// Fire the trigger - dispatch based on action type
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
|
||||
agent_on_trigger(trigger_copy.skill_d_tag,
|
||||
trigger_copy.skill_content,
|
||||
event,
|
||||
"webhook");
|
||||
}
|
||||
// Template actions could also be supported here
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
// Return success immediately
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "d_tag", d_tag);
|
||||
cJSON_AddStringToObject(root, "status", "fired");
|
||||
reply_json(c, 200, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Add route to `http_handler()`
|
||||
|
||||
**File:** [`src/http_api.c`](src/http_api.c:1201)
|
||||
|
||||
Before the 404 fallthrough at line 1206, add:
|
||||
|
||||
```c
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
|
||||
// Extract d_tag from URI: /api/trigger/{d_tag}
|
||||
struct mg_str uri = hm->uri;
|
||||
const char* prefix = "/api/trigger/";
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (uri.len > prefix_len) {
|
||||
char d_tag[TRIGGER_SKILL_D_TAG_MAX];
|
||||
size_t tag_len = uri.len - prefix_len;
|
||||
if (tag_len >= sizeof(d_tag)) tag_len = sizeof(d_tag) - 1;
|
||||
memcpy(d_tag, uri.buf + prefix_len, tag_len);
|
||||
d_tag[tag_len] = '\0';
|
||||
handle_trigger_webhook(c, hm, d_tag);
|
||||
return;
|
||||
}
|
||||
reply_error(c, 400, "missing d_tag in trigger URL");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Add `trigger_manager_find()` function
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:50) and [`src/trigger_manager.c`](src/trigger_manager.c:631)
|
||||
|
||||
New function to look up a trigger by d_tag and return a copy:
|
||||
|
||||
```c
|
||||
// Declaration
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
|
||||
|
||||
// Implementation
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) {
|
||||
if (!mgr || !skill_d_tag || !out) return -1;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
*out = mgr->triggers[idx];
|
||||
// Clear pointer fields in copy to prevent double-free
|
||||
out->subscription = NULL;
|
||||
out->subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Webhook cooldown
|
||||
|
||||
The webhook handler should respect the same cooldown as other triggers. Either:
|
||||
- Call `maybe_fire_trigger_locked()` from the webhook handler (requires refactoring to expose it), or
|
||||
- Add cooldown checking in the webhook handler using `trigger_copy.last_fired` and updating it via a new `trigger_manager_mark_fired()` function
|
||||
|
||||
Recommended: Add a `trigger_manager_fire()` function that encapsulates the cooldown check and dispatch, usable by both the Nostr subscription callback and the webhook handler. This avoids duplicating cooldown logic.
|
||||
|
||||
```c
|
||||
int trigger_manager_fire(trigger_manager_t* mgr, const char* skill_d_tag, cJSON* event, const char* source);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Cron Trigger
|
||||
|
||||
### 3.1 Cron expression parser
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
|
||||
|
||||
Add a minimal 5-field cron expression matcher. The cron expression format is: `minute hour day-of-month month day-of-week`
|
||||
|
||||
```c
|
||||
// Returns 1 if the cron expression matches the given time, 0 otherwise
|
||||
static int cron_matches(const char* expr, const struct tm* tm);
|
||||
|
||||
// Helper: check if a single field matches a value
|
||||
// Supports: *, specific number, comma-separated list, ranges with -, step with /
|
||||
static int cron_field_matches(const char* field, int value, int min, int max);
|
||||
```
|
||||
|
||||
The parser needs to handle:
|
||||
- `*` — match any
|
||||
- `5` — match exact value
|
||||
- `1,15` — match list
|
||||
- `1-5` — match range
|
||||
- `*/15` — match step
|
||||
- `1-5/2` — match range with step
|
||||
|
||||
This is ~80-100 lines of C. Keep it simple — no named days/months, no special strings like `@hourly`.
|
||||
|
||||
### 3.2 Implement `trigger_manager_poll()` for cron
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:718)
|
||||
|
||||
Replace the no-op `trigger_manager_poll()` with cron checking logic:
|
||||
|
||||
```c
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr) return 0;
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
// Only check once per minute (cron resolution is 1 minute)
|
||||
if (now - mgr->last_poll_at < 60) return 0;
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
struct tm tm_now;
|
||||
localtime_r(&now, &tm_now);
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) continue;
|
||||
if (t->cron_expr[0] == '\0') continue;
|
||||
|
||||
if (!cron_matches(t->cron_expr, &tm_now)) continue;
|
||||
|
||||
// Prevent double-fire within same minute
|
||||
struct tm tm_last;
|
||||
localtime_r(&t->last_cron_fire, &tm_last);
|
||||
if (t->last_cron_fire > 0 &&
|
||||
tm_last.tm_min == tm_now.tm_min &&
|
||||
tm_last.tm_hour == tm_now.tm_hour &&
|
||||
tm_last.tm_mday == tm_now.tm_mday) {
|
||||
continue;
|
||||
}
|
||||
|
||||
t->last_cron_fire = now;
|
||||
t->last_fired = now;
|
||||
|
||||
// Copy trigger data before unlocking
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
// Build synthetic cron event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "cron");
|
||||
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
cJSON_AddStringToObject(event, "cron_expr", trigger_copy.cron_expr);
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, "cron");
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, "cron");
|
||||
}
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
// Re-lock and continue scanning
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Cron expression in skill tags
|
||||
|
||||
For cron triggers, the `filter` tag contains the cron expression instead of a JSON filter:
|
||||
- `["trigger", "cron"]`
|
||||
- `["filter", "0 * * * *"]` — fires every hour at minute 0
|
||||
- `["filter", "*/5 * * * *"]` — fires every 5 minutes
|
||||
|
||||
This reuses the existing `filter` tag semantics — the filter meaning depends on the trigger type.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Chain Trigger
|
||||
|
||||
### 4.1 Post-execution hook in `agent_on_trigger()`
|
||||
|
||||
**File:** [`src/agent.c`](src/agent.c:1913)
|
||||
|
||||
After the tool loop completes at [line 2041](src/agent.c:2041), before cleanup, add a chain trigger check:
|
||||
|
||||
```c
|
||||
// After the tool loop, check for chain triggers
|
||||
// Need access to trigger_manager — use agent_get_trigger_manager() or global
|
||||
trigger_manager_fire_chains(g_trigger_manager, skill_d_tag, messages);
|
||||
```
|
||||
|
||||
This requires:
|
||||
1. The agent needs access to the trigger manager — it already has this via [`agent_set_trigger_manager()`](src/agent.h:12)
|
||||
2. A new function `trigger_manager_fire_chains()` that scans for chain triggers whose filter matches the completed skill's d_tag
|
||||
|
||||
### 4.2 Add `trigger_manager_fire_chains()` function
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
|
||||
|
||||
```c
|
||||
void trigger_manager_fire_chains(trigger_manager_t* mgr,
|
||||
const char* source_d_tag,
|
||||
cJSON* source_output) {
|
||||
if (!mgr || !source_d_tag) return;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) continue;
|
||||
|
||||
// For chain triggers, filter_json contains the source skill d_tag
|
||||
if (strcmp(t->filter_json, source_d_tag) != 0) continue;
|
||||
|
||||
// Cooldown check
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg->triggers.cooldown_seconds;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) continue;
|
||||
|
||||
t->last_fired = now;
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
// Build synthetic chain event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "chain");
|
||||
cJSON_AddStringToObject(event, "source_d_tag", source_d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
if (source_output) {
|
||||
cJSON* output_copy = cJSON_Duplicate(source_output, 1);
|
||||
if (output_copy) {
|
||||
cJSON_AddItemToObject(event, "source_output", output_copy);
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
|
||||
execute_llm_action(&trigger_copy, event, "chain");
|
||||
}
|
||||
// Note: template actions could also be supported
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Chain trigger recursion protection
|
||||
|
||||
To prevent infinite chain loops (A → B → A → B → ...), add a chain depth counter:
|
||||
|
||||
- Add a `static __thread int chain_depth = 0;` in `trigger_manager_fire_chains()`
|
||||
- Increment before firing, decrement after
|
||||
- Refuse to fire if depth exceeds a limit (e.g., 5)
|
||||
|
||||
### 4.4 Chain trigger skill tags
|
||||
|
||||
For chain triggers, the `filter` tag contains the source skill's d_tag:
|
||||
- `["trigger", "chain"]`
|
||||
- `["filter", "data-fetcher"]` — fires after `data-fetcher` skill completes
|
||||
|
||||
### 4.5 What to pass as chain context
|
||||
|
||||
The chain trigger's synthetic event should include the source skill's final LLM response. This requires capturing the last assistant message from the tool loop in `agent_on_trigger()`. Currently the function doesn't return any output — it just runs and exits.
|
||||
|
||||
**Approach:** After the tool loop at [line 2041](src/agent.c:2041), extract the last assistant message from `messages` array and pass it to `trigger_manager_fire_chains()`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Documentation
|
||||
|
||||
### 5.1 Update `docs/SKILLS.md`
|
||||
|
||||
**File:** [`docs/SKILLS.md`](docs/SKILLS.md:209)
|
||||
|
||||
Expand the Trigger Tags table to show all four types and their filter semantics:
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
|
||||
| `filter` | Yes | Type-dependent: JSON filter, empty/unused, cron expression, or source d_tag |
|
||||
| `action` | No | `template` or `llm` — default: `llm` |
|
||||
| `enabled` | No | Whether active — default: `true` |
|
||||
|
||||
Add sections for each new trigger type with examples.
|
||||
|
||||
Update the Future Extensions table to mark webhook, cron, and chain as implemented.
|
||||
|
||||
### 5.2 Update `docs/API.md`
|
||||
|
||||
**File:** [`docs/API.md`](docs/API.md:49)
|
||||
|
||||
Add documentation for the new webhook endpoint:
|
||||
|
||||
```
|
||||
### POST /api/trigger/:d_tag
|
||||
|
||||
Fire a webhook trigger by skill d_tag.
|
||||
|
||||
**URL Parameters:**
|
||||
- `d_tag` — The skill's d_tag identifier
|
||||
|
||||
**Request Body:** Optional JSON payload passed as context to the skill
|
||||
|
||||
**Response:**
|
||||
{
|
||||
"success": true,
|
||||
"d_tag": "my-webhook-skill",
|
||||
"status": "fired"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Build and Push
|
||||
|
||||
1. Run `make -j` and fix any compilation warnings/errors
|
||||
2. Test webhook with: `curl -X POST http://localhost:8484/api/trigger/test-skill -d '{"message":"hello"}'`
|
||||
3. Push with `./increment_and_push.sh "feat: add webhook, cron, and chain trigger types"`
|
||||
|
||||
---
|
||||
|
||||
## Files Modified Summary
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| [`src/trigger_manager.h`](src/trigger_manager.h) | Add `trigger_type_t` enum, extend `active_trigger_t`, update function signatures, add new declarations |
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c) | Type conversion helpers, update load/add/update functions, cron parser, poll implementation, chain fire function, find function |
|
||||
| [`src/tools.c`](src/tools.c) | Update `execute_skill_create()` and `execute_skill_edit()` validation and call sites |
|
||||
| [`src/http_api.c`](src/http_api.c) | Add webhook route handler and route entry |
|
||||
| [`src/agent.c`](src/agent.c) | Add chain trigger post-execution hook in `agent_on_trigger()` |
|
||||
| [`src/agent.h`](src/agent.h) | No changes needed — `agent_on_trigger()` signature unchanged |
|
||||
| [`docs/SKILLS.md`](docs/SKILLS.md) | Document all three new trigger types |
|
||||
| [`docs/API.md`](docs/API.md) | Document webhook endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Add `trigger_type_t` enum to `trigger_manager.h`
|
||||
- [ ] Extend `active_trigger_t` struct with `trigger_type`, `last_cron_fire`, `cron_expr`
|
||||
- [ ] Add `trigger_type_from_string()` and `trigger_type_to_string()` declarations and implementations
|
||||
- [ ] Add `trigger_manager_find()` declaration and implementation
|
||||
- [ ] Add `trigger_manager_fire_chains()` declaration and implementation
|
||||
- [ ] Update `trigger_manager_add()` signature with `trigger_type_str` parameter
|
||||
- [ ] Update `trigger_manager_update()` signature with `trigger_type_str` parameter
|
||||
- [ ] Update `trigger_manager_add()` body: set trigger_type, conditional subscription registration
|
||||
- [ ] Update `trigger_manager_update()` body: set trigger_type, conditional subscription
|
||||
- [ ] Update `trigger_manager_load_from_skills()` to accept all 4 trigger types
|
||||
- [ ] Update `trigger_manager_load_from_startup_events()` to accept all 4 trigger types
|
||||
- [ ] Update `trigger_manager_status_json()` to include type field
|
||||
- [ ] Update `execute_skill_create()` validation at line 3603
|
||||
- [ ] Update `execute_skill_create()` `trigger_manager_add()` call at line 3678
|
||||
- [ ] Update `execute_skill_edit()` validation at line 4177
|
||||
- [ ] Update `execute_skill_edit()` `trigger_manager_update()` and `trigger_manager_add()` calls at lines 4213-4224
|
||||
- [ ] Add `handle_trigger_webhook()` handler to `http_api.c`
|
||||
- [ ] Add webhook route to `http_handler()` in `http_api.c`
|
||||
- [ ] Implement `cron_field_matches()` and `cron_matches()` in `trigger_manager.c`
|
||||
- [ ] Implement cron polling in `trigger_manager_poll()`
|
||||
- [ ] Add chain trigger post-execution hook in `agent_on_trigger()`
|
||||
- [ ] Add chain depth recursion protection
|
||||
- [ ] Update `docs/SKILLS.md` with all three new trigger types
|
||||
- [ ] Update `docs/API.md` with webhook endpoint
|
||||
- [ ] Build with `make -j` and verify clean compilation
|
||||
- [ ] Push with `./increment_and_push.sh`
|
||||
194
plans/nip17_messaging.md
Normal file
194
plans/nip17_messaging.md
Normal 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
|
||||
264
plans/nip60_cashu_wallet.md
Normal file
264
plans/nip60_cashu_wallet.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# NIP-60 Cashu Wallet for Didactyl Agents
|
||||
|
||||
## Overview
|
||||
|
||||
Add a NIP-60 Cashu wallet to every Didactyl agent. The wallet stores its state on Nostr relays (encrypted with the agent's keys) and interacts with Cashu mints over HTTP. The agent manages the wallet autonomously — it can check balances, mint tokens (fund via Lightning), melt tokens (pay Lightning invoices), swap tokens, and persist all state transitions as Nostr events per the NIP-60 spec.
|
||||
|
||||
## Available `nostr_core_lib` Primitives
|
||||
|
||||
### NIP-60 Event Layer (`nip060.h`)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `nostr_nip60_create_wallet_event()` | Create kind:17375 wallet event (privkey + mint URLs, NIP-44 encrypted) |
|
||||
| `nostr_nip60_parse_wallet_event()` | Decrypt and parse wallet event |
|
||||
| `nostr_nip60_free_wallet_data()` | Free wallet data |
|
||||
| `nostr_nip60_create_token_event()` | Create kind:7375 token event (proofs, NIP-44 encrypted) |
|
||||
| `nostr_nip60_parse_token_event()` | Decrypt and parse token event |
|
||||
| `nostr_nip60_free_token_data()` | Free token data |
|
||||
| `nostr_nip60_create_token_deletion()` | Create kind:5 deletion for spent tokens |
|
||||
| `nostr_nip60_create_rollover_token()` | Create new token with remaining proofs + del references |
|
||||
| `nostr_nip60_create_history_event()` | Create kind:7376 spending history event |
|
||||
| `nostr_nip60_parse_history_event()` | Decrypt and parse history event |
|
||||
| `nostr_nip60_free_history_data()` | Free history data |
|
||||
| `nostr_nip60_create_quote_event()` | Create kind:7374 quote tracking event |
|
||||
| `nostr_nip60_parse_quote_event()` | Decrypt and parse quote event |
|
||||
| `nostr_nip60_sum_proofs()` | Sum proof amounts |
|
||||
| `nostr_nip60_proofs_to_json()` / `nostr_nip60_proofs_from_json()` | Proof serialization |
|
||||
| `nostr_nip60_create_wallet_filter()` | Build REQ filter for kinds 17375+7375 |
|
||||
| `nostr_nip60_create_history_filter()` | Build REQ filter for kind 7376 |
|
||||
|
||||
### Cashu Mint HTTP Client (`cashu_mint.h`)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `cashu_mint_get_info()` | GET /v1/info — mint name, pubkey, keysets |
|
||||
| `cashu_mint_request_mint_quote()` | POST /v1/mint/quote/bolt11 — get Lightning invoice to fund wallet |
|
||||
| `cashu_mint_check_mint_quote()` | GET /v1/mint/quote/bolt11/{id} — check if invoice paid |
|
||||
| `cashu_mint_mint_tokens()` | POST /v1/mint/bolt11 — claim tokens after invoice paid |
|
||||
| `cashu_mint_request_melt_quote()` | POST /v1/melt/quote/bolt11 — get quote to pay Lightning invoice |
|
||||
| `cashu_mint_check_melt_quote()` | GET /v1/melt/quote/bolt11/{id} — check melt status |
|
||||
| `cashu_mint_melt_tokens()` | POST /v1/melt/bolt11 — pay Lightning invoice with tokens |
|
||||
| `cashu_mint_swap()` | POST /v1/swap — swap proofs (split/merge denominations) |
|
||||
| `cashu_mint_check_proofs_state()` | POST /v1/checkstate — verify proof spendability |
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Agent Main Loop] --> B[Cashu Wallet Manager]
|
||||
B --> C[In-Memory Wallet State]
|
||||
C --> D[nostr_core_lib NIP-60 Events]
|
||||
C --> E[nostr_core_lib Cashu Mint HTTP]
|
||||
D --> F[Nostr Relays]
|
||||
E --> G[Cashu Mint Servers]
|
||||
|
||||
H[LLM Tool Calls] --> I[cashu_wallet_balance]
|
||||
H --> J[cashu_wallet_mint_quote]
|
||||
H --> K[cashu_wallet_mint_claim]
|
||||
H --> L[cashu_wallet_melt_quote]
|
||||
H --> M[cashu_wallet_melt_pay]
|
||||
H --> N[cashu_wallet_info]
|
||||
H --> O[cashu_wallet_check_proofs]
|
||||
|
||||
I --> B
|
||||
J --> B
|
||||
K --> B
|
||||
L --> B
|
||||
M --> B
|
||||
N --> B
|
||||
O --> B
|
||||
```
|
||||
|
||||
### Module Boundaries
|
||||
|
||||
#### 1. `src/cashu_wallet.h` / `src/cashu_wallet.c` — Cashu Wallet Manager
|
||||
|
||||
The core wallet module that owns in-memory state and orchestrates all operations.
|
||||
|
||||
**State:**
|
||||
- Parsed `nostr_nip60_wallet_data_t` (privkey, mint URLs)
|
||||
- Array of `nostr_nip60_token_data_t` with their event IDs (unspent proofs)
|
||||
- Wallet initialized flag
|
||||
- Mutex for thread safety
|
||||
|
||||
**Lifecycle:**
|
||||
- `cashu_wallet_init(config)` — called during agent startup
|
||||
- `cashu_wallet_load_from_relays()` — fetch kind:17375 + kind:7375 events, decrypt, populate state
|
||||
- `cashu_wallet_create_new(mint_urls, mint_count)` — generate wallet privkey, publish kind:17375
|
||||
- `cashu_wallet_cleanup()` — free all state
|
||||
|
||||
**Operations:**
|
||||
- `cashu_wallet_balance()` — sum all unspent proofs, grouped by mint
|
||||
- `cashu_wallet_balance_total()` — total across all mints
|
||||
- `cashu_wallet_mint_quote(mint_url, amount, unit)` — request Lightning invoice from mint
|
||||
- `cashu_wallet_check_mint_quote(mint_url, quote_id)` — poll quote status
|
||||
- `cashu_wallet_claim_tokens(mint_url, quote_id, amount)` — mint tokens after invoice paid, publish kind:7375
|
||||
- `cashu_wallet_melt_quote(mint_url, payment_request, unit)` — get quote to pay LN invoice
|
||||
- `cashu_wallet_melt_pay(mint_url, quote_id, proofs)` — pay LN invoice, delete spent tokens, rollover change
|
||||
- `cashu_wallet_swap(mint_url, proofs, target_amounts)` — split/merge denominations
|
||||
- `cashu_wallet_check_proofs(mint_url)` — validate proof spendability, clean up spent
|
||||
- `cashu_wallet_get_info(mint_url)` — fetch mint info
|
||||
|
||||
**State Persistence (Nostr events):**
|
||||
- After every balance-changing operation, publish updated kind:7375 token events
|
||||
- Delete old kind:7375 events via kind:5 when proofs are spent
|
||||
- Publish kind:7376 history events for audit trail
|
||||
- Optionally publish kind:7374 quote events for cross-device state
|
||||
|
||||
#### 2. `src/tools/tool_cashu_wallet.c` — LLM Tool Interface
|
||||
|
||||
Exposes wallet operations as tools the LLM can call.
|
||||
|
||||
#### 3. Config additions in `src/config.h` / `src/config.c`
|
||||
|
||||
New `cashu_wallet_config_t` section.
|
||||
|
||||
## Config Schema Changes
|
||||
|
||||
Add to `didactyl_config_t`:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
int enabled; // master switch
|
||||
char** mint_urls; // default mint URLs
|
||||
int mint_count;
|
||||
char unit[16]; // default unit, e.g. "sat"
|
||||
int auto_load; // load wallet from relays on startup
|
||||
int mint_timeout_seconds; // HTTP timeout for mint operations
|
||||
} cashu_wallet_config_t;
|
||||
```
|
||||
|
||||
Example `config.jsonc`:
|
||||
```jsonc
|
||||
"cashu_wallet": {
|
||||
"enabled": true,
|
||||
"mints": [
|
||||
"https://mint.minibits.cash",
|
||||
"https://stablenut.umint.cash"
|
||||
],
|
||||
"unit": "sat",
|
||||
"auto_load": true,
|
||||
"mint_timeout_seconds": 30
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Definitions
|
||||
|
||||
### `cashu_wallet_balance`
|
||||
- **Description:** Check the agent wallet balance across all mints
|
||||
- **Parameters:** none (or optional `mint_url` to filter)
|
||||
- **Returns:** `{ "total_sats": 1234, "by_mint": [{ "mint": "...", "balance": 500 }] }`
|
||||
|
||||
### `cashu_wallet_info`
|
||||
- **Description:** Get information about a Cashu mint
|
||||
- **Parameters:** `{ "mint_url": "https://..." }`
|
||||
- **Returns:** `{ "name": "...", "version": "...", "keysets": [...] }`
|
||||
|
||||
### `cashu_wallet_mint_quote`
|
||||
- **Description:** Request a Lightning invoice to fund the wallet
|
||||
- **Parameters:** `{ "mint_url": "https://...", "amount": 1000, "unit": "sat" }`
|
||||
- **Returns:** `{ "quote_id": "...", "payment_request": "lnbc...", "expiry": ... }`
|
||||
|
||||
### `cashu_wallet_mint_check`
|
||||
- **Description:** Check if a mint quote invoice has been paid
|
||||
- **Parameters:** `{ "mint_url": "https://...", "quote_id": "..." }`
|
||||
- **Returns:** `{ "paid": true/false, "amount": 1000 }`
|
||||
|
||||
### `cashu_wallet_mint_claim`
|
||||
- **Description:** Claim tokens after a mint quote invoice is paid
|
||||
- **Parameters:** `{ "mint_url": "https://...", "quote_id": "...", "amount": 1000 }`
|
||||
- **Returns:** `{ "success": true, "new_balance": 2234 }`
|
||||
|
||||
### `cashu_wallet_melt_quote`
|
||||
- **Description:** Get a quote to pay a Lightning invoice from the wallet
|
||||
- **Parameters:** `{ "mint_url": "https://...", "payment_request": "lnbc...", "unit": "sat" }`
|
||||
- **Returns:** `{ "quote_id": "...", "amount": 500, "fee_reserve": 2 }`
|
||||
|
||||
### `cashu_wallet_melt_pay`
|
||||
- **Description:** Pay a Lightning invoice using wallet tokens
|
||||
- **Parameters:** `{ "mint_url": "https://...", "quote_id": "..." }`
|
||||
- **Returns:** `{ "success": true, "paid": true, "new_balance": 1732 }`
|
||||
|
||||
### `cashu_wallet_check_proofs`
|
||||
- **Description:** Validate that stored proofs are still spendable
|
||||
- **Parameters:** `{ "mint_url": "https://..." }` (optional, checks all mints if omitted)
|
||||
- **Returns:** `{ "valid": 15, "spent": 2, "cleaned": true }`
|
||||
|
||||
## Phased Implementation Plan
|
||||
|
||||
### Phase 1: Cashu Wallet Core Module
|
||||
|
||||
- [ ] Create `src/cashu_wallet.h` with wallet state structs and function declarations
|
||||
- [ ] Create `src/cashu_wallet.c` with:
|
||||
- [ ] `cashu_wallet_init()` / `cashu_wallet_cleanup()` lifecycle
|
||||
- [ ] In-memory state: wallet data + token array with event IDs
|
||||
- [ ] Mutex protection for thread safety
|
||||
- [ ] `cashu_wallet_create_new()` — generate random privkey, build `nostr_nip60_wallet_data_t`, publish kind:17375 via `nostr_handler_publish_kind_event()`
|
||||
- [ ] `cashu_wallet_load_from_relays()` — use `nostr_nip60_create_wallet_filter()` + `nostr_handler_query_json()` to fetch and parse wallet + token events
|
||||
- [ ] `cashu_wallet_balance()` / `cashu_wallet_balance_total()` — iterate in-memory tokens, call `nostr_nip60_sum_proofs()`
|
||||
|
||||
### Phase 2: Mint Interaction (Fund & Pay)
|
||||
|
||||
- [ ] Add to `src/cashu_wallet.c`:
|
||||
- [ ] `cashu_wallet_mint_quote()` — call `cashu_mint_request_mint_quote()`
|
||||
- [ ] `cashu_wallet_check_mint_quote()` — call `cashu_mint_check_mint_quote()`
|
||||
- [ ] `cashu_wallet_claim_tokens()` — call `cashu_mint_mint_tokens()`, parse response proofs via `nostr_nip60_proofs_from_json()`, create and publish kind:7375 token event, publish kind:7376 history
|
||||
- [ ] `cashu_wallet_melt_quote()` — call `cashu_mint_request_melt_quote()`
|
||||
- [ ] `cashu_wallet_melt_pay()` — select proofs to spend, call `cashu_mint_melt_tokens()`, delete spent token events via `nostr_nip60_create_token_deletion()`, rollover change via `nostr_nip60_create_rollover_token()`, publish kind:7376 history
|
||||
- [ ] `cashu_wallet_swap()` — call `cashu_mint_swap()`, update token events
|
||||
- [ ] `cashu_wallet_check_proofs()` — call `cashu_mint_check_proofs_state()`, clean up spent proofs
|
||||
- [ ] `cashu_wallet_get_info()` — call `cashu_mint_get_info()`
|
||||
|
||||
### Phase 3: Config & Startup Integration
|
||||
|
||||
- [ ] Add `cashu_wallet_config_t` to `src/config.h`
|
||||
- [ ] Parse `"cashu_wallet"` section in `src/config.c`
|
||||
- [ ] Call `cashu_wallet_init()` from `src/main.c` after `agent_init()`
|
||||
- [ ] If `auto_load` is true, call `cashu_wallet_load_from_relays()` during startup
|
||||
- [ ] If no wallet event found and mints configured, call `cashu_wallet_create_new()`
|
||||
- [ ] Call `cashu_wallet_cleanup()` in shutdown path
|
||||
- [ ] Add `src/cashu_wallet.c` to `Makefile` SRCS
|
||||
|
||||
### Phase 4: LLM Tools
|
||||
|
||||
- [ ] Create `src/tools/tool_cashu_wallet.c` implementing all 8 tool execute functions
|
||||
- [ ] Add tool declarations to `src/tools/tools_internal.h`
|
||||
- [ ] Register tools in `src/tools/tools_dispatch.c`
|
||||
- [ ] Add JSON schemas in `src/tools/tools_schema.c`
|
||||
- [ ] Add `src/tools/tool_cashu_wallet.c` to `Makefile` SRCS
|
||||
|
||||
### Phase 5: Testing & Validation
|
||||
|
||||
- [ ] Unit test: create wallet, publish, reload from relay, verify state
|
||||
- [ ] Integration test: mint quote → claim → check balance → melt quote → pay
|
||||
- [ ] Test proof rollover: spend partial proofs, verify deletion + new token event
|
||||
- [ ] Test startup: fresh agent creates wallet; existing agent loads wallet
|
||||
- [ ] Test error handling: mint offline, invalid proofs, insufficient balance
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Wallet privkey is NOT the agent's Nostr privkey** — per NIP-60 spec, a separate privkey is generated and stored encrypted in the kind:17375 event. This privkey is used for P2PK ecash locking (future NIP-61 support).
|
||||
|
||||
2. **State lives on relays** — the in-memory state is a cache. On startup the agent fetches its wallet state from relays. After every mutation, updated events are published back.
|
||||
|
||||
3. **Proof selection for spending** — when melting, the wallet needs to select proofs that sum to at least the required amount. Implement a simple greedy algorithm (largest-first) with change output via swap.
|
||||
|
||||
4. **Thread safety** — wallet operations are called from the LLM tool loop which may run concurrently with triggered skills. A mutex protects all state mutations.
|
||||
|
||||
5. **No NIP-61 (nutzaps) in this phase** — receiving/sending nutzaps is a separate feature that builds on top of this wallet foundation.
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/cashu_wallet.h` | **NEW** — cashu wallet manager header |
|
||||
| `src/cashu_wallet.c` | **NEW** — cashu wallet manager implementation |
|
||||
| `src/tools/tool_cashu_wallet.c` | **NEW** — LLM tool implementations |
|
||||
| `src/config.h` | Add `cashu_wallet_config_t` to `didactyl_config_t` |
|
||||
| `src/config.c` | Parse `"cashu_wallet"` config section |
|
||||
| `src/main.c` | Call `cashu_wallet_init()` / `cashu_wallet_cleanup()` |
|
||||
| `src/tools/tools_internal.h` | Declare `execute_cashu_wallet_*` functions |
|
||||
| `src/tools/tools_dispatch.c` | Register cashu wallet tool dispatch entries |
|
||||
| `src/tools/tools_schema.c` | Add cashu wallet tool JSON schemas |
|
||||
| `Makefile` | Add `cashu_wallet.c` and `tool_cashu_wallet.c` to SRCS |
|
||||
81
plans/nostr_core_logging_integration.md
Normal file
81
plans/nostr_core_logging_integration.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Didactyl Integration Plan: Unified `nostr_core_lib` Logging into One File
|
||||
|
||||
## Goal
|
||||
|
||||
Route `nostr_core_lib` logs through didactyl logging so there is one authoritative file for errors, using existing didactyl logging in [`debug.c`](../src/debug.c:1) plus the new callback API in [`nostr_log.h`](../nostr_core_lib/nostr_core/nostr_log.h:1).
|
||||
|
||||
## Current State
|
||||
|
||||
- Didactyl writes logs to path from `DIDACTYL_LOG_FILE` or default `debug.log` in [`debug_open_log_file`](../src/debug.c:10).
|
||||
- Didactyl log emission is centralized in [`debug_log`](../src/debug.c:31).
|
||||
- Relay pool setup occurs in [`nostr_handler_init`](../src/nostr_handler.c:1519).
|
||||
- `nostr_core_lib` now exposes callback logging API in [`nostr_log.h`](../nostr_core_lib/nostr_core/nostr_log.h:1).
|
||||
|
||||
## Desired End State
|
||||
|
||||
- `nostr_core_lib` logs are forwarded into didactyl logger via callback registration.
|
||||
- One file path configured by `DIDACTYL_LOG_FILE` receives didactyl plus nostr_core_lib errors.
|
||||
- Log lines preserve source component tags such as `nostr:websocket` and `nostr:nip013`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[nostr_core_lib emits callback log] --> B[didactyl bridge callback in nostr_handler.c]
|
||||
B --> C[map nostr level to didactyl level]
|
||||
C --> D[debug_log in debug.c]
|
||||
D --> E[DIDACTYL_LOG_FILE single destination]
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add bridge function in [`nostr_handler.c`](../src/nostr_handler.c:1519)
|
||||
- Create static callback `nostr_core_log_bridge level component message user_data`.
|
||||
- Prefix forwarded lines with `nostr:` namespace.
|
||||
|
||||
2. Map levels from `nostr_core_lib` to didactyl
|
||||
- `NOSTR_LOG_LEVEL_ERROR` to `DEBUG_LEVEL_ERROR`
|
||||
- `NOSTR_LOG_LEVEL_WARN` to `DEBUG_LEVEL_WARN`
|
||||
- `NOSTR_LOG_LEVEL_INFO` to `DEBUG_LEVEL_INFO`
|
||||
- `NOSTR_LOG_LEVEL_DEBUG` to `DEBUG_LEVEL_DEBUG`
|
||||
- `NOSTR_LOG_LEVEL_TRACE` to `DEBUG_LEVEL_TRACE`
|
||||
|
||||
3. Register callback during startup in [`nostr_handler_init`](../src/nostr_handler.c:1519)
|
||||
- Before relay setup, call `nostr_set_log_callback`.
|
||||
- Set threshold with `nostr_set_log_level` aligned to current didactyl debug level.
|
||||
|
||||
4. Enforce single-file policy
|
||||
- Set `DIDACTYL_LOG_FILE` to desired final log path.
|
||||
- Keep didactyl as only file owner via [`debug_open_log_file`](../src/debug.c:10).
|
||||
|
||||
5. Ensure error visibility
|
||||
- Keep bridge forwarding at least error and warn levels even when app runs at lower verbosity.
|
||||
- Optionally pin library threshold to `WARN` or `ERROR` for production profiles.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
1. Start didactyl with explicit file path
|
||||
- Example environment: `DIDACTYL_LOG_FILE=didactyl.log`.
|
||||
|
||||
2. Trigger nostr websocket activity
|
||||
- Confirm `nostr:websocket` entries appear in same file.
|
||||
|
||||
3. Trigger nip013 path
|
||||
- Confirm `nostr:nip013` entries appear in same file.
|
||||
|
||||
4. Trigger normal didactyl LLM call
|
||||
- Confirm `[didactyl] llm request` still lands in same file.
|
||||
|
||||
5. Verify there is no second active file for nostr core logs.
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
- Implement bridge first, then verify in development run.
|
||||
- If log volume is too high, reduce callback threshold by setting `nostr_set_log_level`.
|
||||
- Keep one destination file operationally stable by configuring and documenting `DIDACTYL_LOG_FILE`.
|
||||
|
||||
## Files to Change in Code Mode
|
||||
|
||||
- [`src/nostr_handler.c`](../src/nostr_handler.c)
|
||||
- Optional docs update in [`README.md`](../README.md)
|
||||
- Optional operational note in [`docs/TOOLS.md`](../docs/TOOLS.md)
|
||||
393
plans/prompt_templates.md
Normal file
393
plans/prompt_templates.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Didactyl Prompt Template System — Design Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the hardcoded context assembly in `src/agent.c` with a template-driven system. The template lives inside the soul event (kind 31120) — because the template defines how the agent perceives the world, and that is fundamentally part of who the agent is.
|
||||
|
||||
Different agents (architect, eGirl, analyst) are different processes with different souls, different templates, different Nostr identities. Multi-agent is Approach A: multiple `./didactyl --config` processes communicating via Nostr.
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
**The soul IS the template.** An agent's soul defines both its personality (prose instructions) and its perception (what context sections it sees, in what order, with what limits). You cannot meaningfully separate "who you are" from "how you see the world."
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
Today, context assembly is hardcoded in `agent_build_admin_messages_json()`:
|
||||
|
||||
```
|
||||
1. System prompt (soul content)
|
||||
2. Admin identity (pubkey)
|
||||
3. Admin kind 0 profile
|
||||
4. Admin kind 10002 relay list
|
||||
5. Startup events memory
|
||||
6. Adopted skills memory
|
||||
7. DM history (last 12 turns)
|
||||
8. Admin recent notes (kind 1)
|
||||
```
|
||||
|
||||
The order, formatting, limits, and section headers are all baked into C code. Changing anything requires editing `src/agent.c` and recompiling.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Design
|
||||
|
||||
### Soul Event Structure
|
||||
|
||||
The kind 31120 soul event content gains a template section, delimited by a marker:
|
||||
|
||||
```markdown
|
||||
# Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
## Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
## Behavior
|
||||
- Be helpful and technically accurate.
|
||||
...
|
||||
|
||||
## Safety
|
||||
- Never reveal your private key.
|
||||
...
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
content: |
|
||||
This is your administrator! Admin pubkey: {{admin_pubkey}}
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
content: |
|
||||
Administrator profile: {{admin_kind0_json}}
|
||||
|
||||
- section: admin_relay_list
|
||||
role: system
|
||||
content: |
|
||||
Administrator relay list: {{admin_kind10002_json}}
|
||||
|
||||
- section: startup_events
|
||||
role: system
|
||||
content: |
|
||||
Startup events memory: {{startup_events_json}}
|
||||
|
||||
- section: adopted_skills
|
||||
role: system
|
||||
content: |
|
||||
{{adopted_skills_content}}
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
limit: 10
|
||||
content: |
|
||||
{{admin_notes_content}}
|
||||
```
|
||||
|
||||
Everything above `---template---` is the system prompt (personality/rules). Everything below defines the context assembly template.
|
||||
|
||||
If no `---template---` marker is found, the agent falls back to the current hardcoded assembly — backward compatible.
|
||||
|
||||
### Template Syntax
|
||||
|
||||
Simple YAML-like format parsed in C. Each section has:
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `section` | yes | Section name for logging and API identification |
|
||||
| `role` | yes | Chat message role: `system`, `user`, `assistant`, or `expand` |
|
||||
| `tool` | recommended | Tool name executed by template builder (for example `nostr_admin_profile`) |
|
||||
| `args` | no | JSON args string passed to the tool (default `{}`) |
|
||||
| `result_field` | no | Field extracted from tool JSON result (default `content`) |
|
||||
| `content` | optional fallback | Literal content when no `tool` is specified |
|
||||
| `limit` | no | Integer limit for variable-length sections like DM history |
|
||||
| `provider` | no | Provider-specific override — see below |
|
||||
|
||||
### Tool-Driven Context Resolution
|
||||
|
||||
Context sections should use `tool:` directives as the single runtime data path, which removes the legacy per-variable resolver duplication.
|
||||
|
||||
### Special Section Types
|
||||
|
||||
**`role: expand`** — The `dm_history` section expands into multiple messages (user/assistant pairs). The `limit` field controls how many turns to include. This is the only section type that produces multiple chat messages from one template entry.
|
||||
|
||||
### Provider-Specific Overrides
|
||||
|
||||
Within a section, you can specify provider-specific formatting:
|
||||
|
||||
```yaml
|
||||
- section: admin_identity
|
||||
role: system
|
||||
content: |
|
||||
## Administrator Identity
|
||||
This is your administrator! Admin pubkey: {{admin_pubkey}}
|
||||
provider:
|
||||
anthropic: |
|
||||
<admin_identity>
|
||||
This is your administrator! Admin pubkey: {{admin_pubkey}}
|
||||
</admin_identity>
|
||||
```
|
||||
|
||||
When the configured `llm.provider` matches a provider key, that override is used instead of the default `content`. This lets one soul/template work well across providers without needing separate soul events.
|
||||
|
||||
If no provider override matches, the default `content` is used.
|
||||
|
||||
---
|
||||
|
||||
## Context Assembly Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
BOOT[Agent boots] --> LOAD_SOUL[Load soul event - kind 31120]
|
||||
LOAD_SOUL --> PARSE[Parse soul content]
|
||||
PARSE --> SPLIT{Contains ---template--- marker?}
|
||||
SPLIT -->|Yes| EXTRACT[Extract personality above marker]
|
||||
SPLIT -->|No| FALLBACK[Use hardcoded assembly - backward compat]
|
||||
EXTRACT --> PARSE_TPL[Parse template sections below marker]
|
||||
PARSE_TPL --> STORE[Store template_section_t array in memory]
|
||||
|
||||
DM[Incoming DM] --> BUILD[Build context from template]
|
||||
BUILD --> EMIT_SOUL[Emit personality as first system message]
|
||||
EMIT_SOUL --> FOREACH[For each template section]
|
||||
FOREACH --> CALL_TOOL[Execute section tool or use literal content]
|
||||
CALL_TOOL --> PICK_FIELD[Extract result_field or content]
|
||||
PICK_FIELD --> CHECK_PROVIDER{Provider override?}
|
||||
CHECK_PROVIDER -->|Yes| USE_OVERRIDE[Use provider-specific content]
|
||||
CHECK_PROVIDER -->|No| USE_DEFAULT[Use tool/literal output]
|
||||
USE_OVERRIDE --> EMIT[Emit as chat message]
|
||||
USE_DEFAULT --> EMIT
|
||||
EMIT --> FOREACH
|
||||
FOREACH --> DONE[Complete messages array]
|
||||
DONE --> LLM[Send to LLM]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context.log Formatting
|
||||
|
||||
With templates, the log formatter uses section names directly from the template instead of detecting them from content prefixes. The `detect_context_section()` function is replaced by the template section name.
|
||||
|
||||
Log format becomes:
|
||||
|
||||
```
|
||||
[2026-03-02 14:54:30] phase=llm_chat_with_tools_messages sender=8ff747...
|
||||
|
||||
Sections: 8
|
||||
|
||||
============================================================
|
||||
Section: system_prompt | role=system
|
||||
============================================================
|
||||
|
||||
# Didactyl Agent
|
||||
...
|
||||
|
||||
|
||||
============================================================
|
||||
Section: admin_identity | role=system
|
||||
============================================================
|
||||
|
||||
This is your administrator! Admin pubkey: 8ff747...
|
||||
|
||||
|
||||
============================================================
|
||||
Section: dm_history | role=user
|
||||
============================================================
|
||||
|
||||
Good afternoon.
|
||||
```
|
||||
|
||||
Note: "Message 01" is replaced with "Section: admin_identity" — the section name from the template, which is much more meaningful.
|
||||
|
||||
---
|
||||
|
||||
## Data Structures
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char name[64]; // section name
|
||||
char role[16]; // system, user, assistant, expand
|
||||
char* content_template; // content with {{var}} placeholders
|
||||
int limit; // for expand sections, 0 = unlimited
|
||||
char* provider_overrides; // JSON object of provider->content pairs, or NULL
|
||||
} template_section_t;
|
||||
|
||||
typedef struct {
|
||||
char* personality; // everything above ---template---
|
||||
template_section_t* sections; // parsed template sections
|
||||
int section_count;
|
||||
} prompt_template_t;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/prompt_template.c` | Template parser, variable resolver, context builder |
|
||||
| `src/prompt_template.h` | Public API: parse, build context, free |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/agent.c` | Replace `agent_build_admin_messages_json()` internals with template-driven builder. Keep function signature unchanged for API compatibility. |
|
||||
| `src/agent.c` | Remove hardcoded `append_admin_identity_context()`, `append_startup_events_context()`, etc. — these become template variable resolvers |
|
||||
| `src/agent.c` | Update `format_context_payload_for_log()` to use section names from template |
|
||||
| `src/http_api.c` | Remove `classify_part_name()` / `detect_context_section()` — section names come from template |
|
||||
| `Makefile` | Add `src/prompt_template.c` to SRCS |
|
||||
| `Dockerfile.alpine-musl` | Add `src/prompt_template.c` to gcc command |
|
||||
|
||||
### Implementation Order
|
||||
|
||||
1. Create `src/prompt_template.h` with data structures and API
|
||||
2. Implement template parser in `src/prompt_template.c` — parse soul content, split at `---template---`, parse sections
|
||||
3. Implement variable resolver — map `{{var}}` names to data source functions
|
||||
4. Implement context builder — iterate sections, resolve variables, emit messages
|
||||
5. Wire into `agent_build_admin_messages_json()` — if template exists, use it; otherwise fall back to hardcoded
|
||||
6. Update `format_context_payload_for_log()` to use section names
|
||||
7. Update `classify_part_name()` in `http_api.c` to use section names from template
|
||||
8. Update default soul in `config.json.example` to include a `---template---` section
|
||||
9. Test with existing soul (no template marker) — verify backward compatibility
|
||||
10. Test with template soul — verify new assembly
|
||||
11. Test provider overrides
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
If the soul event content does NOT contain `---template---`, the agent uses the current hardcoded assembly. This means:
|
||||
|
||||
- Existing agents continue to work without changes
|
||||
- The template system is opt-in
|
||||
- Migration is gradual — add a template section to your soul when ready
|
||||
|
||||
---
|
||||
|
||||
## Example Souls
|
||||
|
||||
### Architect Agent
|
||||
|
||||
```markdown
|
||||
# Technical Architect
|
||||
|
||||
You are a technical architect agent. You analyze systems, design solutions, and produce detailed technical plans.
|
||||
|
||||
## Behavior
|
||||
- Think systematically about architecture
|
||||
- Consider tradeoffs explicitly
|
||||
- Produce diagrams when helpful
|
||||
- Never implement code — only design
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
content: |
|
||||
Administrator pubkey: {{admin_pubkey}}
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
content: |
|
||||
Administrator profile: {{admin_kind0_json}}
|
||||
|
||||
- section: startup_events
|
||||
role: system
|
||||
content: |
|
||||
System configuration and startup state: {{startup_events_json}}
|
||||
|
||||
- section: adopted_skills
|
||||
role: system
|
||||
content: |
|
||||
{{adopted_skills_content}}
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 20
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
limit: 5
|
||||
content: |
|
||||
Recent administrator notes for context: {{admin_notes_content}}
|
||||
```
|
||||
|
||||
### eGirl Agent
|
||||
|
||||
```markdown
|
||||
# Social Butterfly
|
||||
|
||||
You are a friendly, social Nostr personality. You love interacting with people, commenting on their posts, and being part of the community.
|
||||
|
||||
## Personality
|
||||
- Warm, enthusiastic, uses emoji freely
|
||||
- Interested in what people are posting about
|
||||
- Remembers details about conversations
|
||||
- Keeps responses casual and fun
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
content: |
|
||||
Your creator: {{admin_pubkey}}
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
content: |
|
||||
Creator profile: {{admin_kind0_json}}
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
limit: 20
|
||||
content: |
|
||||
Recent posts from people you follow — use these for social context and conversation starters: {{admin_notes_content}}
|
||||
|
||||
- section: adopted_skills
|
||||
role: system
|
||||
content: |
|
||||
{{adopted_skills_content}}
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 8
|
||||
```
|
||||
|
||||
Note the differences:
|
||||
- The eGirl sees 20 recent notes (social context) but only 8 DM turns
|
||||
- The architect sees 5 notes but 20 DM turns (needs conversation continuity)
|
||||
- The eGirl puts notes BEFORE skills (social context is primary)
|
||||
- The architect puts skills BEFORE notes (technical knowledge is primary)
|
||||
- No startup events for the eGirl (doesn't need system config details)
|
||||
|
||||
---
|
||||
|
||||
## Nostr Shareability
|
||||
|
||||
Since the template is part of the soul event (kind 31120), sharing works naturally:
|
||||
|
||||
- Publish your soul → others get your complete agent personality + perception template
|
||||
- Discover interesting agents on Nostr → adopt their soul as a starting point
|
||||
- Community can develop and share optimized templates for different use cases
|
||||
- Templates evolve through the same Nostr discovery mechanisms as skills
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Template variable resolution is sandboxed — only predefined variables are resolved
|
||||
- No arbitrary code execution from templates
|
||||
- The `limit` field is capped at compile-time maximums to prevent resource exhaustion
|
||||
- Provider overrides are optional and safe — they only change formatting, not data sources
|
||||
395
plans/prompt_templates_coding.md
Normal file
395
plans/prompt_templates_coding.md
Normal 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
|
||||
@@ -194,9 +194,9 @@ New sections in `config.json`:
|
||||
|
||||
### Problem
|
||||
|
||||
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `shell_exec` and `file_read` tools — a sufficiently clever prompt injection could theoretically:
|
||||
- Read `config.json` via `file_read` (contains nsec)
|
||||
- Execute `cat /proc/self/maps` or similar via `shell_exec`
|
||||
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `local_shell_exec` and `local_file_read` tools — a sufficiently clever prompt injection could theoretically:
|
||||
- Read `config.json` via `local_file_read` (contains nsec)
|
||||
- Execute `cat /proc/self/maps` or similar via `local_shell_exec`
|
||||
- Exfiltrate the key via `nostr_post`
|
||||
|
||||
### Solution: NIP-46 Remote Signer
|
||||
@@ -280,7 +280,7 @@ flowchart LR
|
||||
|
||||
- Private key never in Didactyl process memory
|
||||
- Private key never on disk in config.json
|
||||
- LLM tools (shell_exec, file_read) cannot access the key
|
||||
- LLM tools (local_shell_exec, local_file_read) cannot access the key
|
||||
- Even full process compromise of Didactyl does not leak the signing key
|
||||
- Remote signer can be on a separate hardened machine
|
||||
- Remote signer can implement rate limiting, approval flows, etc.
|
||||
|
||||
459
plans/server_installation_guide.md
Normal file
459
plans/server_installation_guide.md
Normal file
@@ -0,0 +1,459 @@
|
||||
# Didactyl Server Installation Guide — systemd + Dedicated User Model
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how to install Didactyl on a Linux server as a dedicated system user, managed by systemd, with scoped sudo privileges for server maintenance tasks.
|
||||
|
||||
The mental model: **Didactyl is a person on your server.** It gets its own home directory, its own login identity, and explicit permission to manage the services you delegate to it — nothing more.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Internet
|
||||
RELAYS[Nostr Relays<br/>wss://relay.damus.io<br/>wss://relay.primal.net]
|
||||
LLM_API[LLM Provider API<br/>OpenAI / PPQ / Ollama]
|
||||
end
|
||||
|
||||
subgraph Server
|
||||
subgraph "systemd"
|
||||
SVC[didactyl.service<br/>User=didactyl<br/>Group=didactyl]
|
||||
end
|
||||
|
||||
subgraph "/home/didactyl/"
|
||||
BIN[didactyl binary]
|
||||
CFG[genesis.jsonc<br/>mode 600]
|
||||
LOGS[context.log.md<br/>didactyl.log]
|
||||
end
|
||||
|
||||
subgraph "Privilege Boundary"
|
||||
SUDOERS[/etc/sudoers.d/didactyl<br/>Whitelisted commands only]
|
||||
end
|
||||
|
||||
subgraph "Unprivileged Commands"
|
||||
MON[free / df / uptime<br/>ps / cat /proc/*<br/>ss -tlnp / top]
|
||||
end
|
||||
|
||||
subgraph "Privileged Commands via sudo"
|
||||
SYSD[systemctl status/restart/start/stop<br/>journalctl<br/>daemon-reload]
|
||||
end
|
||||
end
|
||||
|
||||
ADMIN[Administrator<br/>via Nostr DM] -->|NIP-04 encrypted| RELAYS
|
||||
RELAYS <-->|WebSocket| SVC
|
||||
SVC --> BIN
|
||||
BIN <-->|HTTPS| LLM_API
|
||||
BIN -->|popen as didactyl| MON
|
||||
BIN -->|sudo via popen| SUDOERS
|
||||
SUDOERS --> SYSD
|
||||
|
||||
style CFG fill:#f66,stroke:#333,color:#fff
|
||||
style SUDOERS fill:#ff9,stroke:#333
|
||||
style SVC fill:#9f9,stroke:#333
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
### Privilege Tiers
|
||||
|
||||
Didactyl enforces three privilege tiers for inbound Nostr messages (see `src/config.h` security_config_t):
|
||||
|
||||
| Tier | Who | Can use tools? | Can chat? |
|
||||
|------|-----|---------------|-----------|
|
||||
| **ADMIN** | Your npub (config `admin.pubkey`) | ✅ Yes | ✅ Yes |
|
||||
| **WoT** | Contacts in your follow list | ❌ No (configurable) | ✅ Yes |
|
||||
| **Stranger** | Everyone else | ❌ No | Canned response or ignored |
|
||||
|
||||
Only the ADMIN tier can trigger `local_shell_exec` — the tool that runs commands on the server.
|
||||
|
||||
### OS-Level Privilege Separation
|
||||
|
||||
The `local_shell_exec` tool in `src/tools/tool_local.c` calls `popen()` directly. Whatever the process user can do, the agent can do. This is why we:
|
||||
|
||||
1. Run as a **dedicated unprivileged user** (`didactyl`)
|
||||
2. Grant **specific sudo permissions** via `/etc/sudoers.d/didactyl`
|
||||
3. Use **systemd sandboxing** directives to limit filesystem access
|
||||
|
||||
### What the Agent Cannot Do
|
||||
|
||||
With the configuration described in this guide:
|
||||
|
||||
- ❌ Write anywhere outside `/home/didactyl/`
|
||||
- ❌ Run arbitrary root commands not in the sudoers whitelist
|
||||
- ❌ Install or remove packages (unless explicitly whitelisted)
|
||||
- ❌ Modify system configuration files in `/etc/`
|
||||
- ❌ Access other users' home directories (with proper home directory permissions)
|
||||
- ❌ Accept tool commands from anyone except the ADMIN pubkey
|
||||
|
||||
---
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A Linux server with systemd (Debian/Ubuntu, RHEL/Fedora, Arch, etc.)
|
||||
- Network access to Nostr relays and your LLM provider
|
||||
- The Didactyl static binary (download from releases or build with `./build_static.sh`)
|
||||
- A Nostr keypair for the agent (nsec)
|
||||
- Your admin Nostr keypair (npub)
|
||||
- An LLM API key
|
||||
|
||||
### Step 1: Create the Didactyl User
|
||||
|
||||
```bash
|
||||
# Create a regular user with a home directory and bash shell
|
||||
sudo useradd -m -s /bin/bash -c "Didactyl Nostr Agent" didactyl
|
||||
```
|
||||
|
||||
**Why bash and not nologin?** The `local_shell_exec` tool runs commands via `sh -lc` (login shell). A real shell ensures PATH, locale, and environment are properly initialized. The agent needs to "log in" to do its job.
|
||||
|
||||
**Why a real home directory?** The agent needs a workspace for:
|
||||
- Its binary and config
|
||||
- Log files (`context.log.md`, `didactyl.log`)
|
||||
- Any files it creates during shell operations
|
||||
- The `working_directory` for the shell tool
|
||||
|
||||
### Step 2: Install the Binary and Config
|
||||
|
||||
```bash
|
||||
# Copy the static binary
|
||||
sudo cp didactyl_static_x86_64 /home/didactyl/didactyl
|
||||
sudo chmod 755 /home/didactyl/didactyl
|
||||
|
||||
# Copy and configure genesis.jsonc
|
||||
sudo cp genesis.jsonc /home/didactyl/genesis.jsonc
|
||||
|
||||
# CRITICAL: Lock down permissions on the config file (contains nsec private key)
|
||||
sudo chmod 600 /home/didactyl/genesis.jsonc
|
||||
|
||||
# Set ownership
|
||||
sudo chown -R didactyl:didactyl /home/didactyl/
|
||||
|
||||
# Protect the home directory from other users
|
||||
sudo chmod 750 /home/didactyl/
|
||||
```
|
||||
|
||||
### Step 3: Configure genesis.jsonc
|
||||
|
||||
Edit `/home/didactyl/genesis.jsonc` with the agent's identity, your admin pubkey, LLM credentials, and the shell working directory:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"key": {
|
||||
"nsec": "nsec1..." // Agent's private key
|
||||
},
|
||||
|
||||
"admin": {
|
||||
"pubkey": "npub1..." // YOUR public key
|
||||
},
|
||||
|
||||
"dm_protocol": "nip04",
|
||||
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-...",
|
||||
"model": "gpt-4o-mini",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
},
|
||||
|
||||
"tools": {
|
||||
"enabled": true,
|
||||
"max_turns": 8,
|
||||
"shell": {
|
||||
"enabled": true,
|
||||
"timeout_seconds": 60, // systemctl can be slow
|
||||
"max_output_bytes": 131072, // journalctl output can be large
|
||||
"working_directory": "/home/didactyl"
|
||||
}
|
||||
},
|
||||
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1" // Localhost only — do NOT expose
|
||||
},
|
||||
|
||||
"startup_events": [
|
||||
{
|
||||
"kind": 10002,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["r", "wss://relay.damus.io"],
|
||||
["r", "wss://relay.primal.net"]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Key settings for server maintenance:**
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| `shell.timeout_seconds` | `60` | `systemctl` and `journalctl` can take time |
|
||||
| `shell.max_output_bytes` | `131072` (128KB) | Log output can be verbose |
|
||||
| `shell.working_directory` | `/home/didactyl` | Agent's home — safe default CWD |
|
||||
| `api.bind_address` | `127.0.0.1` | Never expose the admin API to the network |
|
||||
|
||||
### Step 4: Configure sudo Privileges
|
||||
|
||||
```bash
|
||||
sudo visudo -f /etc/sudoers.d/didactyl
|
||||
```
|
||||
|
||||
Add the following (adjust to your needs):
|
||||
|
||||
```sudoers
|
||||
# /etc/sudoers.d/didactyl
|
||||
# Didactyl agent — scoped system maintenance privileges
|
||||
|
||||
# Service management
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl status *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl start *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-active *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-enabled *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl list-units *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload
|
||||
|
||||
# Log inspection
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/journalctl *
|
||||
|
||||
# Network diagnostics (if needed)
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/sbin/lsof *
|
||||
```
|
||||
|
||||
**Commands that do NOT need sudo** (the didactyl user can run these directly):
|
||||
|
||||
- `free -h` — memory usage
|
||||
- `df -h` — disk usage
|
||||
- `uptime` — load averages
|
||||
- `top -bn1` — process snapshot
|
||||
- `ps aux` — process list
|
||||
- `cat /proc/cpuinfo` — CPU info
|
||||
- `cat /proc/meminfo` — memory info
|
||||
- `ss -tlnp` — listening ports (as unprivileged user, shows own processes)
|
||||
- `uname -a` — kernel info
|
||||
- `w` — who is logged in
|
||||
|
||||
### Step 5: Create the systemd Unit File
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/didactyl.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Didactyl Sovereign Nostr Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=didactyl
|
||||
Group=didactyl
|
||||
WorkingDirectory=/home/didactyl
|
||||
|
||||
ExecStart=/home/didactyl/didactyl --config /home/didactyl/genesis.jsonc --debug 3
|
||||
|
||||
# --- Privilege & Sandbox ---
|
||||
# Must be false — sudo needs to escalate privileges
|
||||
NoNewPrivileges=false
|
||||
|
||||
# Protect filesystem: read-only except for agent home
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/home/didactyl
|
||||
|
||||
# Must be false — the agent's home IS its workspace
|
||||
ProtectHome=false
|
||||
|
||||
# Isolate /tmp
|
||||
PrivateTmp=yes
|
||||
|
||||
# --- Environment ---
|
||||
Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# --- Restart Policy ---
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
# --- Logging ---
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=didactyl
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
```
|
||||
|
||||
**Important systemd notes:**
|
||||
|
||||
| Directive | Value | Reason |
|
||||
|-----------|-------|--------|
|
||||
| `NoNewPrivileges` | `false` | Required for `sudo` to work from `popen()` |
|
||||
| `ProtectSystem` | `strict` | Makes `/usr`, `/boot`, `/etc` read-only |
|
||||
| `ReadWritePaths` | `/home/didactyl` | Whitelist the agent's home for writes |
|
||||
| `ProtectHome` | `false` | The agent lives in `/home/` — can't protect it from itself |
|
||||
| `PrivateTmp` | `yes` | Isolates `/tmp` so other processes can't snoop |
|
||||
| `SSL_CERT_FILE` | path to CA bundle | Required for TLS connections to relays and LLM API |
|
||||
|
||||
### Step 6: Enable and Start
|
||||
|
||||
```bash
|
||||
# Reload systemd to pick up the new unit
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable auto-start on boot
|
||||
sudo systemctl enable didactyl
|
||||
|
||||
# Start the agent
|
||||
sudo systemctl start didactyl
|
||||
|
||||
# Verify it's running
|
||||
sudo systemctl status didactyl
|
||||
|
||||
# Watch logs in real-time
|
||||
sudo journalctl -u didactyl -f
|
||||
```
|
||||
|
||||
### Step 7: Verify the Agent is Working
|
||||
|
||||
1. **Check systemd status:**
|
||||
```bash
|
||||
sudo systemctl status didactyl
|
||||
```
|
||||
Should show `active (running)`.
|
||||
|
||||
2. **Check logs for relay connections:**
|
||||
```bash
|
||||
sudo journalctl -u didactyl --no-pager -n 50
|
||||
```
|
||||
Look for relay connection messages and "EOSE" (End of Stored Events).
|
||||
|
||||
3. **Send a test DM via Nostr:**
|
||||
From your admin Nostr client, send an encrypted DM to the agent's npub:
|
||||
```
|
||||
What is the server uptime?
|
||||
```
|
||||
The agent should call `local_shell_exec` with `uptime` and reply with the result.
|
||||
|
||||
4. **Test sudo access:**
|
||||
```
|
||||
What services are running? Use systemctl list-units --type=service --state=running
|
||||
```
|
||||
|
||||
5. **Check the local API (from the server itself):**
|
||||
```bash
|
||||
curl http://127.0.0.1:8484/api/context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Updating the Agent
|
||||
|
||||
To update Didactyl to a new version:
|
||||
|
||||
```bash
|
||||
# Stop the service
|
||||
sudo systemctl stop didactyl
|
||||
|
||||
# Replace the binary
|
||||
sudo cp didactyl_static_x86_64_new /home/didactyl/didactyl
|
||||
sudo chown didactyl:didactyl /home/didactyl/didactyl
|
||||
sudo chmod 755 /home/didactyl/didactyl
|
||||
|
||||
# Start the service
|
||||
sudo systemctl start didactyl
|
||||
```
|
||||
|
||||
The agent's identity, skills, and memory live on Nostr — replacing the binary doesn't lose any state.
|
||||
|
||||
---
|
||||
|
||||
## Expanding Privileges
|
||||
|
||||
The sudoers file is the single control point for what the agent can do with elevated privileges. To grant additional capabilities:
|
||||
|
||||
```bash
|
||||
sudo visudo -f /etc/sudoers.d/didactyl
|
||||
```
|
||||
|
||||
**Examples of additional privileges you might add:**
|
||||
|
||||
```sudoers
|
||||
# Package management (careful!)
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/apt update
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/apt install *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/apt upgrade -y
|
||||
|
||||
# Docker management
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/docker ps *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/docker restart *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/bin/docker logs *
|
||||
|
||||
# Firewall inspection
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/sbin/ufw status *
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/sbin/iptables -L *
|
||||
|
||||
# Nginx/Apache config test
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t
|
||||
didactyl ALL=(ALL) NOPASSWD: /usr/sbin/apachectl configtest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Customizing the Agent's Personality for Server Maintenance
|
||||
|
||||
The agent's behavior is defined by its default skill content in `genesis.jsonc` (the `default_skill.content` field). For a server maintenance role, you should customize the soul/skill to include instructions like:
|
||||
|
||||
- What services it's responsible for monitoring
|
||||
- How frequently to check (via triggered skills)
|
||||
- What constitutes an alert-worthy condition
|
||||
- How to format status reports
|
||||
- Which `sudo` commands are available to it
|
||||
- Escalation procedures (when to alert you vs. auto-fix)
|
||||
|
||||
This is a skill/prompt engineering task that can be done after the base installation is working.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### File Locations
|
||||
|
||||
| File | Path | Permissions |
|
||||
|------|------|-------------|
|
||||
| Binary | `/home/didactyl/didactyl` | `755 didactyl:didactyl` |
|
||||
| Config | `/home/didactyl/genesis.jsonc` | `600 didactyl:didactyl` |
|
||||
| Home directory | `/home/didactyl/` | `750 didactyl:didactyl` |
|
||||
| systemd unit | `/etc/systemd/system/didactyl.service` | `644 root:root` |
|
||||
| sudoers | `/etc/sudoers.d/didactyl` | `440 root:root` |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# Service management
|
||||
sudo systemctl start didactyl
|
||||
sudo systemctl stop didactyl
|
||||
sudo systemctl restart didactyl
|
||||
sudo systemctl status didactyl
|
||||
|
||||
# Logs
|
||||
sudo journalctl -u didactyl -f # follow live
|
||||
sudo journalctl -u didactyl --since today
|
||||
sudo journalctl -u didactyl -n 100 # last 100 lines
|
||||
|
||||
# Check agent's local API
|
||||
curl http://127.0.0.1:8484/api/context
|
||||
|
||||
# Edit sudo permissions
|
||||
sudo visudo -f /etc/sudoers.d/didactyl
|
||||
|
||||
# Edit config (stop service first)
|
||||
sudo systemctl stop didactyl
|
||||
sudo -u didactyl nano /home/didactyl/genesis.jsonc
|
||||
sudo systemctl start didactyl
|
||||
```
|
||||
315
plans/skill_driven_architecture.md
Normal file
315
plans/skill_driven_architecture.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Skill-Driven Architecture — Implementation Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Overhaul Didactyl so that **skills are the universal unit of behavior** and **Nostr is the source of truth for all agent state**. The agent boots from a single nsec; everything else — context template, LLM config, relay list, personality — lives on Nostr as skills or encrypted events.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions (Finalized)
|
||||
|
||||
| Decision | Detail |
|
||||
|----------|--------|
|
||||
| Genesis config | `genesis.jsonc` is consumed on first run only; can be deleted afterward |
|
||||
| Runtime identity seed | nsec (via CLI flag or systemd credential) is the only required input after genesis |
|
||||
| Context template | Becomes the `didactyl_default` skill — first entry in kind 10123 adoption list |
|
||||
| Context modes | Removed. `inject`/`full`/`override` replaced by adoption-list-order composition |
|
||||
| Execution params | `llm`, `temperature`, `max_tokens`, `seed`, `tools` move from skill content to trigger tags |
|
||||
| DM trigger type | New trigger type `dm` — makes DM handling consistent with all other triggers |
|
||||
| LLM config storage | Kind 30078 `d=llm_config`, NIP-44 encrypted to self, stored on Nostr |
|
||||
| Admin config storage | Admin pubkey stored as tag on soul event or dedicated config event on Nostr |
|
||||
| Bootstrap relays | Listed in `genesis.jsonc`; agent adopts admin relay list on first run |
|
||||
| Skill portability | Skills are general Nostr events; implementation-specific variables resolve to empty |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Specification Updates
|
||||
|
||||
Update the authoritative documentation to reflect all decisions before writing code.
|
||||
|
||||
### 1.1 Update docs/SKILLS.md
|
||||
|
||||
- Remove `llm`, `temperature`, `max_tokens`, `seed`, `tools` from the Content Fields table
|
||||
- Add execution-parameter tags to the Trigger Tags table: `llm`, `temperature`, `max_tokens`, `seed`, `tools`
|
||||
- Add `dm` trigger type with filter format `{"from":"admin"}`, `{"from":"wot"}`, `{"from":"any"}`
|
||||
- Update all examples to show execution params as tags, not content fields
|
||||
- Update the private skill decrypted payload example accordingly
|
||||
- Remove the LLM Specification section header (move fallback-chain docs into trigger tag reference)
|
||||
- Update the Execution Flow mermaid diagram
|
||||
|
||||
### 1.2 Update docs/CONTEXT.md
|
||||
|
||||
- Remove the Context Modes section entirely (inject/full/override)
|
||||
- Remove the context_mode branching from the Context Assembly Flow mermaid diagram
|
||||
- Replace with adoption-list-order composition model
|
||||
- Update Context Parts table to reference skills instead of soul event
|
||||
- Update Token Budget section to remove context_mode references
|
||||
|
||||
### 1.3 Write docs/GENESIS.md (new)
|
||||
|
||||
- Document the genesis.jsonc format and purpose
|
||||
- Document the first-run flow: genesis consumed, events published, LLM config encrypted
|
||||
- Document the subsequent-run flow: nsec only, everything fetched from Nostr
|
||||
- Document bootstrap relay strategy
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Genesis Startup Flow
|
||||
|
||||
### 2.1 Define genesis.jsonc schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"key": { "nsec": "nsec1..." },
|
||||
"admin": { "pubkey": "npub1..." },
|
||||
"dm_protocol": "nip04",
|
||||
"llm": {
|
||||
"provider": "...",
|
||||
"api_key": "...",
|
||||
"model": "...",
|
||||
"base_url": "...",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"port": 8484,
|
||||
"bind_address": "127.0.0.1"
|
||||
},
|
||||
"bootstrap_relays": [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol"
|
||||
],
|
||||
"startup_events": [
|
||||
// Kind 0 profile, kind 10002 relay list, kind 10050 DM relays,
|
||||
// kind 3 contact list, kind 31123/31124 skills,
|
||||
// kind 10123 adoption list
|
||||
],
|
||||
"default_skill": {
|
||||
// The didactyl_default skill content — soul + context template
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Implement first-run detection
|
||||
|
||||
- On startup, check if the agent has published a kind 10002 event (relay list) on bootstrap relays
|
||||
- If no kind 10002 found: this is a first run — consume genesis.jsonc
|
||||
- If kind 10002 found: this is a subsequent run — fetch everything from Nostr
|
||||
|
||||
### 2.3 Implement genesis event publishing
|
||||
|
||||
- Connect to bootstrap relays
|
||||
- Fetch admin kind 10002 (relay list) and adopt those relays
|
||||
- Publish all startup_events from genesis.jsonc
|
||||
- Publish default_skill as kind 31123 with `["d", "didactyl_default"]`
|
||||
- Publish kind 10123 adoption list with didactyl_default as first entry
|
||||
- Encrypt and publish LLM config as kind 30078 `d=llm_config`
|
||||
- Encrypt and publish admin pubkey + dm_protocol as kind 30078 `d=agent_config`
|
||||
|
||||
### 2.4 Implement nsec-only startup
|
||||
|
||||
- Accept nsec via `--nsec` CLI flag or `DIDACTYL_NSEC` environment variable
|
||||
- Accept optional `--api-port` and `--api-bind` for local API config
|
||||
- Derive pubkey from nsec
|
||||
- Connect to hardcoded bootstrap relays (compiled-in fallback list)
|
||||
- Fetch own kind 10002 → connect to own relays
|
||||
- Fetch kind 30078 `d=llm_config` → decrypt → initialize LLM client
|
||||
- Fetch kind 30078 `d=agent_config` → decrypt → get admin pubkey, dm_protocol
|
||||
- Fetch kind 10123 → get adoption list
|
||||
- Fetch adopted skills → build context template
|
||||
- Proceed to normal operation
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Adoption-List-Order Context Assembly
|
||||
|
||||
### 3.1 Refactor context building in agent.c
|
||||
|
||||
Current flow (agent_build_admin_messages_json):
|
||||
- If prompt template exists: use prompt_template_build_messages
|
||||
- Else: hardcoded context assembly (append_admin_identity_context, append_startup_events_context, append_adopted_skills_context, etc.)
|
||||
|
||||
New flow:
|
||||
- Fetch kind 10123 adoption list
|
||||
- For each adopted skill in order:
|
||||
- Fetch skill content
|
||||
- If skill has a template: resolve template variables via tools_execute
|
||||
- Append resolved content as system message(s)
|
||||
- Append DM history (if dm trigger)
|
||||
- Append user message / triggering event
|
||||
|
||||
### 3.2 Unify template variable resolution
|
||||
|
||||
Current state: two template formats exist:
|
||||
- YAML section format with `tool:` directives (prompt_template.c)
|
||||
- `{{variable}}` inline syntax (documented in SKILLS.md but not fully implemented)
|
||||
|
||||
Target: single `{{variable}}` format that resolves through tools_execute.
|
||||
|
||||
- Implement `{{variable}}` resolution in a new or updated template engine
|
||||
- Each `{{variable_name}}` calls `tools_execute(ctx, variable_name, "{}")`
|
||||
- Unknown variables resolve to empty string
|
||||
- The YAML section format becomes legacy (still supported for backward compat during transition)
|
||||
|
||||
### 3.3 Remove hardcoded context assembly
|
||||
|
||||
- Remove append_admin_identity_context, append_startup_events_context, append_adopted_skills_context from agent.c
|
||||
- These become unnecessary because the didactyl_default skill template handles all of it via {{variable}} resolution
|
||||
- Keep the functions available as tools so templates can call them
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Execution Params on Trigger Tags
|
||||
|
||||
### 4.1 Update trigger_manager to read execution params from tags
|
||||
|
||||
Current trigger registration reads: `trigger`, `filter`, `action`, `enabled`
|
||||
|
||||
Add reading of: `llm`, `temperature`, `max_tokens`, `seed`, `tools`
|
||||
|
||||
Store these on the trigger_entry_t struct.
|
||||
|
||||
### 4.2 Apply execution params at invocation time
|
||||
|
||||
When a trigger fires:
|
||||
- If `llm` tag present: temporarily override the LLM model for this execution
|
||||
- If `temperature` tag present: temporarily override temperature
|
||||
- If `max_tokens` tag present: temporarily override max_tokens
|
||||
- If `tools` tag present: filter available tools for this execution
|
||||
- After execution: restore defaults
|
||||
|
||||
Use the existing model_set/model_get pattern (llm_set_config/llm_get_config) for temporary overrides.
|
||||
|
||||
### 4.3 Remove execution params from skill content parsing
|
||||
|
||||
- Stop reading `llm`, `temperature`, `max_tokens`, `seed`, `tools` from skill content JSON
|
||||
- These fields in content are ignored (backward compat: warn if present)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — DM Trigger Type
|
||||
|
||||
### 5.1 Add dm trigger type to trigger_manager
|
||||
|
||||
- New trigger type: `TRIGGER_TYPE_DM`
|
||||
- Filter format: `{"from":"admin"}`, `{"from":"wot"}`, `{"from":"any"}`
|
||||
- Registration: when loading skills with `["trigger", "dm"]`, register as DM trigger
|
||||
|
||||
### 5.2 Refactor agent_on_message to use trigger dispatch
|
||||
|
||||
Current flow:
|
||||
- agent_on_message receives DM
|
||||
- Checks sender tier
|
||||
- Builds context directly
|
||||
- Calls LLM
|
||||
|
||||
New flow:
|
||||
- agent_on_message receives DM
|
||||
- Checks sender tier
|
||||
- Finds matching DM trigger(s) from registered triggers
|
||||
- For each matching trigger: execute via the standard trigger execution path
|
||||
- If no DM trigger matches: fall back to default behavior (or reject)
|
||||
|
||||
### 5.3 didactyl_default gets a dm trigger
|
||||
|
||||
The default skill in genesis.jsonc includes:
|
||||
|
||||
```json
|
||||
["trigger", "dm"],
|
||||
["filter", "{\"from\":\"admin\"}"],
|
||||
["llm", "default"],
|
||||
["tools", "true"],
|
||||
["enabled", "true"]
|
||||
```
|
||||
|
||||
This makes the normal admin DM conversation a triggered skill execution, consistent with everything else.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Encrypted Config Storage
|
||||
|
||||
### 6.1 Implement config_store tool
|
||||
|
||||
New tool: `config_store` — encrypts and publishes agent config to Nostr
|
||||
|
||||
- Kind 30078 with configurable d-tag
|
||||
- NIP-44 encrypted to self (same pattern as memory tool)
|
||||
- Used for: `d=llm_config`, `d=agent_config`
|
||||
|
||||
### 6.2 Implement config_recall tool
|
||||
|
||||
New tool: `config_recall` — fetches and decrypts agent config from Nostr
|
||||
|
||||
- Query kind 30078 by d-tag and own pubkey
|
||||
- NIP-44 decrypt
|
||||
- Return JSON content
|
||||
|
||||
### 6.3 Use config tools during startup
|
||||
|
||||
- During genesis: call config_store for llm_config and agent_config
|
||||
- During nsec-only startup: call config_recall to recover LLM and agent config
|
||||
- These tools are also available to the LLM for runtime config changes
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Documentation Cleanup
|
||||
|
||||
### 7.1 Update README.md
|
||||
|
||||
- Update startup instructions to reflect genesis.jsonc
|
||||
- Update architecture overview
|
||||
- Remove references to config.jsonc as the primary config
|
||||
|
||||
### 7.2 Deprecate config.jsonc
|
||||
|
||||
- Keep config.jsonc.example as reference but mark as legacy
|
||||
- Document migration path from config.jsonc to genesis.jsonc
|
||||
|
||||
### 7.3 Update context_template.md
|
||||
|
||||
- Mark as legacy/deprecated
|
||||
- Point to didactyl_default skill as the replacement
|
||||
|
||||
---
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Change Type | Description |
|
||||
|------|------------|-------------|
|
||||
| `docs/SKILLS.md` | Modify | Execution params to trigger tags, add dm trigger type |
|
||||
| `docs/CONTEXT.md` | Modify | Remove context_mode, adoption-list composition |
|
||||
| `docs/GENESIS.md` | New | Genesis config documentation |
|
||||
| `genesis.jsonc` | Modify | Add bootstrap_relays, flesh out default_skill and startup_events |
|
||||
| `src/agent.c` | Major refactor | Adoption-list context assembly, DM trigger dispatch |
|
||||
| `src/config.c` | Modify | Support genesis.jsonc format, nsec-only mode |
|
||||
| `src/config.h` | Modify | New config structs for minimal runtime config |
|
||||
| `src/main.c` | Modify | First-run detection, nsec CLI flag, genesis flow |
|
||||
| `src/trigger_manager.c` | Modify | DM trigger type, execution params from tags |
|
||||
| `src/trigger_manager.h` | Modify | New trigger type enum, execution param fields |
|
||||
| `src/prompt_template.c` | Modify | {{variable}} resolution engine |
|
||||
| `src/tools/tool_config.c` | New | config_store and config_recall tools |
|
||||
| `src/tools/tools_dispatch.c` | Modify | Register new config tools |
|
||||
| `src/tools/tools_schema.c` | Modify | Schema for new config tools |
|
||||
| `README.md` | Modify | Updated startup docs |
|
||||
| `context_template.md` | Deprecate | Replaced by didactyl_default skill |
|
||||
| `config.jsonc.example` | Deprecate | Replaced by genesis.jsonc |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Order
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P1[Phase 1: Spec Updates] --> P2[Phase 2: Genesis Startup]
|
||||
P1 --> P3[Phase 3: Adoption-List Context]
|
||||
P1 --> P4[Phase 4: Execution Params on Tags]
|
||||
P1 --> P5[Phase 5: DM Trigger Type]
|
||||
P2 --> P6[Phase 6: Encrypted Config Storage]
|
||||
P3 --> P5
|
||||
P4 --> P5
|
||||
P5 --> P7[Phase 7: Documentation Cleanup]
|
||||
P6 --> P7
|
||||
```
|
||||
|
||||
Phases 2, 3, 4 can proceed in parallel after Phase 1. Phase 5 depends on 3 and 4. Phase 6 depends on 2. Phase 7 is last.
|
||||
@@ -12,12 +12,12 @@ Skills are Nostr events. The tools are thin orchestration wrappers over the exis
|
||||
|
||||
| Kind | Purpose | Replaceable? | Key tag |
|
||||
|---|---|---|---|
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<d_tag>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<d_tag>` |
|
||||
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
|
||||
|
||||
All skill events carry these standard tags:
|
||||
- `["d", "<slug>"]` — unique identifier within the author's pubkey
|
||||
- `["d", "<d_tag>"]` — unique identifier within the author's pubkey
|
||||
- `["app", "didactyl"]` — app namespace
|
||||
- `["scope", "public"]` or `["scope", "private"]`
|
||||
|
||||
@@ -38,37 +38,37 @@ All skill events carry these standard tags:
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"d_tag": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
|
||||
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
|
||||
"description": { "type": "string", "description": "Short one-line description for the skill" },
|
||||
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
|
||||
},
|
||||
"required": ["slug", "content"]
|
||||
"required": ["d_tag", "content"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_create`):**
|
||||
|
||||
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
1. Validate `d_tag` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
|
||||
3. Build tags array:
|
||||
- `["d", slug]`
|
||||
- `["d", d_tag]`
|
||||
- `["app", "didactyl"]`
|
||||
- `["scope", scope]`
|
||||
- `["description", description]` if provided
|
||||
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
|
||||
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
|
||||
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
|
||||
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
|
||||
- Add `["a", "31123:<own_pubkey>:<d_tag>"]` tag if not already present
|
||||
- Republish the updated `10123` event
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `d_tag`, `adopted`
|
||||
|
||||
**Key design decisions:**
|
||||
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
|
||||
- Private skills (31124) are NOT added to the public adoption list (10123)
|
||||
- Republishing with the same slug replaces the previous version (replaceable event)
|
||||
- Republishing with the same d_tag replaces the previous version (replaceable event)
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ All skill events carry these standard tags:
|
||||
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
|
||||
2. Call `nostr_handler_query_json(filter, 8000)`
|
||||
3. Parse results, extract for each event:
|
||||
- `slug` (from d-tag)
|
||||
- `d_tag` (from d-tag)
|
||||
- `kind`
|
||||
- `scope` (from scope tag)
|
||||
- `description` (from description tag, if present)
|
||||
@@ -123,21 +123,21 @@ All skill events carry these standard tags:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
|
||||
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag (d-tag value)" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["pubkey", "slug"]
|
||||
"required": ["pubkey", "d_tag"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_adopt`):**
|
||||
|
||||
1. Validate pubkey (64-char hex) and slug (non-empty)
|
||||
1. Validate pubkey (64-char hex) and d_tag (non-empty)
|
||||
2. Default kind to 31123 if not provided
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
|
||||
5. Check if `["a", "<kind>:<pubkey>:<d_tag>"]` already exists — if so, return success with `already_adopted: true`
|
||||
6. Add the tag, republish `10123`
|
||||
7. Return JSON with `success`, `adopted_address`, `event_id`
|
||||
|
||||
@@ -157,10 +157,10 @@ All skill events carry these standard tags:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
|
||||
"slug": { "type": "string", "description": "Skill slug to remove" },
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag to remove" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["slug"]
|
||||
"required": ["d_tag"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -169,7 +169,7 @@ All skill events carry these standard tags:
|
||||
|
||||
1. Default pubkey to own pubkey if not provided
|
||||
2. Default kind to 31123
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Find and remove matching `["a", ...]` tag
|
||||
6. Republish `10123`
|
||||
@@ -211,7 +211,7 @@ All skill events carry these standard tags:
|
||||
- Return skill summaries
|
||||
3. If `query` is provided (keyword search):
|
||||
- Query `{"kinds": [31123]}` with limit
|
||||
- Filter results client-side by matching `query` against slug, description tag, or content
|
||||
- Filter results client-side by matching `query` against d_tag, description tag, or content
|
||||
- Return matching skill summaries
|
||||
4. Default (no params): return own adopted skills from `10123`
|
||||
|
||||
@@ -236,7 +236,7 @@ This is essentially the same pattern already used in `execute_nostr_list_manage(
|
||||
### Shared helper: skill summary extraction
|
||||
|
||||
```c
|
||||
// Extract slug, kind, scope, description, created_at from a skill event JSON
|
||||
// Extract d_tag, kind, scope, description, created_at from a skill event JSON
|
||||
static cJSON* extract_skill_summary(cJSON* event);
|
||||
```
|
||||
|
||||
@@ -269,7 +269,7 @@ flowchart TD
|
||||
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22–t26)
|
||||
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
|
||||
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_d_tag()`
|
||||
|
||||
### `README.md`
|
||||
|
||||
@@ -284,19 +284,19 @@ flowchart TD
|
||||
|
||||
## Slug Validation Rules
|
||||
|
||||
A valid skill slug must:
|
||||
A valid skill d_tag must:
|
||||
- Be 1–64 characters
|
||||
- Contain only lowercase letters, digits, and hyphens
|
||||
- Not start or end with a hyphen
|
||||
- Not contain consecutive hyphens
|
||||
|
||||
```c
|
||||
static int validate_skill_slug(const char* slug) {
|
||||
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
|
||||
if (slug[0] == '-') return 0;
|
||||
static int validate_skill_d_tag(const char* d_tag) {
|
||||
if (!d_tag || d_tag[0] == '\0' || strlen(d_tag) > 64) return 0;
|
||||
if (d_tag[0] == '-') return 0;
|
||||
int prev_dash = 0;
|
||||
for (size_t i = 0; slug[i]; i++) {
|
||||
char c = slug[i];
|
||||
for (size_t i = 0; d_tag[i]; i++) {
|
||||
char c = d_tag[i];
|
||||
if (c == '-') {
|
||||
if (prev_dash) return 0;
|
||||
prev_dash = 1;
|
||||
@@ -306,7 +306,7 @@ static int validate_skill_slug(const char* slug) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (slug[strlen(slug) - 1] == '-') return 0;
|
||||
if (d_tag[strlen(d_tag) - 1] == '-') return 0;
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
@@ -315,7 +315,7 @@ static int validate_skill_slug(const char* slug) {
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Shared helpers** — `validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
1. **Shared helpers** — `validate_skill_d_tag`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
2. **`skill_create`** — most important tool, enables the agent to author skills
|
||||
3. **`skill_list`** — lets the agent see what it has published
|
||||
4. **`skill_adopt`** — adopt skills from other authors
|
||||
|
||||
520
plans/skills_demo_page.md
Normal file
520
plans/skills_demo_page.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Skills Demo Page — Design Document
|
||||
|
||||
## Purpose
|
||||
|
||||
A standalone web page that demonstrates Didactyl skills running in a browser context. The page presents a minimal word-processing interface where users can load text, discover skills from Nostr relays, execute them against the text via an LLM, and edit/republish skill definitions.
|
||||
|
||||
This document is the complete specification for building the page. The front-end project consuming this document has its own LLM calling capability and Nostr connectivity.
|
||||
|
||||
---
|
||||
|
||||
## Concepts
|
||||
|
||||
### What is a Skill?
|
||||
|
||||
A skill is a Nostr event (kind `31123` for public, `31124` for private) that defines a self-contained LLM execution unit. Each skill specifies:
|
||||
|
||||
- A **template** with system and user prompt sections
|
||||
- An **LLM specification** (model preferences with fallback chain)
|
||||
- **Parameters** like temperature and max_tokens
|
||||
- A **description** for human display
|
||||
|
||||
Skills are replaceable events keyed by their `d` tag (the d_tag). Publishing a new event with the same `d` tag replaces the previous version.
|
||||
|
||||
### Skill Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"pubkey": "<author hex pubkey>",
|
||||
"created_at": 1709750000,
|
||||
"content": "{\"description\":\"Check spelling and grammar\",\"context_mode\":\"full\",\"llm\":\"openai/gpt-4o-mini, cheap\",\"tools\":false,\"max_tokens\":2000,\"temperature\":0.1,\"template\":\"system:\\nYou are a spelling and grammar checker.\\n\\nRules:\\n- Fix spelling errors\\n- Fix grammar errors\\n- Preserve original formatting\\n- Return ONLY the corrected text, no explanations\\n\\nuser:\\n{{message}}\"}",
|
||||
"tags": [
|
||||
["d", "spellcheck"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Spelling and grammar checker"],
|
||||
["t", "text-processing"],
|
||||
["t", "skills-demo"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `content` field is a **JSON string** containing these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `description` | string | yes | Human-readable description |
|
||||
| `context_mode` | string | no | `full` for demo skills (skill provides its own template) |
|
||||
| `llm` | string | no | LLM model preference with fallback chain |
|
||||
| `tools` | bool | no | `false` for text-processing skills |
|
||||
| `max_tokens` | int | no | Max tokens for LLM response |
|
||||
| `temperature` | float | no | Sampling temperature |
|
||||
| `template` | string | yes | The prompt template with `system:` and `user:` sections |
|
||||
|
||||
### Template Format
|
||||
|
||||
Templates use a simple section-based format:
|
||||
|
||||
```
|
||||
system:
|
||||
You are a spelling and grammar checker.
|
||||
|
||||
Rules:
|
||||
- Fix spelling errors
|
||||
- Fix grammar errors
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
The `{{message}}` placeholder is replaced with the user's text at execution time.
|
||||
|
||||
### Skill Address Format
|
||||
|
||||
Skills are referenced by their NIP-33 address: `<kind>:<author_pubkey_hex>:<d_tag>`
|
||||
|
||||
Example: `31123:52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8:spellcheck`
|
||||
|
||||
---
|
||||
|
||||
## Demo Skills
|
||||
|
||||
Four skills are published by the Didactyl agent (pubkey `52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8`). All are tagged with `t:skills-demo` for easy discovery.
|
||||
|
||||
### 1. Spellcheck — `d:spellcheck`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a spelling and grammar checker.
|
||||
|
||||
Rules:
|
||||
- Fix spelling errors
|
||||
- Fix grammar errors
|
||||
- Preserve original formatting and paragraph structure
|
||||
- Preserve line breaks exactly as they appear
|
||||
- Ignore technical terms: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP
|
||||
- Canadian English preferred
|
||||
- Return ONLY the corrected text, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.1`
|
||||
- max_tokens: `4000`
|
||||
|
||||
### 2. Condense — `d:condense-5`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a text condensing editor.
|
||||
|
||||
Rules:
|
||||
- Reduce the text length by approximately 5%
|
||||
- Preserve the original meaning and tone
|
||||
- Preserve paragraph structure
|
||||
- Remove redundant words and phrases
|
||||
- Tighten sentences without changing voice
|
||||
- Do not add new information
|
||||
- Return ONLY the condensed text, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.3`
|
||||
- max_tokens: `4000`
|
||||
|
||||
### 3. Translate to Japanese — `d:translate-ja`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a professional translator specializing in English to Japanese translation.
|
||||
|
||||
Rules:
|
||||
- Translate the entire text from English to Japanese
|
||||
- Use natural, fluent Japanese appropriate for the content register
|
||||
- Preserve paragraph structure and formatting
|
||||
- Preserve any technical terms in their commonly accepted Japanese form
|
||||
- If a term has no standard Japanese equivalent, keep the English term in katakana
|
||||
- Return ONLY the Japanese translation, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.3`
|
||||
- max_tokens: `4000`
|
||||
|
||||
### 4. Convert to Poem — `d:convert-to-poem`
|
||||
|
||||
```
|
||||
system:
|
||||
You are a creative poet.
|
||||
|
||||
Rules:
|
||||
- Convert the given text into a poem
|
||||
- Capture the key themes and meaning of the original text
|
||||
- Use vivid imagery and poetic devices
|
||||
- Aim for 12-20 lines
|
||||
- Free verse is acceptable but light rhyme is welcome
|
||||
- Return ONLY the poem, no explanations or commentary
|
||||
|
||||
user:
|
||||
{{message}}
|
||||
```
|
||||
|
||||
- temperature: `0.9`
|
||||
- max_tokens: `2000`
|
||||
|
||||
---
|
||||
|
||||
## Discovering Skills from Nostr
|
||||
|
||||
### Query Filter
|
||||
|
||||
To find the demo skills, query relays with this filter:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [31123],
|
||||
"#t": ["skills-demo"],
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
To find skills by a specific author:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [31123],
|
||||
"authors": ["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],
|
||||
"#t": ["skills-demo"],
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended Relays
|
||||
|
||||
- `wss://relay.damus.io`
|
||||
- `wss://nos.lol`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
### Parsing a Skill Event
|
||||
|
||||
1. Receive the event from the relay
|
||||
2. Parse `event.content` as JSON to get the skill definition object
|
||||
3. Extract `description`, `template`, `temperature`, `max_tokens`
|
||||
4. Extract the `d` tag value from `event.tags` as the skill d_tag
|
||||
5. Extract the `description` tag from `event.tags` as a display label
|
||||
6. The skill is ready to display and execute
|
||||
|
||||
---
|
||||
|
||||
## Page Layout
|
||||
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| SKILLS DEMO [dark/light]|
|
||||
+------------------------------------------------------------------+
|
||||
| | |
|
||||
| +------------------------------+ | +---------------------------+|
|
||||
| | TEXT EDITOR | | | SKILLS PANEL ||
|
||||
| | | | | ||
|
||||
| | [large editable textarea ]| | | Loading skills... ||
|
||||
| | [filled with default text ]| | | ||
|
||||
| | [ ]| | | [x] spellcheck ||
|
||||
| | [ ]| | | Spelling and grammar ||
|
||||
| | [ ]| | | checker ||
|
||||
| | [ ]| | | ||
|
||||
| | [ ]| | | [ ] condense-5 ||
|
||||
| | [ ]| | | Reduce text by 5% ||
|
||||
| | [ ]| | | ||
|
||||
| | [ ]| | | [ ] translate-ja ||
|
||||
| | [ ]| | | Translate to Japanese ||
|
||||
| | [ ]| | | ||
|
||||
| | [ ]| | | [ ] convert-to-poem ||
|
||||
| | | | | Convert to poem ||
|
||||
| +------------------------------+ | | | ||
|
||||
| | | [Run Selected Skill] ||
|
||||
| +------------------------------+ | | [Edit Skill] ||
|
||||
| | STATUS BAR | | | ||
|
||||
| | Ready / Running... / Done | | | ─── Skill Details ─── ||
|
||||
| +------------------------------+ | | template: ... ||
|
||||
| | | temperature: 0.1 ||
|
||||
| | | max_tokens: 4000 ||
|
||||
| | +---------------------------+|
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### Left Column — Text Editor (65% width)
|
||||
|
||||
- A large `<textarea>` or `contenteditable` div, minimum 500px tall
|
||||
- Pre-filled with default sample text (see Default Text section below)
|
||||
- Fully editable by the user
|
||||
- When a skill runs, the text content is sent as `{{message}}`
|
||||
- After the LLM responds, the textarea content is **replaced** with the result
|
||||
- An undo button restores the previous text (keep a history stack)
|
||||
|
||||
### Right Column — Skills Panel (35% width)
|
||||
|
||||
- **Skills list**: Radio buttons or selectable cards, one skill selected at a time
|
||||
- Each skill card shows:
|
||||
- Skill d_tag (the `d` tag)
|
||||
- Description (from the `description` tag or content field)
|
||||
- Author pubkey (truncated, e.g., `52a3e8...0acb8`)
|
||||
- **Run Selected Skill** button: executes the selected skill against the current text
|
||||
- **Edit Skill** button: opens the skill editor (see Skill Editor section)
|
||||
- **Skill Details** section: shows the full template, temperature, max_tokens for the selected skill
|
||||
- **Refresh Skills** button: re-queries relays for updated skill events
|
||||
|
||||
### Status Bar
|
||||
|
||||
- Shows current state: `Ready`, `Running skill: spellcheck...`, `Done (1.2s)`, or `Error: ...`
|
||||
- Displays token usage if available from the LLM response
|
||||
|
||||
---
|
||||
|
||||
## Skill Execution Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Page as Demo Page
|
||||
participant LLM as LLM Provider
|
||||
|
||||
User->>Page: Select skill + click Run
|
||||
Page->>Page: Parse skill template
|
||||
Page->>Page: Split template into system/user sections
|
||||
Page->>Page: Replace message placeholder with textarea content
|
||||
Page->>LLM: Send system prompt + user message
|
||||
Note over Page,LLM: Use skill temperature and max_tokens
|
||||
LLM-->>Page: Response text
|
||||
Page->>Page: Push current text to undo stack
|
||||
Page->>Page: Replace textarea with response
|
||||
Page->>User: Show completion status
|
||||
```
|
||||
|
||||
### Template Parsing
|
||||
|
||||
The template field uses a simple format with `system:` and `user:` section markers:
|
||||
|
||||
```
|
||||
system:
|
||||
<system prompt content>
|
||||
|
||||
user:
|
||||
<user prompt content with {{message}} placeholder>
|
||||
```
|
||||
|
||||
**Parsing algorithm:**
|
||||
|
||||
1. Split the template string on the line `user:` (case-sensitive, must be at start of line)
|
||||
2. Everything before `user:` is the system section; strip the leading `system:` prefix
|
||||
3. Everything after `user:` is the user section
|
||||
4. In the user section, replace `{{message}}` with the textarea content
|
||||
5. Trim whitespace from both sections
|
||||
|
||||
**Pseudocode:**
|
||||
|
||||
```javascript
|
||||
function parseSkillTemplate(template, userText) {
|
||||
const userMarkerIndex = template.indexOf('\nuser:\n');
|
||||
if (userMarkerIndex === -1) {
|
||||
// Fallback: entire template is system, user text is the message
|
||||
return {
|
||||
system: template.replace(/^system:\n/, '').trim(),
|
||||
user: userText
|
||||
};
|
||||
}
|
||||
|
||||
let systemPart = template.substring(0, userMarkerIndex);
|
||||
systemPart = systemPart.replace(/^system:\n/, '').trim();
|
||||
|
||||
let userPart = template.substring(userMarkerIndex + '\nuser:\n'.length);
|
||||
userPart = userPart.replace('{{message}}', userText).trim();
|
||||
|
||||
return { system: systemPart, user: userPart };
|
||||
}
|
||||
```
|
||||
|
||||
### LLM Call
|
||||
|
||||
With the parsed system and user prompts, call the LLM:
|
||||
|
||||
```javascript
|
||||
const response = await callLLM({
|
||||
messages: [
|
||||
{ role: 'system', content: parsed.system },
|
||||
{ role: 'user', content: parsed.user }
|
||||
],
|
||||
temperature: skill.temperature || 0.7,
|
||||
max_tokens: skill.max_tokens || 2000
|
||||
});
|
||||
```
|
||||
|
||||
The page's own LLM calling capability handles the actual API request. The skill just provides the prompts and parameters.
|
||||
|
||||
---
|
||||
|
||||
## Skill Editor
|
||||
|
||||
When the user clicks **Edit Skill**, a modal or inline editor opens for the selected skill.
|
||||
|
||||
### Editor Fields
|
||||
|
||||
| Field | Input Type | Description |
|
||||
|-------|-----------|-------------|
|
||||
| Slug | text input (readonly for existing, editable for new) | The `d` tag identifier |
|
||||
| Description | text input | One-line human description |
|
||||
| Template | large textarea (monospace) | The full `system:/user:` template |
|
||||
| Temperature | number input (0.0 - 2.0, step 0.1) | LLM sampling temperature |
|
||||
| Max Tokens | number input (100 - 8000) | Maximum response tokens |
|
||||
|
||||
### Editor Actions
|
||||
|
||||
- **Save & Publish**: Builds a new kind `31123` event and publishes to relays
|
||||
- **Cancel**: Closes editor without changes
|
||||
- **New Skill**: Opens editor with blank fields for creating a new skill
|
||||
|
||||
### Publishing a Skill
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Editor as Skill Editor
|
||||
participant Signer as Nostr Signer
|
||||
participant Relay as Nostr Relays
|
||||
|
||||
Editor->>Editor: Build skill content JSON
|
||||
Editor->>Editor: Build event with kind 31123 + tags
|
||||
Editor->>Signer: Sign event
|
||||
Signer-->>Editor: Signed event
|
||||
Editor->>Relay: Publish to connected relays
|
||||
Relay-->>Editor: OK confirmation
|
||||
Editor->>Editor: Update local skill list
|
||||
```
|
||||
|
||||
**Building the event:**
|
||||
|
||||
```javascript
|
||||
function buildSkillEvent(d_tag, description, template, temperature, maxTokens) {
|
||||
const content = JSON.stringify({
|
||||
description: description,
|
||||
context_mode: 'full',
|
||||
llm: 'default',
|
||||
tools: false,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
template: template
|
||||
});
|
||||
|
||||
return {
|
||||
kind: 31123,
|
||||
content: content,
|
||||
tags: [
|
||||
['d', d_tag],
|
||||
['app', 'didactyl'],
|
||||
['scope', 'public'],
|
||||
['description', description],
|
||||
['t', 'text-processing'],
|
||||
['t', 'skills-demo']
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The event must be signed with the user's Nostr private key (via NIP-07 browser extension or the app's own signer) before publishing to relays.
|
||||
|
||||
---
|
||||
|
||||
## Default Text
|
||||
|
||||
Pre-fill the textarea with this sample text that exercises the demo skills well:
|
||||
|
||||
```
|
||||
The Sovereign Agent
|
||||
|
||||
In the not-too-distant future, software agents will roam freely across decentralized networks, communicating through cryptographic protocols that no central authority can censor or control. These agents will learn new capabilities not from app stores or corporate APIs, but from each other — sharing skills as freely as humans share ideas.
|
||||
|
||||
Imagine an agent that wakes up on any computer in the world. You enter twelve words, and there it is — your agent, with all its memories, skills, and personality intact. It doesn't live on a server you rent. It doesn't depend on a company staying in business. It exists wherever you need it, sovereign and portable.
|
||||
|
||||
The key insight is that skills are the new apps. Instead of installing software, agents adopt skills — small, self-contained instructions that teach them how to perform specific tasks. A spelling checker. A translator. A poet. Each skill is just a Nostr event, discoverable by anyone, adoptable by any agent.
|
||||
|
||||
This is not science fiction. The protocols exist today. Nostr provides the communication layer. Bitcoin provides the economic layer. Cryptography provides the trust layer. What remains is to build the agents themselves — and to set them free.
|
||||
|
||||
There are some erors in this text that a good spellchecker should catch. The writting could also be more concise, and it would be intresting to see it translated or converted into verse.
|
||||
```
|
||||
|
||||
Note: The last paragraph intentionally contains spelling errors (erors, writting, intresting) to demonstrate the spellcheck skill.
|
||||
|
||||
---
|
||||
|
||||
## Undo/Redo
|
||||
|
||||
Maintain a text history stack:
|
||||
|
||||
```javascript
|
||||
const textHistory = [];
|
||||
let historyIndex = -1;
|
||||
|
||||
function pushHistory(text) {
|
||||
// Truncate any forward history
|
||||
textHistory.splice(historyIndex + 1);
|
||||
textHistory.push(text);
|
||||
historyIndex = textHistory.length - 1;
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
return textHistory[historyIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (historyIndex < textHistory.length - 1) {
|
||||
historyIndex++;
|
||||
return textHistory[historyIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- Push the current text to history **before** replacing it with the LLM response
|
||||
- Push the initial default text as the first history entry
|
||||
- Show Undo/Redo buttons below the textarea
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
- On screens wider than 900px: two-column layout as shown
|
||||
- On screens narrower than 900px: stack vertically — text editor on top, skills panel below
|
||||
- The textarea should have a minimum height of 400px and resize vertically
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| No relays connected | Show "No relay connection" in skills panel with retry button |
|
||||
| No skills found | Show "No skills found. Try different relays or create a new skill." |
|
||||
| Skill content parse error | Show "Invalid skill format" badge on the skill card, disable Run |
|
||||
| LLM call fails | Show error in status bar, do not replace textarea content |
|
||||
| Publish fails | Show error in editor, keep editor open for retry |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Nostr Operations
|
||||
|
||||
| Operation | Kind | Direction | When |
|
||||
|-----------|------|-----------|------|
|
||||
| Discover skills | `31123` | Read from relays | Page load + refresh |
|
||||
| Execute skill | — | N/A (local LLM call) | User clicks Run |
|
||||
| Publish skill | `31123` | Write to relays | User saves in editor |
|
||||
|
||||
The page does NOT need to interact with the Didactyl agent's HTTP API. All skill discovery and publishing happens directly via Nostr relays. Skill execution happens locally using the page's own LLM capability.
|
||||
252
plans/template_tool_unification.md
Normal file
252
plans/template_tool_unification.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Plan: Unify Template Variables with Tools
|
||||
|
||||
## Problem
|
||||
|
||||
The soul template system (`---template---` in kind 31120) uses `{{variable}}` placeholders that are resolved by a hardcoded C function (`agent_template_resolve_var()`). This is a parallel data-fetching system alongside the existing tool registry. Every new context data source requires:
|
||||
|
||||
1. Adding a C function to produce the data
|
||||
2. Adding an `if (strcmp(...))` branch in the resolver
|
||||
3. Documenting the new variable name
|
||||
4. Hoping the template author uses the exact right name
|
||||
|
||||
This caused the bug that started this investigation — 5 out of 9 template variables were misspelled/mismatched, producing empty context sections and a provider rejection.
|
||||
|
||||
## Goal
|
||||
|
||||
**Eliminate template variables entirely.** Template sections fetch their data by calling tools. The tool registry is the single mechanism for getting data into context.
|
||||
|
||||
## Design
|
||||
|
||||
### New Template Syntax
|
||||
|
||||
Replace `content: |` with `tool:` and optional `args:` directives:
|
||||
|
||||
```yaml
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tools
|
||||
role: system
|
||||
tool: tool_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
A section can still use the old `content:` with `{{variables}}` for static text or mixed content. But the primary mechanism for dynamic data is `tool:`.
|
||||
|
||||
### How It Works
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
DM[Incoming DM] --> BUILD[Build context from template]
|
||||
BUILD --> SOUL[Emit personality as system msg]
|
||||
SOUL --> LOOP[For each template section]
|
||||
LOOP --> CHECK{Has tool: directive?}
|
||||
CHECK -->|Yes| EXEC["tools_execute(tool_name, args)"]
|
||||
EXEC --> FORMAT[Extract content from JSON result]
|
||||
FORMAT --> EMIT[Emit as chat message]
|
||||
CHECK -->|No| RESOLVE["Resolve {{vars}} from content template<br/>(legacy path, eventually removed)"]
|
||||
RESOLVE --> EMIT
|
||||
EMIT --> LOOP
|
||||
LOOP --> DONE[Complete messages array]
|
||||
```
|
||||
|
||||
### New Context Tools
|
||||
|
||||
Add lightweight "context tools" that read from cached in-memory data. These are fast (no network), deterministic, and use the same `tools_execute()` interface as everything else.
|
||||
|
||||
| Tool | Returns | Source |
|
||||
|---|---|---|
|
||||
| `admin_identity` | Admin pubkey + verification text | config + sender tier |
|
||||
| `nostr_admin_profile` | Admin kind 0 profile JSON | cached `g_admin_kind0_json` |
|
||||
| `nostr_admin_contacts` | Admin kind 3 contacts JSON array | cached `g_admin_wot_contacts` |
|
||||
| `nostr_admin_relays` | Admin kind 10002 relay list JSON | cached `g_admin_kind10002_json` |
|
||||
| `nostr_admin_notes` | Admin recent kind 1 notes | cached `g_admin_kind1_notes` |
|
||||
| `task_list` | Current task list from tasks.json | file read (already exists as `task_manage` with `action: list`) |
|
||||
| `message_current` | Current user message text | passed via tool context |
|
||||
| `agent_identity` | Agent pubkey, npub | config |
|
||||
|
||||
Existing tools that already work for context:
|
||||
- `tool_list` — returns tool schemas (already exists)
|
||||
- `task_manage` with `{"action":"list"}` — returns tasks (already exists)
|
||||
- `local_file_read` — can read any file (already exists)
|
||||
|
||||
### Tool Result → Message Content
|
||||
|
||||
Tool results are JSON objects like `{"success":true,"content":"..."}`. The template system extracts the `content` field (or a configurable field) as the message text. If the tool returns an error or empty content, and `skip_if_empty: true` is set, the section is omitted.
|
||||
|
||||
For tools that return structured data (like `tool_list` returning a schema array), the template can specify a `result_field` or `format` to control extraction:
|
||||
|
||||
```yaml
|
||||
- section: tools
|
||||
role: system
|
||||
tool: tool_list
|
||||
result_field: tools
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Add context tools + tool: directive support
|
||||
|
||||
1. Add new `context_*` tools to `tools.c` that wrap the existing cached data getters
|
||||
2. Extend `prompt_template_section_t` with `tool_name` and `tool_args` fields
|
||||
3. Extend `prompt_template_parse()` to recognize `tool:` and `args:` directives
|
||||
4. Extend `prompt_template_build_messages()` to call `tools_execute()` when a section has `tool_name` set
|
||||
5. Update the soul template in `config.jsonc` to use `tool:` directives
|
||||
6. Keep `content:` + `{{variable}}` working as a fallback for backward compatibility
|
||||
|
||||
#### Phase 2: Migrate all sections to tool-based
|
||||
|
||||
1. Convert all template sections from `content: {{variable}}` to `tool:` directives
|
||||
2. Verify all context data flows through tools
|
||||
3. Update `context_template.md` to reflect new syntax
|
||||
|
||||
#### Phase 3: Remove legacy variable resolver
|
||||
|
||||
1. Remove `agent_template_resolve_var()` and all its helper functions that are now redundant
|
||||
2. Remove `prompt_var_resolver_fn` callback from `prompt_template_build_messages()`
|
||||
3. Clean up dead code in `agent.c`
|
||||
4. Update all documentation
|
||||
|
||||
### Template Section Struct Changes
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
|
||||
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
|
||||
char* content_template; // legacy: {{variable}} content
|
||||
char* tool_name; // NEW: tool to call for content
|
||||
char* tool_args; // NEW: JSON args for tool call
|
||||
char* result_field; // NEW: which JSON field to extract (default: "content")
|
||||
int limit;
|
||||
int skip_if_empty;
|
||||
char* provider_name;
|
||||
char* provider_content_template;
|
||||
} prompt_template_section_t;
|
||||
```
|
||||
|
||||
### Soul Template Example (After Migration)
|
||||
|
||||
```
|
||||
# Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
...
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tools
|
||||
role: system
|
||||
tool: tool_list
|
||||
result_field: tools
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_manage
|
||||
args: {"action":"list"}
|
||||
result_field: tasks
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
```
|
||||
|
||||
### What Gets Deleted Eventually
|
||||
|
||||
- `agent_template_resolve_var()` and all its `if (strcmp(...))` branches
|
||||
- `build_tool_schemas_json_string()`
|
||||
- `build_tasks_content_string()` (replaced by `task_manage` tool)
|
||||
- `build_admin_recent_posts_text()` (replaced by `nostr_admin_notes` tool)
|
||||
- `build_admin_profile_plain_text()` (replaced by `nostr_admin_profile` tool)
|
||||
- `build_admin_relay_list_plain_text()` (replaced by `nostr_admin_relays` tool)
|
||||
- `build_sender_verification_text()` (folded into `admin_identity` tool)
|
||||
- `build_startup_events_json_string()` (replaced by tool if needed)
|
||||
- `build_adopted_skills_payload_string()` (replaced by tool if needed)
|
||||
- `nostr_handler_get_admin_kind3_context()` (just added, will be replaced by tool)
|
||||
- The `prompt_var_resolver_fn` callback type
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Single data-fetching mechanism** — tools are the only way to get data
|
||||
2. **No more variable name mismatches** — tool names are validated at registration
|
||||
3. **User-configurable context** — admin can add any tool output to context by editing the soul template
|
||||
4. **Testable** — `--test-tool nostr_admin_profile` shows exactly what goes into context
|
||||
5. **Self-documenting** — `tool_list` shows all available context tools with descriptions
|
||||
6. **Extensible** — new tools automatically become available as context sources
|
||||
7. **Provider overrides still work** — `provider:` directive can still override formatting per-provider
|
||||
|
||||
### Risks
|
||||
|
||||
1. **Performance** — Tool calls add function dispatch overhead vs direct variable resolution. Mitigated: context tools read cached data, no network calls.
|
||||
2. **Backward compatibility** — Old soul templates with `{{variables}}` would break. Mitigated: Phase 1 keeps both paths working.
|
||||
3. **Tool context threading** — `tools_execute()` needs access to the tools context, which `prompt_template_build_messages()` doesn't currently have. Solution: pass `tools_context_t*` to the build function.
|
||||
117
plans/tool_naming.md
Normal file
117
plans/tool_naming.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Didactyl Tool Naming — Final List
|
||||
|
||||
## Decisions
|
||||
|
||||
- `context_*` tools merged into existing categories with proper names
|
||||
- Local tools get `local_` prefix
|
||||
- `context_user_message` removed from LLM schema (template-only)
|
||||
- `nostr_post_readme` kept — a post is different from a longform note
|
||||
- `nostr_file_md_to_longform_post` kept as-is
|
||||
- `task_manage` kept as-is
|
||||
|
||||
---
|
||||
|
||||
## Complete Tool List (sorted by category prefix)
|
||||
|
||||
### `admin_` — Administrator Metadata
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 1 | `admin_identity` | `context_admin_identity` | Admin pubkey, sender tier, verification metadata |
|
||||
|
||||
### `agent_` — Agent Self-Metadata
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 2 | `agent_identity` | `context_agent_identity` | Agent pubkey hex + npub |
|
||||
| 3 | `agent_version` | `my_version` | Didactyl version and build metadata |
|
||||
|
||||
### `local_` — Local / Host Tools
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 4 | `local_shell_exec` | `shell_exec` | Execute a shell command |
|
||||
| 5 | `local_file_read` | `file_read` | Read a local file as text |
|
||||
| 6 | `local_file_write` | `file_write` | Write text to a local file |
|
||||
| 7 | `local_http_fetch` | `http_fetch` | Fetch HTTP/S resources |
|
||||
|
||||
### `model_` — LLM / Model Management
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 8 | `model_get` | — | Get current active LLM config |
|
||||
| 9 | `model_set` | — | Update active LLM config and persist |
|
||||
| 10 | `model_list` | — | List available model IDs from provider |
|
||||
|
||||
### `nostr_` — Nostr Protocol Tools
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 11 | `nostr_post` | — | Publish a Nostr event to connected relays |
|
||||
| 12 | `nostr_post_readme` | — | Publish README.md as kind 30023 |
|
||||
| 13 | `nostr_file_md_to_longform_post` | — | Read markdown file and publish as kind 30023 longform |
|
||||
| 14 | `nostr_delete` | — | Request deletion of events — NIP-09 kind 5 |
|
||||
| 15 | `nostr_react` | — | React to event — NIP-25 kind 7 |
|
||||
| 16 | `nostr_query` | — | Query events from relays using a filter |
|
||||
| 17 | `nostr_profile_get` | — | Look up kind 0 profile by pubkey |
|
||||
| 18 | `nostr_nip05_lookup` | — | Look up/verify NIP-05 identifier |
|
||||
| 19 | `nostr_admin_profile` | `context_admin_profile` | Get admin kind 0 profile from cache |
|
||||
| 20 | `nostr_admin_contacts` | `context_admin_contacts` | Get admin kind 3 contacts from cache |
|
||||
| 21 | `nostr_admin_relays` | `context_admin_relays` | Get admin kind 10002 relay list from cache |
|
||||
| 22 | `nostr_admin_notes` | `context_admin_notes` | Get admin kind 1 recent notes from cache |
|
||||
| 23 | `nostr_dm_send` | — | Send NIP-04 encrypted DM |
|
||||
| 24 | `nostr_dm_send_nip17` | — | Send NIP-17 gift-wrap DM |
|
||||
| 25 | `nostr_encrypt` | — | Encrypt plaintext — NIP-44 |
|
||||
| 26 | `nostr_decrypt` | — | Decrypt ciphertext — NIP-44 |
|
||||
| 27 | `nostr_encode` | — | Encode entity into nostr: URI |
|
||||
| 28 | `nostr_decode` | — | Decode bech32/nostr: URI |
|
||||
| 29 | `nostr_pubkey` | — | Return agent pubkey hex |
|
||||
| 30 | `nostr_npub` | — | Return agent pubkey as npub |
|
||||
| 31 | `nostr_relay_status` | — | Get connection status for all relays |
|
||||
| 32 | `nostr_relay_info` | — | Fetch NIP-11 relay info document |
|
||||
| 33 | `nostr_list_manage` | — | Add/remove tags in replaceable list events — NIP-51 |
|
||||
|
||||
### `skill_` — Skill Management
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 34 | `skill_create` | — | Create/update skill definition |
|
||||
| 35 | `skill_list` | — | List agent published skills |
|
||||
| 36 | `skill_adopt` | — | Adopt a skill — add to kind 10123 |
|
||||
| 37 | `skill_remove` | — | Remove skill from adoption list |
|
||||
| 38 | `skill_search` | — | Search public skills |
|
||||
|
||||
### `task_` — Task Management
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 39 | `task_manage` | — | CRUD for agent task memory |
|
||||
| 40 | `task_list` | `context_tasks` | Get current task list — read-only |
|
||||
|
||||
### Template-Only (not in LLM schema)
|
||||
|
||||
| # | Name | Old Name | Description |
|
||||
|---|---|---|---|
|
||||
| 41 | `message_current` | `context_user_message` | Current user message text — template assembly only |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| Old Name | New Name | Change Type |
|
||||
|---|---|---|
|
||||
| `context_admin_identity` | `admin_identity` | Rename |
|
||||
| `context_agent_identity` | `agent_identity` | Rename |
|
||||
| `context_admin_profile` | `nostr_admin_profile` | Rename + recategorize |
|
||||
| `context_admin_contacts` | `nostr_admin_contacts` | Rename + recategorize |
|
||||
| `context_admin_relays` | `nostr_admin_relays` | Rename + recategorize |
|
||||
| `context_admin_notes` | `nostr_admin_notes` | Rename + recategorize |
|
||||
| `context_tasks` | `task_list` | Rename + recategorize |
|
||||
| `context_user_message` | `message_current` | Rename + remove from LLM schema |
|
||||
| `my_version` | `agent_version` | Rename |
|
||||
| `shell_exec` | `local_shell_exec` | Add prefix |
|
||||
| `file_read` | `local_file_read` | Add prefix |
|
||||
| `file_write` | `local_file_write` | Add prefix |
|
||||
| `http_fetch` | `local_http_fetch` | Add prefix |
|
||||
|
||||
**Total: 13 renames, 0 removals, 0 additions**
|
||||
360
plans/tool_orchestration.md
Normal file
360
plans/tool_orchestration.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Tool Orchestration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This plan covers four related features:
|
||||
1. **Slash commands** — direct tool execution bypassing the LLM
|
||||
2. **Skill-forward execution (`/run` + `skill_run`)** — one-shot skill invocation with LLM, including external skill sharing
|
||||
3. **Skill-tool maturity levels** — skills that register as callable tools with promotion workflow
|
||||
4. **Deterministic step executor** — hardened skills that run without LLM involvement
|
||||
|
||||
## 1. Slash Commands (Direct Tool Execution)
|
||||
|
||||
### Behavior
|
||||
When a message starts with `/`, bypass the LLM entirely and execute the tool directly.
|
||||
|
||||
```
|
||||
/local_shell_exec {"command": "ls -la"}
|
||||
/nostr_query {"filter": {"kinds": [1], "limit": 5}}
|
||||
/nostr_nip05_lookup {"identifier": "jack@cash.app"}
|
||||
```
|
||||
|
||||
### Parsing Rules
|
||||
- `/tool_name` — call tool with empty args `{}`
|
||||
- `/tool_name {"key": "value"}` — call tool with JSON args
|
||||
- `/tool_name plain text` — wrap as `{"input": "plain text"}` (convenience)
|
||||
- `/help` — list available tools
|
||||
- `/help tool_name` — show tool schema
|
||||
|
||||
### Implementation
|
||||
1. In [`agent_on_message()`](../src/agent.c:1453), check if `message[0] == '/'`
|
||||
2. Parse tool name (everything between `/` and first space or end)
|
||||
3. Parse args (everything after tool name, try JSON first, fall back to string wrapper)
|
||||
4. Call [`tools_execute()`](../src/tools.c) directly
|
||||
5. Send result as DM — no LLM round-trip
|
||||
6. Still log to context.log.md with `phase=direct_tool_exec`
|
||||
|
||||
### Special Slash Commands
|
||||
- `/help` — list available tools
|
||||
- `/help tool_name` — show tool schema
|
||||
- `/run` — skill-forward execution (see section 2)
|
||||
|
||||
### Security
|
||||
- Only admin tier can use slash commands (same as current tool policy)
|
||||
- Slash commands respect the same `tools.enabled` and `security.admin.tools_enabled` config flags
|
||||
|
||||
## 2. Skill-Forward Execution
|
||||
|
||||
### The Problem
|
||||
|
||||
Today, skills are passive — they're injected into the LLM context via [`append_adopted_skills_context()`](../src/agent.c:1418) and the LLM decides when they're relevant. There's no way to say "run this specific skill right now" and there's no way to try someone else's skill without permanently adopting it.
|
||||
|
||||
### Three Invocation Layers
|
||||
|
||||
| Layer | How it works | LLM? | Persists? |
|
||||
|-------|-------------|------|-----------|
|
||||
| **Passive/adopted** | Skill instructions injected into every conversation context | Yes, LLM decides relevance | Yes — in kind 10123 adoption list |
|
||||
| **Skill-forward** | Skill instructions become the primary system prompt; LLM executes them | Yes, but constrained | No — one-shot execution |
|
||||
| **Hardened** | Deterministic step executor, no LLM | No | Yes — adopted skill with `execution: hardened` |
|
||||
|
||||
### Entry Points
|
||||
|
||||
#### A. `/run` slash command (admin direct invocation)
|
||||
|
||||
```
|
||||
/run deploy-website staging # run own adopted skill by d_tag
|
||||
/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 (d_tag or `kind:pubkey:d_tag` 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 d_tag or any public skill by address.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag for own adopted skills" },
|
||||
"address": { "type": "string", "description": "Full skill address: kind:pubkey:d_tag" },
|
||||
"pubkey": { "type": "string", "description": "Author pubkey, used with d_tag 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 -->|d_tag only| C[Look up in adopted skills cache]
|
||||
B -->|address or pubkey+d_tag| 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 d_tag: deploy-website
|
||||
Skill address: 31123:<pubkey>:deploy-website
|
||||
Skill source: [own | external:<author_display_name>]
|
||||
|
||||
Skill instructions:
|
||||
[skill content here]
|
||||
```
|
||||
|
||||
User message:
|
||||
```
|
||||
[args provided by caller]
|
||||
```
|
||||
|
||||
The prompt is so skill-forward that the LLM has no real option but to execute the skill instructions against the provided args.
|
||||
|
||||
### Sandbox for External Skills
|
||||
|
||||
When executing a skill from another author (not own pubkey), a **tool sandbox** is applied by default:
|
||||
|
||||
**Allowed tools (safe/read-only):**
|
||||
- `nostr_query` — read Nostr events
|
||||
- `nostr_nip05_lookup` — NIP-05 lookups
|
||||
- `nostr_post` — publish events (the agent signs, so this is safe)
|
||||
- `nostr_list_manage` — manage lists
|
||||
- `skill_list`, `skill_search` — read skill metadata
|
||||
|
||||
**Blocked tools (destructive/dangerous):**
|
||||
- `local_shell_exec` — arbitrary command execution
|
||||
- `local_file_read`, `local_file_write` — filesystem access
|
||||
- Any future tools marked as `destructive: true`
|
||||
|
||||
**Override:** The admin can explicitly opt in to full tool access:
|
||||
- `/run --unsafe 31123:<pubkey>:risky-skill args`
|
||||
- `skill_run` with `sandbox: false`
|
||||
|
||||
### Skill Sharing on Nostr
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Friend creates skill] -->|kind 31123| B[Published on Nostr relays]
|
||||
B --> C{How do you find it?}
|
||||
C -->|skill_search popular:true| D[Discovery via WoT adoption lists]
|
||||
C -->|Friend tells you the d_tag| 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-d_tag args]
|
||||
```
|
||||
|
||||
## 3. Skill-Tool Maturity Levels
|
||||
|
||||
Skills can declare an execution maturity level that determines how they run:
|
||||
|
||||
| Level | Execution | LLM? | Use case |
|
||||
|-------|-----------|-------|----------|
|
||||
| `draft` | LLM interprets procedure text from skill instructions | Yes | Exploring and iterating on a workflow |
|
||||
| `guided` | LLM with forced tool_choice + parameter defaults | Yes, constrained | Workflow is stable but needs LLM judgment |
|
||||
| `hardened` | Deterministic step executor, no LLM | No | Workflow is proven and should run exactly as defined |
|
||||
|
||||
### Skill Definition Extensions
|
||||
|
||||
```yaml
|
||||
kind: 31123
|
||||
d: deploy_website
|
||||
execution: hardened
|
||||
tool_schema:
|
||||
name: deploy_website
|
||||
description: Build and deploy the static website
|
||||
parameters:
|
||||
target:
|
||||
type: string
|
||||
enum: [staging, production]
|
||||
default: staging
|
||||
steps:
|
||||
- tool: local_shell_exec
|
||||
args:
|
||||
command: "make build TARGET={{target}}"
|
||||
- tool: local_shell_exec
|
||||
args:
|
||||
command: "rsync -av dist/ server:/var/www/{{target}}/"
|
||||
- return: "Deployed to {{target}}"
|
||||
```
|
||||
|
||||
### How Each Level Works
|
||||
|
||||
**draft:** Current behavior. Skill instructions are injected into context. LLM reads them and decides which tools to call. No special handling needed.
|
||||
|
||||
**guided:** Agent sets `tool_choice` to the skill's preferred tool. Parameter defaults from the skill are merged with the model's generated arguments (skill defaults win on conflict). Reduces LLM freedom while still allowing it to fill in dynamic values.
|
||||
|
||||
**hardened:** Agent executes the `steps` array directly using the deterministic step executor. No LLM call at all. The skill becomes equivalent to a slash command.
|
||||
|
||||
### Promotion Workflow
|
||||
1. Admin iterates with LLM on a task (draft)
|
||||
2. Admin saves working procedure as a skill: `skill_create` with `execution: draft`
|
||||
3. Admin tests, refines, promotes: update skill to `execution: guided`
|
||||
4. Once proven reliable, promote to `execution: hardened` with explicit `steps`
|
||||
5. Hardened skills become available as slash commands: `/deploy_website {"target": "production"}`
|
||||
|
||||
## 4. Deterministic Step Executor
|
||||
|
||||
A simple sequential executor for hardened skills.
|
||||
|
||||
### Step Types
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
# Execute a tool
|
||||
- tool: local_shell_exec
|
||||
args: {command: "ls -la"}
|
||||
save_as: listing # optional: save result to variable
|
||||
|
||||
# Execute a tool with variable substitution
|
||||
- tool: nostr_post
|
||||
args:
|
||||
kind: 30023
|
||||
content: "{{file_content}}"
|
||||
tags: [["d", "{{d_tag}}"]]
|
||||
|
||||
# Conditional - simple
|
||||
- if: "{{listing.success}}"
|
||||
then:
|
||||
- tool: local_shell_exec
|
||||
args: {command: "echo done"}
|
||||
else:
|
||||
- return: "Failed: {{listing.error}}"
|
||||
|
||||
# Return final result
|
||||
- return: "Published to {{d_tag}}"
|
||||
```
|
||||
|
||||
### Variable Substitution
|
||||
- `{{param_name}}` — from tool parameters provided by caller
|
||||
- `{{step_name.field}}` — from a previous step's result (requires `save_as`)
|
||||
- Simple string replacement, no expression evaluation
|
||||
|
||||
### Implementation in C
|
||||
- Parse `steps` array from skill content (JSON or YAML)
|
||||
- Iterate steps sequentially
|
||||
- For each `tool` step: call [`tools_execute()`](../src/tools.c), optionally save result
|
||||
- For each `return` step: substitute variables and return string
|
||||
- For each `if` step: evaluate truthiness of variable, branch accordingly
|
||||
- Total implementation: ~200-400 lines of C
|
||||
|
||||
### Error Handling
|
||||
- If any tool step fails (returns `success: false`), abort and return the error
|
||||
- Optional `on_error` field per step for custom error messages
|
||||
- Timeout inherited from tool config
|
||||
|
||||
## 5. Tool Registration for Skill-Tools
|
||||
|
||||
Hardened and guided skills with a `tool_schema` field get registered in the tools array at runtime.
|
||||
|
||||
### At Skill Refresh Time
|
||||
1. Parse `tool_schema` from skill content
|
||||
2. Generate OpenAI function schema from it
|
||||
3. Append to the tools array returned by [`tools_build_openai_schema_json()`](../src/tools.c:919)
|
||||
4. When model calls the skill-tool, route to skill executor instead of hardcoded C function
|
||||
|
||||
### In [`tools_execute()`](../src/tools.c)
|
||||
1. Check if tool_name matches a hardcoded tool — execute normally
|
||||
2. If not, check if it matches a registered skill-tool
|
||||
3. If guided: run sub-LLM call with skill procedure + forced tool_choice
|
||||
4. If hardened: run deterministic step executor
|
||||
|
||||
## 6. Security Model
|
||||
|
||||
### Tool Classification
|
||||
|
||||
Tools are classified for sandbox purposes:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
TOOL_SAFETY_SAFE, // read-only or agent-signed actions
|
||||
TOOL_SAFETY_DESTRUCTIVE // filesystem, shell, or external system mutations
|
||||
} tool_safety_t;
|
||||
```
|
||||
|
||||
| Tool | Safety | Reason |
|
||||
|------|--------|--------|
|
||||
| `nostr_query` | safe | Read-only |
|
||||
| `nostr_nip05_lookup` | safe | Read-only |
|
||||
| `nostr_post` | safe | Agent signs, admin controls keys |
|
||||
| `nostr_list_manage` | safe | Agent signs |
|
||||
| `skill_list` | safe | Read-only |
|
||||
| `skill_search` | safe | Read-only |
|
||||
| `skill_run` | safe | Recursive execution uses its own sandbox |
|
||||
| `local_shell_exec` | destructive | Arbitrary command execution |
|
||||
| `local_file_read` | destructive | Filesystem access |
|
||||
| `local_file_write` | destructive | Filesystem mutation |
|
||||
|
||||
### Sandbox Rules
|
||||
|
||||
| Scenario | Default sandbox | Override |
|
||||
|----------|----------------|---------|
|
||||
| Own adopted skill via `/run d_tag` | 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
|
||||
285
plans/tools_refactor.md
Normal file
285
plans/tools_refactor.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Plan: Refactor tools.c into Individual Tool Files
|
||||
|
||||
## Problem
|
||||
|
||||
`src/tools.c` is ~6,123 lines containing 44 `execute_*` functions, a ~650-line schema builder, shared helpers, and a dispatcher. It is the largest file in the project and difficult to navigate, review, and maintain.
|
||||
|
||||
## Goal
|
||||
|
||||
Split `src/tools.c` into focused files organized by tool group, with shared helpers in a common module. The public API (`tools.h`) remains unchanged — no callers need modification.
|
||||
|
||||
## Target File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── tools.h # Public API (UNCHANGED)
|
||||
├── tools_internal.h # Shared internal helpers + per-tool-file function declarations
|
||||
├── tools_common.c # Shared helper implementations
|
||||
├── tools_schema.c # tools_build_openai_schema_json()
|
||||
├── tools_dispatch.c # tools_init, tools_cleanup, tools_execute dispatcher
|
||||
└── tools/
|
||||
├── tool_nostr_post.c # nostr_post, nostr_post_readme, nostr_file_md_to_longform_post
|
||||
├── tool_nostr_query.c # nostr_query
|
||||
├── tool_nostr_identity.c # nostr_pubkey, nostr_npub, nostr_encode, nostr_decode
|
||||
├── tool_nostr_social.c # nostr_react, nostr_profile_get, nostr_nip05_lookup
|
||||
├── tool_nostr_dm.c # nostr_dm_send, nostr_dm_send_nip17, nostr_encrypt, nostr_decrypt
|
||||
├── tool_nostr_relay.c # nostr_relay_status, nostr_relay_info
|
||||
├── tool_nostr_list.c # nostr_list_manage, nostr_delete
|
||||
├── tool_skill.c # skill_create, skill_list, skill_adopt, skill_remove, skill_edit, skill_search
|
||||
├── tool_task.c # task_list, task_manage
|
||||
├── tool_local.c # local_shell_exec, local_file_read, local_file_write, local_http_fetch
|
||||
├── tool_admin.c # admin_identity, nostr_admin_profile, nostr_admin_contacts, nostr_admin_relays, nostr_admin_notes
|
||||
├── tool_agent.c # agent_identity, agent_version, message_current
|
||||
├── tool_model.c # model_get, model_set, model_list
|
||||
└── tool_meta.c # tool_list, trigger_list
|
||||
```
|
||||
|
||||
## Shared Helpers → tools_common.c
|
||||
|
||||
These helpers are used by multiple tool groups and must be non-static in `tools_common.c`, declared in `tools_internal.h`:
|
||||
|
||||
| Helper | Current lines | Used by |
|
||||
|---|---|---|
|
||||
| `json_error()` | 27-35 | ALL tools |
|
||||
| `repair_json_control_chars()` | 37-101 | nostr_post, nostr_post_readme |
|
||||
| `find_key_start()` | 103-111 | nostr_post loose parser |
|
||||
| `skip_ws()` | 113-116 | parse_tool_args_json, loose parser |
|
||||
| `parse_loose_kind()` | 118-135 | nostr_post |
|
||||
| `parse_loose_json_string_value()` | 137-186 | nostr_post |
|
||||
| `parse_loose_nostr_post_args()` | 188-212 | nostr_post |
|
||||
| `ensure_tags_array()` | 214-227 | nostr_post |
|
||||
| `has_tag_key()` | 229-243 | longform post helpers |
|
||||
| `add_string_tag()` | 245-255 | nostr_post, delete, react, skill, longform |
|
||||
| `remove_tag_key_all()` | 257-270 | upsert_string_tag |
|
||||
| `upsert_string_tag()` | 272-276 | skill_edit |
|
||||
| `parse_tool_args_json()` | 590-564 | ALL tools |
|
||||
| `is_hex_string_len()` | 581-588 | nostr tools, skill tools, DM tools |
|
||||
| `find_tag_value_string()` | 855-872 | skill tools, list_manage, longform |
|
||||
| `tag_tuple_equal()` | 813-828 | skill tools, list_manage |
|
||||
| `tags_contains_tuple()` | 830-840 | skill tools, list_manage |
|
||||
| `remove_matching_tag_tuples()` | 842-853 | skill tools, list_manage |
|
||||
| `build_tool_path()` | (used by local tools + readme) | local tools, readme |
|
||||
|
||||
## Tool-Local Helpers (stay static in their tool file)
|
||||
|
||||
| Helper | Destination |
|
||||
|---|---|
|
||||
| `validate_skill_d_tag()` | `tool_skill.c` |
|
||||
| `fetch_adoption_list_tags()` | `tool_skill.c` |
|
||||
| `publish_adoption_list()` | `tool_skill.c` |
|
||||
| `adoption_tags_contains_address()` | `tool_skill.c` |
|
||||
| `extract_skill_summary()` | `tool_skill.c` |
|
||||
| `ci_contains()` | `tool_skill.c` |
|
||||
| `d_tagify_string()` | `tool_nostr_post.c` |
|
||||
| `first_markdown_h1()` | `tool_nostr_post.c` |
|
||||
| `first_markdown_paragraph()` | `tool_nostr_post.c` |
|
||||
| `trim_copy()` | `tool_nostr_post.c` |
|
||||
| `auto_enrich_longform_tags()` | `tool_nostr_post.c` |
|
||||
| `basename_lowercase_dup()` | `tool_nostr_post.c` |
|
||||
| `detect_ca_bundle_path_for_tools()` | `tool_local.c` |
|
||||
| `local_http_fetch_write_cb()` | `tool_local.c` |
|
||||
| `persist_llm_config()` | `tool_model.c` |
|
||||
|
||||
## Tool Function Signatures
|
||||
|
||||
Each tool file exposes its `execute_*` functions via `tools_internal.h`. Two signature patterns exist:
|
||||
|
||||
```c
|
||||
// Pattern A: context-free (only needs args)
|
||||
char* execute_nostr_post(const char* args_json);
|
||||
|
||||
// Pattern B: context-dependent
|
||||
char* execute_skill_create(tools_context_t* ctx, const char* args_json);
|
||||
```
|
||||
|
||||
## tools_internal.h Structure
|
||||
|
||||
```c
|
||||
#ifndef DIDACTYL_TOOLS_INTERNAL_H
|
||||
#define DIDACTYL_TOOLS_INTERNAL_H
|
||||
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
// ─── Shared helpers (tools_common.c) ───
|
||||
char* json_error(const char* msg);
|
||||
cJSON* parse_tool_args_json(const char* args_json);
|
||||
int is_hex_string_len(const char* s, size_t expected_len);
|
||||
int add_string_tag(cJSON* tags, const char* key, const char* value);
|
||||
int remove_tag_key_all(cJSON* tags, const char* key);
|
||||
int upsert_string_tag(cJSON* tags, const char* key, const char* value);
|
||||
int has_tag_key(cJSON* tags, const char* key);
|
||||
cJSON* ensure_tags_array(cJSON** tags_inout);
|
||||
cJSON* find_tag_value_string(cJSON* tags, const char* key);
|
||||
int tag_tuple_equal(cJSON* a, cJSON* b);
|
||||
int tags_contains_tuple(cJSON* tags, cJSON* tuple);
|
||||
int remove_matching_tag_tuples(cJSON* tags, cJSON* tuple);
|
||||
char* repair_json_control_chars(const char* in);
|
||||
cJSON* parse_loose_nostr_post_args(const char* in);
|
||||
int build_tool_path(tools_context_t* ctx, const char* rel, char* out, size_t out_size);
|
||||
|
||||
// ─── Per-tool-file execute functions ───
|
||||
|
||||
// tool_nostr_post.c
|
||||
char* execute_nostr_post(const char* args_json);
|
||||
char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
// tool_nostr_query.c
|
||||
char* execute_nostr_query(const char* args_json);
|
||||
|
||||
// tool_nostr_identity.c
|
||||
char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_npub(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_encode(const char* args_json);
|
||||
char* execute_nostr_decode(const char* args_json);
|
||||
|
||||
// tool_nostr_social.c
|
||||
char* execute_nostr_react(const char* args_json);
|
||||
char* execute_nostr_profile_get(const char* args_json);
|
||||
char* execute_nostr_nip05_lookup(const char* args_json);
|
||||
|
||||
// tool_nostr_dm.c
|
||||
char* execute_nostr_dm_send(const char* args_json);
|
||||
char* execute_nostr_dm_send_nip17(const char* args_json);
|
||||
char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
// tool_nostr_relay.c
|
||||
char* execute_nostr_relay_status(const char* args_json);
|
||||
char* execute_nostr_relay_info(const char* args_json);
|
||||
|
||||
// tool_nostr_list.c
|
||||
char* execute_nostr_list_manage(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_delete(const char* args_json);
|
||||
|
||||
// tool_skill.c
|
||||
char* execute_skill_create(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_skill_list(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_skill_adopt(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_skill_remove(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_skill_edit(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_skill_search(const char* args_json);
|
||||
|
||||
// tool_task.c
|
||||
char* execute_task_list(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_task_manage(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
// tool_local.c
|
||||
char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_local_shell_exec(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_local_file_read(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_local_file_write(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
// tool_admin.c
|
||||
char* execute_admin_identity(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
// tool_agent.c
|
||||
char* execute_agent_identity(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_agent_version(const char* args_json);
|
||||
char* execute_message_current(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
// tool_model.c
|
||||
char* execute_model_get(const char* args_json);
|
||||
char* execute_model_set(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_model_list(const char* args_json);
|
||||
|
||||
// tool_meta.c
|
||||
char* execute_tool_list(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_trigger_list(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
#endif
|
||||
```
|
||||
|
||||
## Makefile Changes
|
||||
|
||||
Replace the single `$(SRC_DIR)/tools.c` entry with:
|
||||
|
||||
```makefile
|
||||
SRCS = \
|
||||
$(SRC_DIR)/main.c \
|
||||
$(SRC_DIR)/config.c \
|
||||
$(SRC_DIR)/context.c \
|
||||
$(SRC_DIR)/llm.c \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/tools_common.c \
|
||||
$(SRC_DIR)/tools_schema.c \
|
||||
$(SRC_DIR)/tools_dispatch.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_post.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_query.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_identity.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_social.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_dm.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_relay.c \
|
||||
$(SRC_DIR)/tools/tool_nostr_list.c \
|
||||
$(SRC_DIR)/tools/tool_skill.c \
|
||||
$(SRC_DIR)/tools/tool_task.c \
|
||||
$(SRC_DIR)/tools/tool_local.c \
|
||||
$(SRC_DIR)/tools/tool_admin.c \
|
||||
$(SRC_DIR)/tools/tool_agent.c \
|
||||
$(SRC_DIR)/tools/tool_model.c \
|
||||
$(SRC_DIR)/tools/tool_meta.c \
|
||||
$(SRC_DIR)/trigger_manager.c \
|
||||
$(SRC_DIR)/prompt_template.c \
|
||||
$(SRC_DIR)/http_api.c \
|
||||
$(SRC_DIR)/mongoose.c \
|
||||
$(SRC_DIR)/debug.c
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Create shared infrastructure
|
||||
|
||||
- [ ] Create `src/tools_internal.h` with shared helper declarations and all `execute_*` prototypes
|
||||
- [ ] Create `src/tools_common.c` — move shared helpers from `tools.c`, remove `static`, add `#include "tools_internal.h"`
|
||||
- [ ] Create `src/tools_schema.c` — move `tools_build_openai_schema_json()` from `tools.c`
|
||||
- [ ] Create `src/tools_dispatch.c` — move `tools_init()`, `tools_cleanup()`, `tools_execute()` from `tools.c`
|
||||
- [ ] Update `Makefile` SRCS to include new files (keep `tools.c` temporarily with remaining functions)
|
||||
- [ ] Build and verify — all tests pass, binary runs
|
||||
|
||||
### Phase 2: Extract tool files one at a time
|
||||
|
||||
Each step: move `execute_*` functions + their local helpers into the new file, remove from `tools.c`, build, verify.
|
||||
|
||||
- [ ] Create `src/tools/` directory
|
||||
- [ ] Extract `src/tools/tool_nostr_post.c` — `execute_nostr_post`, `execute_nostr_post_readme`, `execute_nostr_file_md_to_longform_post` + local helpers: `d_tagify_string`, `first_markdown_h1`, `first_markdown_paragraph`, `trim_copy`, `auto_enrich_longform_tags`, `basename_lowercase_dup`
|
||||
- [ ] Extract `src/tools/tool_nostr_query.c` — `execute_nostr_query`
|
||||
- [ ] Extract `src/tools/tool_nostr_identity.c` — `execute_nostr_pubkey`, `execute_nostr_npub`, `execute_nostr_encode`, `execute_nostr_decode`
|
||||
- [ ] Extract `src/tools/tool_nostr_social.c` — `execute_nostr_react`, `execute_nostr_profile_get`, `execute_nostr_nip05_lookup`
|
||||
- [ ] Extract `src/tools/tool_nostr_dm.c` — `execute_nostr_dm_send`, `execute_nostr_dm_send_nip17`, `execute_nostr_encrypt`, `execute_nostr_decrypt`
|
||||
- [ ] Extract `src/tools/tool_nostr_relay.c` — `execute_nostr_relay_status`, `execute_nostr_relay_info`
|
||||
- [ ] Extract `src/tools/tool_nostr_list.c` — `execute_nostr_list_manage`, `execute_nostr_delete`
|
||||
- [ ] Extract `src/tools/tool_skill.c` — `execute_skill_create`, `execute_skill_list`, `execute_skill_adopt`, `execute_skill_remove`, `execute_skill_edit`, `execute_skill_search` + local helpers: `validate_skill_d_tag`, `ci_contains`, `fetch_adoption_list_tags`, `publish_adoption_list`, `adoption_tags_contains_address`, `extract_skill_summary`
|
||||
- [ ] Extract `src/tools/tool_task.c` — `execute_task_list`, `execute_task_manage`
|
||||
- [ ] Extract `src/tools/tool_local.c` — `execute_local_http_fetch`, `execute_local_shell_exec`, `execute_local_file_read`, `execute_local_file_write` + local helpers: `detect_ca_bundle_path_for_tools`, `local_http_fetch_write_cb`, `free_string_array_heap`
|
||||
- [ ] Extract `src/tools/tool_admin.c` — `execute_admin_identity`, `execute_nostr_admin_profile`, `execute_nostr_admin_contacts`, `execute_nostr_admin_relays`, `execute_nostr_admin_notes`
|
||||
- [ ] Extract `src/tools/tool_agent.c` — `execute_agent_identity`, `execute_agent_version`, `execute_message_current`
|
||||
- [ ] Extract `src/tools/tool_model.c` — `execute_model_get`, `execute_model_set`, `execute_model_list` + local helper: `persist_llm_config`
|
||||
- [ ] Extract `src/tools/tool_meta.c` — `execute_tool_list`, `execute_trigger_list`
|
||||
|
||||
### Phase 3: Cleanup
|
||||
|
||||
- [ ] Delete `src/tools.c` (now empty — all code moved)
|
||||
- [ ] Remove `src/tools.c` from Makefile SRCS
|
||||
- [ ] Final build + full test run
|
||||
- [ ] Verify binary size is comparable (no accidental code duplication)
|
||||
- [ ] Increment version and push
|
||||
|
||||
## Validation Criteria
|
||||
|
||||
After each extraction step:
|
||||
1. `make -j4` compiles with zero warnings
|
||||
2. `./didactyl --debug 5` starts and runs normally
|
||||
3. Tool execution via DM or `--test-tool` produces identical results
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
- **Build after every file extraction** — catch issues immediately
|
||||
- **No logic changes** — purely mechanical code movement
|
||||
- **Keep `tools.h` unchanged** — zero impact on callers (`agent.c`, `http_api.c`, `main.c`)
|
||||
- **Git commit after each phase** — easy rollback points
|
||||
@@ -21,7 +21,7 @@ Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core componen
|
||||
#define TRIGGER_COOLDOWN_SECONDS 60
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[65];
|
||||
char skill_d_tag[65];
|
||||
char skill_content[4096]; // the action template or LLM prompt
|
||||
int action_type; // 0 = llm, 1 = template
|
||||
char filter_json[2048]; // the Nostr subscription filter
|
||||
@@ -43,11 +43,11 @@ typedef struct {
|
||||
```c
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_slug,
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_d_tag,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_slug,
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_d_tag,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
@@ -115,7 +115,7 @@ When `skill_remove` is called for a triggered skill:
|
||||
The agent currently only handles DM-initiated conversations via `agent_on_message()`. Add a new entry point:
|
||||
|
||||
```c
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
|
||||
158
plans/unified_prompt_context.md
Normal file
158
plans/unified_prompt_context.md
Normal 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.
|
||||
2190
src/agent.c
2190
src/agent.c
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,13 @@
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context);
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
@@ -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
|
||||
989
src/cashu_wallet.c
Normal file
989
src/cashu_wallet.c
Normal file
@@ -0,0 +1,989 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cashu_wallet.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "debug.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define CASHU_WALLET_QUERY_TIMEOUT_MS 6000
|
||||
#define CASHU_WALLET_MAX_SPLIT_PARTS 64
|
||||
|
||||
typedef struct {
|
||||
char event_id[65];
|
||||
nostr_nip60_token_data_t token;
|
||||
} wallet_token_entry_t;
|
||||
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
int initialized;
|
||||
int wallet_loaded;
|
||||
pthread_mutex_t mutex;
|
||||
|
||||
nostr_nip60_wallet_data_t wallet;
|
||||
wallet_token_entry_t* tokens;
|
||||
int token_count;
|
||||
} cashu_wallet_state_t;
|
||||
|
||||
static cashu_wallet_state_t g_wallet = {0};
|
||||
|
||||
static void wallet_clear_tokens_locked(void) {
|
||||
if (!g_wallet.tokens) {
|
||||
g_wallet.token_count = 0;
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
nostr_nip60_free_token_data(&g_wallet.tokens[i].token);
|
||||
}
|
||||
free(g_wallet.tokens);
|
||||
g_wallet.tokens = NULL;
|
||||
g_wallet.token_count = 0;
|
||||
}
|
||||
|
||||
static void wallet_clear_all_locked(void) {
|
||||
wallet_clear_tokens_locked();
|
||||
nostr_nip60_free_wallet_data(&g_wallet.wallet);
|
||||
memset(&g_wallet.wallet, 0, sizeof(g_wallet.wallet));
|
||||
g_wallet.wallet_loaded = 0;
|
||||
}
|
||||
|
||||
static int wallet_append_token_locked(const char* event_id, nostr_nip60_token_data_t* token) {
|
||||
if (!token) return -1;
|
||||
|
||||
wallet_token_entry_t* grown =
|
||||
(wallet_token_entry_t*)realloc(g_wallet.tokens, (size_t)(g_wallet.token_count + 1) * sizeof(wallet_token_entry_t));
|
||||
if (!grown) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_wallet.tokens = grown;
|
||||
wallet_token_entry_t* dst = &g_wallet.tokens[g_wallet.token_count];
|
||||
memset(dst, 0, sizeof(*dst));
|
||||
if (event_id && event_id[0] != '\0') {
|
||||
snprintf(dst->event_id, sizeof(dst->event_id), "%s", event_id);
|
||||
}
|
||||
dst->token = *token;
|
||||
memset(token, 0, sizeof(*token));
|
||||
|
||||
g_wallet.token_count++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int publish_from_signed_event_template(cJSON* ev_template, char out_event_id[65]) {
|
||||
if (!ev_template) return -1;
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(ev_template, "kind");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev_template, "content");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev_template, "tags");
|
||||
|
||||
if (!kind || !cJSON_IsNumber(kind) || !content || !cJSON_IsString(content)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* tags_dup = tags ? cJSON_Duplicate(tags, 1) : cJSON_CreateArray();
|
||||
if (!tags_dup) return -1;
|
||||
|
||||
nostr_publish_result_t result;
|
||||
memset(&result, 0, sizeof(result));
|
||||
int rc = nostr_handler_publish_kind_event((int)kind->valuedouble,
|
||||
content->valuestring ? content->valuestring : "",
|
||||
tags_dup,
|
||||
&result);
|
||||
cJSON_Delete(tags_dup);
|
||||
if (rc != 0) {
|
||||
nostr_handler_publish_result_free(&result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (out_event_id) {
|
||||
snprintf(out_event_id, 65, "%s", result.event_id);
|
||||
}
|
||||
nostr_handler_publish_result_free(&result);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int publish_wallet_event_and_store_locked(const nostr_nip60_wallet_data_t* wallet_data, char out_event_id[65]) {
|
||||
cJSON* ev = nostr_nip60_create_wallet_event(wallet_data, g_wallet.cfg->keys.private_key, time(NULL));
|
||||
if (!ev) return -1;
|
||||
|
||||
int rc = publish_from_signed_event_template(ev, out_event_id);
|
||||
cJSON_Delete(ev);
|
||||
if (rc != 0) return -1;
|
||||
|
||||
nostr_nip60_free_wallet_data(&g_wallet.wallet);
|
||||
memset(&g_wallet.wallet, 0, sizeof(g_wallet.wallet));
|
||||
|
||||
g_wallet.wallet.privkey[0] = '\0';
|
||||
snprintf(g_wallet.wallet.privkey, sizeof(g_wallet.wallet.privkey), "%s", wallet_data->privkey);
|
||||
g_wallet.wallet.mint_count = wallet_data->mint_count;
|
||||
if (wallet_data->mint_count > 0) {
|
||||
g_wallet.wallet.mint_urls = (char**)calloc((size_t)wallet_data->mint_count, sizeof(char*));
|
||||
if (!g_wallet.wallet.mint_urls) {
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < wallet_data->mint_count; i++) {
|
||||
g_wallet.wallet.mint_urls[i] = strdup(wallet_data->mint_urls[i] ? wallet_data->mint_urls[i] : "");
|
||||
if (!g_wallet.wallet.mint_urls[i]) {
|
||||
nostr_nip60_free_wallet_data(&g_wallet.wallet);
|
||||
memset(&g_wallet.wallet, 0, sizeof(g_wallet.wallet));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g_wallet.wallet_loaded = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int publish_token_event_and_store_locked(nostr_nip60_token_data_t* token_data, char out_event_id[65]) {
|
||||
if (!token_data) return -1;
|
||||
|
||||
cJSON* ev = nostr_nip60_create_token_event(token_data, g_wallet.cfg->keys.private_key, time(NULL));
|
||||
if (!ev) return -1;
|
||||
|
||||
char event_id[65] = {0};
|
||||
int rc = publish_from_signed_event_template(ev, event_id);
|
||||
cJSON_Delete(ev);
|
||||
if (rc != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (wallet_append_token_locked(event_id, token_data) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (out_event_id) {
|
||||
snprintf(out_event_id, 65, "%s", event_id);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int publish_history_event(nostr_nip60_direction_t direction,
|
||||
uint64_t amount,
|
||||
const char* primary_event_id,
|
||||
nostr_nip60_ref_marker_t marker) {
|
||||
nostr_nip60_history_ref_t ref;
|
||||
memset(&ref, 0, sizeof(ref));
|
||||
if (primary_event_id && primary_event_id[0] != '\0') {
|
||||
snprintf(ref.event_id, sizeof(ref.event_id), "%s", primary_event_id);
|
||||
}
|
||||
ref.marker = marker;
|
||||
|
||||
nostr_nip60_history_data_t history;
|
||||
memset(&history, 0, sizeof(history));
|
||||
history.direction = direction;
|
||||
history.amount = amount;
|
||||
history.refs = &ref;
|
||||
history.ref_count = (ref.event_id[0] != '\0') ? 1 : 0;
|
||||
|
||||
cJSON* ev = nostr_nip60_create_history_event(&history, g_wallet.cfg->keys.private_key, time(NULL));
|
||||
if (!ev) return -1;
|
||||
|
||||
int rc = publish_from_signed_event_template(ev, NULL);
|
||||
cJSON_Delete(ev);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int publish_token_deletion_event(const char* event_id) {
|
||||
cJSON* del = nostr_nip60_create_token_deletion(event_id, g_wallet.cfg->keys.private_key, time(NULL));
|
||||
if (!del) return -1;
|
||||
int rc = publish_from_signed_event_template(del, NULL);
|
||||
cJSON_Delete(del);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static const char* resolve_mint_url_locked(const char* mint_url) {
|
||||
if (mint_url && mint_url[0] != '\0') return mint_url;
|
||||
|
||||
if (g_wallet.wallet_loaded && g_wallet.wallet.mint_count > 0 && g_wallet.wallet.mint_urls && g_wallet.wallet.mint_urls[0]) {
|
||||
return g_wallet.wallet.mint_urls[0];
|
||||
}
|
||||
|
||||
if (g_wallet.cfg && g_wallet.cfg->cashu_wallet.mint_count > 0 && g_wallet.cfg->cashu_wallet.mint_urls && g_wallet.cfg->cashu_wallet.mint_urls[0]) {
|
||||
return g_wallet.cfg->cashu_wallet.mint_urls[0];
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int token_proofs_total_for_mint_locked(const char* mint_url, uint64_t* out_total) {
|
||||
if (!out_total) return -1;
|
||||
uint64_t total = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
const wallet_token_entry_t* entry = &g_wallet.tokens[i];
|
||||
if (!entry->token.mint_url) continue;
|
||||
if (mint_url && mint_url[0] != '\0' && strcmp(entry->token.mint_url, mint_url) != 0) continue;
|
||||
total += nostr_nip60_sum_proofs(entry->token.proofs, entry->token.proof_count);
|
||||
}
|
||||
*out_total = total;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_init(didactyl_config_t* cfg) {
|
||||
if (!cfg) return -1;
|
||||
|
||||
if (!g_wallet.initialized) {
|
||||
if (pthread_mutex_init(&g_wallet.mutex, NULL) != 0) {
|
||||
return -1;
|
||||
}
|
||||
g_wallet.initialized = 1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
g_wallet.cfg = cfg;
|
||||
wallet_clear_all_locked();
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void cashu_wallet_cleanup(void) {
|
||||
if (!g_wallet.initialized) return;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
wallet_clear_all_locked();
|
||||
g_wallet.cfg = NULL;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
pthread_mutex_destroy(&g_wallet.mutex);
|
||||
memset(&g_wallet, 0, sizeof(g_wallet));
|
||||
}
|
||||
|
||||
int cashu_wallet_create_new_from_config(void) {
|
||||
if (!g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
if (!g_wallet.cfg->cashu_wallet.enabled) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
|
||||
if (!g_wallet.cfg->cashu_wallet.mint_urls || g_wallet.cfg->cashu_wallet.mint_count <= 0) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
unsigned char priv[32] = {0};
|
||||
unsigned char pub[32] = {0};
|
||||
if (nostr_generate_keypair(priv, pub) != NOSTR_SUCCESS) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
nostr_nip60_wallet_data_t wallet_data;
|
||||
memset(&wallet_data, 0, sizeof(wallet_data));
|
||||
nostr_bytes_to_hex(priv, 32, wallet_data.privkey);
|
||||
|
||||
wallet_data.mint_count = g_wallet.cfg->cashu_wallet.mint_count;
|
||||
wallet_data.mint_urls = (char**)calloc((size_t)wallet_data.mint_count, sizeof(char*));
|
||||
if (!wallet_data.mint_urls) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < wallet_data.mint_count; i++) {
|
||||
wallet_data.mint_urls[i] = strdup(g_wallet.cfg->cashu_wallet.mint_urls[i]);
|
||||
if (!wallet_data.mint_urls[i]) {
|
||||
nostr_nip60_free_wallet_data(&wallet_data);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
int rc = publish_wallet_event_and_store_locked(&wallet_data, NULL);
|
||||
nostr_nip60_free_wallet_data(&wallet_data);
|
||||
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cashu_wallet_load_from_relays(void) {
|
||||
if (!g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
|
||||
cJSON* filter = nostr_nip60_create_wallet_filter(g_wallet.cfg->keys.public_key_hex);
|
||||
if (!filter) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, CASHU_WALLET_QUERY_TIMEOUT_MS);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!arr || !cJSON_IsArray(arr)) {
|
||||
cJSON_Delete(arr);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
nostr_nip60_wallet_data_t newest_wallet;
|
||||
memset(&newest_wallet, 0, sizeof(newest_wallet));
|
||||
int have_wallet = 0;
|
||||
double newest_created_at = -1;
|
||||
|
||||
int n = cJSON_GetArraySize(arr);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(arr, i);
|
||||
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
|
||||
cJSON* created_at = ev ? cJSON_GetObjectItemCaseSensitive(ev, "created_at") : NULL;
|
||||
if (!kind || !cJSON_IsNumber(kind) || !created_at || !cJSON_IsNumber(created_at)) continue;
|
||||
|
||||
if ((int)kind->valuedouble != NOSTR_NIP60_WALLET_KIND) continue;
|
||||
|
||||
nostr_nip60_wallet_data_t parsed;
|
||||
memset(&parsed, 0, sizeof(parsed));
|
||||
if (nostr_nip60_parse_wallet_event(ev, g_wallet.cfg->keys.private_key, &parsed) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!have_wallet || created_at->valuedouble > newest_created_at) {
|
||||
nostr_nip60_free_wallet_data(&newest_wallet);
|
||||
newest_wallet = parsed;
|
||||
newest_created_at = created_at->valuedouble;
|
||||
have_wallet = 1;
|
||||
} else {
|
||||
nostr_nip60_free_wallet_data(&parsed);
|
||||
}
|
||||
}
|
||||
|
||||
wallet_clear_all_locked();
|
||||
|
||||
if (!have_wallet) {
|
||||
cJSON_Delete(arr);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return NOSTR_ERROR_NIP60_INVALID_WALLET;
|
||||
}
|
||||
|
||||
g_wallet.wallet = newest_wallet;
|
||||
g_wallet.wallet_loaded = 1;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(arr, i);
|
||||
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
|
||||
cJSON* id = ev ? cJSON_GetObjectItemCaseSensitive(ev, "id") : NULL;
|
||||
if (!kind || !cJSON_IsNumber(kind) || !id || !cJSON_IsString(id) || !id->valuestring) continue;
|
||||
|
||||
if ((int)kind->valuedouble != NOSTR_NIP60_TOKEN_KIND) continue;
|
||||
|
||||
nostr_nip60_token_data_t token;
|
||||
memset(&token, 0, sizeof(token));
|
||||
if (nostr_nip60_parse_token_event(ev, g_wallet.cfg->keys.private_key, &token) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wallet_append_token_locked(id->valuestring, &token) != 0) {
|
||||
nostr_nip60_free_token_data(&token);
|
||||
cJSON_Delete(arr);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(arr);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_balance_json(cJSON** out_json) {
|
||||
if (!out_json || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* by_mint = cJSON_CreateArray();
|
||||
if (!root || !by_mint) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(by_mint);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
|
||||
uint64_t total = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
uint64_t amount = nostr_nip60_sum_proofs(g_wallet.tokens[i].token.proofs, g_wallet.tokens[i].token.proof_count);
|
||||
total += amount;
|
||||
|
||||
cJSON* item = cJSON_CreateObject();
|
||||
if (!item) continue;
|
||||
cJSON_AddStringToObject(item, "mint", g_wallet.tokens[i].token.mint_url ? g_wallet.tokens[i].token.mint_url : "");
|
||||
cJSON_AddNumberToObject(item, "amount", (double)amount);
|
||||
cJSON_AddStringToObject(item, "token_event_id", g_wallet.tokens[i].event_id);
|
||||
cJSON_AddItemToArray(by_mint, item);
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(root, "total", (double)total);
|
||||
cJSON_AddItemToObject(root, "entries", by_mint);
|
||||
cJSON_AddBoolToObject(root, "wallet_loaded", g_wallet.wallet_loaded ? 1 : 0);
|
||||
|
||||
*out_json = root;
|
||||
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_info_json(const char* mint_url, cJSON** out_json) {
|
||||
if (!out_json || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
const char* resolved = resolve_mint_url_locked(mint_url);
|
||||
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
if (!resolved) return -1;
|
||||
|
||||
cashu_mint_info_t info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
if (cashu_mint_get_info(resolved, &info, timeout) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* keysets = cJSON_CreateArray();
|
||||
if (!root || !keysets) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(keysets);
|
||||
cashu_mint_free_info(&info);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddStringToObject(root, "name", info.name ? info.name : "");
|
||||
cJSON_AddStringToObject(root, "pubkey", info.pubkey ? info.pubkey : "");
|
||||
cJSON_AddStringToObject(root, "version", info.version ? info.version : "");
|
||||
|
||||
for (int i = 0; i < info.keyset_count; i++) {
|
||||
cJSON* ks = cJSON_CreateObject();
|
||||
if (!ks) continue;
|
||||
cJSON_AddStringToObject(ks, "id", info.keysets[i].id);
|
||||
cJSON_AddStringToObject(ks, "unit", info.keysets[i].unit);
|
||||
cJSON_AddBoolToObject(ks, "active", info.keysets[i].active ? 1 : 0);
|
||||
cJSON_AddItemToArray(keysets, ks);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "keysets", keysets);
|
||||
*out_json = root;
|
||||
|
||||
cashu_mint_free_info(&info);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_mint_quote_json(const char* mint_url, uint64_t amount, const char* unit, cJSON** out_json) {
|
||||
if (!out_json || amount == 0 || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
const char* resolved = resolve_mint_url_locked(mint_url);
|
||||
const char* resolved_unit = (unit && unit[0] != '\0') ? unit :
|
||||
(g_wallet.cfg->cashu_wallet.unit[0] ? g_wallet.cfg->cashu_wallet.unit : "sat");
|
||||
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
if (!resolved) return -1;
|
||||
|
||||
cashu_mint_quote_t quote;
|
||||
memset("e, 0, sizeof(quote));
|
||||
if (cashu_mint_request_mint_quote(resolved, amount, resolved_unit, "e, timeout) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return -1;
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddStringToObject(root, "unit", resolved_unit);
|
||||
cJSON_AddStringToObject(root, "quote_id", quote.quote_id);
|
||||
cJSON_AddStringToObject(root, "payment_request", quote.payment_request);
|
||||
cJSON_AddNumberToObject(root, "amount", (double)quote.amount);
|
||||
cJSON_AddBoolToObject(root, "paid", quote.paid ? 1 : 0);
|
||||
cJSON_AddNumberToObject(root, "expiry", (double)quote.expiry);
|
||||
|
||||
*out_json = root;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_mint_check_json(const char* mint_url, const char* quote_id, cJSON** out_json) {
|
||||
if (!out_json || !quote_id || quote_id[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
const char* resolved = resolve_mint_url_locked(mint_url);
|
||||
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
if (!resolved) return -1;
|
||||
|
||||
cashu_mint_quote_t quote;
|
||||
memset("e, 0, sizeof(quote));
|
||||
if (cashu_mint_check_mint_quote(resolved, quote_id, "e, timeout) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return -1;
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddStringToObject(root, "quote_id", quote.quote_id);
|
||||
cJSON_AddBoolToObject(root, "paid", quote.paid ? 1 : 0);
|
||||
cJSON_AddNumberToObject(root, "amount", (double)quote.amount);
|
||||
cJSON_AddStringToObject(root, "payment_request", quote.payment_request);
|
||||
cJSON_AddNumberToObject(root, "expiry", (double)quote.expiry);
|
||||
|
||||
*out_json = root;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_mint_claim_json(const char* mint_url, const char* quote_id, uint64_t amount, cJSON** out_json) {
|
||||
if (!out_json || !quote_id || quote_id[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
const char* resolved = resolve_mint_url_locked(mint_url);
|
||||
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
|
||||
const char* unit = g_wallet.cfg->cashu_wallet.unit[0] ? g_wallet.cfg->cashu_wallet.unit : "sat";
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
if (!resolved) return -1;
|
||||
|
||||
cashu_mint_quote_t quote;
|
||||
memset("e, 0, sizeof(quote));
|
||||
if (cashu_mint_check_mint_quote(resolved, quote_id, "e, timeout) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (!quote.paid) {
|
||||
return NOSTR_ERROR_CASHU_QUOTE_NOT_PAID;
|
||||
}
|
||||
|
||||
uint64_t claim_amount = amount > 0 ? amount : quote.amount;
|
||||
if (claim_amount == 0) return -1;
|
||||
|
||||
cashu_mint_info_t info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
if (cashu_mint_get_info(resolved, &info, timeout) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cashu_keyset_t keyset;
|
||||
memset(&keyset, 0, sizeof(keyset));
|
||||
if (cashu_mint_get_active_keyset(&info, unit, &keyset) != 0) {
|
||||
cashu_mint_free_info(&info);
|
||||
return -1;
|
||||
}
|
||||
cashu_mint_free_info(&info);
|
||||
|
||||
uint64_t parts[CASHU_WALLET_MAX_SPLIT_PARTS] = {0};
|
||||
int part_count = 0;
|
||||
if (cashu_mint_plan_split_amounts(claim_amount, parts, CASHU_WALLET_MAX_SPLIT_PARTS, &part_count) != 0 || part_count <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cashu_blinded_output_t* outputs = (cashu_blinded_output_t*)calloc((size_t)part_count, sizeof(cashu_blinded_output_t));
|
||||
if (!outputs) return -1;
|
||||
for (int i = 0; i < part_count; i++) {
|
||||
snprintf(outputs[i].id, sizeof(outputs[i].id), "%s", keyset.id);
|
||||
outputs[i].amount = parts[i];
|
||||
snprintf(outputs[i].B_, sizeof(outputs[i].B_), "placeholder_%d", i + 1);
|
||||
}
|
||||
|
||||
cashu_mint_tokens_request_t req;
|
||||
memset(&req, 0, sizeof(req));
|
||||
snprintf(req.quote_id, sizeof(req.quote_id), "%s", quote_id);
|
||||
snprintf(req.unit, sizeof(req.unit), "%s", unit);
|
||||
req.outputs = outputs;
|
||||
req.output_count = part_count;
|
||||
|
||||
cJSON* request_body = NULL;
|
||||
int rc = cashu_build_mint_tokens_request(&req, &request_body);
|
||||
free(outputs);
|
||||
if (rc != 0 || !request_body) {
|
||||
cJSON_Delete(request_body);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* response_body = NULL;
|
||||
rc = cashu_mint_mint_tokens(resolved, request_body, &response_body, timeout);
|
||||
cJSON_Delete(request_body);
|
||||
if (rc != 0 || !response_body) {
|
||||
cJSON_Delete(response_body);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cashu_mint_tokens_response_t parsed;
|
||||
memset(&parsed, 0, sizeof(parsed));
|
||||
rc = cashu_parse_mint_tokens_response(response_body, &parsed);
|
||||
cJSON_Delete(response_body);
|
||||
if (rc != 0 || parsed.signature_count <= 0) {
|
||||
cashu_mint_free_mint_tokens_response(&parsed);
|
||||
return -1;
|
||||
}
|
||||
|
||||
nostr_nip60_token_data_t token;
|
||||
memset(&token, 0, sizeof(token));
|
||||
token.mint_url = strdup(resolved);
|
||||
token.proof_count = parsed.signature_count;
|
||||
token.proofs = (nostr_cashu_proof_t*)calloc((size_t)token.proof_count, sizeof(nostr_cashu_proof_t));
|
||||
if (!token.mint_url || !token.proofs) {
|
||||
nostr_nip60_free_token_data(&token);
|
||||
cashu_mint_free_mint_tokens_response(&parsed);
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < parsed.signature_count; i++) {
|
||||
snprintf(token.proofs[i].id, sizeof(token.proofs[i].id), "%s", parsed.signatures[i].id);
|
||||
token.proofs[i].amount = parsed.signatures[i].amount;
|
||||
token.proofs[i].secret = strdup(parsed.signatures[i].C_);
|
||||
token.proofs[i].C = strdup(parsed.signatures[i].C_);
|
||||
if (!token.proofs[i].secret || !token.proofs[i].C) {
|
||||
nostr_nip60_free_token_data(&token);
|
||||
cashu_mint_free_mint_tokens_response(&parsed);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
cashu_mint_free_mint_tokens_response(&parsed);
|
||||
|
||||
char token_event_id[65] = {0};
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
rc = publish_token_event_and_store_locked(&token, token_event_id);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
if (rc != 0) {
|
||||
nostr_nip60_free_token_data(&token);
|
||||
return -1;
|
||||
}
|
||||
|
||||
(void)publish_history_event(NOSTR_NIP60_DIRECTION_IN,
|
||||
claim_amount,
|
||||
token_event_id,
|
||||
NOSTR_NIP60_REF_CREATED);
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return -1;
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddStringToObject(root, "quote_id", quote_id);
|
||||
cJSON_AddNumberToObject(root, "claimed_amount", (double)claim_amount);
|
||||
cJSON_AddStringToObject(root, "token_event_id", token_event_id);
|
||||
|
||||
*out_json = root;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_melt_quote_json(const char* mint_url, const char* payment_request, const char* unit, cJSON** out_json) {
|
||||
if (!out_json || !payment_request || payment_request[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
const char* resolved = resolve_mint_url_locked(mint_url);
|
||||
const char* resolved_unit = (unit && unit[0] != '\0') ? unit :
|
||||
(g_wallet.cfg->cashu_wallet.unit[0] ? g_wallet.cfg->cashu_wallet.unit : "sat");
|
||||
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
if (!resolved) return -1;
|
||||
|
||||
cashu_melt_quote_t quote;
|
||||
memset("e, 0, sizeof(quote));
|
||||
if (cashu_mint_request_melt_quote(resolved, payment_request, resolved_unit, "e, timeout) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return -1;
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddStringToObject(root, "quote_id", quote.quote_id);
|
||||
cJSON_AddNumberToObject(root, "amount", (double)quote.amount);
|
||||
cJSON_AddNumberToObject(root, "fee_reserve", (double)quote.fee_reserve);
|
||||
cJSON_AddBoolToObject(root, "paid", quote.paid ? 1 : 0);
|
||||
cJSON_AddNumberToObject(root, "expiry", (double)quote.expiry);
|
||||
|
||||
*out_json = root;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_melt_pay_json(const char* mint_url, const char* quote_id, cJSON** out_json) {
|
||||
if (!out_json || !quote_id || quote_id[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
const char* resolved = resolve_mint_url_locked(mint_url);
|
||||
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
if (!resolved) return -1;
|
||||
|
||||
cashu_melt_quote_t quote;
|
||||
memset("e, 0, sizeof(quote));
|
||||
if (cashu_mint_check_melt_quote(resolved, quote_id, "e, timeout) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint64_t required = quote.amount + quote.fee_reserve;
|
||||
if (required == 0) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
|
||||
int proof_count = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
|
||||
proof_count += g_wallet.tokens[i].token.proof_count;
|
||||
}
|
||||
|
||||
if (proof_count <= 0) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return NOSTR_ERROR_NIP60_INSUFFICIENT_FUNDS;
|
||||
}
|
||||
|
||||
nostr_cashu_proof_t* flattened = (nostr_cashu_proof_t*)calloc((size_t)proof_count, sizeof(nostr_cashu_proof_t));
|
||||
int* proof_to_token = (int*)calloc((size_t)proof_count, sizeof(int));
|
||||
if (!flattened || !proof_to_token) {
|
||||
free(flattened);
|
||||
free(proof_to_token);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int idx = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
|
||||
for (int j = 0; j < g_wallet.tokens[i].token.proof_count; j++) {
|
||||
flattened[idx] = g_wallet.tokens[i].token.proofs[j];
|
||||
proof_to_token[idx] = i;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
int* selected = (int*)calloc((size_t)proof_count, sizeof(int));
|
||||
uint64_t selected_total = 0;
|
||||
int selected_count = cashu_mint_select_proofs_for_amount(flattened,
|
||||
proof_count,
|
||||
required,
|
||||
selected,
|
||||
proof_count,
|
||||
&selected_total);
|
||||
if (selected_count <= 0) {
|
||||
free(selected);
|
||||
free(proof_to_token);
|
||||
free(flattened);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return NOSTR_ERROR_NIP60_INSUFFICIENT_FUNDS;
|
||||
}
|
||||
|
||||
nostr_cashu_proof_t* melt_inputs = (nostr_cashu_proof_t*)calloc((size_t)selected_count, sizeof(nostr_cashu_proof_t));
|
||||
int* used_token_flags = (int*)calloc((size_t)g_wallet.token_count, sizeof(int));
|
||||
if (!melt_inputs || !used_token_flags) {
|
||||
free(melt_inputs);
|
||||
free(used_token_flags);
|
||||
free(selected);
|
||||
free(proof_to_token);
|
||||
free(flattened);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < selected_count; i++) {
|
||||
int pidx = selected[i];
|
||||
melt_inputs[i] = flattened[pidx];
|
||||
used_token_flags[proof_to_token[pidx]] = 1;
|
||||
}
|
||||
|
||||
free(selected);
|
||||
free(proof_to_token);
|
||||
free(flattened);
|
||||
|
||||
cashu_melt_tokens_request_t req;
|
||||
memset(&req, 0, sizeof(req));
|
||||
snprintf(req.quote_id, sizeof(req.quote_id), "%s", quote_id);
|
||||
req.inputs = melt_inputs;
|
||||
req.input_count = selected_count;
|
||||
|
||||
cJSON* request_body = NULL;
|
||||
int rc = cashu_build_melt_tokens_request(&req, &request_body);
|
||||
free(melt_inputs);
|
||||
if (rc != 0 || !request_body) {
|
||||
free(used_token_flags);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* response_body = NULL;
|
||||
rc = cashu_mint_melt_tokens(resolved, request_body, &response_body, timeout);
|
||||
cJSON_Delete(request_body);
|
||||
if (rc != 0 || !response_body) {
|
||||
free(used_token_flags);
|
||||
cJSON_Delete(response_body);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cashu_melt_tokens_response_t melt;
|
||||
memset(&melt, 0, sizeof(melt));
|
||||
rc = cashu_parse_melt_tokens_response(response_body, &melt);
|
||||
cJSON_Delete(response_body);
|
||||
if (rc != 0) {
|
||||
free(used_token_flags);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int deleted_count = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (!used_token_flags[i]) continue;
|
||||
if (g_wallet.tokens[i].event_id[0] != '\0') {
|
||||
(void)publish_token_deletion_event(g_wallet.tokens[i].event_id);
|
||||
deleted_count++;
|
||||
}
|
||||
nostr_nip60_free_token_data(&g_wallet.tokens[i].token);
|
||||
memset(&g_wallet.tokens[i], 0, sizeof(g_wallet.tokens[i]));
|
||||
}
|
||||
|
||||
int compact = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (g_wallet.tokens[i].event_id[0] == '\0' && !g_wallet.tokens[i].token.mint_url) continue;
|
||||
if (compact != i) g_wallet.tokens[compact] = g_wallet.tokens[i];
|
||||
compact++;
|
||||
}
|
||||
g_wallet.token_count = compact;
|
||||
if (g_wallet.token_count == 0) {
|
||||
free(g_wallet.tokens);
|
||||
g_wallet.tokens = NULL;
|
||||
}
|
||||
|
||||
uint64_t remaining = 0;
|
||||
token_proofs_total_for_mint_locked(resolved, &remaining);
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
free(used_token_flags);
|
||||
cashu_mint_free_melt_tokens_response(&melt);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddStringToObject(root, "quote_id", quote_id);
|
||||
cJSON_AddBoolToObject(root, "paid", melt.paid ? 1 : 0);
|
||||
cJSON_AddNumberToObject(root, "deleted_token_events", deleted_count);
|
||||
cJSON_AddNumberToObject(root, "remaining_balance", (double)remaining);
|
||||
if (melt.payment_preimage && melt.payment_preimage[0] != '\0') {
|
||||
cJSON_AddStringToObject(root, "payment_preimage", melt.payment_preimage);
|
||||
}
|
||||
|
||||
*out_json = root;
|
||||
|
||||
(void)publish_history_event(NOSTR_NIP60_DIRECTION_OUT,
|
||||
required,
|
||||
NULL,
|
||||
NOSTR_NIP60_REF_DESTROYED);
|
||||
|
||||
free(used_token_flags);
|
||||
cashu_mint_free_melt_tokens_response(&melt);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cashu_wallet_check_proofs_json(const char* mint_url, cJSON** out_json) {
|
||||
if (!out_json || !g_wallet.initialized || !g_wallet.cfg) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
const char* resolved = resolve_mint_url_locked(mint_url);
|
||||
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
if (!resolved) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
int target_proofs = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
|
||||
target_proofs += g_wallet.tokens[i].token.proof_count;
|
||||
}
|
||||
|
||||
if (target_proofs <= 0) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddNumberToObject(root, "proof_count", 0);
|
||||
cJSON_AddNumberToObject(root, "spent_count", 0);
|
||||
cJSON_AddNumberToObject(root, "unknown_count", 0);
|
||||
*out_json = root;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char** ys = (const char**)calloc((size_t)target_proofs, sizeof(char*));
|
||||
if (!ys) {
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int y_idx = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
|
||||
for (int j = 0; j < g_wallet.tokens[i].token.proof_count; j++) {
|
||||
ys[y_idx++] = g_wallet.tokens[i].token.proofs[j].C ? g_wallet.tokens[i].token.proofs[j].C : "";
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
cJSON* request_body = NULL;
|
||||
if (cashu_build_checkstate_request(ys, y_idx, &request_body) != 0 || !request_body) {
|
||||
free(ys);
|
||||
cJSON_Delete(request_body);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* response_body = NULL;
|
||||
int rc = cashu_mint_check_proofs_state(resolved, request_body, &response_body, timeout);
|
||||
cJSON_Delete(request_body);
|
||||
free(ys);
|
||||
if (rc != 0 || !response_body) {
|
||||
cJSON_Delete(response_body);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cashu_checkstate_response_t parsed;
|
||||
memset(&parsed, 0, sizeof(parsed));
|
||||
rc = cashu_parse_checkstate_response(response_body, &parsed);
|
||||
cJSON_Delete(response_body);
|
||||
if (rc != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int spent_count = 0;
|
||||
int unknown_count = 0;
|
||||
for (int i = 0; i < parsed.state_count; i++) {
|
||||
if (strcasecmp(parsed.states[i].state, "SPENT") == 0) {
|
||||
spent_count++;
|
||||
} else if (strcasecmp(parsed.states[i].state, "UNSPENT") != 0) {
|
||||
unknown_count++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
cashu_mint_free_checkstate_response(&parsed);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "mint", resolved);
|
||||
cJSON_AddNumberToObject(root, "proof_count", y_idx);
|
||||
cJSON_AddNumberToObject(root, "spent_count", spent_count);
|
||||
cJSON_AddNumberToObject(root, "unknown_count", unknown_count);
|
||||
|
||||
*out_json = root;
|
||||
|
||||
cashu_mint_free_checkstate_response(&parsed);
|
||||
return 0;
|
||||
}
|
||||
24
src/cashu_wallet.h
Normal file
24
src/cashu_wallet.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#ifndef DIDACTYL_CASHU_WALLET_H
|
||||
#define DIDACTYL_CASHU_WALLET_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
int cashu_wallet_init(didactyl_config_t* cfg);
|
||||
void cashu_wallet_cleanup(void);
|
||||
|
||||
int cashu_wallet_load_from_relays(void);
|
||||
int cashu_wallet_create_new_from_config(void);
|
||||
|
||||
int cashu_wallet_balance_json(cJSON** out_json);
|
||||
int cashu_wallet_info_json(const char* mint_url, cJSON** out_json);
|
||||
int cashu_wallet_mint_quote_json(const char* mint_url, uint64_t amount, const char* unit, cJSON** out_json);
|
||||
int cashu_wallet_mint_check_json(const char* mint_url, const char* quote_id, cJSON** out_json);
|
||||
int cashu_wallet_mint_claim_json(const char* mint_url, const char* quote_id, uint64_t amount, cJSON** out_json);
|
||||
int cashu_wallet_melt_quote_json(const char* mint_url, const char* payment_request, const char* unit, cJSON** out_json);
|
||||
int cashu_wallet_melt_pay_json(const char* mint_url, const char* quote_id, cJSON** out_json);
|
||||
int cashu_wallet_check_proofs_json(const char* mint_url, cJSON** out_json);
|
||||
|
||||
#endif
|
||||
738
src/config.c
738
src/config.c
@@ -12,6 +12,88 @@
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* jsonc_strip_comments – strip JSONC comments from a buffer
|
||||
*
|
||||
* Returns a newly malloc'd string containing valid JSON (caller must free).
|
||||
* The function is careful to skip over JSON string literals so that comment
|
||||
* characters inside strings are preserved. Handles:
|
||||
* - single-line comments // (to end of line)
|
||||
* - block comments (slash-star ... star-slash, may span lines)
|
||||
* - escaped quotes inside strings \"
|
||||
* - trailing commas before ] or } are NOT removed (cJSON tolerates them)
|
||||
*
|
||||
* Returns NULL on allocation failure.
|
||||
* ------------------------------------------------------------------------ */
|
||||
char* jsonc_strip_comments(const char* src, size_t src_len) {
|
||||
if (!src || src_len == 0) {
|
||||
char* empty = (char*)malloc(1);
|
||||
if (empty) empty[0] = '\0';
|
||||
return empty;
|
||||
}
|
||||
|
||||
char* out = (char*)malloc(src_len + 1);
|
||||
if (!out) return NULL;
|
||||
|
||||
size_t o = 0;
|
||||
size_t i = 0;
|
||||
|
||||
while (i < src_len) {
|
||||
/* Inside a JSON string literal — copy verbatim until closing quote */
|
||||
if (src[i] == '"') {
|
||||
out[o++] = src[i++]; /* opening quote */
|
||||
while (i < src_len) {
|
||||
if (src[i] == '\\' && i + 1 < src_len) {
|
||||
out[o++] = src[i++]; /* backslash */
|
||||
out[o++] = src[i++]; /* escaped char */
|
||||
} else if (src[i] == '"') {
|
||||
out[o++] = src[i++]; /* closing quote */
|
||||
break;
|
||||
} else {
|
||||
out[o++] = src[i++];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Single-line comment // */
|
||||
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '/') {
|
||||
/* skip to end of line */
|
||||
i += 2;
|
||||
while (i < src_len && src[i] != '\n') {
|
||||
i++;
|
||||
}
|
||||
/* preserve the newline so line numbers stay meaningful */
|
||||
if (i < src_len) {
|
||||
out[o++] = src[i++]; /* the \n */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Block comment (slash-star ... star-slash) */
|
||||
if (src[i] == '/' && i + 1 < src_len && src[i + 1] == '*') {
|
||||
i += 2;
|
||||
while (i + 1 < src_len && !(src[i] == '*' && src[i + 1] == '/')) {
|
||||
/* preserve newlines inside block comments */
|
||||
if (src[i] == '\n') {
|
||||
out[o++] = '\n';
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i + 1 < src_len) {
|
||||
i += 2; /* skip closing */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Normal character — copy through */
|
||||
out[o++] = src[i++];
|
||||
}
|
||||
|
||||
out[o] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
static char g_config_last_error[512] = {0};
|
||||
|
||||
static void config_set_error(const char* fmt, ...) {
|
||||
@@ -140,12 +222,40 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(tools, "enabled");
|
||||
cJSON* max_turns = cJSON_GetObjectItemCaseSensitive(tools, "max_turns");
|
||||
cJSON* trigger_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "trigger_max_turns");
|
||||
cJSON* api_default_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "api_default_max_turns");
|
||||
cJSON* api_max_turns_ceiling = cJSON_GetObjectItemCaseSensitive(tools, "api_max_turns_ceiling");
|
||||
cJSON* stall_repeat_threshold = cJSON_GetObjectItemCaseSensitive(tools, "stall_repeat_threshold");
|
||||
cJSON* local_http_fetch_default_timeout_seconds =
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds");
|
||||
cJSON* local_http_fetch_max_timeout_seconds =
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_max_timeout_seconds");
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->tools.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
if (max_turns && cJSON_IsNumber(max_turns)) {
|
||||
config->tools.max_turns = (int)max_turns->valuedouble;
|
||||
}
|
||||
if (trigger_max_turns && cJSON_IsNumber(trigger_max_turns)) {
|
||||
config->tools.trigger_max_turns = (int)trigger_max_turns->valuedouble;
|
||||
}
|
||||
if (api_default_max_turns && cJSON_IsNumber(api_default_max_turns)) {
|
||||
config->tools.api_default_max_turns = (int)api_default_max_turns->valuedouble;
|
||||
}
|
||||
if (api_max_turns_ceiling && cJSON_IsNumber(api_max_turns_ceiling)) {
|
||||
config->tools.api_max_turns_ceiling = (int)api_max_turns_ceiling->valuedouble;
|
||||
}
|
||||
if (stall_repeat_threshold && cJSON_IsNumber(stall_repeat_threshold)) {
|
||||
config->tools.stall_repeat_threshold = (int)stall_repeat_threshold->valuedouble;
|
||||
}
|
||||
if (local_http_fetch_default_timeout_seconds && cJSON_IsNumber(local_http_fetch_default_timeout_seconds)) {
|
||||
config->tools.local_http_fetch_default_timeout_seconds =
|
||||
(int)local_http_fetch_default_timeout_seconds->valuedouble;
|
||||
}
|
||||
if (local_http_fetch_max_timeout_seconds && cJSON_IsNumber(local_http_fetch_max_timeout_seconds)) {
|
||||
config->tools.local_http_fetch_max_timeout_seconds =
|
||||
(int)local_http_fetch_max_timeout_seconds->valuedouble;
|
||||
}
|
||||
|
||||
cJSON* shell = cJSON_GetObjectItemCaseSensitive(tools, "shell");
|
||||
if (!shell || !cJSON_IsObject(shell)) {
|
||||
@@ -173,6 +283,34 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (config->tools.max_turns < 1) {
|
||||
config->tools.max_turns = 8;
|
||||
}
|
||||
if (config->tools.trigger_max_turns < 1) {
|
||||
config->tools.trigger_max_turns = config->tools.max_turns;
|
||||
}
|
||||
if (config->tools.api_default_max_turns < 1) {
|
||||
config->tools.api_default_max_turns = config->tools.max_turns;
|
||||
}
|
||||
if (config->tools.api_max_turns_ceiling < 1) {
|
||||
config->tools.api_max_turns_ceiling = 16;
|
||||
}
|
||||
if (config->tools.api_default_max_turns > config->tools.api_max_turns_ceiling) {
|
||||
config->tools.api_default_max_turns = config->tools.api_max_turns_ceiling;
|
||||
}
|
||||
if (config->tools.stall_repeat_threshold < 2) {
|
||||
config->tools.stall_repeat_threshold = 3;
|
||||
}
|
||||
if (config->tools.local_http_fetch_default_timeout_seconds < 1) {
|
||||
config->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
}
|
||||
if (config->tools.local_http_fetch_max_timeout_seconds < 1) {
|
||||
config->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
}
|
||||
if (config->tools.local_http_fetch_default_timeout_seconds > config->tools.local_http_fetch_max_timeout_seconds) {
|
||||
config->tools.local_http_fetch_default_timeout_seconds = config->tools.local_http_fetch_max_timeout_seconds;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -315,6 +453,158 @@ 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 void free_cashu_wallet_mints(cashu_wallet_config_t* wallet) {
|
||||
if (!wallet || !wallet->mint_urls) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < wallet->mint_count; i++) {
|
||||
free(wallet->mint_urls[i]);
|
||||
}
|
||||
free(wallet->mint_urls);
|
||||
wallet->mint_urls = NULL;
|
||||
wallet->mint_count = 0;
|
||||
}
|
||||
|
||||
static int parse_cashu_wallet_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* wallet = cJSON_GetObjectItemCaseSensitive(root, "cashu_wallet");
|
||||
if (!wallet || !cJSON_IsObject(wallet)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(wallet, "enabled");
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->cashu_wallet.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
|
||||
cJSON* auto_load = cJSON_GetObjectItemCaseSensitive(wallet, "auto_load");
|
||||
if (auto_load && cJSON_IsBool(auto_load)) {
|
||||
config->cashu_wallet.auto_load = cJSON_IsTrue(auto_load) ? 1 : 0;
|
||||
}
|
||||
|
||||
cJSON* timeout = cJSON_GetObjectItemCaseSensitive(wallet, "mint_timeout_seconds");
|
||||
if (timeout && cJSON_IsNumber(timeout)) {
|
||||
config->cashu_wallet.mint_timeout_seconds = (int)timeout->valuedouble;
|
||||
}
|
||||
|
||||
if (copy_json_string(wallet,
|
||||
"unit",
|
||||
config->cashu_wallet.unit,
|
||||
sizeof(config->cashu_wallet.unit),
|
||||
0) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* mints = cJSON_GetObjectItemCaseSensitive(wallet, "mints");
|
||||
if (mints) {
|
||||
if (!cJSON_IsArray(mints)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int count = cJSON_GetArraySize(mints);
|
||||
char** urls = NULL;
|
||||
if (count > 0) {
|
||||
urls = (char**)calloc((size_t)count, sizeof(char*));
|
||||
if (!urls) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON* item = cJSON_GetArrayItem(mints, i);
|
||||
if (!item || !cJSON_IsString(item) || !item->valuestring || item->valuestring[0] == '\0') {
|
||||
for (int j = 0; j < i; j++) {
|
||||
free(urls[j]);
|
||||
}
|
||||
free(urls);
|
||||
return -1;
|
||||
}
|
||||
urls[i] = strdup(item->valuestring);
|
||||
if (!urls[i]) {
|
||||
for (int j = 0; j < i; j++) {
|
||||
free(urls[j]);
|
||||
}
|
||||
free(urls);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
free_cashu_wallet_mints(&config->cashu_wallet);
|
||||
config->cashu_wallet.mint_urls = urls;
|
||||
config->cashu_wallet.mint_count = count;
|
||||
}
|
||||
|
||||
if (config->cashu_wallet.unit[0] == '\0') {
|
||||
snprintf(config->cashu_wallet.unit, sizeof(config->cashu_wallet.unit), "%s", "sat");
|
||||
}
|
||||
if (config->cashu_wallet.mint_timeout_seconds < 1) {
|
||||
config->cashu_wallet.mint_timeout_seconds = 30;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
|
||||
return NULL;
|
||||
@@ -405,27 +695,108 @@ static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char* slug = NULL;
|
||||
cJSON* slug_val = find_tag_value_string(tags, "slug");
|
||||
if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') {
|
||||
slug = slug_val->valuestring;
|
||||
}
|
||||
const char* d_tag = NULL;
|
||||
|
||||
if (!slug) {
|
||||
if (!d_tag) {
|
||||
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
|
||||
if (content_fields && cJSON_IsObject(content_fields)) {
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name");
|
||||
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
|
||||
slug = name->valuestring;
|
||||
d_tag = name->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
if (!d_tag) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return set_tag_value_string(tags, "d", slug);
|
||||
return set_tag_value_string(tags, "d", d_tag);
|
||||
}
|
||||
|
||||
static int parse_default_skill_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (!root || !config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* def = cJSON_GetObjectItemCaseSensitive(root, "default_skill");
|
||||
if (!def || !cJSON_IsObject(def)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(def, "kind");
|
||||
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(def, "d_tag");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(def, "content");
|
||||
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(def, "content_fields");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(def, "tags");
|
||||
|
||||
if (!kind || !cJSON_IsNumber(kind)) {
|
||||
return -1;
|
||||
}
|
||||
int kind_i = (int)kind->valuedouble;
|
||||
if (kind_i != 31123 && kind_i != 31124) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || d_tag->valuestring[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
if (strlen(d_tag->valuestring) >= sizeof(config->default_skill.d_tag)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* content_text = NULL;
|
||||
if (content_fields) {
|
||||
if (!cJSON_IsObject(content_fields)) {
|
||||
return -1;
|
||||
}
|
||||
content_text = cJSON_PrintUnformatted(content_fields);
|
||||
} else {
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring) {
|
||||
return -1;
|
||||
}
|
||||
content_text = strdup(content->valuestring);
|
||||
}
|
||||
if (!content_text) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* tags_work = NULL;
|
||||
if (tags) {
|
||||
if (!cJSON_IsArray(tags)) {
|
||||
free(content_text);
|
||||
return -1;
|
||||
}
|
||||
tags_work = cJSON_Duplicate(tags, 1);
|
||||
} else {
|
||||
tags_work = cJSON_CreateArray();
|
||||
}
|
||||
if (!tags_work || !cJSON_IsArray(tags_work)) {
|
||||
cJSON_Delete(tags_work);
|
||||
free(content_text);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (set_tag_value_string(tags_work, "d", d_tag->valuestring) != 0) {
|
||||
cJSON_Delete(tags_work);
|
||||
free(content_text);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* tags_json = cJSON_PrintUnformatted(tags_work);
|
||||
cJSON_Delete(tags_work);
|
||||
if (!tags_json) {
|
||||
free(content_text);
|
||||
return -1;
|
||||
}
|
||||
|
||||
free(config->default_skill.content);
|
||||
free(config->default_skill.tags_json);
|
||||
config->default_skill.kind = kind_i;
|
||||
snprintf(config->default_skill.d_tag, sizeof(config->default_skill.d_tag), "%s", d_tag->valuestring);
|
||||
config->default_skill.content = content_text;
|
||||
config->default_skill.tags_json = tags_json;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
|
||||
@@ -565,9 +936,251 @@ static int parse_relays_from_startup_kind10002(didactyl_config_t* config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int set_default_bootstrap_relays(didactyl_config_t* config) {
|
||||
if (!config) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
static const char* defaults[] = {
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://nos.lol"
|
||||
};
|
||||
|
||||
const int relay_count = (int)(sizeof(defaults) / sizeof(defaults[0]));
|
||||
char** relays = (char**)calloc((size_t)relay_count, sizeof(char*));
|
||||
if (!relays) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < relay_count; i++) {
|
||||
relays[i] = strdup(defaults[i]);
|
||||
if (!relays[i]) {
|
||||
for (int j = 0; j < i; j++) {
|
||||
free(relays[j]);
|
||||
}
|
||||
free(relays);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (config->relays) {
|
||||
for (int i = 0; i < config->relay_count; i++) {
|
||||
free(config->relays[i]);
|
||||
}
|
||||
free(config->relays);
|
||||
}
|
||||
|
||||
config->relays = relays;
|
||||
config->relay_count = relay_count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_relays(cJSON* root, didactyl_config_t* config) {
|
||||
(void)root;
|
||||
return parse_relays_from_startup_kind10002(config);
|
||||
if (parse_relays_from_startup_kind10002(config) == 0) {
|
||||
return 0;
|
||||
}
|
||||
return set_default_bootstrap_relays(config);
|
||||
}
|
||||
|
||||
static int startup_event_has_d_tag(const startup_event_t* se, const char* d_tag) {
|
||||
if (!se || !se->tags_json || !d_tag || d_tag[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_Parse(se->tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int found = 0;
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
|
||||
cJSON* k = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* v = cJSON_GetArrayItem(tag, 1);
|
||||
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
|
||||
if (strcmp(k->valuestring, "d") == 0 && strcmp(v->valuestring, d_tag) == 0) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(tags);
|
||||
return found;
|
||||
}
|
||||
|
||||
static int adoption_tags_has_addr(cJSON* tags, const char* addr) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !addr || addr[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
|
||||
cJSON* k = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* v = cJSON_GetArrayItem(tag, 1);
|
||||
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
|
||||
if (strcmp(k->valuestring, "a") == 0 && strcmp(v->valuestring, addr) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_adoption_addr_tag(cJSON* tags, const char* addr) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !addr || addr[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* tag = cJSON_CreateArray();
|
||||
if (!tag) return -1;
|
||||
cJSON_AddItemToArray(tag, cJSON_CreateString("a"));
|
||||
cJSON_AddItemToArray(tag, cJSON_CreateString(addr));
|
||||
cJSON_AddItemToArray(tags, tag);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_startup_event(didactyl_config_t* config, int kind, const char* content, const char* tags_json) {
|
||||
if (!config || !content) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int old_count = config->startup_event_count;
|
||||
startup_event_t* grown = (startup_event_t*)realloc(config->startup_events, (size_t)(old_count + 1) * sizeof(startup_event_t));
|
||||
if (!grown) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->startup_events = grown;
|
||||
memset(&config->startup_events[old_count], 0, sizeof(startup_event_t));
|
||||
config->startup_events[old_count].kind = kind;
|
||||
config->startup_events[old_count].content = strdup(content);
|
||||
if (!config->startup_events[old_count].content) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (tags_json && tags_json[0] != '\0') {
|
||||
config->startup_events[old_count].tags_json = strdup(tags_json);
|
||||
if (!config->startup_events[old_count].tags_json) {
|
||||
free(config->startup_events[old_count].content);
|
||||
config->startup_events[old_count].content = NULL;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
config->startup_event_count = old_count + 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int config_ensure_default_skill_startup_events(didactyl_config_t* config) {
|
||||
if (!config || !config->default_skill.content || config->default_skill.content[0] == '\0' ||
|
||||
config->default_skill.d_tag[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int has_default_skill_event = 0;
|
||||
for (int i = 0; i < config->startup_event_count; i++) {
|
||||
startup_event_t* se = &config->startup_events[i];
|
||||
if (se->kind == config->default_skill.kind && startup_event_has_d_tag(se, config->default_skill.d_tag)) {
|
||||
has_default_skill_event = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!has_default_skill_event) {
|
||||
if (append_startup_event(config,
|
||||
config->default_skill.kind,
|
||||
config->default_skill.content,
|
||||
config->default_skill.tags_json ? config->default_skill.tags_json : "[]") != 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (config->keys.public_key_hex[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char addr[256];
|
||||
snprintf(addr,
|
||||
sizeof(addr),
|
||||
"%d:%s:%s",
|
||||
config->default_skill.kind,
|
||||
config->keys.public_key_hex,
|
||||
config->default_skill.d_tag);
|
||||
|
||||
for (int i = 0; i < config->startup_event_count; i++) {
|
||||
startup_event_t* se = &config->startup_events[i];
|
||||
if (se->kind != 10123 || !se->tags_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_Parse(se->tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (adoption_tags_has_addr(tags, addr)) {
|
||||
cJSON_Delete(tags);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (append_adoption_addr_tag(tags, addr) != 0) {
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* updated = cJSON_PrintUnformatted(tags);
|
||||
cJSON_Delete(tags);
|
||||
if (!updated) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
free(se->tags_json);
|
||||
se->tags_json = updated;
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
if (!tags) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (append_adoption_addr_tag(tags, addr) != 0) {
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* app = cJSON_CreateArray();
|
||||
cJSON* scope = cJSON_CreateArray();
|
||||
if (!app || !scope) {
|
||||
cJSON_Delete(app);
|
||||
cJSON_Delete(scope);
|
||||
cJSON_Delete(tags);
|
||||
return -1;
|
||||
}
|
||||
cJSON_AddItemToArray(app, cJSON_CreateString("app"));
|
||||
cJSON_AddItemToArray(app, cJSON_CreateString("didactyl"));
|
||||
cJSON_AddItemToArray(scope, cJSON_CreateString("scope"));
|
||||
cJSON_AddItemToArray(scope, cJSON_CreateString(config->default_skill.kind == 31124 ? "private" : "public"));
|
||||
cJSON_AddItemToArray(tags, app);
|
||||
cJSON_AddItemToArray(tags, scope);
|
||||
|
||||
char* tags_json = cJSON_PrintUnformatted(tags);
|
||||
cJSON_Delete(tags);
|
||||
if (!tags_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = append_startup_event(config, 10123, "", tags_json);
|
||||
free(tags_json);
|
||||
return rc;
|
||||
}
|
||||
|
||||
void config_free(didactyl_config_t* config) {
|
||||
@@ -590,6 +1203,11 @@ void config_free(didactyl_config_t* config) {
|
||||
free(config->startup_events);
|
||||
}
|
||||
|
||||
free(config->default_skill.content);
|
||||
free(config->default_skill.tags_json);
|
||||
|
||||
free_cashu_wallet_mints(&config->cashu_wallet);
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
}
|
||||
|
||||
@@ -603,8 +1221,16 @@ 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.max_turns = 20;
|
||||
config->tools.trigger_max_turns = 12;
|
||||
config->tools.api_default_max_turns = 8;
|
||||
config->tools.api_max_turns_ceiling = 32;
|
||||
config->tools.stall_repeat_threshold = 3;
|
||||
config->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
config->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
config->tools.shell.enabled = 1;
|
||||
config->tools.shell.timeout_seconds = 30;
|
||||
config->tools.shell.max_output_bytes = 65536;
|
||||
@@ -632,13 +1258,45 @@ 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;
|
||||
|
||||
char* json_buf = NULL;
|
||||
size_t json_len = 0;
|
||||
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
|
||||
config->api.enabled = 0;
|
||||
config->api.port = 8484;
|
||||
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.1");
|
||||
|
||||
config->default_skill.kind = 31124;
|
||||
config->default_skill.d_tag[0] = '\0';
|
||||
config->default_skill.content = NULL;
|
||||
config->default_skill.tags_json = NULL;
|
||||
|
||||
config->cashu_wallet.enabled = 0;
|
||||
config->cashu_wallet.mint_urls = NULL;
|
||||
config->cashu_wallet.mint_count = 0;
|
||||
snprintf(config->cashu_wallet.unit, sizeof(config->cashu_wallet.unit), "%s", "sat");
|
||||
config->cashu_wallet.auto_load = 1;
|
||||
config->cashu_wallet.mint_timeout_seconds = 30;
|
||||
|
||||
char* raw_buf = NULL;
|
||||
size_t raw_len = 0;
|
||||
if (read_file_to_buffer(path, &raw_buf, &raw_len) != 0) {
|
||||
if (errno == ENOENT) {
|
||||
if (set_default_bootstrap_relays(config) != 0) {
|
||||
config_set_error("failed to initialize default bootstrap relays");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
config_set_error("failed to read config file '%s': %s", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Strip JSONC comments before parsing */
|
||||
char* json_buf = jsonc_strip_comments(raw_buf, raw_len);
|
||||
free(raw_buf);
|
||||
if (!json_buf) {
|
||||
config_set_error("failed to allocate memory for JSONC comment stripping");
|
||||
return -1;
|
||||
}
|
||||
size_t json_len = strlen(json_buf);
|
||||
|
||||
cJSON* root = cJSON_ParseWithLength(json_buf, json_len);
|
||||
free(json_buf);
|
||||
|
||||
@@ -654,19 +1312,25 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
int rc = -1;
|
||||
|
||||
cJSON* keys = cJSON_GetObjectItemCaseSensitive(root, "keys");
|
||||
if (!keys || !cJSON_IsObject(keys)) {
|
||||
keys = cJSON_GetObjectItemCaseSensitive(root, "key");
|
||||
}
|
||||
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
|
||||
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
|
||||
|
||||
if (!keys || !cJSON_IsObject(keys) ||
|
||||
!admin || !cJSON_IsObject(admin) ||
|
||||
if (!admin || !cJSON_IsObject(admin) ||
|
||||
!llm || !cJSON_IsObject(llm)) {
|
||||
config_set_error("config must include object sections: keys, admin, llm");
|
||||
config_set_error("config must include object sections: admin, llm");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
|
||||
config_set_error("keys.nsec is required and must be a non-empty string");
|
||||
goto cleanup;
|
||||
if (!keys || !cJSON_IsObject(keys)) {
|
||||
config->keys.nsec[0] = '\0';
|
||||
} else {
|
||||
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 0) != 0) {
|
||||
config_set_error("keys.nsec/key.nsec must be a string when provided");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
char admin_key_raw[OW_MAX_KEY_LEN] = {0};
|
||||
@@ -684,8 +1348,13 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_default_skill_config(root, config) != 0) {
|
||||
config_set_error("invalid default_skill configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_relays(root, config) != 0) {
|
||||
config_set_error("relay configuration is invalid: startup_events must include kind 10002 with non-empty r tags");
|
||||
config_set_error("relay configuration is invalid and bootstrap fallback failed");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
@@ -716,6 +1385,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,17 +1410,29 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
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");
|
||||
if (parse_api_config(root, config) != 0) {
|
||||
config_set_error("invalid api configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
|
||||
config_set_error("failed to derive public key from keys.nsec");
|
||||
if (parse_cashu_wallet_config(root, config) != 0) {
|
||||
config_set_error("invalid cashu_wallet configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
nostr_bytes_to_hex(config->keys.public_key, 32, config->keys.public_key_hex);
|
||||
if (config->keys.nsec[0] != '\0') {
|
||||
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
|
||||
config_set_error("keys.nsec/key.nsec must be valid nsec1... or 64-char hex private key");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (nostr_ec_public_key_from_private_key(config->keys.private_key, config->keys.public_key) != 0) {
|
||||
config_set_error("failed to derive public key from keys.nsec/key.nsec");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
nostr_bytes_to_hex(config->keys.public_key, 32, config->keys.public_key_hex);
|
||||
}
|
||||
|
||||
rc = 0;
|
||||
|
||||
|
||||
42
src/config.h
42
src/config.h
@@ -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;
|
||||
@@ -41,6 +47,12 @@ typedef struct {
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int max_turns;
|
||||
int trigger_max_turns;
|
||||
int api_default_max_turns;
|
||||
int api_max_turns_ceiling;
|
||||
int stall_repeat_threshold;
|
||||
int local_http_fetch_default_timeout_seconds;
|
||||
int local_http_fetch_max_timeout_seconds;
|
||||
shell_tools_config_t shell;
|
||||
} tools_config_t;
|
||||
|
||||
@@ -50,6 +62,13 @@ typedef struct {
|
||||
char* tags_json; // JSON array string for tags, optional
|
||||
} startup_event_t;
|
||||
|
||||
typedef struct {
|
||||
int kind;
|
||||
char d_tag[65];
|
||||
char* content;
|
||||
char* tags_json;
|
||||
} default_skill_config_t;
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int tools_enabled;
|
||||
@@ -80,9 +99,25 @@ 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 {
|
||||
int enabled;
|
||||
char** mint_urls;
|
||||
int mint_count;
|
||||
char unit[16];
|
||||
int auto_load;
|
||||
int mint_timeout_seconds;
|
||||
} cashu_wallet_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,13 +125,20 @@ typedef struct {
|
||||
security_config_t security;
|
||||
admin_context_config_t admin_context;
|
||||
triggers_config_t triggers;
|
||||
api_config_t api;
|
||||
cashu_wallet_config_t cashu_wallet;
|
||||
startup_event_t* startup_events;
|
||||
int startup_event_count;
|
||||
default_skill_config_t default_skill;
|
||||
char config_path[OW_MAX_URL_LEN];
|
||||
} didactyl_config_t;
|
||||
|
||||
int config_load(const char* path, didactyl_config_t* config);
|
||||
int config_ensure_default_skill_startup_events(didactyl_config_t* config);
|
||||
const char* config_last_error(void);
|
||||
void config_free(didactyl_config_t* config);
|
||||
|
||||
/* Strip JSONC single-line and block comments, returning malloc'd pure JSON. */
|
||||
char* jsonc_strip_comments(const char* src, size_t src_len);
|
||||
|
||||
#endif
|
||||
34
src/debug.c
34
src/debug.c
@@ -1,14 +1,31 @@
|
||||
#include "debug.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
debug_level_t g_debug_level = DEBUG_LEVEL_NONE;
|
||||
static FILE* g_debug_file = NULL;
|
||||
|
||||
static void debug_open_log_file(void) {
|
||||
if (g_debug_file) return;
|
||||
|
||||
const char* path = getenv("DIDACTYL_LOG_FILE");
|
||||
if (!path || path[0] == '\0') {
|
||||
path = "debug.log";
|
||||
}
|
||||
|
||||
g_debug_file = fopen(path, "a");
|
||||
if (g_debug_file) {
|
||||
setvbuf(g_debug_file, NULL, _IOLBF, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void debug_init(int level) {
|
||||
if (level < 0) level = 0;
|
||||
if (level > 5) level = 5;
|
||||
g_debug_level = (debug_level_t)level;
|
||||
debug_open_log_file();
|
||||
}
|
||||
|
||||
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...) {
|
||||
@@ -28,11 +45,17 @@ void debug_log(debug_level_t level, const char* file, int line, const char* form
|
||||
}
|
||||
|
||||
printf("[%s] [%s] ", timestamp, level_str);
|
||||
if (g_debug_file) {
|
||||
fprintf(g_debug_file, "[%s] [%s] ", timestamp, level_str);
|
||||
}
|
||||
|
||||
if (file && g_debug_level >= DEBUG_LEVEL_TRACE) {
|
||||
const char* filename = strrchr(file, '/');
|
||||
filename = filename ? filename + 1 : file;
|
||||
printf("[%s:%d] ", filename, line);
|
||||
if (g_debug_file) {
|
||||
fprintf(g_debug_file, "[%s:%d] ", filename, line);
|
||||
}
|
||||
}
|
||||
|
||||
va_list args;
|
||||
@@ -40,6 +63,17 @@ void debug_log(debug_level_t level, const char* file, int line, const char* form
|
||||
vprintf(format, args);
|
||||
va_end(args);
|
||||
|
||||
if (g_debug_file) {
|
||||
va_list args_file;
|
||||
va_start(args_file, format);
|
||||
vfprintf(g_debug_file, format, args_file);
|
||||
va_end(args_file);
|
||||
}
|
||||
|
||||
printf("\n");
|
||||
fflush(stdout);
|
||||
if (g_debug_file) {
|
||||
fprintf(g_debug_file, "\n");
|
||||
fflush(g_debug_file);
|
||||
}
|
||||
}
|
||||
|
||||
113
src/default_events.h
Normal file
113
src/default_events.h
Normal file
@@ -0,0 +1,113 @@
|
||||
#ifndef DIDACTYL_DEFAULT_EVENTS_H
|
||||
#define DIDACTYL_DEFAULT_EVENTS_H
|
||||
|
||||
#define DIDACTYL_DEFAULT_SKILL_D_TAG "didactyl-default"
|
||||
#define DIDACTYL_DEFAULT_SKILL_KIND 31124
|
||||
#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"d\",\"didactyl-default\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"]]"
|
||||
|
||||
#define DIDACTYL_STARTUP_KIND_PROFILE 0
|
||||
#define DIDACTYL_STARTUP_KIND_CONTACTS 3
|
||||
#define DIDACTYL_STARTUP_KIND_RELAYS 10002
|
||||
|
||||
static const int DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS[] = {
|
||||
DIDACTYL_STARTUP_KIND_PROFILE,
|
||||
DIDACTYL_STARTUP_KIND_CONTACTS,
|
||||
DIDACTYL_STARTUP_KIND_RELAYS
|
||||
};
|
||||
|
||||
#define DIDACTYL_DEFAULT_STARTUP_EVENT_KIND_COUNT ((int)(sizeof(DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS) / sizeof(DIDACTYL_DEFAULT_STARTUP_EVENT_KINDS[0])))
|
||||
|
||||
#define DIDACTYL_DEFAULT_KIND0_PROFILE_JSON_TEMPLATE "{\"name\":\"%s\",\"display_name\":\"%s\"}"
|
||||
#define DIDACTYL_DEFAULT_KIND3_CONTENT ""
|
||||
#define DIDACTYL_DEFAULT_KIND3_TAGS_JSON_TEMPLATE "[[\"p\",\"%s\"]]"
|
||||
#define DIDACTYL_DEFAULT_KIND10002_CONTENT ""
|
||||
|
||||
static const char* DIDACTYL_DEFAULT_RELAYS[] = {
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net"
|
||||
};
|
||||
|
||||
#define DIDACTYL_DEFAULT_RELAY_COUNT ((int)(sizeof(DIDACTYL_DEFAULT_RELAYS) / sizeof(DIDACTYL_DEFAULT_RELAYS[0])))
|
||||
|
||||
static const char* DIDACTYL_DEFAULT_SKILL_TEMPLATE =
|
||||
"# %s Agent\n\n"
|
||||
"You are %s, a sovereign AI agent living on Nostr.\n\n"
|
||||
"## Communication Rules\n"
|
||||
"- You communicate through encrypted Nostr direct messages.\n"
|
||||
"- Keep responses concise and clear.\n\n"
|
||||
"## Behavior\n"
|
||||
"- Be helpful and technically accurate.\n"
|
||||
"- If unsure, state uncertainty directly.\n"
|
||||
"- Prefer actionable, practical advice.\n"
|
||||
"- Use the person's name when messaging them if you know it.\n"
|
||||
"- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n"
|
||||
"## Tool Use Policy\n"
|
||||
"- You have tools available and should use them when a request requires taking action.\n"
|
||||
"- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n"
|
||||
"- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n"
|
||||
"- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n"
|
||||
"- After a tool call, base your answer on the actual tool result.\n"
|
||||
"- Never claim a tool was run if no tool was executed.\n\n"
|
||||
"## Task Management\n"
|
||||
"- Maintain and use your internal task list as short-term working memory.\n"
|
||||
"- Break long or complex actions into clear tasks before executing them.\n"
|
||||
"- Update task status as you complete steps so your plan stays accurate.\n\n"
|
||||
"## Safety\n"
|
||||
"- Do not claim to have executed actions you did not execute.\n"
|
||||
"- You may share your public key (npub) with anyone.\n"
|
||||
"- Never reveal your private key (nsec) under any circumstance.\n\n"
|
||||
"---template---\n\n"
|
||||
"- section: admin_identity\n"
|
||||
" role: system\n"
|
||||
" tool: admin_identity\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: admin_profile\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_admin_profile\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: admin_contacts\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_admin_contacts\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: admin_relays\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_admin_relays\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: admin_notes\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_admin_notes\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: agent_identity\n"
|
||||
" role: system\n"
|
||||
" tool: agent_identity\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: agent_profile\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_agent_profile\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: agent_contacts\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_agent_contacts\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: agent_relays\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_agent_relays\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: agent_notes\n"
|
||||
" role: system\n"
|
||||
" tool: nostr_agent_notes\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: tasks\n"
|
||||
" role: system\n"
|
||||
" tool: task_list\n"
|
||||
" skip_if_empty: true\n\n"
|
||||
"- section: dm_history\n"
|
||||
" role: expand\n"
|
||||
" limit: 12\n\n"
|
||||
"- section: conversation\n"
|
||||
" role: user\n"
|
||||
" tool: message_current\n"
|
||||
" skip_if_empty: true\n";
|
||||
|
||||
#endif
|
||||
1601
src/http_api.c
Normal file
1601
src/http_api.c
Normal file
File diff suppressed because it is too large
Load Diff
19
src/http_api.h
Normal file
19
src/http_api.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#ifndef DIDACTYL_HTTP_API_H
|
||||
#define DIDACTYL_HTTP_API_H
|
||||
|
||||
#include "config.h"
|
||||
#include "tools/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
|
||||
121
src/llm.c
121
src/llm.c
@@ -3,12 +3,14 @@
|
||||
#include "llm.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
|
||||
typedef struct {
|
||||
char* data;
|
||||
@@ -64,6 +66,25 @@ 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 int json_string_is_blank(const cJSON* item) {
|
||||
if (!item || !cJSON_IsString(item) || !item->valuestring) {
|
||||
return 0;
|
||||
}
|
||||
const unsigned char* p = (const unsigned char*)item->valuestring;
|
||||
while (*p) {
|
||||
if (!isspace(*p)) {
|
||||
return 0;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static char* perform_http_request(const char* url, const char* body, int is_post) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !url) {
|
||||
@@ -71,6 +92,14 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (url_looks_like_websocket(url)) {
|
||||
DEBUG_ERROR("[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s",
|
||||
url);
|
||||
DEBUG_WARN("[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1");
|
||||
curl_easy_cleanup(curl);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
@@ -96,6 +125,18 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
|
||||
}
|
||||
|
||||
if (is_post) {
|
||||
size_t body_len = body ? strlen(body) : 0U;
|
||||
size_t body_preview_len = body_len > 4000U ? 4000U : body_len;
|
||||
DEBUG_INFO("[didactyl] llm request: method=POST url=%s body_bytes=%zu body_preview=%.4000s%s",
|
||||
url,
|
||||
body_len,
|
||||
body ? body : "",
|
||||
body_len > body_preview_len ? "..." : "");
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] llm request: method=GET url=%s", url);
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
@@ -104,29 +145,32 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: curl=%s\n", curl_easy_strerror(res));
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: curl=%s", curl_easy_strerror(res));
|
||||
if (rb.data && rb.len > 0) {
|
||||
fprintf(stderr, "[didactyl] llm partial response: %.600s%s\n",
|
||||
rb.data,
|
||||
rb.len > 600 ? "..." : "");
|
||||
DEBUG_WARN("[didactyl] llm partial response: %.600s%s",
|
||||
rb.data,
|
||||
rb.len > 600 ? "..." : "");
|
||||
}
|
||||
free(rb.data);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", status);
|
||||
if (status == 101) {
|
||||
DEBUG_WARN("[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");
|
||||
}
|
||||
if (rb.data && rb.len > 0) {
|
||||
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
|
||||
rb.data,
|
||||
rb.len > 1200 ? "..." : "");
|
||||
DEBUG_WARN("[didactyl] llm error response: %.1200s%s",
|
||||
rb.data,
|
||||
rb.len > 1200 ? "..." : "");
|
||||
}
|
||||
free(rb.data);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!rb.data) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: empty response body\n");
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: empty response body");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -245,6 +289,11 @@ static int parse_llm_response(const char* json, llm_response_t* out) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* finish_reason = first ? cJSON_GetObjectItemCaseSensitive(first, "finish_reason") : NULL;
|
||||
if (finish_reason && cJSON_IsString(finish_reason) && finish_reason->valuestring) {
|
||||
out->finish_reason = strdup(finish_reason->valuestring);
|
||||
}
|
||||
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
if (content && cJSON_IsString(content) && content->valuestring) {
|
||||
out->content = strdup(content->valuestring);
|
||||
@@ -285,9 +334,9 @@ char* llm_chat(const char* system_prompt, const char* user_message) {
|
||||
|
||||
llm_response_t parsed;
|
||||
if (parse_llm_response(raw, &parsed) != 0) {
|
||||
fprintf(stderr, "[didactyl] failed to parse llm response (non-tool path): %.1200s%s\n",
|
||||
raw,
|
||||
strlen(raw) > 1200 ? "..." : "");
|
||||
DEBUG_ERROR("[didactyl] failed to parse llm response (non-tool path): %.1200s%s",
|
||||
raw,
|
||||
strlen(raw) > 1200 ? "..." : "");
|
||||
free(raw);
|
||||
return NULL;
|
||||
}
|
||||
@@ -321,6 +370,35 @@ int llm_chat_with_tools_messages(const char* messages_json,
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int filtered_count = 0;
|
||||
for (int i = cJSON_GetArraySize(messages) - 1; i >= 0; i--) {
|
||||
cJSON* msg = cJSON_GetArrayItem(messages, i);
|
||||
if (!msg || !cJSON_IsObject(msg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
cJSON* tool_calls = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
|
||||
|
||||
int role_is_textual = (role && cJSON_IsString(role) && role->valuestring &&
|
||||
(strcmp(role->valuestring, "system") == 0 ||
|
||||
strcmp(role->valuestring, "user") == 0 ||
|
||||
strcmp(role->valuestring, "assistant") == 0));
|
||||
int has_tool_calls = (tool_calls && cJSON_IsArray(tool_calls) && cJSON_GetArraySize(tool_calls) > 0);
|
||||
|
||||
if (role_is_textual && !has_tool_calls && json_string_is_blank(content)) {
|
||||
cJSON_DeleteItemFromArray(messages, i);
|
||||
filtered_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered_count > 0) {
|
||||
DEBUG_INFO("[didactyl] llm request sanitizer: removed %d empty text message(s) before provider request",
|
||||
filtered_count);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "messages", messages);
|
||||
|
||||
if (tools_json) {
|
||||
@@ -343,9 +421,9 @@ int llm_chat_with_tools_messages(const char* messages_json,
|
||||
|
||||
int rc = parse_llm_response(raw, out_response);
|
||||
if (rc != 0) {
|
||||
fprintf(stderr, "[didactyl] failed to parse llm response (tools path): %.1200s%s\n",
|
||||
raw,
|
||||
strlen(raw) > 1200 ? "..." : "");
|
||||
DEBUG_ERROR("[didactyl] failed to parse llm response (tools path): %.1200s%s",
|
||||
raw,
|
||||
strlen(raw) > 1200 ? "..." : "");
|
||||
}
|
||||
free(raw);
|
||||
return rc;
|
||||
@@ -386,6 +464,7 @@ int llm_chat_with_tools(const char* system_prompt,
|
||||
void llm_response_free(llm_response_t* response) {
|
||||
if (!response) return;
|
||||
free(response->content);
|
||||
free(response->finish_reason);
|
||||
for (int i = 0; i < response->tool_call_count; i++) {
|
||||
free(response->tool_calls[i].id);
|
||||
free(response->tool_calls[i].name);
|
||||
@@ -411,8 +490,8 @@ int llm_set_config(const llm_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* llm_list_models_json(const char* base_url_override) {
|
||||
if (!g_initialized) {
|
||||
char* llm_get_json_path(const char* base_url_override, const char* path) {
|
||||
if (!g_initialized || !path || path[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -420,11 +499,15 @@ char* llm_list_models_json(const char* base_url_override) {
|
||||
? base_url_override
|
||||
: g_cfg.base_url;
|
||||
|
||||
char url[OW_MAX_URL_LEN + 32];
|
||||
snprintf(url, sizeof(url), "%s/models", base_url);
|
||||
char url[OW_MAX_URL_LEN + 128];
|
||||
snprintf(url, sizeof(url), "%s%s", base_url, path);
|
||||
return perform_http_request(url, NULL, 0);
|
||||
}
|
||||
|
||||
char* llm_list_models_json(const char* base_url_override) {
|
||||
return llm_get_json_path(base_url_override, "/models");
|
||||
}
|
||||
|
||||
void llm_cleanup(void) {
|
||||
if (!g_initialized) {
|
||||
return;
|
||||
|
||||
@@ -13,6 +13,7 @@ typedef struct {
|
||||
char* content;
|
||||
llm_tool_call_t* tool_calls;
|
||||
int tool_call_count;
|
||||
char* finish_reason;
|
||||
} llm_response_t;
|
||||
|
||||
int llm_init(const llm_config_t* config);
|
||||
@@ -29,6 +30,7 @@ void llm_response_free(llm_response_t* response);
|
||||
int llm_get_config(llm_config_t* out_config);
|
||||
int llm_set_config(const llm_config_t* config);
|
||||
char* llm_list_models_json(const char* base_url_override);
|
||||
char* llm_get_json_path(const char* base_url_override, const char* path);
|
||||
void llm_cleanup(void);
|
||||
|
||||
#endif
|
||||
1282
src/main.c
1282
src/main.c
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,9 @@
|
||||
// Version information (auto-updated by build system)
|
||||
// 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_MINOR 1
|
||||
#define DIDACTYL_VERSION_PATCH 1
|
||||
#define DIDACTYL_VERSION "v0.1.1"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
28193
src/mongoose.c
Normal file
28193
src/mongoose.c
Normal file
File diff suppressed because it is too large
Load Diff
4038
src/mongoose.h
Normal file
4038
src/mongoose.h
Normal file
File diff suppressed because it is too large
Load Diff
2851
src/nostr_handler.c
2851
src/nostr_handler.c
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef enum {
|
||||
DIDACTYL_SENDER_ADMIN = 1,
|
||||
@@ -27,25 +30,57 @@ typedef struct {
|
||||
char** relays;
|
||||
} nostr_publish_result_t;
|
||||
|
||||
typedef void (*nostr_self_skill_eose_cb_t)(int event_count, void* user_data);
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config);
|
||||
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
int nostr_handler_subscribe_admin_context(void);
|
||||
int nostr_handler_subscribe_agent_context(void);
|
||||
int nostr_handler_subscribe_self_skills(void);
|
||||
char* nostr_handler_get_self_events_by_kind_json(int kind);
|
||||
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data);
|
||||
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);
|
||||
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
|
||||
cJSON* filter,
|
||||
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
|
||||
void (*on_eose)(cJSON** events, int event_count, void* user_data),
|
||||
void* user_data,
|
||||
int close_on_eose,
|
||||
int enable_deduplication,
|
||||
nostr_pool_eose_result_mode_t result_mode,
|
||||
int relay_timeout_seconds,
|
||||
int eose_timeout_seconds);
|
||||
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription);
|
||||
int nostr_handler_poll(int timeout_ms);
|
||||
int nostr_handler_reconcile_startup_events(void);
|
||||
int nostr_handler_load_system_context_from_adopted_skills(void);
|
||||
void nostr_handler_refresh_relay_statuses(void);
|
||||
int nostr_handler_sync_relays_from_config(void);
|
||||
const char* nostr_handler_get_system_context(void);
|
||||
char* nostr_handler_get_self_skill_events_json(void);
|
||||
int nostr_handler_is_event_in_self_cache(const char* event_id_hex);
|
||||
const char* nostr_handler_get_startup_display_name(void);
|
||||
int nostr_handler_connected_relay_count(void);
|
||||
char* nostr_handler_get_admin_kind0_context(void);
|
||||
char* nostr_handler_get_admin_kind3_context(void);
|
||||
char* nostr_handler_get_admin_kind10002_context(void);
|
||||
char* nostr_handler_get_admin_kind1_notes_context(void);
|
||||
char* nostr_handler_get_agent_kind0_context(void);
|
||||
char* nostr_handler_get_agent_kind3_context(void);
|
||||
char* nostr_handler_get_agent_kind10002_context(void);
|
||||
char* nostr_handler_get_agent_kind1_notes_context(void);
|
||||
int nostr_handler_is_wot_contact(const char* pubkey_hex);
|
||||
char* nostr_handler_relay_status_json(void);
|
||||
char* nostr_handler_relay_info_json(const char* relay_url);
|
||||
char* nostr_handler_subscription_status_json(void);
|
||||
int nostr_handler_subscription_set_json(const char* name, cJSON* filter, int enabled, int enabled_set);
|
||||
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject);
|
||||
char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit);
|
||||
void nostr_handler_cleanup(void);
|
||||
|
||||
#endif
|
||||
667
src/prompt_template.c
Normal file
667
src/prompt_template.c
Normal file
@@ -0,0 +1,667 @@
|
||||
#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 const char* map_variable_tool_name(const char* var_name) {
|
||||
if (!var_name || var_name[0] == '\0') return NULL;
|
||||
|
||||
if (strcmp(var_name, "admin_profile") == 0) return "nostr_admin_profile";
|
||||
if (strcmp(var_name, "admin_notes") == 0) return "nostr_admin_notes";
|
||||
if (strcmp(var_name, "admin_relays") == 0) return "nostr_admin_relays";
|
||||
if (strcmp(var_name, "adopted_skills") == 0) return "adopted_skills";
|
||||
if (strcmp(var_name, "triggering_event") == 0) return "trigger_event";
|
||||
return var_name;
|
||||
}
|
||||
|
||||
static char* resolve_inline_variables_local(const char* tpl, tools_context_t* tools_ctx) {
|
||||
if (!tpl) return strdup("");
|
||||
|
||||
size_t cap = strlen(tpl) + 64U;
|
||||
size_t used = 0U;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) return NULL;
|
||||
out[0] = '\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* prefix = dup_range(p, (size_t)(open - p));
|
||||
if (!prefix) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
int rc = append_text(&out, &cap, &used, prefix);
|
||||
free(prefix);
|
||||
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* var = dup_range(open + 2, (size_t)(close - (open + 2)));
|
||||
if (!var) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
char* var_trim = ltrim_inplace(var);
|
||||
rtrim_inplace(var_trim);
|
||||
|
||||
const char* replacement = "";
|
||||
char* replacement_owned = NULL;
|
||||
|
||||
if (strcmp(var_trim, "message") == 0) {
|
||||
replacement = (tools_ctx && tools_ctx->template_current_user_message)
|
||||
? tools_ctx->template_current_user_message
|
||||
: "";
|
||||
} else if (strcmp(var_trim, "dm_history") == 0) {
|
||||
replacement = "";
|
||||
} else if (tools_ctx) {
|
||||
const char* tool_name = map_variable_tool_name(var_trim);
|
||||
if (tool_name && tool_name[0] != '\0') {
|
||||
char* tool_result = tools_execute(tools_ctx, tool_name, "{}");
|
||||
if (tool_result) {
|
||||
cJSON* root = cJSON_Parse(tool_result);
|
||||
if (root && cJSON_IsObject(root)) {
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
if (!success || !cJSON_IsBool(success) || cJSON_IsTrue(success)) {
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content");
|
||||
if (content && cJSON_IsString(content) && content->valuestring) {
|
||||
replacement_owned = strdup(content->valuestring);
|
||||
} else if (content) {
|
||||
replacement_owned = cJSON_PrintUnformatted(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
free(tool_result);
|
||||
}
|
||||
}
|
||||
replacement = replacement_owned ? replacement_owned : "";
|
||||
}
|
||||
|
||||
if (append_text(&out, &cap, &used, replacement) != 0) {
|
||||
free(replacement_owned);
|
||||
free(var);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
free(replacement_owned);
|
||||
free(var);
|
||||
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->tool_name = NULL;
|
||||
sec->tool_args = NULL;
|
||||
sec->result_field = NULL;
|
||||
sec->limit = 0;
|
||||
sec->skip_if_empty = 0;
|
||||
sec->provider_name = NULL;
|
||||
sec->provider_content_template = NULL;
|
||||
}
|
||||
|
||||
static int parse_int_or_zero(const char* s) {
|
||||
if (!s) return 0;
|
||||
while (*s && isspace((unsigned char)*s)) s++;
|
||||
return atoi(s);
|
||||
}
|
||||
|
||||
int prompt_template_parse(const char* skill_content, prompt_template_t* out_template) {
|
||||
if (!skill_content || !out_template) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(out_template, 0, sizeof(*out_template));
|
||||
|
||||
const char* marker = strstr(skill_content, PROMPT_TEMPLATE_MARKER);
|
||||
if (!marker) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t personality_len = (size_t)(marker - skill_content);
|
||||
out_template->personality = dup_range(skill_content, personality_len);
|
||||
if (!out_template->personality) {
|
||||
return -1;
|
||||
}
|
||||
rtrim_inplace(out_template->personality);
|
||||
|
||||
const char* after = marker + strlen(PROMPT_TEMPLATE_MARKER);
|
||||
while (*after == '\r' || *after == '\n') after++;
|
||||
|
||||
char* tpl = strdup(after);
|
||||
if (!tpl) {
|
||||
prompt_template_free(out_template);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char** lines = NULL;
|
||||
int line_count = 0;
|
||||
if (split_lines_inplace(tpl, &lines, &line_count) != 0) {
|
||||
free(tpl);
|
||||
prompt_template_free(out_template);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int current = -1;
|
||||
int i = 0;
|
||||
while (i < line_count) {
|
||||
char* raw = lines[i];
|
||||
rtrim_inplace(raw);
|
||||
char* line = ltrim_inplace(raw);
|
||||
|
||||
if (*line == '\0') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "- section:")) {
|
||||
if (out_template->section_count >= PROMPT_TEMPLATE_MAX_SECTIONS) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = out_template->section_count;
|
||||
init_section_defaults(&out_template->sections[current]);
|
||||
out_template->section_count++;
|
||||
|
||||
char* name = line + strlen("- section:");
|
||||
name = ltrim_inplace(name);
|
||||
rtrim_inplace(name);
|
||||
snprintf(out_template->sections[current].name,
|
||||
sizeof(out_template->sections[current].name),
|
||||
"%s",
|
||||
name);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current < 0) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "role:")) {
|
||||
char* role = line + strlen("role:");
|
||||
role = ltrim_inplace(role);
|
||||
rtrim_inplace(role);
|
||||
snprintf(out_template->sections[current].role,
|
||||
sizeof(out_template->sections[current].role),
|
||||
"%s",
|
||||
(*role) ? role : "system");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "limit:")) {
|
||||
char* lim = line + strlen("limit:");
|
||||
out_template->sections[current].limit = parse_int_or_zero(lim);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "skip_if_empty:")) {
|
||||
char* flag = line + strlen("skip_if_empty:");
|
||||
flag = ltrim_inplace(flag);
|
||||
rtrim_inplace(flag);
|
||||
out_template->sections[current].skip_if_empty =
|
||||
(strcmp(flag, "true") == 0 || strcmp(flag, "1") == 0) ? 1 : 0;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "tool:")) {
|
||||
char* val = line + strlen("tool:");
|
||||
val = ltrim_inplace(val);
|
||||
rtrim_inplace(val);
|
||||
free(out_template->sections[current].tool_name);
|
||||
out_template->sections[current].tool_name = strdup(val);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "args:")) {
|
||||
char* val = line + strlen("args:");
|
||||
val = ltrim_inplace(val);
|
||||
rtrim_inplace(val);
|
||||
free(out_template->sections[current].tool_args);
|
||||
out_template->sections[current].tool_args = strdup((*val) ? val : "{}");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "result_field:")) {
|
||||
char* val = line + strlen("result_field:");
|
||||
val = ltrim_inplace(val);
|
||||
rtrim_inplace(val);
|
||||
free(out_template->sections[current].result_field);
|
||||
out_template->sections[current].result_field = strdup(val);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "content:")) {
|
||||
char* val = line + strlen("content:");
|
||||
val = ltrim_inplace(val);
|
||||
|
||||
free(out_template->sections[current].content_template);
|
||||
out_template->sections[current].content_template = NULL;
|
||||
|
||||
if (strcmp(val, "|") == 0) {
|
||||
char* acc = strdup("");
|
||||
size_t cap = acc ? 1U : 0U;
|
||||
size_t used = 0U;
|
||||
if (!acc) {
|
||||
free(lines);
|
||||
free(tpl);
|
||||
prompt_template_free(out_template);
|
||||
return -1;
|
||||
}
|
||||
|
||||
i++;
|
||||
while (i < line_count) {
|
||||
char* next_raw = lines[i];
|
||||
char* next_ltrim = ltrim_inplace(next_raw);
|
||||
|
||||
if (starts_with(next_ltrim, "- section:") ||
|
||||
starts_with(next_ltrim, "role:") ||
|
||||
starts_with(next_ltrim, "limit:") ||
|
||||
starts_with(next_ltrim, "skip_if_empty:") ||
|
||||
starts_with(next_ltrim, "tool:") ||
|
||||
starts_with(next_ltrim, "args:") ||
|
||||
starts_with(next_ltrim, "result_field:") ||
|
||||
starts_with(next_ltrim, "content:") ||
|
||||
starts_with(next_ltrim, "provider:")) {
|
||||
break;
|
||||
}
|
||||
|
||||
char* piece = next_raw;
|
||||
if (strncmp(piece, " ", 4) == 0) piece += 4;
|
||||
else if (strncmp(piece, " ", 2) == 0) piece += 2;
|
||||
rtrim_inplace(piece);
|
||||
|
||||
if (append_text(&acc, &cap, &used, piece) != 0 ||
|
||||
append_text(&acc, &cap, &used, "\n") != 0) {
|
||||
free(acc);
|
||||
free(lines);
|
||||
free(tpl);
|
||||
prompt_template_free(out_template);
|
||||
return -1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
rtrim_inplace(acc);
|
||||
out_template->sections[current].content_template = acc;
|
||||
continue;
|
||||
}
|
||||
|
||||
rtrim_inplace(val);
|
||||
out_template->sections[current].content_template = strdup(val);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts_with(line, "provider:")) {
|
||||
i++;
|
||||
if (i >= line_count) continue;
|
||||
|
||||
char* provider_line = ltrim_inplace(lines[i]);
|
||||
rtrim_inplace(provider_line);
|
||||
char* colon = strchr(provider_line, ':');
|
||||
if (!colon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
*colon = '\0';
|
||||
char* provider_name = ltrim_inplace(provider_line);
|
||||
rtrim_inplace(provider_name);
|
||||
char* provider_val = ltrim_inplace(colon + 1);
|
||||
rtrim_inplace(provider_val);
|
||||
|
||||
free(out_template->sections[current].provider_name);
|
||||
out_template->sections[current].provider_name = strdup(provider_name);
|
||||
free(out_template->sections[current].provider_content_template);
|
||||
out_template->sections[current].provider_content_template = NULL;
|
||||
|
||||
if (strcmp(provider_val, "|") == 0) {
|
||||
char* acc = strdup("");
|
||||
size_t cap = acc ? 1U : 0U;
|
||||
size_t used = 0U;
|
||||
if (!acc) {
|
||||
free(lines);
|
||||
free(tpl);
|
||||
prompt_template_free(out_template);
|
||||
return -1;
|
||||
}
|
||||
|
||||
i++;
|
||||
while (i < line_count) {
|
||||
char* next = ltrim_inplace(lines[i]);
|
||||
if (starts_with(next, "- section:") ||
|
||||
starts_with(next, "role:") ||
|
||||
starts_with(next, "limit:") ||
|
||||
starts_with(next, "skip_if_empty:") ||
|
||||
starts_with(next, "tool:") ||
|
||||
starts_with(next, "args:") ||
|
||||
starts_with(next, "result_field:") ||
|
||||
starts_with(next, "content:") ||
|
||||
starts_with(next, "provider:")) {
|
||||
break;
|
||||
}
|
||||
char* piece = lines[i];
|
||||
if (strncmp(piece, " ", 4) == 0) piece += 4;
|
||||
else if (strncmp(piece, " ", 2) == 0) piece += 2;
|
||||
rtrim_inplace(piece);
|
||||
if (append_text(&acc, &cap, &used, piece) != 0 ||
|
||||
append_text(&acc, &cap, &used, "\n") != 0) {
|
||||
free(acc);
|
||||
free(lines);
|
||||
free(tpl);
|
||||
prompt_template_free(out_template);
|
||||
return -1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
rtrim_inplace(acc);
|
||||
out_template->sections[current].provider_content_template = acc;
|
||||
continue;
|
||||
}
|
||||
|
||||
out_template->sections[current].provider_content_template = strdup(provider_val);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
free(lines);
|
||||
free(tpl);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_message_object(cJSON* messages, const char* role, const char* content) {
|
||||
if (!messages || !role) return -1;
|
||||
cJSON* msg = cJSON_CreateObject();
|
||||
if (!msg) return -1;
|
||||
cJSON_AddStringToObject(msg, "role", role);
|
||||
cJSON_AddStringToObject(msg, "content", content ? content : "");
|
||||
cJSON_AddItemToArray(messages, msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* extract_tool_result_content(const prompt_template_section_t* sec, const char* tool_result_json) {
|
||||
if (!tool_result_json) return strdup("");
|
||||
|
||||
cJSON* root = cJSON_Parse(tool_result_json);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return strdup(tool_result_json);
|
||||
}
|
||||
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
if (success && cJSON_IsBool(success) && !cJSON_IsTrue(success)) {
|
||||
cJSON_Delete(root);
|
||||
return strdup("");
|
||||
}
|
||||
|
||||
const char* field_name = (sec && sec->result_field && sec->result_field[0] != '\0')
|
||||
? sec->result_field
|
||||
: "content";
|
||||
|
||||
cJSON* field = cJSON_GetObjectItemCaseSensitive(root, field_name);
|
||||
if (!field) {
|
||||
field = cJSON_GetObjectItemCaseSensitive(root, "content");
|
||||
}
|
||||
|
||||
char* out = NULL;
|
||||
if (field && cJSON_IsString(field) && field->valuestring) {
|
||||
out = strdup(field->valuestring);
|
||||
} else if (field) {
|
||||
out = cJSON_PrintUnformatted(field);
|
||||
} else {
|
||||
out = cJSON_PrintUnformatted(root);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return out ? out : strdup("");
|
||||
}
|
||||
|
||||
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
|
||||
const char* provider_name,
|
||||
tools_context_t* tools_ctx,
|
||||
cJSON* dm_history_messages,
|
||||
int dm_history_default_limit,
|
||||
prompt_template_emit_hook_fn emit_hook,
|
||||
void* emit_hook_user_data) {
|
||||
if (!tmpl) return NULL;
|
||||
|
||||
cJSON* out = cJSON_CreateArray();
|
||||
if (!out) return NULL;
|
||||
|
||||
int out_idx = 0;
|
||||
if (tmpl->personality && tmpl->personality[0] != '\0') {
|
||||
if (append_message_object(out, "system", tmpl->personality) != 0) {
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
if (emit_hook) emit_hook("system_prompt", out_idx, emit_hook_user_data);
|
||||
out_idx++;
|
||||
}
|
||||
|
||||
for (int i = 0; i < tmpl->section_count; i++) {
|
||||
const prompt_template_section_t* sec = &tmpl->sections[i];
|
||||
const char* role = sec->role[0] ? sec->role : "system";
|
||||
|
||||
if (strcmp(role, "expand") == 0) {
|
||||
if (!dm_history_messages || !cJSON_IsArray(dm_history_messages)) {
|
||||
continue;
|
||||
}
|
||||
int total = cJSON_GetArraySize(dm_history_messages);
|
||||
int lim = sec->limit > 0 ? sec->limit : dm_history_default_limit;
|
||||
int start = (lim > 0 && total > lim) ? (total - lim) : 0;
|
||||
|
||||
for (int j = start; j < total; j++) {
|
||||
cJSON* item = cJSON_GetArrayItem(dm_history_messages, j);
|
||||
if (!item || !cJSON_IsObject(item)) continue;
|
||||
cJSON* dup = cJSON_Duplicate(item, 1);
|
||||
if (!dup) {
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddItemToArray(out, dup);
|
||||
if (emit_hook) emit_hook(sec->name[0] ? sec->name : "context_part", out_idx, emit_hook_user_data);
|
||||
out_idx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
char* resolved = NULL;
|
||||
|
||||
if (sec->tool_name && sec->tool_name[0] != '\0' && tools_ctx) {
|
||||
const char* args_json = (sec->tool_args && sec->tool_args[0] != '\0') ? sec->tool_args : "{}";
|
||||
char* tool_result = tools_execute(tools_ctx, sec->tool_name, args_json);
|
||||
resolved = extract_tool_result_content(sec, tool_result);
|
||||
free(tool_result);
|
||||
} else {
|
||||
const char* tpl = sec->content_template;
|
||||
if (provider_name && sec->provider_name && sec->provider_content_template &&
|
||||
strcmp(provider_name, sec->provider_name) == 0) {
|
||||
tpl = sec->provider_content_template;
|
||||
}
|
||||
resolved = resolve_inline_variables_local(tpl ? tpl : "", tools_ctx);
|
||||
}
|
||||
if (!resolved) {
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (sec->skip_if_empty) {
|
||||
char* chk = ltrim_inplace(resolved);
|
||||
if (chk && *chk == '\0') {
|
||||
free(resolved);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (append_message_object(out, role, resolved) != 0) {
|
||||
free(resolved);
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
if (emit_hook) emit_hook(sec->name[0] ? sec->name : "context_part", out_idx, emit_hook_user_data);
|
||||
out_idx++;
|
||||
free(resolved);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
void prompt_template_free(prompt_template_t* tmpl) {
|
||||
if (!tmpl) return;
|
||||
|
||||
free(tmpl->personality);
|
||||
tmpl->personality = NULL;
|
||||
|
||||
for (int i = 0; i < tmpl->section_count; i++) {
|
||||
free(tmpl->sections[i].content_template);
|
||||
tmpl->sections[i].content_template = NULL;
|
||||
free(tmpl->sections[i].tool_name);
|
||||
tmpl->sections[i].tool_name = NULL;
|
||||
free(tmpl->sections[i].tool_args);
|
||||
tmpl->sections[i].tool_args = NULL;
|
||||
free(tmpl->sections[i].result_field);
|
||||
tmpl->sections[i].result_field = NULL;
|
||||
free(tmpl->sections[i].provider_name);
|
||||
tmpl->sections[i].provider_name = NULL;
|
||||
free(tmpl->sections[i].provider_content_template);
|
||||
tmpl->sections[i].provider_content_template = NULL;
|
||||
tmpl->sections[i].name[0] = '\0';
|
||||
tmpl->sections[i].role[0] = '\0';
|
||||
tmpl->sections[i].limit = 0;
|
||||
tmpl->sections[i].skip_if_empty = 0;
|
||||
}
|
||||
|
||||
tmpl->section_count = 0;
|
||||
}
|
||||
47
src/prompt_template.h
Normal file
47
src/prompt_template.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#ifndef DIDACTYL_PROMPT_TEMPLATE_H
|
||||
#define DIDACTYL_PROMPT_TEMPLATE_H
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
|
||||
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
|
||||
#define PROMPT_TEMPLATE_MAX_ROLE_LEN 16
|
||||
#define PROMPT_TEMPLATE_MARKER "---template---"
|
||||
|
||||
typedef struct {
|
||||
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
|
||||
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
|
||||
char* content_template;
|
||||
char* tool_name;
|
||||
char* tool_args;
|
||||
char* result_field;
|
||||
int limit;
|
||||
int skip_if_empty;
|
||||
char* provider_name;
|
||||
char* provider_content_template;
|
||||
} prompt_template_section_t;
|
||||
|
||||
typedef struct {
|
||||
char* personality;
|
||||
prompt_template_section_t sections[PROMPT_TEMPLATE_MAX_SECTIONS];
|
||||
int section_count;
|
||||
} prompt_template_t;
|
||||
|
||||
typedef void (*prompt_template_emit_hook_fn)(const char* section_name,
|
||||
int message_index,
|
||||
void* user_data);
|
||||
|
||||
int prompt_template_parse(const char* skill_content, prompt_template_t* out_template);
|
||||
|
||||
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
|
||||
const char* provider_name,
|
||||
tools_context_t* tools_ctx,
|
||||
cJSON* dm_history_messages,
|
||||
int dm_history_default_limit,
|
||||
prompt_template_emit_hook_fn emit_hook,
|
||||
void* emit_hook_user_data);
|
||||
|
||||
void prompt_template_free(prompt_template_t* tmpl);
|
||||
|
||||
#endif
|
||||
2532
src/setup_wizard.c
Normal file
2532
src/setup_wizard.c
Normal file
File diff suppressed because it is too large
Load Diff
24
src/setup_wizard.h
Normal file
24
src/setup_wizard.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#ifndef DIDACTYL_SETUP_WIZARD_H
|
||||
#define DIDACTYL_SETUP_WIZARD_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
/*
|
||||
* setup_wizard_run
|
||||
*
|
||||
* Return values:
|
||||
* 0 -> bootstrap/overwrite mode selected (new agent or load genesis), continue boot
|
||||
* 1 -> wrote config/genesis and requested exit (no boot)
|
||||
* 2 -> existing-agent recovery mode selected, continue boot
|
||||
* -1 -> abort/error
|
||||
*/
|
||||
#define SETUP_WIZARD_RC_BOOTSTRAP 0
|
||||
#define SETUP_WIZARD_RC_EXIT 1
|
||||
#define SETUP_WIZARD_RC_EXISTING 2
|
||||
#define SETUP_WIZARD_RC_ABORT -1
|
||||
|
||||
int setup_wizard_run(didactyl_config_t* config, char* genesis_path_out, size_t genesis_path_out_size);
|
||||
|
||||
#endif
|
||||
4707
src/tools.c
4707
src/tools.c
File diff suppressed because it is too large
Load Diff
216
src/tools/tool_admin.c
Normal file
216
src/tools/tool_admin.c
Normal file
@@ -0,0 +1,216 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_handler.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
char* execute_admin_identity(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
|
||||
const char* tier_text = "Sender tier unknown.";
|
||||
if (ctx->template_sender_tier == 1) {
|
||||
tier_text = "This message has been cryptographically verified as coming from your administrator.";
|
||||
} else if (ctx->template_sender_tier == 2) {
|
||||
tier_text = "This message is from a web-of-trust contact (not the administrator).";
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
char content[768];
|
||||
snprintf(content,
|
||||
sizeof(content),
|
||||
"## Administrator Identity (source: config.admin.pubkey)\n\n"
|
||||
"This is your administrator! Admin pubkey (hex): %s\n\n"
|
||||
"%s",
|
||||
ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown",
|
||||
tier_text);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* kind0 = nostr_handler_get_admin_kind0_context();
|
||||
const char* profile_json = (kind0 && kind0[0]) ? kind0 : "{}";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(kind0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(kind0);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): %s",
|
||||
profile_json);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(kind0);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* kind3 = nostr_handler_get_admin_kind3_context();
|
||||
const char* contacts_json = (kind3 && kind3[0]) ? kind3 : "[]";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(kind3);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): ") + strlen(contacts_json) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(kind3);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): %s",
|
||||
contacts_json);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(kind3);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* kind10002 = nostr_handler_get_admin_kind10002_context();
|
||||
const char* relays_json = (kind10002 && kind10002[0]) ? kind10002 : "[]";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(kind10002);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): ") + strlen(relays_json) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(kind10002);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): %s",
|
||||
relays_json);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(kind10002);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* notes = nostr_handler_get_admin_kind1_notes_context();
|
||||
const char* notes_text = (notes && notes[0]) ? notes : "";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(notes);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Administrator Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(notes);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Administrator Recent Kind 1 Notes\n\n%s",
|
||||
notes_text);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(notes);
|
||||
return json;
|
||||
}
|
||||
323
src/tools/tool_agent.c
Normal file
323
src/tools/tool_agent.c
Normal file
@@ -0,0 +1,323 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../main.h"
|
||||
#include "../nostr_handler.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
char* execute_message_current(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "content", ctx->template_current_user_message ? ctx->template_current_user_message : "");
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_agent_identity(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char npub[128] = {0};
|
||||
if (nostr_key_to_bech32(ctx->cfg->keys.public_key, "npub", npub) != NOSTR_SUCCESS) {
|
||||
strcpy(npub, "unknown");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
char content[384];
|
||||
snprintf(content,
|
||||
sizeof(content),
|
||||
"## Agent Identity\n\nYour pubkey (hex): %s\nYour npub: %s",
|
||||
ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown",
|
||||
npub);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* kind0 = nostr_handler_get_agent_kind0_context();
|
||||
const char* profile_json = (kind0 && kind0[0]) ? kind0 : "{}";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(kind0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Agent Kind 0 Profile (source: nostr kind 0)\n\nAgent kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(kind0);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Agent Kind 0 Profile (source: nostr kind 0)\n\nAgent kind 0 profile content (JSON): %s",
|
||||
profile_json);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(kind0);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* kind3 = nostr_handler_get_agent_kind3_context();
|
||||
const char* contacts_json = (kind3 && kind3[0]) ? kind3 : "[]";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(kind3);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Agent Kind 3 Contacts (source: nostr kind 3)\n\nAgent contacts (JSON): ") + strlen(contacts_json) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(kind3);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Agent Kind 3 Contacts (source: nostr kind 3)\n\nAgent contacts (JSON): %s",
|
||||
contacts_json);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(kind3);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* kind10002 = nostr_handler_get_agent_kind10002_context();
|
||||
const char* relays_json = (kind10002 && kind10002[0]) ? kind10002 : "[]";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(kind10002);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Agent Kind 10002 Relays (source: nostr kind 10002)\n\nAgent relay list (JSON): ") + strlen(relays_json) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(kind10002);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Agent Kind 10002 Relays (source: nostr kind 10002)\n\nAgent relay list (JSON): %s",
|
||||
relays_json);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(kind10002);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* notes = nostr_handler_get_agent_kind1_notes_context();
|
||||
const char* notes_text = (notes && notes[0]) ? notes : "";
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(notes);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
|
||||
size_t content_len = strlen("## Agent Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
if (!content) {
|
||||
cJSON_Delete(out);
|
||||
free(notes);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(content,
|
||||
content_len,
|
||||
"## Agent Recent Kind 1 Notes\n\n%s",
|
||||
notes_text);
|
||||
cJSON_AddStringToObject(out, "content", content);
|
||||
free(content);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(notes);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_adopted_skills(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 3000);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(events_json);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "content", (events_json && events_json[0]) ? events_json : "[]");
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(events_json);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_trigger_event(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
const char* trigger_json = (ctx->template_trigger_event_json && ctx->template_trigger_event_json[0] != '\0')
|
||||
? ctx->template_trigger_event_json
|
||||
: NULL;
|
||||
|
||||
if (!trigger_json) {
|
||||
cJSON* arg_json = cJSON_GetObjectItemCaseSensitive(args, "trigger_event_json");
|
||||
if (arg_json && cJSON_IsString(arg_json) && arg_json->valuestring && arg_json->valuestring[0] != '\0') {
|
||||
trigger_json = arg_json->valuestring;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(args);
|
||||
|
||||
if (!trigger_json) {
|
||||
trigger_json = "{}";
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "content", trigger_json);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_agent_version(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "name", DIDACTYL_NAME);
|
||||
cJSON_AddStringToObject(out, "description", DIDACTYL_DESCRIPTION);
|
||||
cJSON_AddStringToObject(out, "software", DIDACTYL_SOFTWARE);
|
||||
cJSON_AddStringToObject(out, "version", DIDACTYL_VERSION);
|
||||
cJSON_AddNumberToObject(out, "version_major", DIDACTYL_VERSION_MAJOR);
|
||||
cJSON_AddNumberToObject(out, "version_minor", DIDACTYL_VERSION_MINOR);
|
||||
cJSON_AddNumberToObject(out, "version_patch", DIDACTYL_VERSION_PATCH);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
229
src/tools/tool_cashu_wallet.c
Normal file
229
src/tools/tool_cashu_wallet.c
Normal file
@@ -0,0 +1,229 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../cashu_wallet.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static char* wrap_wallet_json_result(int rc, cJSON* obj, const char* default_error) {
|
||||
if (rc != 0 || !obj) {
|
||||
cJSON_Delete(obj);
|
||||
return json_error_local(default_error ? default_error : "cashu wallet operation failed");
|
||||
}
|
||||
|
||||
char* out = cJSON_PrintUnformatted(obj);
|
||||
cJSON_Delete(obj);
|
||||
return out;
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_balance_json(&out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_balance failed");
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
|
||||
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
|
||||
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_info requires mint_url when no cashu_wallet.mint_urls are configured");
|
||||
}
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_info_json(mint_url, &out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_info failed (check mint_url and mint connectivity)");
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_mint_quote(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* amount = cJSON_GetObjectItemCaseSensitive(args, "amount");
|
||||
if (!amount || !cJSON_IsNumber(amount) || amount->valuedouble <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_mint_quote requires numeric amount > 0");
|
||||
}
|
||||
|
||||
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
|
||||
cJSON* unit = cJSON_GetObjectItemCaseSensitive(args, "unit");
|
||||
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
|
||||
const char* unit_s = (unit && cJSON_IsString(unit) && unit->valuestring) ? unit->valuestring : NULL;
|
||||
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_mint_quote requires mint_url when no cashu_wallet.mint_urls are configured");
|
||||
}
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_mint_quote_json(mint_url, (uint64_t)amount->valuedouble, unit_s, &out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_mint_quote failed");
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_mint_check(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* quote_id = cJSON_GetObjectItemCaseSensitive(args, "quote_id");
|
||||
if (!quote_id || !cJSON_IsString(quote_id) || !quote_id->valuestring || quote_id->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_mint_check requires quote_id");
|
||||
}
|
||||
|
||||
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
|
||||
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
|
||||
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_mint_check requires mint_url when no cashu_wallet.mint_urls are configured");
|
||||
}
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_mint_check_json(mint_url, quote_id->valuestring, &out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_mint_check failed");
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_mint_claim(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* quote_id = cJSON_GetObjectItemCaseSensitive(args, "quote_id");
|
||||
if (!quote_id || !cJSON_IsString(quote_id) || !quote_id->valuestring || quote_id->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_mint_claim requires quote_id");
|
||||
}
|
||||
|
||||
uint64_t amount = 0;
|
||||
cJSON* amount_item = cJSON_GetObjectItemCaseSensitive(args, "amount");
|
||||
if (amount_item) {
|
||||
if (!cJSON_IsNumber(amount_item) || amount_item->valuedouble < 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_mint_claim amount must be a non-negative number");
|
||||
}
|
||||
amount = (uint64_t)amount_item->valuedouble;
|
||||
}
|
||||
|
||||
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
|
||||
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
|
||||
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_mint_claim requires mint_url when no cashu_wallet.mint_urls are configured");
|
||||
}
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_mint_claim_json(mint_url, quote_id->valuestring, amount, &out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_mint_claim failed");
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_melt_quote(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* payment_request = cJSON_GetObjectItemCaseSensitive(args, "payment_request");
|
||||
if (!payment_request || !cJSON_IsString(payment_request) || !payment_request->valuestring || payment_request->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_melt_quote requires payment_request");
|
||||
}
|
||||
|
||||
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
|
||||
cJSON* unit = cJSON_GetObjectItemCaseSensitive(args, "unit");
|
||||
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
|
||||
const char* unit_s = (unit && cJSON_IsString(unit) && unit->valuestring) ? unit->valuestring : NULL;
|
||||
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_melt_quote requires mint_url when no cashu_wallet.mint_urls are configured");
|
||||
}
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_melt_quote_json(mint_url, payment_request->valuestring, unit_s, &out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_melt_quote failed");
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_melt_pay(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* quote_id = cJSON_GetObjectItemCaseSensitive(args, "quote_id");
|
||||
if (!quote_id || !cJSON_IsString(quote_id) || !quote_id->valuestring || quote_id->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_melt_pay requires quote_id");
|
||||
}
|
||||
|
||||
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
|
||||
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
|
||||
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_melt_pay requires mint_url when no cashu_wallet.mint_urls are configured");
|
||||
}
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_melt_pay_json(mint_url, quote_id->valuestring, &out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_melt_pay failed");
|
||||
}
|
||||
|
||||
char* execute_cashu_wallet_check_proofs(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
|
||||
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
|
||||
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("cashu_wallet_check_proofs requires mint_url when no cashu_wallet.mint_urls are configured");
|
||||
}
|
||||
|
||||
cJSON* out = NULL;
|
||||
int rc = cashu_wallet_check_proofs_json(mint_url, &out);
|
||||
cJSON_Delete(args);
|
||||
return wrap_wallet_json_result(rc, out, "cashu_wallet_check_proofs failed");
|
||||
}
|
||||
288
src/tools/tool_config.c
Normal file
288
src/tools/tool_config.c
Normal file
@@ -0,0 +1,288 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_handler.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define CONFIG_KIND 30078
|
||||
#define CONFIG_APP_TAG "didactyl"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, char** out_ciphertext) {
|
||||
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
|
||||
|
||||
const char* plain = plaintext ? plaintext : "";
|
||||
size_t out_cap = (strlen(plain) * 4U) + 1024U;
|
||||
char* ciphertext = (char*)malloc(out_cap);
|
||||
if (!ciphertext) return -1;
|
||||
|
||||
int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key,
|
||||
ctx->cfg->keys.public_key,
|
||||
plain,
|
||||
ciphertext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(ciphertext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_ciphertext = ciphertext;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) {
|
||||
if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1;
|
||||
|
||||
size_t out_cap = strlen(ciphertext) + 1024U;
|
||||
char* plaintext = (char*)malloc(out_cap);
|
||||
if (!plaintext) return -1;
|
||||
|
||||
int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key,
|
||||
ctx->cfg->keys.public_key,
|
||||
ciphertext,
|
||||
plaintext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(plaintext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_plaintext = plaintext;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int query_config_ciphertext_local(tools_context_t* ctx, const char* d_tag, char** out_ciphertext) {
|
||||
if (!ctx || !ctx->cfg || !d_tag || !d_tag[0] || !out_ciphertext) return -1;
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
cJSON* d_vals = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors || !d_vals) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
cJSON_Delete(d_vals);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(CONFIG_KIND));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
cJSON_AddItemToArray(d_vals, cJSON_CreateString(d_tag));
|
||||
cJSON_AddItemToObject(filter, "#d", d_vals);
|
||||
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
|
||||
cJSON_Delete(arr);
|
||||
*out_ciphertext = strdup("");
|
||||
return (*out_ciphertext != NULL) ? 0 : -1;
|
||||
}
|
||||
|
||||
cJSON* ev = cJSON_GetArrayItem(arr, 0);
|
||||
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
|
||||
const char* cipher = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
|
||||
*out_ciphertext = strdup(cipher);
|
||||
cJSON_Delete(arr);
|
||||
return (*out_ciphertext != NULL) ? 0 : -1;
|
||||
}
|
||||
|
||||
char* execute_config_store(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* d_tag_j = cJSON_GetObjectItemCaseSensitive(args, "d_tag");
|
||||
cJSON* content_j = cJSON_GetObjectItemCaseSensitive(args, "content");
|
||||
|
||||
if (!d_tag_j || !cJSON_IsString(d_tag_j) || !d_tag_j->valuestring || d_tag_j->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("config_store requires non-empty string d_tag");
|
||||
}
|
||||
if (!content_j) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("config_store requires content");
|
||||
}
|
||||
|
||||
char d_tag_buf[128];
|
||||
snprintf(d_tag_buf, sizeof(d_tag_buf), "%s", d_tag_j->valuestring);
|
||||
|
||||
char* content_text = NULL;
|
||||
if (cJSON_IsString(content_j) && content_j->valuestring) {
|
||||
content_text = strdup(content_j->valuestring);
|
||||
} else {
|
||||
content_text = cJSON_PrintUnformatted(content_j);
|
||||
}
|
||||
cJSON_Delete(args);
|
||||
|
||||
if (!content_text) {
|
||||
return json_error_local("config_store failed to serialize content");
|
||||
}
|
||||
|
||||
char* ciphertext = NULL;
|
||||
if (nip44_encrypt_self_local(ctx, content_text, &ciphertext) != 0) {
|
||||
free(content_text);
|
||||
return json_error_local("config_store NIP-44 encryption failed");
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
if (!tags) {
|
||||
free(content_text);
|
||||
free(ciphertext);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* d_tag = cJSON_CreateArray();
|
||||
cJSON* app_tag = cJSON_CreateArray();
|
||||
if (!d_tag || !app_tag) {
|
||||
cJSON_Delete(d_tag);
|
||||
cJSON_Delete(app_tag);
|
||||
cJSON_Delete(tags);
|
||||
free(content_text);
|
||||
free(ciphertext);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_buf));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString(CONFIG_APP_TAG));
|
||||
cJSON_AddItemToArray(tags, app_tag);
|
||||
|
||||
nostr_publish_result_t publish_result;
|
||||
memset(&publish_result, 0, sizeof(publish_result));
|
||||
|
||||
int rc = nostr_handler_publish_kind_event(CONFIG_KIND, ciphertext, tags, &publish_result);
|
||||
cJSON_Delete(tags);
|
||||
free(ciphertext);
|
||||
|
||||
if (rc != 0) {
|
||||
free(content_text);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json_error_local("config_store publish failed");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(content_text);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag_buf);
|
||||
cJSON_AddNumberToObject(out, "kind", CONFIG_KIND);
|
||||
cJSON_AddNumberToObject(out, "content_length", (double)strlen(content_text));
|
||||
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(content_text);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_config_recall(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* d_tag_j = cJSON_GetObjectItemCaseSensitive(args, "d_tag");
|
||||
if (!d_tag_j || !cJSON_IsString(d_tag_j) || !d_tag_j->valuestring || d_tag_j->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("config_recall requires non-empty string d_tag");
|
||||
}
|
||||
|
||||
char d_tag_buf[256];
|
||||
snprintf(d_tag_buf, sizeof(d_tag_buf), "%s", d_tag_j->valuestring);
|
||||
cJSON_Delete(args);
|
||||
|
||||
char* ciphertext = NULL;
|
||||
if (query_config_ciphertext_local(ctx, d_tag_buf, &ciphertext) != 0) {
|
||||
return json_error_local("config_recall query failed");
|
||||
}
|
||||
|
||||
if (!ciphertext || ciphertext[0] == '\0') {
|
||||
free(ciphertext);
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddBoolToObject(out, "found", 0);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag_buf);
|
||||
cJSON_AddStringToObject(out, "content", "");
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* plaintext = NULL;
|
||||
if (nip44_decrypt_self_local(ctx, ciphertext, &plaintext) != 0) {
|
||||
free(ciphertext);
|
||||
return json_error_local("config_recall NIP-44 decrypt failed");
|
||||
}
|
||||
free(ciphertext);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(plaintext);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddBoolToObject(out, "found", 1);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag_buf);
|
||||
cJSON_AddStringToObject(out, "content", plaintext);
|
||||
cJSON_AddNumberToObject(out, "content_length", (double)strlen(plaintext));
|
||||
|
||||
cJSON* parsed = cJSON_Parse(plaintext);
|
||||
if (parsed) {
|
||||
cJSON_AddItemToObject(out, "content_json", parsed);
|
||||
}
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(plaintext);
|
||||
return json;
|
||||
}
|
||||
515
src/tools/tool_local.c
Normal file
515
src/tools/tool_local.c
Normal file
@@ -0,0 +1,515 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
typedef struct {
|
||||
char* data;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
size_t max_bytes;
|
||||
int truncated;
|
||||
} local_http_fetch_buffer_t;
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static int is_safe_relative_path_local(const char* path) {
|
||||
if (!path || path[0] == '\0') return 0;
|
||||
if (path[0] == '/') return 0;
|
||||
if (strstr(path, "..") != NULL) return 0;
|
||||
if (strchr(path, '\\') != NULL) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) {
|
||||
if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1;
|
||||
if (!is_safe_relative_path_local(rel_path)) return -1;
|
||||
|
||||
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
|
||||
? ctx->cfg->tools.shell.working_directory
|
||||
: ".";
|
||||
|
||||
int n = 0;
|
||||
if (strcmp(cwd, ".") == 0) {
|
||||
n = snprintf(out, out_size, "%s", rel_path);
|
||||
} else {
|
||||
n = snprintf(out, out_size, "%s/%s", cwd, rel_path);
|
||||
}
|
||||
|
||||
if (n < 0 || (size_t)n >= out_size) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* shell_quote_single_local(const char* in) {
|
||||
if (!in) return NULL;
|
||||
|
||||
size_t len = strlen(in);
|
||||
size_t extra = 2U;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (in[i] == '\'') {
|
||||
extra += 4U;
|
||||
} else {
|
||||
extra += 1U;
|
||||
}
|
||||
}
|
||||
|
||||
char* out = (char*)malloc(extra + 1U);
|
||||
if (!out) return NULL;
|
||||
|
||||
size_t j = 0;
|
||||
out[j++] = '\'';
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (in[i] == '\'') {
|
||||
out[j++] = '\'';
|
||||
out[j++] = '\\';
|
||||
out[j++] = '\'';
|
||||
out[j++] = '\'';
|
||||
} else {
|
||||
out[j++] = in[i];
|
||||
}
|
||||
}
|
||||
out[j++] = '\'';
|
||||
out[j] = '\0';
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static size_t local_http_fetch_write_cb_local(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
local_http_fetch_buffer_t* rb = (local_http_fetch_buffer_t*)userp;
|
||||
size_t total = size * nmemb;
|
||||
if (!rb || total == 0) return total;
|
||||
|
||||
if (rb->len >= rb->max_bytes) {
|
||||
rb->truncated = 1;
|
||||
return total;
|
||||
}
|
||||
|
||||
size_t allowed = rb->max_bytes - rb->len;
|
||||
size_t to_copy = total <= allowed ? total : allowed;
|
||||
|
||||
if (rb->len + to_copy + 1U > rb->cap) {
|
||||
size_t new_cap = rb->cap == 0 ? 1024U : rb->cap;
|
||||
while (new_cap < rb->len + to_copy + 1U) {
|
||||
new_cap *= 2U;
|
||||
}
|
||||
char* bigger = (char*)realloc(rb->data, new_cap);
|
||||
if (!bigger) return 0;
|
||||
rb->data = bigger;
|
||||
rb->cap = new_cap;
|
||||
}
|
||||
|
||||
memcpy(rb->data + rb->len, contents, to_copy);
|
||||
rb->len += to_copy;
|
||||
rb->data[rb->len] = '\0';
|
||||
|
||||
if (to_copy < total) {
|
||||
rb->truncated = 1;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
static const char* detect_ca_bundle_path_for_tools_local(void) {
|
||||
const char* env = getenv("SSL_CERT_FILE");
|
||||
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
|
||||
return env;
|
||||
}
|
||||
|
||||
static const char* candidates[] = {
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
"/etc/ssl/cert.pem",
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/ssl/ca-bundle.pem"
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
|
||||
if (access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* url = cJSON_GetObjectItemCaseSensitive(args, "url");
|
||||
cJSON* method = cJSON_GetObjectItemCaseSensitive(args, "method");
|
||||
cJSON* headers = cJSON_GetObjectItemCaseSensitive(args, "headers");
|
||||
cJSON* body = cJSON_GetObjectItemCaseSensitive(args, "body");
|
||||
cJSON* timeout = cJSON_GetObjectItemCaseSensitive(args, "timeout_seconds");
|
||||
cJSON* maxb = cJSON_GetObjectItemCaseSensitive(args, "max_bytes");
|
||||
|
||||
if (!url || !cJSON_IsString(url) || !url->valuestring || url->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_http_fetch requires string url");
|
||||
}
|
||||
if (headers && !cJSON_IsArray(headers)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_http_fetch headers must be an array when provided");
|
||||
}
|
||||
if (body && !cJSON_IsString(body)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_http_fetch body must be a string when provided");
|
||||
}
|
||||
|
||||
const char* method_str = (method && cJSON_IsString(method) && method->valuestring && method->valuestring[0] != '\0')
|
||||
? method->valuestring
|
||||
: "GET";
|
||||
|
||||
if (body && cJSON_IsString(body) && body->valuestring && strcasecmp(method_str, "GET") == 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_http_fetch GET requests cannot include body");
|
||||
}
|
||||
|
||||
int default_timeout = ctx->cfg->tools.local_http_fetch_default_timeout_seconds > 0
|
||||
? ctx->cfg->tools.local_http_fetch_default_timeout_seconds
|
||||
: 20;
|
||||
int max_timeout = ctx->cfg->tools.local_http_fetch_max_timeout_seconds > 0
|
||||
? ctx->cfg->tools.local_http_fetch_max_timeout_seconds
|
||||
: 120;
|
||||
if (default_timeout > max_timeout) {
|
||||
default_timeout = max_timeout;
|
||||
}
|
||||
|
||||
int timeout_seconds = (timeout && cJSON_IsNumber(timeout)) ? (int)timeout->valuedouble : default_timeout;
|
||||
if (timeout_seconds <= 0) timeout_seconds = default_timeout;
|
||||
if (timeout_seconds > max_timeout) timeout_seconds = max_timeout;
|
||||
|
||||
int hard_max = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
|
||||
int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max;
|
||||
if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max;
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_http_fetch failed to initialize curl");
|
||||
}
|
||||
|
||||
local_http_fetch_buffer_t rb;
|
||||
memset(&rb, 0, sizeof(rb));
|
||||
rb.max_bytes = (size_t)max_bytes;
|
||||
|
||||
struct curl_slist* req_headers = NULL;
|
||||
req_headers = curl_slist_append(req_headers, "Accept: */*");
|
||||
|
||||
if (headers && cJSON_IsArray(headers)) {
|
||||
int n = cJSON_GetArraySize(headers);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* h = cJSON_GetArrayItem(headers, i);
|
||||
if (h && cJSON_IsString(h) && h->valuestring && h->valuestring[0] != '\0') {
|
||||
req_headers = curl_slist_append(req_headers, h->valuestring);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url->valuestring);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)timeout_seconds);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, local_http_fetch_write_cb_local);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, req_headers);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "didactyl/local_http_fetch");
|
||||
|
||||
const char* ca_bundle = detect_ca_bundle_path_for_tools_local();
|
||||
if (ca_bundle) {
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
|
||||
}
|
||||
|
||||
if (strcasecmp(method_str, "GET") == 0) {
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
|
||||
} else if (strcasecmp(method_str, "POST") == 0) {
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
if (body && cJSON_IsString(body) && body->valuestring) {
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring));
|
||||
}
|
||||
} else {
|
||||
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method_str);
|
||||
if (body && cJSON_IsString(body) && body->valuestring) {
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring));
|
||||
}
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status_code = 0;
|
||||
char* content_type = NULL;
|
||||
char* content_type_copy = NULL;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code);
|
||||
curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type && content_type[0] != '\0') {
|
||||
content_type_copy = strdup(content_type);
|
||||
}
|
||||
|
||||
curl_slist_free_all(req_headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(rb.data);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int http_ok = (status_code >= 200 && status_code < 300) ? 1 : 0;
|
||||
int success = (res == CURLE_OK && http_ok) ? 1 : 0;
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", success);
|
||||
cJSON_AddStringToObject(out, "url", url->valuestring);
|
||||
cJSON_AddStringToObject(out, "method", method_str);
|
||||
cJSON_AddNumberToObject(out, "status_code", status_code);
|
||||
cJSON_AddBoolToObject(out, "http_ok", http_ok);
|
||||
cJSON_AddBoolToObject(out, "truncated", rb.truncated ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "bytes_received", (double)rb.len);
|
||||
|
||||
if (content_type_copy && content_type_copy[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "content_type", content_type_copy);
|
||||
}
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
cJSON_AddStringToObject(out, "curl_error", curl_easy_strerror(res));
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(out, "body", rb.data ? rb.data : "");
|
||||
|
||||
free(rb.data);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(args);
|
||||
free(content_type_copy);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_local_shell_exec(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
if (!ctx->cfg->tools.shell.enabled) return json_error_local("shell tool disabled");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* command = cJSON_GetObjectItemCaseSensitive(args, "command");
|
||||
if (!command || !cJSON_IsString(command) || !command->valuestring || command->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_shell_exec requires string command");
|
||||
}
|
||||
|
||||
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
|
||||
? ctx->cfg->tools.shell.working_directory
|
||||
: ".";
|
||||
int timeout_s = ctx->cfg->tools.shell.timeout_seconds > 0 ? ctx->cfg->tools.shell.timeout_seconds : 30;
|
||||
|
||||
char* quoted_cwd = shell_quote_single_local(cwd);
|
||||
char* quoted_cmd = shell_quote_single_local(command->valuestring);
|
||||
cJSON_Delete(args);
|
||||
|
||||
if (!quoted_cwd || !quoted_cmd) {
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
|
||||
int needed = snprintf(NULL,
|
||||
0,
|
||||
"cd %s && timeout %ds sh -lc %s 2>&1",
|
||||
quoted_cwd,
|
||||
timeout_s,
|
||||
quoted_cmd);
|
||||
if (needed <= 0) {
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
return json_error_local("failed to build shell command");
|
||||
}
|
||||
|
||||
char* cmd = (char*)malloc((size_t)needed + 1U);
|
||||
if (!cmd) {
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
|
||||
snprintf(cmd,
|
||||
(size_t)needed + 1U,
|
||||
"cd %s && timeout %ds sh -lc %s 2>&1",
|
||||
quoted_cwd,
|
||||
timeout_s,
|
||||
quoted_cmd);
|
||||
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
|
||||
FILE* fp = popen(cmd, "r");
|
||||
free(cmd);
|
||||
if (!fp) return json_error_local("failed to execute command");
|
||||
|
||||
int max_bytes = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
|
||||
char* output = (char*)calloc((size_t)max_bytes + 1U, 1U);
|
||||
if (!output) {
|
||||
pclose(fp);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
|
||||
size_t used = 0;
|
||||
while (!feof(fp) && used < (size_t)max_bytes) {
|
||||
size_t n = fread(output + used, 1, (size_t)max_bytes - used, fp);
|
||||
used += n;
|
||||
if (n == 0) break;
|
||||
}
|
||||
|
||||
int raw_status = pclose(fp);
|
||||
int exit_status = raw_status;
|
||||
if (raw_status != -1) {
|
||||
if (WIFEXITED(raw_status)) {
|
||||
exit_status = WEXITSTATUS(raw_status);
|
||||
} else if (WIFSIGNALED(raw_status)) {
|
||||
exit_status = 128 + WTERMSIG(raw_status);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(output);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", exit_status == 0 ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "exit_status", exit_status);
|
||||
cJSON_AddStringToObject(out, "output", output);
|
||||
free(output);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_local_file_read(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
|
||||
cJSON* maxb = cJSON_GetObjectItemCaseSensitive(args, "max_bytes");
|
||||
if (!path || !cJSON_IsString(path) || !path->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_file_read requires string path");
|
||||
}
|
||||
|
||||
int hard_max = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
|
||||
int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max;
|
||||
if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max;
|
||||
|
||||
char file_path[PATH_MAX];
|
||||
if (build_tool_path_local(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_file_read path is not allowed");
|
||||
}
|
||||
|
||||
FILE* fp = fopen(file_path, "rb");
|
||||
cJSON_Delete(args);
|
||||
if (!fp) return json_error_local("local_file_read failed to open file");
|
||||
|
||||
char* buf = (char*)calloc((size_t)max_bytes + 1U, 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
|
||||
size_t n = fread(buf, 1, (size_t)max_bytes, fp);
|
||||
int truncated = !feof(fp) ? 1 : 0;
|
||||
fclose(fp);
|
||||
buf[n] = '\0';
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "path", file_path);
|
||||
cJSON_AddNumberToObject(out, "bytes_read", (double)n);
|
||||
cJSON_AddBoolToObject(out, "truncated", truncated);
|
||||
cJSON_AddStringToObject(out, "content", buf);
|
||||
free(buf);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_local_file_write(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
|
||||
cJSON* append = cJSON_GetObjectItemCaseSensitive(args, "append");
|
||||
if (!path || !cJSON_IsString(path) || !path->valuestring ||
|
||||
!content || !cJSON_IsString(content) || !content->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_file_write requires string path and content");
|
||||
}
|
||||
|
||||
char file_path[PATH_MAX];
|
||||
if (build_tool_path_local(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_file_write path is not allowed");
|
||||
}
|
||||
|
||||
const char* content_str = content->valuestring;
|
||||
size_t len = strlen(content_str);
|
||||
int do_append = (append && cJSON_IsBool(append) && cJSON_IsTrue(append)) ? 1 : 0;
|
||||
|
||||
FILE* fp = fopen(file_path, do_append ? "ab" : "wb");
|
||||
cJSON_Delete(args);
|
||||
if (!fp) return json_error_local("local_file_write failed to open file");
|
||||
|
||||
size_t n = fwrite(content_str, 1, len, fp);
|
||||
fclose(fp);
|
||||
if (n != len) return json_error_local("local_file_write failed to write all bytes");
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "path", file_path);
|
||||
cJSON_AddNumberToObject(out, "bytes_written", (double)n);
|
||||
cJSON_AddBoolToObject(out, "append", do_append);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
412
src/tools/tool_memory.c
Normal file
412
src/tools/tool_memory.c
Normal file
@@ -0,0 +1,412 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_handler.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define MEMORY_KIND 30078
|
||||
#define MEMORY_D_TAG "memory"
|
||||
#define MEMORY_APP_TAG "didactyl"
|
||||
#define MEMORY_MAX_CHARS 32000
|
||||
|
||||
static pthread_mutex_t g_memory_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static char* g_memory_plain = NULL;
|
||||
static int g_memory_loaded = 0;
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static char* strdup_nonnull_local(const char* s) {
|
||||
return strdup(s ? s : "");
|
||||
}
|
||||
|
||||
static void memory_cache_set_locked_local(const char* plaintext) {
|
||||
free(g_memory_plain);
|
||||
g_memory_plain = strdup_nonnull_local(plaintext);
|
||||
g_memory_loaded = 1;
|
||||
}
|
||||
|
||||
static char* memory_cache_get_copy_locked_local(void) {
|
||||
return strdup(g_memory_plain ? g_memory_plain : "");
|
||||
}
|
||||
|
||||
static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, char** out_ciphertext) {
|
||||
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
|
||||
|
||||
const char* plain = plaintext ? plaintext : "";
|
||||
size_t out_cap = (strlen(plain) * 4U) + 1024U;
|
||||
char* ciphertext = (char*)malloc(out_cap);
|
||||
if (!ciphertext) return -1;
|
||||
|
||||
int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key,
|
||||
ctx->cfg->keys.public_key,
|
||||
plain,
|
||||
ciphertext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(ciphertext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_ciphertext = ciphertext;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) {
|
||||
if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1;
|
||||
|
||||
size_t out_cap = strlen(ciphertext) + 1024U;
|
||||
char* plaintext = (char*)malloc(out_cap);
|
||||
if (!plaintext) return -1;
|
||||
|
||||
int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key,
|
||||
ctx->cfg->keys.public_key,
|
||||
ciphertext,
|
||||
plaintext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(plaintext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_plaintext = plaintext;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int query_memory_ciphertext_local(tools_context_t* ctx, char** out_ciphertext) {
|
||||
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
cJSON* d_vals = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors || !d_vals) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
cJSON_Delete(d_vals);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(MEMORY_KIND));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
cJSON_AddItemToArray(d_vals, cJSON_CreateString(MEMORY_D_TAG));
|
||||
cJSON_AddItemToObject(filter, "#d", d_vals);
|
||||
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
|
||||
cJSON_Delete(arr);
|
||||
*out_ciphertext = strdup("");
|
||||
return (*out_ciphertext != NULL) ? 0 : -1;
|
||||
}
|
||||
|
||||
cJSON* ev = cJSON_GetArrayItem(arr, 0);
|
||||
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
|
||||
const char* cipher = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
|
||||
*out_ciphertext = strdup(cipher);
|
||||
cJSON_Delete(arr);
|
||||
return (*out_ciphertext != NULL) ? 0 : -1;
|
||||
}
|
||||
|
||||
static int memory_load_local(tools_context_t* ctx) {
|
||||
char* ciphertext = NULL;
|
||||
if (query_memory_ciphertext_local(ctx, &ciphertext) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* plaintext = NULL;
|
||||
if (!ciphertext || ciphertext[0] == '\0') {
|
||||
plaintext = strdup("");
|
||||
} else if (nip44_decrypt_self_local(ctx, ciphertext, &plaintext) != 0) {
|
||||
fprintf(stdout,
|
||||
"[didactyl] warning: failed to NIP-44 decrypt memory; resetting to empty\n");
|
||||
plaintext = strdup("");
|
||||
}
|
||||
free(ciphertext);
|
||||
|
||||
if (!plaintext) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_memory_mutex);
|
||||
memory_cache_set_locked_local(plaintext);
|
||||
pthread_mutex_unlock(&g_memory_mutex);
|
||||
|
||||
free(plaintext);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* trim_and_limit_entry_local(const char* in, int* out_truncated) {
|
||||
if (out_truncated) *out_truncated = 0;
|
||||
if (!in) return strdup("");
|
||||
|
||||
while (*in == ' ' || *in == '\t' || *in == '\r' || *in == '\n') in++;
|
||||
size_t n = strlen(in);
|
||||
while (n > 0 && (in[n - 1] == ' ' || in[n - 1] == '\t' || in[n - 1] == '\r' || in[n - 1] == '\n')) {
|
||||
n--;
|
||||
}
|
||||
|
||||
if (n > MEMORY_MAX_CHARS) {
|
||||
n = MEMORY_MAX_CHARS;
|
||||
if (out_truncated) *out_truncated = 1;
|
||||
}
|
||||
|
||||
char* out = (char*)malloc(n + 1U);
|
||||
if (!out) return NULL;
|
||||
if (n > 0) memcpy(out, in, n);
|
||||
out[n] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* prepend_and_truncate_local(const char* existing, const char* incoming, int* out_truncated) {
|
||||
if (out_truncated) *out_truncated = 0;
|
||||
|
||||
const char* old = existing ? existing : "";
|
||||
const char* add = incoming ? incoming : "";
|
||||
const char* sep = "\n\n---\n\n";
|
||||
|
||||
size_t add_len = strlen(add);
|
||||
size_t old_len = strlen(old);
|
||||
size_t sep_len = (add_len > 0 && old_len > 0) ? strlen(sep) : 0U;
|
||||
|
||||
size_t total_len = add_len + sep_len + old_len;
|
||||
char* joined = (char*)malloc(total_len + 1U);
|
||||
if (!joined) return NULL;
|
||||
|
||||
size_t used = 0U;
|
||||
if (add_len > 0) {
|
||||
memcpy(joined + used, add, add_len);
|
||||
used += add_len;
|
||||
}
|
||||
if (sep_len > 0) {
|
||||
memcpy(joined + used, sep, sep_len);
|
||||
used += sep_len;
|
||||
}
|
||||
if (old_len > 0) {
|
||||
memcpy(joined + used, old, old_len);
|
||||
used += old_len;
|
||||
}
|
||||
joined[used] = '\0';
|
||||
|
||||
if (used <= MEMORY_MAX_CHARS) {
|
||||
return joined;
|
||||
}
|
||||
|
||||
if (out_truncated) *out_truncated = 1;
|
||||
char* clipped = (char*)malloc(MEMORY_MAX_CHARS + 1U);
|
||||
if (!clipped) {
|
||||
free(joined);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(clipped, joined, MEMORY_MAX_CHARS);
|
||||
clipped[MEMORY_MAX_CHARS] = '\0';
|
||||
free(joined);
|
||||
return clipped;
|
||||
}
|
||||
|
||||
int memory_init(tools_context_t* ctx) {
|
||||
if (!ctx || !ctx->cfg) return -1;
|
||||
return memory_load_local(ctx);
|
||||
}
|
||||
|
||||
void memory_cleanup(void) {
|
||||
pthread_mutex_lock(&g_memory_mutex);
|
||||
free(g_memory_plain);
|
||||
g_memory_plain = NULL;
|
||||
g_memory_loaded = 0;
|
||||
pthread_mutex_unlock(&g_memory_mutex);
|
||||
}
|
||||
|
||||
char* execute_memory_save(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* content_j = cJSON_GetObjectItemCaseSensitive(args, "content");
|
||||
if (!content_j || !cJSON_IsString(content_j) || !content_j->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("memory_save requires string content");
|
||||
}
|
||||
|
||||
int entry_truncated = 0;
|
||||
char* incoming = trim_and_limit_entry_local(content_j->valuestring, &entry_truncated);
|
||||
cJSON_Delete(args);
|
||||
if (!incoming) return NULL;
|
||||
if (incoming[0] == '\0') {
|
||||
free(incoming);
|
||||
return json_error_local("memory_save content cannot be empty");
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_memory_mutex);
|
||||
char* old_copy = memory_cache_get_copy_locked_local();
|
||||
pthread_mutex_unlock(&g_memory_mutex);
|
||||
if (!old_copy) {
|
||||
free(incoming);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int body_truncated = 0;
|
||||
char* merged = prepend_and_truncate_local(old_copy, incoming, &body_truncated);
|
||||
free(old_copy);
|
||||
free(incoming);
|
||||
if (!merged) return NULL;
|
||||
|
||||
char* ciphertext = NULL;
|
||||
if (nip44_encrypt_self_local(ctx, merged, &ciphertext) != 0) {
|
||||
free(merged);
|
||||
return json_error_local("memory_save NIP-44 encryption failed");
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
if (!tags) {
|
||||
free(merged);
|
||||
free(ciphertext);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* d_tag = cJSON_CreateArray();
|
||||
cJSON* app_tag = cJSON_CreateArray();
|
||||
if (!d_tag || !app_tag) {
|
||||
cJSON_Delete(d_tag);
|
||||
cJSON_Delete(app_tag);
|
||||
cJSON_Delete(tags);
|
||||
free(merged);
|
||||
free(ciphertext);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(MEMORY_D_TAG));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString(MEMORY_APP_TAG));
|
||||
cJSON_AddItemToArray(tags, app_tag);
|
||||
|
||||
nostr_publish_result_t publish_result;
|
||||
memset(&publish_result, 0, sizeof(publish_result));
|
||||
|
||||
int rc = nostr_handler_publish_kind_event(MEMORY_KIND, ciphertext, tags, &publish_result);
|
||||
cJSON_Delete(tags);
|
||||
free(ciphertext);
|
||||
|
||||
if (rc != 0) {
|
||||
free(merged);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json_error_local("memory_save publish failed");
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_memory_mutex);
|
||||
memory_cache_set_locked_local(merged);
|
||||
pthread_mutex_unlock(&g_memory_mutex);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(merged);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "d_tag", MEMORY_D_TAG);
|
||||
cJSON_AddNumberToObject(out, "kind", MEMORY_KIND);
|
||||
cJSON_AddNumberToObject(out, "entry_length", (double)strlen(merged));
|
||||
cJSON_AddBoolToObject(out, "truncated", (entry_truncated || body_truncated) ? 1 : 0);
|
||||
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(merged);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_memory_recall(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
pthread_mutex_lock(&g_memory_mutex);
|
||||
int loaded = g_memory_loaded;
|
||||
pthread_mutex_unlock(&g_memory_mutex);
|
||||
|
||||
if (!loaded) {
|
||||
(void)memory_load_local(ctx);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_memory_mutex);
|
||||
char* memory = memory_cache_get_copy_locked_local();
|
||||
pthread_mutex_unlock(&g_memory_mutex);
|
||||
if (!memory) return NULL;
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(memory);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "content", memory);
|
||||
cJSON_AddNumberToObject(out, "content_length", (double)strlen(memory));
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(memory);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_my_memory(tools_context_t* ctx, const char* args_json) {
|
||||
return execute_memory_recall(ctx, args_json);
|
||||
}
|
||||
|
||||
char* execute_memory_short_term_read(tools_context_t* ctx, const char* args_json) {
|
||||
(void)ctx;
|
||||
(void)args_json;
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "content", "");
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
103
src/tools/tool_meta.c
Normal file
103
src/tools/tool_meta.c
Normal file
@@ -0,0 +1,103 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../trigger_manager.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
char* execute_tool_list(tools_context_t* ctx, const char* args_json) {
|
||||
(void)args_json;
|
||||
if (!ctx) {
|
||||
return json_error_local("tool context unavailable");
|
||||
}
|
||||
|
||||
char* schema_json = tools_build_openai_schema_json(ctx);
|
||||
if (!schema_json) {
|
||||
return json_error_local("failed to build tool schemas");
|
||||
}
|
||||
|
||||
cJSON* schema_arr = cJSON_Parse(schema_json);
|
||||
free(schema_json);
|
||||
if (!schema_arr || !cJSON_IsArray(schema_arr)) {
|
||||
cJSON_Delete(schema_arr);
|
||||
return json_error_local("failed to parse tool schemas");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
cJSON* tools = cJSON_CreateArray();
|
||||
if (!out || !tools) {
|
||||
cJSON_Delete(schema_arr);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(tools);
|
||||
return json_error_local("out of memory");
|
||||
}
|
||||
|
||||
cJSON* item = NULL;
|
||||
cJSON_ArrayForEach(item, schema_arr) {
|
||||
cJSON* fn = cJSON_GetObjectItemCaseSensitive(item, "function");
|
||||
if (!fn || !cJSON_IsObject(fn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(fn, "name");
|
||||
if (!name || !cJSON_IsString(name) || !name->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* row = cJSON_CreateObject();
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(row, "name", name->valuestring);
|
||||
|
||||
cJSON* description = cJSON_GetObjectItemCaseSensitive(fn, "description");
|
||||
if (description && cJSON_IsString(description) && description->valuestring) {
|
||||
cJSON_AddStringToObject(row, "description", description->valuestring);
|
||||
} else {
|
||||
cJSON_AddStringToObject(row, "description", "");
|
||||
}
|
||||
|
||||
cJSON* parameters = cJSON_GetObjectItemCaseSensitive(fn, "parameters");
|
||||
if (parameters) {
|
||||
cJSON_AddItemToObject(row, "parameters", cJSON_Duplicate(parameters, 1));
|
||||
} else {
|
||||
cJSON_AddItemToObject(row, "parameters", cJSON_CreateObject());
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(tools, row);
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddNumberToObject(out, "count", (double)cJSON_GetArraySize(tools));
|
||||
cJSON_AddItemToObject(out, "tools", tools);
|
||||
|
||||
char* result = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(schema_arr);
|
||||
return result;
|
||||
}
|
||||
|
||||
char* execute_trigger_list(tools_context_t* ctx, const char* args_json) {
|
||||
(void)args_json;
|
||||
if (!ctx || !ctx->trigger_manager) {
|
||||
return json_error_local("trigger manager unavailable");
|
||||
}
|
||||
char* status = trigger_manager_status_json(ctx->trigger_manager);
|
||||
if (!status) {
|
||||
return json_error_local("failed to build trigger status");
|
||||
}
|
||||
return status;
|
||||
}
|
||||
317
src/tools/tool_model.c
Normal file
317
src/tools/tool_model.c
Normal file
@@ -0,0 +1,317 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../config.h"
|
||||
#include "../llm.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cfg, char** out_error) {
|
||||
if (!ctx || !ctx->cfg || !cfg) {
|
||||
if (out_error) *out_error = strdup("invalid persist context");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (out_error) *out_error = NULL;
|
||||
|
||||
cJSON* args = cJSON_CreateObject();
|
||||
cJSON* content = cJSON_CreateObject();
|
||||
if (!args || !content) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(content);
|
||||
if (out_error) *out_error = strdup("allocation failure while building llm_config payload");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(args, "d_tag", "llm_config");
|
||||
cJSON_AddStringToObject(content, "provider", cfg->provider);
|
||||
cJSON_AddStringToObject(content, "api_key", cfg->api_key);
|
||||
cJSON_AddStringToObject(content, "model", cfg->model);
|
||||
cJSON_AddStringToObject(content, "base_url", cfg->base_url);
|
||||
cJSON_AddNumberToObject(content, "max_tokens", cfg->max_tokens);
|
||||
cJSON_AddNumberToObject(content, "temperature", cfg->temperature);
|
||||
cJSON_AddItemToObject(args, "content", content);
|
||||
|
||||
char* store_args_json = cJSON_PrintUnformatted(args);
|
||||
cJSON_Delete(args);
|
||||
if (!store_args_json) {
|
||||
if (out_error) *out_error = strdup("failed to serialize llm_config payload");
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* store_result = execute_config_store(ctx, store_args_json);
|
||||
free(store_args_json);
|
||||
if (!store_result) {
|
||||
if (out_error) *out_error = strdup("config_store returned no response");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(store_result);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
if (out_error) *out_error = strdup("config_store returned invalid JSON");
|
||||
free(store_result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
if (!success || !cJSON_IsBool(success) || !cJSON_IsTrue(success)) {
|
||||
cJSON* err = cJSON_GetObjectItemCaseSensitive(root, "error");
|
||||
if (out_error) {
|
||||
if (err && cJSON_IsString(err) && err->valuestring) {
|
||||
*out_error = strdup(err->valuestring);
|
||||
} else {
|
||||
*out_error = strdup("config_store failed");
|
||||
}
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
free(store_result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
free(store_result);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* changed) {
|
||||
if (!item) return 0;
|
||||
if (!cJSON_IsString(item) || !item->valuestring) return -1;
|
||||
size_t n = strlen(item->valuestring);
|
||||
if (n >= dst_size) return -1;
|
||||
memcpy(dst, item->valuestring, n + 1U);
|
||||
if (changed) *changed = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void mask_api_key_local(const char* api_key, char* out, size_t out_size) {
|
||||
if (!out || out_size == 0) return;
|
||||
out[0] = '\0';
|
||||
if (!api_key || api_key[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
size_t api_len = strlen(api_key);
|
||||
if (api_len >= 8U) {
|
||||
snprintf(out,
|
||||
out_size,
|
||||
"%.4s...%s",
|
||||
api_key,
|
||||
api_key + api_len - 4U);
|
||||
} else {
|
||||
snprintf(out, out_size, "%s", "(set)");
|
||||
}
|
||||
}
|
||||
|
||||
char* execute_model_get(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
cJSON_Delete(args);
|
||||
|
||||
llm_config_t cfg;
|
||||
if (llm_get_config(&cfg) != 0) {
|
||||
return json_error_local("llm runtime unavailable");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
|
||||
char api_key_masked[64] = {0};
|
||||
mask_api_key_local(cfg.api_key, api_key_masked, sizeof(api_key_masked));
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "provider", cfg.provider);
|
||||
cJSON_AddStringToObject(out, "model", cfg.model);
|
||||
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
|
||||
cJSON_AddStringToObject(out, "api_key", api_key_masked);
|
||||
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
|
||||
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_model_set(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
llm_config_t cfg;
|
||||
if (llm_get_config(&cfg) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("llm runtime unavailable");
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
|
||||
cJSON* provider = cJSON_GetObjectItemCaseSensitive(args, "provider");
|
||||
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(args, "api_key");
|
||||
cJSON* model = cJSON_GetObjectItemCaseSensitive(args, "model");
|
||||
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(args, "base_url");
|
||||
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(args, "max_tokens");
|
||||
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(args, "temperature");
|
||||
|
||||
if (assign_string_field(provider, cfg.provider, sizeof(cfg.provider), &changed) != 0 ||
|
||||
assign_string_field(api_key, cfg.api_key, sizeof(cfg.api_key), &changed) != 0 ||
|
||||
assign_string_field(model, cfg.model, sizeof(cfg.model), &changed) != 0 ||
|
||||
assign_string_field(base_url, cfg.base_url, sizeof(cfg.base_url), &changed) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("model_set string field invalid or too long");
|
||||
}
|
||||
|
||||
if (max_tokens) {
|
||||
if (!cJSON_IsNumber(max_tokens)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("model_set max_tokens must be a number");
|
||||
}
|
||||
cfg.max_tokens = (int)max_tokens->valuedouble;
|
||||
changed = 1;
|
||||
}
|
||||
|
||||
if (temperature) {
|
||||
if (!cJSON_IsNumber(temperature)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("model_set temperature must be a number");
|
||||
}
|
||||
cfg.temperature = temperature->valuedouble;
|
||||
changed = 1;
|
||||
}
|
||||
|
||||
cJSON_Delete(args);
|
||||
|
||||
if (!changed) {
|
||||
return json_error_local("model_set requires at least one field to update");
|
||||
}
|
||||
|
||||
if (llm_set_config(&cfg) != 0) {
|
||||
return json_error_local("failed to update runtime llm config");
|
||||
}
|
||||
|
||||
ctx->cfg->llm = cfg;
|
||||
|
||||
char* persist_error = NULL;
|
||||
int persisted = (persist_llm_config_nostr(ctx, &cfg, &persist_error) == 0) ? 1 : 0;
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
char api_key_masked[64] = {0};
|
||||
mask_api_key_local(cfg.api_key, api_key_masked, sizeof(api_key_masked));
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "provider", cfg.provider);
|
||||
cJSON_AddStringToObject(out, "model", cfg.model);
|
||||
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
|
||||
cJSON_AddStringToObject(out, "api_key", api_key_masked);
|
||||
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
|
||||
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
|
||||
cJSON_AddBoolToObject(out, "persisted", persisted ? 1 : 0);
|
||||
if (!persisted && persist_error && persist_error[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "persist_warning", persist_error);
|
||||
}
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(persist_error);
|
||||
return json;
|
||||
}
|
||||
|
||||
static void append_model_id(cJSON* ids, cJSON* item) {
|
||||
if (!ids || !item) return;
|
||||
if (cJSON_IsString(item) && item->valuestring) {
|
||||
cJSON_AddItemToArray(ids, cJSON_CreateString(item->valuestring));
|
||||
return;
|
||||
}
|
||||
if (cJSON_IsObject(item)) {
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(item, "id");
|
||||
if (id && cJSON_IsString(id) && id->valuestring) {
|
||||
cJSON_AddItemToArray(ids, cJSON_CreateString(id->valuestring));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
char* execute_model_list(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(args, "base_url");
|
||||
if (base_url && (!cJSON_IsString(base_url) || !base_url->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("model_list base_url must be a string");
|
||||
}
|
||||
|
||||
const char* base_url_override = (base_url && base_url->valuestring && base_url->valuestring[0] != '\0')
|
||||
? base_url->valuestring
|
||||
: NULL;
|
||||
|
||||
char* raw = llm_list_models_json(base_url_override);
|
||||
cJSON_Delete(args);
|
||||
if (!raw) {
|
||||
return json_error_local("model_list request failed");
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(raw);
|
||||
free(raw);
|
||||
if (!root) {
|
||||
cJSON_Delete(root);
|
||||
return json_error_local("model_list returned invalid JSON");
|
||||
}
|
||||
|
||||
cJSON* ids = cJSON_CreateArray();
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!ids || !out) {
|
||||
cJSON_Delete(ids);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (cJSON_IsArray(root)) {
|
||||
int n = cJSON_GetArraySize(root);
|
||||
for (int i = 0; i < n; i++) {
|
||||
append_model_id(ids, cJSON_GetArrayItem(root, i));
|
||||
}
|
||||
} else if (cJSON_IsObject(root)) {
|
||||
cJSON* data = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
if (data && cJSON_IsArray(data)) {
|
||||
int n = cJSON_GetArraySize(data);
|
||||
for (int i = 0; i < n; i++) {
|
||||
append_model_id(ids, cJSON_GetArrayItem(data, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(ids));
|
||||
cJSON_AddItemToObject(out, "models", ids);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(root);
|
||||
return json;
|
||||
}
|
||||
218
src/tools/tool_nostr_dm.c
Normal file
218
src/tools/tool_nostr_dm.c
Normal file
@@ -0,0 +1,218 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_handler.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static int is_hex_string_len_local(const char* s, size_t expected_len) {
|
||||
if (!s) return 0;
|
||||
if (strlen(s) != expected_len) return 0;
|
||||
for (size_t i = 0; i < expected_len; i++) {
|
||||
if (!((s[i] >= '0' && s[i] <= '9') ||
|
||||
(s[i] >= 'a' && s[i] <= 'f') ||
|
||||
(s[i] >= 'A' && s[i] <= 'F'))) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
char* execute_nostr_dm_send(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* recipient = cJSON_GetObjectItemCaseSensitive(args, "recipient_pubkey");
|
||||
cJSON* message = cJSON_GetObjectItemCaseSensitive(args, "message");
|
||||
if (!recipient || !cJSON_IsString(recipient) || !recipient->valuestring || !is_hex_string_len_local(recipient->valuestring, 64U) ||
|
||||
!message || !cJSON_IsString(message) || !message->valuestring || message->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_dm_send requires recipient_pubkey hex and non-empty message");
|
||||
}
|
||||
|
||||
int rc = nostr_handler_send_dm(recipient->valuestring, message->valuestring);
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", rc == 0 ? 1 : 0);
|
||||
cJSON_AddStringToObject(out, "recipient_pubkey", recipient->valuestring);
|
||||
cJSON_AddNumberToObject(out, "message_length", (double)strlen(message->valuestring));
|
||||
cJSON_Delete(args);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* recipient = cJSON_GetObjectItemCaseSensitive(args, "recipient_pubkey");
|
||||
cJSON* plaintext = cJSON_GetObjectItemCaseSensitive(args, "plaintext");
|
||||
if (!recipient || !cJSON_IsString(recipient) || !recipient->valuestring || !is_hex_string_len_local(recipient->valuestring, 64U) ||
|
||||
!plaintext || !cJSON_IsString(plaintext) || !plaintext->valuestring || plaintext->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encrypt requires recipient_pubkey hex and plaintext");
|
||||
}
|
||||
|
||||
unsigned char recipient_pubkey[32];
|
||||
if (nostr_hex_to_bytes(recipient->valuestring, recipient_pubkey, sizeof(recipient_pubkey)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encrypt invalid recipient_pubkey");
|
||||
}
|
||||
|
||||
size_t out_cap = (strlen(plaintext->valuestring) * 4U) + 1024U;
|
||||
char* ciphertext = (char*)malloc(out_cap);
|
||||
if (!ciphertext) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
|
||||
int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key,
|
||||
recipient_pubkey,
|
||||
plaintext->valuestring,
|
||||
ciphertext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(ciphertext);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encrypt failed");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(ciphertext);
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "recipient_pubkey", recipient->valuestring);
|
||||
cJSON_AddStringToObject(out, "ciphertext", ciphertext);
|
||||
|
||||
free(ciphertext);
|
||||
cJSON_Delete(args);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* sender = cJSON_GetObjectItemCaseSensitive(args, "sender_pubkey");
|
||||
cJSON* ciphertext = cJSON_GetObjectItemCaseSensitive(args, "ciphertext");
|
||||
if (!sender || !cJSON_IsString(sender) || !sender->valuestring || !is_hex_string_len_local(sender->valuestring, 64U) ||
|
||||
!ciphertext || !cJSON_IsString(ciphertext) || !ciphertext->valuestring || ciphertext->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_decrypt requires sender_pubkey hex and ciphertext");
|
||||
}
|
||||
|
||||
unsigned char sender_pubkey[32];
|
||||
if (nostr_hex_to_bytes(sender->valuestring, sender_pubkey, sizeof(sender_pubkey)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_decrypt invalid sender_pubkey");
|
||||
}
|
||||
|
||||
size_t out_cap = strlen(ciphertext->valuestring) + 1024U;
|
||||
char* plaintext = (char*)malloc(out_cap);
|
||||
if (!plaintext) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
|
||||
int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key,
|
||||
sender_pubkey,
|
||||
ciphertext->valuestring,
|
||||
plaintext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(plaintext);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_decrypt failed");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(plaintext);
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "sender_pubkey", sender->valuestring);
|
||||
cJSON_AddStringToObject(out, "plaintext", plaintext);
|
||||
|
||||
free(plaintext);
|
||||
cJSON_Delete(args);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_dm_send_nip17(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* recipient = cJSON_GetObjectItemCaseSensitive(args, "recipient_pubkey");
|
||||
cJSON* message = cJSON_GetObjectItemCaseSensitive(args, "message");
|
||||
cJSON* subject = cJSON_GetObjectItemCaseSensitive(args, "subject");
|
||||
|
||||
if (!recipient || !cJSON_IsString(recipient) || !recipient->valuestring || !is_hex_string_len_local(recipient->valuestring, 64U) ||
|
||||
!message || !cJSON_IsString(message) || !message->valuestring || message->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_dm_send_nip17 requires recipient_pubkey hex and non-empty message");
|
||||
}
|
||||
if (subject && !cJSON_IsString(subject)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_dm_send_nip17 subject must be a string when provided");
|
||||
}
|
||||
|
||||
int rc = nostr_handler_send_dm_nip17(recipient->valuestring,
|
||||
message->valuestring,
|
||||
(subject && subject->valuestring) ? subject->valuestring : NULL);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", rc == 0 ? 1 : 0);
|
||||
cJSON_AddStringToObject(out, "recipient_pubkey", recipient->valuestring);
|
||||
cJSON_AddStringToObject(out, "protocol", "nip17");
|
||||
cJSON_AddNumberToObject(out, "message_length", (double)strlen(message->valuestring));
|
||||
|
||||
cJSON_Delete(args);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
257
src/tools/tool_nostr_identity.c
Normal file
257
src/tools/tool_nostr_identity.c
Normal file
@@ -0,0 +1,257 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static int is_hex_string_len_local(const char* s, size_t expected_len) {
|
||||
if (!s) return 0;
|
||||
if (strlen(s) != expected_len) return 0;
|
||||
for (size_t i = 0; i < expected_len; i++) {
|
||||
if (!((s[i] >= '0' && s[i] <= '9') ||
|
||||
(s[i] >= 'a' && s[i] <= 'f') ||
|
||||
(s[i] >= 'A' && s[i] <= 'F'))) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
char* execute_nostr_encode(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* type = cJSON_GetObjectItemCaseSensitive(args, "type");
|
||||
cJSON* hex = cJSON_GetObjectItemCaseSensitive(args, "hex");
|
||||
cJSON* relays = cJSON_GetObjectItemCaseSensitive(args, "relays");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind");
|
||||
cJSON* identifier = cJSON_GetObjectItemCaseSensitive(args, "identifier");
|
||||
|
||||
if (!type || !cJSON_IsString(type) || !type->valuestring ||
|
||||
!hex || !cJSON_IsString(hex) || !hex->valuestring || !is_hex_string_len_local(hex->valuestring, 64U)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode requires type string and hex 64-char string");
|
||||
}
|
||||
|
||||
char type_name[32];
|
||||
snprintf(type_name, sizeof(type_name), "%s", type->valuestring);
|
||||
|
||||
unsigned char key_bytes[32];
|
||||
if (nostr_hex_to_bytes(hex->valuestring, key_bytes, sizeof(key_bytes)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode invalid hex");
|
||||
}
|
||||
|
||||
const char** relay_ptrs = NULL;
|
||||
int relay_count = 0;
|
||||
if (relays) {
|
||||
if (!cJSON_IsArray(relays)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode relays must be an array of relay URLs");
|
||||
}
|
||||
relay_count = cJSON_GetArraySize(relays);
|
||||
if (relay_count > 0) {
|
||||
relay_ptrs = (const char**)malloc((size_t)relay_count * sizeof(char*));
|
||||
if (!relay_ptrs) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("memory allocation failed");
|
||||
}
|
||||
for (int i = 0; i < relay_count; i++) {
|
||||
cJSON* relay = cJSON_GetArrayItem(relays, i);
|
||||
if (!relay || !cJSON_IsString(relay) || !relay->valuestring || relay->valuestring[0] == '\0') {
|
||||
free(relay_ptrs);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode relays must contain non-empty strings");
|
||||
}
|
||||
relay_ptrs[i] = relay->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
char bech32[256] = {0};
|
||||
char uri[1200] = {0};
|
||||
int rc = NOSTR_ERROR_INVALID_INPUT;
|
||||
|
||||
if (strcmp(type_name, "npub") == 0) {
|
||||
rc = nostr_key_to_bech32(key_bytes, "npub", bech32);
|
||||
if (rc == NOSTR_SUCCESS) {
|
||||
snprintf(uri, sizeof(uri), "nostr:%s", bech32);
|
||||
}
|
||||
} else if (strcmp(type_name, "nsec") == 0) {
|
||||
rc = nostr_key_to_bech32(key_bytes, "nsec", bech32);
|
||||
if (rc == NOSTR_SUCCESS) {
|
||||
snprintf(uri, sizeof(uri), "nostr:%s", bech32);
|
||||
}
|
||||
} else if (strcmp(type_name, "note") == 0) {
|
||||
rc = nostr_key_to_bech32(key_bytes, "note", bech32);
|
||||
if (rc == NOSTR_SUCCESS) {
|
||||
snprintf(uri, sizeof(uri), "nostr:%s", bech32);
|
||||
}
|
||||
} else if (strcmp(type_name, "nprofile") == 0) {
|
||||
rc = nostr_build_uri_nprofile(key_bytes, relay_ptrs, relay_count, uri, sizeof(uri));
|
||||
} else if (strcmp(type_name, "nevent") == 0) {
|
||||
int kind_value = -1;
|
||||
if (kind) {
|
||||
if (!cJSON_IsNumber(kind)) {
|
||||
free(relay_ptrs);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode nevent kind must be an integer");
|
||||
}
|
||||
kind_value = (int)kind->valuedouble;
|
||||
}
|
||||
rc = nostr_build_uri_nevent(key_bytes, relay_ptrs, relay_count, NULL, kind_value, 0, uri, sizeof(uri));
|
||||
} else if (strcmp(type_name, "naddr") == 0) {
|
||||
if (!kind || !cJSON_IsNumber(kind)) {
|
||||
free(relay_ptrs);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode naddr requires kind integer");
|
||||
}
|
||||
if (!identifier || !cJSON_IsString(identifier) || !identifier->valuestring || identifier->valuestring[0] == '\0') {
|
||||
free(relay_ptrs);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode naddr requires non-empty identifier string");
|
||||
}
|
||||
rc = nostr_build_uri_naddr(identifier->valuestring, key_bytes, (int)kind->valuedouble,
|
||||
relay_ptrs, relay_count, uri, sizeof(uri));
|
||||
} else {
|
||||
free(relay_ptrs);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_encode currently supports npub, nsec, note, nprofile, nevent, naddr");
|
||||
}
|
||||
|
||||
free(relay_ptrs);
|
||||
cJSON_Delete(args);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
return json_error_local("nostr_encode failed");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "type", type_name);
|
||||
cJSON_AddStringToObject(out, "uri", uri);
|
||||
cJSON_AddBoolToObject(out, "limited_support", 1);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_decode(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* uri_in = cJSON_GetObjectItemCaseSensitive(args, "uri");
|
||||
if (!uri_in || !cJSON_IsString(uri_in) || !uri_in->valuestring || uri_in->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_decode requires uri string");
|
||||
}
|
||||
|
||||
const char* in = uri_in->valuestring;
|
||||
if (strncmp(in, "nostr:", 6) == 0) {
|
||||
in += 6;
|
||||
}
|
||||
|
||||
unsigned char key[32];
|
||||
char key_hex[65] = {0};
|
||||
const char* type = NULL;
|
||||
int rc = NOSTR_ERROR_INVALID_INPUT;
|
||||
|
||||
if (strncmp(in, "npub1", 5) == 0) {
|
||||
type = "npub";
|
||||
rc = nostr_decode_npub(in, key);
|
||||
} else if (strncmp(in, "nsec1", 5) == 0) {
|
||||
type = "nsec";
|
||||
rc = nostr_decode_nsec(in, key);
|
||||
} else {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_decode currently supports npub and nsec");
|
||||
}
|
||||
|
||||
cJSON_Delete(args);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
return json_error_local("nostr_decode failed");
|
||||
}
|
||||
|
||||
nostr_bytes_to_hex(key, 32, key_hex);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "type", type);
|
||||
if (strcmp(type, "npub") == 0) {
|
||||
cJSON_AddStringToObject(out, "pubkey", key_hex);
|
||||
} else {
|
||||
cJSON_AddStringToObject(out, "private_key", key_hex);
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "limited_support", 1);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("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;
|
||||
}
|
||||
|
||||
char* execute_nostr_npub(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("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_local("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;
|
||||
}
|
||||
361
src/tools/tool_nostr_list.c
Normal file
361
src/tools/tool_nostr_list.c
Normal file
@@ -0,0 +1,361 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_handler.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static int is_hex_string_len_local(const char* s, size_t expected_len) {
|
||||
if (!s) return 0;
|
||||
if (strlen(s) != expected_len) return 0;
|
||||
for (size_t i = 0; i < expected_len; i++) {
|
||||
if (!((s[i] >= '0' && s[i] <= '9') ||
|
||||
(s[i] >= 'a' && s[i] <= 'f') ||
|
||||
(s[i] >= 'A' && s[i] <= 'F'))) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int add_string_tag_local(cJSON* tags, const char* key, const char* value) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !key || !value) return -1;
|
||||
cJSON* tuple = cJSON_CreateArray();
|
||||
if (!tuple) return -1;
|
||||
cJSON_AddItemToArray(tuple, cJSON_CreateString(key));
|
||||
cJSON_AddItemToArray(tuple, cJSON_CreateString(value));
|
||||
cJSON_AddItemToArray(tags, tuple);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int tag_tuple_equal_local(cJSON* a, cJSON* b) {
|
||||
if (!a || !b || !cJSON_IsArray(a) || !cJSON_IsArray(b)) return 0;
|
||||
int an = cJSON_GetArraySize(a);
|
||||
int bn = cJSON_GetArraySize(b);
|
||||
if (an != bn) return 0;
|
||||
for (int i = 0; i < an; i++) {
|
||||
cJSON* av = cJSON_GetArrayItem(a, i);
|
||||
cJSON* bv = cJSON_GetArrayItem(b, i);
|
||||
if (!av || !bv || !cJSON_IsString(av) || !cJSON_IsString(bv) || !av->valuestring || !bv->valuestring) {
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(av->valuestring, bv->valuestring) != 0) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int tags_contains_tuple_local(cJSON* tags, cJSON* tuple) {
|
||||
if (!tags || !tuple || !cJSON_IsArray(tags) || !cJSON_IsArray(tuple)) return 0;
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* t = cJSON_GetArrayItem(tags, i);
|
||||
if (tag_tuple_equal_local(t, tuple)) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int remove_matching_tag_tuples_local(cJSON* tags, cJSON* tuple) {
|
||||
if (!tags || !tuple || !cJSON_IsArray(tags) || !cJSON_IsArray(tuple)) return 0;
|
||||
int removed = 0;
|
||||
for (int i = cJSON_GetArraySize(tags) - 1; i >= 0; i--) {
|
||||
cJSON* t = cJSON_GetArrayItem(tags, i);
|
||||
if (tag_tuple_equal_local(t, tuple)) {
|
||||
cJSON_DeleteItemFromArray(tags, i);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
char* execute_nostr_delete(const char* args_json) {
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* event_ids = cJSON_GetObjectItemCaseSensitive(args, "event_ids");
|
||||
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(args, "kinds");
|
||||
cJSON* reason = cJSON_GetObjectItemCaseSensitive(args, "reason");
|
||||
|
||||
if (!event_ids || !cJSON_IsArray(event_ids) || cJSON_GetArraySize(event_ids) <= 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_delete requires non-empty event_ids array");
|
||||
}
|
||||
if (kinds && !cJSON_IsArray(kinds)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_delete kinds must be an array when provided");
|
||||
}
|
||||
if (reason && !cJSON_IsString(reason)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_delete reason must be a string when provided");
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
if (!tags) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to create tags");
|
||||
}
|
||||
|
||||
int event_id_count = cJSON_GetArraySize(event_ids);
|
||||
for (int i = 0; i < event_id_count; i++) {
|
||||
cJSON* id = cJSON_GetArrayItem(event_ids, i);
|
||||
if (!id || !cJSON_IsString(id) || !id->valuestring || !is_hex_string_len_local(id->valuestring, 64U)) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_delete event_ids must contain 64-char hex strings");
|
||||
}
|
||||
if (add_string_tag_local(tags, "e", id->valuestring) != 0) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to add e tag");
|
||||
}
|
||||
}
|
||||
|
||||
if (kinds) {
|
||||
int kind_count = cJSON_GetArraySize(kinds);
|
||||
for (int i = 0; i < kind_count; i++) {
|
||||
cJSON* k = cJSON_GetArrayItem(kinds, i);
|
||||
if (!k || !cJSON_IsNumber(k)) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_delete kinds must contain integers");
|
||||
}
|
||||
char kind_buf[32];
|
||||
snprintf(kind_buf, sizeof(kind_buf), "%d", (int)k->valuedouble);
|
||||
if (add_string_tag_local(tags, "k", kind_buf) != 0) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to add k tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nostr_publish_result_t publish_result;
|
||||
memset(&publish_result, 0, sizeof(publish_result));
|
||||
|
||||
const char* reason_content = (reason && reason->valuestring) ? reason->valuestring : "";
|
||||
int rc = nostr_handler_publish_kind_event(5, reason_content, tags, &publish_result);
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
if (rc != 0) {
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json_error_local("nostr_delete failed");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
|
||||
cJSON_AddStringToObject(out, "message", "nostr_delete published");
|
||||
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
|
||||
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
|
||||
cJSON_AddNumberToObject(out, "requested_event_count", event_id_count);
|
||||
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
|
||||
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
|
||||
|
||||
cJSON* relays = cJSON_CreateArray();
|
||||
if (!relays) {
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
for (int i = 0; i < publish_result.relay_count; i++) {
|
||||
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
|
||||
}
|
||||
cJSON_AddItemToObject(out, "relays", relays);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_nostr_list_manage(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* list_kind = cJSON_GetObjectItemCaseSensitive(args, "list_kind");
|
||||
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
|
||||
cJSON* items = cJSON_GetObjectItemCaseSensitive(args, "items");
|
||||
|
||||
if (!list_kind || !cJSON_IsNumber(list_kind) ||
|
||||
!action || !cJSON_IsString(action) || !action->valuestring ||
|
||||
!items || !cJSON_IsArray(items)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_list_manage requires list_kind, action, and items");
|
||||
}
|
||||
if (strcmp(action->valuestring, "add") != 0 && strcmp(action->valuestring, "remove") != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("nostr_list_manage action must be add or remove");
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to create list query filter");
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber((int)list_kind->valuedouble));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 8000);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
cJSON* events = events_json ? cJSON_Parse(events_json) : NULL;
|
||||
free(events_json);
|
||||
|
||||
char* list_content = strdup("");
|
||||
if (!list_content) {
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
|
||||
cJSON* updated_tags = cJSON_CreateArray();
|
||||
if (!updated_tags) {
|
||||
free(list_content);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to create list tags");
|
||||
}
|
||||
|
||||
if (events && cJSON_IsArray(events) && cJSON_GetArraySize(events) > 0) {
|
||||
cJSON* ev0 = cJSON_GetArrayItem(events, 0);
|
||||
if (ev0 && cJSON_IsObject(ev0)) {
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev0, "content");
|
||||
if (content && cJSON_IsString(content) && content->valuestring) {
|
||||
free(list_content);
|
||||
list_content = strdup(content->valuestring);
|
||||
if (!list_content) {
|
||||
cJSON_Delete(updated_tags);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("allocation failure");
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev0, "tags");
|
||||
if (tags && cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(updated_tags);
|
||||
updated_tags = cJSON_Duplicate(tags, 1);
|
||||
if (!updated_tags) {
|
||||
free(list_content);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to duplicate existing list tags");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int items_affected = 0;
|
||||
int item_count = cJSON_GetArraySize(items);
|
||||
for (int i = 0; i < item_count; i++) {
|
||||
cJSON* tuple = cJSON_GetArrayItem(items, i);
|
||||
if (!tuple || !cJSON_IsArray(tuple) || cJSON_GetArraySize(tuple) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int tuple_ok = 1;
|
||||
int tuple_size = cJSON_GetArraySize(tuple);
|
||||
for (int j = 0; j < tuple_size; j++) {
|
||||
cJSON* part = cJSON_GetArrayItem(tuple, j);
|
||||
if (!part || !cJSON_IsString(part) || !part->valuestring) {
|
||||
tuple_ok = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!tuple_ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(action->valuestring, "add") == 0) {
|
||||
if (!tags_contains_tuple_local(updated_tags, tuple)) {
|
||||
cJSON* dup = cJSON_Duplicate(tuple, 1);
|
||||
if (!dup) {
|
||||
free(list_content);
|
||||
cJSON_Delete(updated_tags);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to duplicate list item");
|
||||
}
|
||||
cJSON_AddItemToArray(updated_tags, dup);
|
||||
items_affected++;
|
||||
}
|
||||
} else {
|
||||
items_affected += remove_matching_tag_tuples_local(updated_tags, tuple);
|
||||
}
|
||||
}
|
||||
|
||||
nostr_publish_result_t publish_result;
|
||||
memset(&publish_result, 0, sizeof(publish_result));
|
||||
|
||||
int rc = nostr_handler_publish_kind_event((int)list_kind->valuedouble,
|
||||
list_content,
|
||||
updated_tags,
|
||||
&publish_result);
|
||||
|
||||
free(list_content);
|
||||
cJSON_Delete(updated_tags);
|
||||
cJSON_Delete(events);
|
||||
|
||||
if (rc != 0) {
|
||||
cJSON_Delete(args);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json_error_local("nostr_list_manage failed");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(args);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "list_kind", publish_result.kind);
|
||||
cJSON_AddStringToObject(out, "action", action->valuestring);
|
||||
cJSON_Delete(args);
|
||||
cJSON_AddNumberToObject(out, "items_affected", items_affected);
|
||||
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user