Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fb1fa0b8 | ||
|
|
9505ee16bf | ||
|
|
d49e4ec4cb | ||
|
|
47bb5d76bc | ||
|
|
90948989ac | ||
|
|
b1a767f9b5 | ||
|
|
650df7bc41 | ||
|
|
9d06f18769 | ||
|
|
527016c078 | ||
|
|
c221104bc0 | ||
|
|
253aa4b5fc | ||
|
|
82362e4f3f | ||
|
|
7675109afc | ||
|
|
42fc14a5b3 | ||
|
|
326acb63d1 | ||
|
|
6b6e25c3f4 | ||
|
|
9a0e90dff0 | ||
|
|
0ce9de002b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,7 +10,7 @@ test_keys.txt
|
||||
|
||||
/mongoose/
|
||||
/Trash/
|
||||
|
||||
deploy_lt.sh
|
||||
|
||||
# Build artifacts
|
||||
/build/
|
||||
|
||||
@@ -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)"; \
|
||||
@@ -103,7 +124,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
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 \
|
||||
|
||||
14
Makefile
14
Makefile
@@ -11,6 +11,7 @@ SRCS = \
|
||||
$(SRC_DIR)/llm.c \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/cashu_wallet.c \
|
||||
$(SRC_DIR)/tools/tools_common.c \
|
||||
$(SRC_DIR)/tools/tools_schema.c \
|
||||
$(SRC_DIR)/tools/tools_dispatch.c \
|
||||
@@ -31,6 +32,7 @@ SRCS = \
|
||||
$(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 \
|
||||
@@ -41,11 +43,11 @@ SRCS = \
|
||||
INCLUDES = \
|
||||
-I$(SRC_DIR) \
|
||||
-I$(SRC_DIR)/tools \
|
||||
-I../nostr_core_lib \
|
||||
-I../nostr_core_lib/cjson \
|
||||
-I../nostr_core_lib/nostr_core
|
||||
-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
|
||||
|
||||
@@ -60,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:
|
||||
@@ -102,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)
|
||||
|
||||
19
README.md
19
README.md
@@ -55,11 +55,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e
|
||||
|
||||
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.73
|
||||
## Current Status — v0.1.1
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.73 — Refined Nostr handler and tool dispatch/schema updates for relay and my-events flows
|
||||
> 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
|
||||
@@ -124,6 +124,13 @@ Edit [`genesis.jsonc`](genesis.jsonc):
|
||||
"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,
|
||||
@@ -216,6 +223,14 @@ CLI debugger notes:
|
||||
./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`.
|
||||
|
||||
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" \
|
||||
|
||||
@@ -35,15 +35,31 @@
|
||||
"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": 8, // default max turns for normal agent tool loops
|
||||
"trigger_max_turns": 6, // max turns for triggered-skill executions
|
||||
"api_default_max_turns": 4, // default when HTTP body omits max_turns
|
||||
"api_max_turns_ceiling": 16, // hard cap applied to API-provided max_turns
|
||||
"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": {
|
||||
|
||||
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."
|
||||
BIN
didactyl_static_arm64
Executable file
BIN
didactyl_static_arm64
Executable file
Binary file not shown.
@@ -107,6 +107,19 @@ These tools manage skill and trigger lifecycle; skill semantics and trigger exec
|
||||
| `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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
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
|
||||
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 |
|
||||
171
src/agent.c
171
src/agent.c
@@ -79,6 +79,72 @@ static uint64_t message_fingerprint(const char* sender_pubkey_hex, const char* m
|
||||
return a ^ (b + 0x9e3779b97f4a7c15ULL + (a << 6) + (a >> 2));
|
||||
}
|
||||
|
||||
static uint64_t tool_calls_fingerprint(const llm_response_t* resp) {
|
||||
if (!resp || resp->tool_call_count <= 0 || !resp->tool_calls) {
|
||||
return 1469598103934665603ULL;
|
||||
}
|
||||
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
for (int i = 0; i < resp->tool_call_count; i++) {
|
||||
const llm_tool_call_t* tc = &resp->tool_calls[i];
|
||||
uint64_t name_h = fnv1a64(tc->name ? tc->name : "");
|
||||
uint64_t args_h = fnv1a64(tc->arguments_json ? tc->arguments_json : "{}");
|
||||
uint64_t part = name_h ^ (args_h + 0x9e3779b97f4a7c15ULL + (name_h << 6) + (name_h >> 2));
|
||||
h ^= part + (uint64_t)(i + 1) * 1099511628211ULL;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
|
||||
h ^= (uint64_t)resp->tool_call_count;
|
||||
h *= 1099511628211ULL;
|
||||
return h;
|
||||
}
|
||||
|
||||
static void notify_admin_limit_diagnostic(const char* source,
|
||||
const char* reason,
|
||||
const char* subject,
|
||||
int max_turns,
|
||||
int turns_run,
|
||||
int stall_repeat_threshold,
|
||||
int repeated_tool_turns,
|
||||
int current_max_tokens,
|
||||
const char* possible_cause,
|
||||
const char* hint,
|
||||
const char* final_answer) {
|
||||
if (!g_cfg || g_cfg->admin.pubkey[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
char diag[1800];
|
||||
snprintf(diag,
|
||||
sizeof(diag),
|
||||
"⚠️ Didactyl limit diagnostic\n"
|
||||
"source=%s\n"
|
||||
"reason=%s\n"
|
||||
"subject=%s\n"
|
||||
"max_turns=%d\n"
|
||||
"turns_run=%d\n"
|
||||
"stall_repeat_threshold=%d\n"
|
||||
"repeated_tool_turns=%d\n"
|
||||
"current_max_tokens=%d\n"
|
||||
"possible_cause=%s\n"
|
||||
"hint=%s\n"
|
||||
"final_answer_preview=%.*s",
|
||||
source ? source : "unknown",
|
||||
reason ? reason : "unknown",
|
||||
subject ? subject : "n/a",
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
current_max_tokens,
|
||||
possible_cause ? possible_cause : "n/a",
|
||||
hint ? hint : "n/a",
|
||||
360,
|
||||
final_answer ? final_answer : "");
|
||||
|
||||
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, diag);
|
||||
}
|
||||
|
||||
static int agent_message_is_debounced(const char* sender_pubkey_hex, const char* message) {
|
||||
time_t now = time(NULL);
|
||||
uint64_t fp = message_fingerprint(sender_pubkey_hex, message);
|
||||
@@ -371,6 +437,15 @@ static char* format_result_markdown_local(const char* raw_result) {
|
||||
return strdup(raw_result);
|
||||
}
|
||||
|
||||
if (cJSON_IsObject(root)) {
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content");
|
||||
if (content && cJSON_IsString(content) && content->valuestring) {
|
||||
char* direct = strdup(content->valuestring);
|
||||
cJSON_Delete(root);
|
||||
return direct ? direct : strdup(raw_result);
|
||||
}
|
||||
}
|
||||
|
||||
size_t cap = strlen(raw_result) * 2U + 512U;
|
||||
if (cap < 2048U) cap = 2048U;
|
||||
size_t used = 0U;
|
||||
@@ -508,10 +583,15 @@ static char* build_slash_help_all_json(void) {
|
||||
|
||||
char* adopted_json = tools_execute(&g_tools_ctx, "adopted_skills", "{}");
|
||||
cJSON* adopted_root = adopted_json ? cJSON_Parse(adopted_json) : NULL;
|
||||
cJSON* adoption_events_content = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "content") : NULL;
|
||||
cJSON* adoption_events_json = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "adoption_events_json") : NULL;
|
||||
cJSON* adoption_events = (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring)
|
||||
? cJSON_Parse(adoption_events_json->valuestring)
|
||||
: NULL;
|
||||
const char* adoption_events_text = NULL;
|
||||
if (adoption_events_content && cJSON_IsString(adoption_events_content) && adoption_events_content->valuestring) {
|
||||
adoption_events_text = adoption_events_content->valuestring;
|
||||
} else if (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring) {
|
||||
adoption_events_text = adoption_events_json->valuestring;
|
||||
}
|
||||
cJSON* adoption_events = adoption_events_text ? cJSON_Parse(adoption_events_text) : NULL;
|
||||
|
||||
int adopted_count = 0;
|
||||
if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) {
|
||||
@@ -661,8 +741,11 @@ static int handle_slash_command(const char* sender_pubkey_hex, const char* messa
|
||||
result_json = strdup("{\"success\":false,\"error\":\"direct tool execution failed\"}");
|
||||
}
|
||||
|
||||
char* markdown_result = format_result_markdown_local(result_json);
|
||||
const char* dm_payload = markdown_result ? markdown_result : result_json;
|
||||
size_t tool_name_len = strlen(tool_name);
|
||||
int send_raw_json = (tool_name_len >= 4U && strcmp(tool_name + tool_name_len - 4U, "_get") == 0);
|
||||
|
||||
char* markdown_result = send_raw_json ? NULL : format_result_markdown_local(result_json);
|
||||
const char* dm_payload = send_raw_json ? result_json : (markdown_result ? markdown_result : result_json);
|
||||
|
||||
size_t log_cap = strlen(message) + strlen(result_json ? result_json : "") + strlen(dm_payload ? dm_payload : "") + 192U;
|
||||
char* log_payload = (char*)malloc(log_cap);
|
||||
@@ -1815,7 +1898,10 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
? g_cfg->tools.trigger_max_turns
|
||||
: (g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8);
|
||||
|
||||
int turns_run = 0;
|
||||
int trigger_completed = 0;
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
turns_run = turn + 1;
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (!messages_json) {
|
||||
break;
|
||||
@@ -1836,6 +1922,7 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
}
|
||||
|
||||
if (resp.tool_call_count <= 0) {
|
||||
trigger_completed = 1;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
@@ -1871,6 +1958,20 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
if (!trigger_completed && turns_run >= max_turns) {
|
||||
notify_admin_limit_diagnostic("trigger_skill_loop",
|
||||
"max_turns_exhausted",
|
||||
skill_d_tag,
|
||||
max_turns,
|
||||
turns_run,
|
||||
g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3,
|
||||
0,
|
||||
g_cfg->llm.max_tokens,
|
||||
NULL,
|
||||
NULL,
|
||||
"");
|
||||
}
|
||||
|
||||
if (g_trigger_manager) {
|
||||
(void)trigger_manager_fire_chains(g_trigger_manager,
|
||||
skill_d_tag,
|
||||
@@ -2097,10 +2198,17 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
cJSON_AddNumberToObject(live_user_msg, "_ts", (double)time(NULL));
|
||||
}
|
||||
|
||||
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8;
|
||||
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 20;
|
||||
int stall_repeat_threshold = g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3;
|
||||
uint64_t last_tool_fp = 0;
|
||||
int repeated_tool_turns = 0;
|
||||
int exited_on_stall = 0;
|
||||
int stall_due_to_length = 0;
|
||||
char* final_answer_owned = NULL;
|
||||
int turns_run = 0;
|
||||
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
turns_run = turn + 1;
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (!messages_json) {
|
||||
break;
|
||||
@@ -2128,6 +2236,21 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
break;
|
||||
}
|
||||
|
||||
uint64_t current_tool_fp = tool_calls_fingerprint(&resp);
|
||||
if (turn == 0 || current_tool_fp != last_tool_fp) {
|
||||
repeated_tool_turns = 1;
|
||||
last_tool_fp = current_tool_fp;
|
||||
} else {
|
||||
repeated_tool_turns++;
|
||||
}
|
||||
|
||||
if (repeated_tool_turns >= stall_repeat_threshold) {
|
||||
exited_on_stall = 1;
|
||||
stall_due_to_length = (resp.finish_reason && strcmp(resp.finish_reason, "length") == 0) ? 1 : 0;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
if (append_assistant_tool_calls_message(messages, &resp) != 0) {
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
@@ -2162,10 +2285,44 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
}
|
||||
|
||||
if (!final_answer_owned) {
|
||||
final_answer_owned = strdup("I hit my tool-use limit for this request.");
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (messages_json) {
|
||||
llm_response_t final_resp;
|
||||
int final_rc = llm_chat_with_tools_messages(messages_json, tools_json, "none", &final_resp);
|
||||
free(messages_json);
|
||||
if (final_rc == 0) {
|
||||
const char* forced_answer = final_resp.content ? final_resp.content : "";
|
||||
if (forced_answer[0] != '\0') {
|
||||
final_answer_owned = strdup(forced_answer);
|
||||
}
|
||||
llm_response_free(&final_resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exhausted_on_max_turns = (!final_answer_owned && !exited_on_stall && turns_run >= max_turns);
|
||||
|
||||
if (!final_answer_owned) {
|
||||
final_answer_owned = strdup(exited_on_stall
|
||||
? "I stopped repeated tool calls after detecting a loop and could not produce a final summary."
|
||||
: "I hit my tool-use limit for this request.");
|
||||
}
|
||||
|
||||
const char* final_answer = final_answer_owned ? final_answer_owned : "I hit my tool-use limit for this request.";
|
||||
|
||||
if (exited_on_stall || exhausted_on_max_turns) {
|
||||
notify_admin_limit_diagnostic("dm_agent_loop",
|
||||
exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted",
|
||||
sender_pubkey_hex,
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
g_cfg->llm.max_tokens,
|
||||
(exited_on_stall && stall_due_to_length) ? "max_tokens_truncation" : NULL,
|
||||
(exited_on_stall && stall_due_to_length) ? "Increase max_tokens (example: /model_set {\"max_tokens\": 4096})" : NULL,
|
||||
final_answer);
|
||||
}
|
||||
fprintf(stdout, "[didactyl] final response: %.240s%s\n",
|
||||
final_answer,
|
||||
strlen(final_answer) > 240 ? "..." : "");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
|
||||
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
|
||||
121
src/config.c
121
src/config.c
@@ -225,6 +225,7 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
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 =
|
||||
@@ -244,6 +245,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
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;
|
||||
@@ -294,6 +298,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
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;
|
||||
}
|
||||
@@ -507,6 +514,97 @@ static int parse_api_config(cJSON* root, didactyl_config_t* config) {
|
||||
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;
|
||||
@@ -1108,6 +1206,8 @@ void config_free(didactyl_config_t* config) {
|
||||
free(config->default_skill.content);
|
||||
free(config->default_skill.tags_json);
|
||||
|
||||
free_cashu_wallet_mints(&config->cashu_wallet);
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
}
|
||||
|
||||
@@ -1124,10 +1224,11 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
config->tools.trigger_max_turns = 6;
|
||||
config->tools.api_default_max_turns = 4;
|
||||
config->tools.api_max_turns_ceiling = 16;
|
||||
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;
|
||||
@@ -1166,6 +1267,13 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
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) {
|
||||
@@ -1307,6 +1415,11 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_cashu_wallet_config(root, config) != 0) {
|
||||
config_set_error("invalid cashu_wallet configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
11
src/config.h
11
src/config.h
@@ -50,6 +50,7 @@ typedef struct {
|
||||
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;
|
||||
@@ -104,6 +105,15 @@ typedef struct {
|
||||
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;
|
||||
@@ -116,6 +126,7 @@ typedef struct {
|
||||
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;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#define DIDACTYL_DEFAULT_SKILL_D_TAG "didactyl-default"
|
||||
#define DIDACTYL_DEFAULT_SKILL_KIND 31124
|
||||
#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"app\",\"didactyl\"],[\"scope\",\"private\"]]"
|
||||
#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
|
||||
|
||||
152
src/http_api.c
152
src/http_api.c
@@ -5,6 +5,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
@@ -81,6 +82,82 @@ static int estimate_tokens_from_chars(int chars) {
|
||||
return (chars + 3) / 4;
|
||||
}
|
||||
|
||||
static uint64_t fnv1a64_local(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
if (!s) return h;
|
||||
while (*s) {
|
||||
h ^= (unsigned char)(*s++);
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
static uint64_t tool_calls_fingerprint_local(const llm_response_t* resp) {
|
||||
if (!resp || resp->tool_call_count <= 0 || !resp->tool_calls) {
|
||||
return 1469598103934665603ULL;
|
||||
}
|
||||
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
for (int i = 0; i < resp->tool_call_count; i++) {
|
||||
const llm_tool_call_t* tc = &resp->tool_calls[i];
|
||||
uint64_t name_h = fnv1a64_local(tc->name ? tc->name : "");
|
||||
uint64_t args_h = fnv1a64_local(tc->arguments_json ? tc->arguments_json : "{}");
|
||||
uint64_t part = name_h ^ (args_h + 0x9e3779b97f4a7c15ULL + (name_h << 6) + (name_h >> 2));
|
||||
h ^= part + (uint64_t)(i + 1) * 1099511628211ULL;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
|
||||
h ^= (uint64_t)resp->tool_call_count;
|
||||
h *= 1099511628211ULL;
|
||||
return h;
|
||||
}
|
||||
|
||||
static void notify_admin_limit_diagnostic_http(const char* source,
|
||||
const char* reason,
|
||||
const char* subject,
|
||||
int max_turns,
|
||||
int turns_run,
|
||||
int stall_repeat_threshold,
|
||||
int repeated_tool_turns,
|
||||
int current_max_tokens,
|
||||
const char* possible_cause,
|
||||
const char* hint,
|
||||
const char* final_answer) {
|
||||
if (!g_api_ctx.cfg || g_api_ctx.cfg->admin.pubkey[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
char diag[1800];
|
||||
snprintf(diag,
|
||||
sizeof(diag),
|
||||
"⚠️ Didactyl limit diagnostic\n"
|
||||
"source=%s\n"
|
||||
"reason=%s\n"
|
||||
"subject=%s\n"
|
||||
"max_turns=%d\n"
|
||||
"turns_run=%d\n"
|
||||
"stall_repeat_threshold=%d\n"
|
||||
"repeated_tool_turns=%d\n"
|
||||
"current_max_tokens=%d\n"
|
||||
"possible_cause=%s\n"
|
||||
"hint=%s\n"
|
||||
"final_answer_preview=%.*s",
|
||||
source ? source : "unknown",
|
||||
reason ? reason : "unknown",
|
||||
subject ? subject : "n/a",
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
current_max_tokens,
|
||||
possible_cause ? possible_cause : "n/a",
|
||||
hint ? hint : "n/a",
|
||||
360,
|
||||
final_answer ? final_answer : "");
|
||||
|
||||
(void)nostr_handler_send_dm_auto(g_api_ctx.cfg->admin.pubkey, diag);
|
||||
}
|
||||
|
||||
static int uri_extract_after_prefix(const struct mg_str* uri,
|
||||
const char* prefix,
|
||||
char* out,
|
||||
@@ -649,10 +726,15 @@ static char* build_slash_help_all_json_local(void) {
|
||||
|
||||
char* adopted_json = tools_execute(g_api_ctx.tools_ctx, "adopted_skills", "{}");
|
||||
cJSON* adopted_root = adopted_json ? cJSON_Parse(adopted_json) : NULL;
|
||||
cJSON* adoption_events_content = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "content") : NULL;
|
||||
cJSON* adoption_events_json = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "adoption_events_json") : NULL;
|
||||
cJSON* adoption_events = (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring)
|
||||
? cJSON_Parse(adoption_events_json->valuestring)
|
||||
: NULL;
|
||||
const char* adoption_events_text = NULL;
|
||||
if (adoption_events_content && cJSON_IsString(adoption_events_content) && adoption_events_content->valuestring) {
|
||||
adoption_events_text = adoption_events_content->valuestring;
|
||||
} else if (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring) {
|
||||
adoption_events_text = adoption_events_json->valuestring;
|
||||
}
|
||||
cJSON* adoption_events = adoption_events_text ? cJSON_Parse(adoption_events_text) : NULL;
|
||||
|
||||
int adopted_count = 0;
|
||||
if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) {
|
||||
@@ -844,9 +926,20 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int stall_repeat_threshold = 3;
|
||||
if (g_api_ctx.cfg && g_api_ctx.cfg->tools.stall_repeat_threshold > 1) {
|
||||
stall_repeat_threshold = g_api_ctx.cfg->tools.stall_repeat_threshold;
|
||||
}
|
||||
|
||||
char* final_response = NULL;
|
||||
uint64_t last_tool_fp = 0;
|
||||
int repeated_tool_turns = 0;
|
||||
int exited_on_stall = 0;
|
||||
int stall_due_to_length = 0;
|
||||
int turns_run = 0;
|
||||
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
turns_run = turn + 1;
|
||||
char* messages_json = cJSON_PrintUnformatted(convo);
|
||||
if (!messages_json) break;
|
||||
|
||||
@@ -877,6 +970,21 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
break;
|
||||
}
|
||||
|
||||
uint64_t current_tool_fp = tool_calls_fingerprint_local(&resp);
|
||||
if (turn == 0 || current_tool_fp != last_tool_fp) {
|
||||
repeated_tool_turns = 1;
|
||||
last_tool_fp = current_tool_fp;
|
||||
} else {
|
||||
repeated_tool_turns++;
|
||||
}
|
||||
|
||||
if (repeated_tool_turns >= stall_repeat_threshold) {
|
||||
exited_on_stall = 1;
|
||||
stall_due_to_length = (resp.finish_reason && strcmp(resp.finish_reason, "length") == 0) ? 1 : 0;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
if (append_assistant_tool_calls_message_local(convo, &resp) != 0) {
|
||||
llm_response_free(&resp);
|
||||
final_response = strdup("Failed to append assistant tool calls.");
|
||||
@@ -917,9 +1025,43 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
done:
|
||||
done:;
|
||||
int hit_max_turn_limit = (!final_response && !exited_on_stall && turns_run >= max_turns);
|
||||
|
||||
if (!final_response) {
|
||||
final_response = strdup(tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run.");
|
||||
char* messages_json = cJSON_PrintUnformatted(convo);
|
||||
if (messages_json) {
|
||||
llm_response_t final_resp;
|
||||
int final_rc = llm_chat_with_tools_messages(messages_json, tools_json, "none", &final_resp);
|
||||
free(messages_json);
|
||||
if (final_rc == 0) {
|
||||
const char* forced_answer = final_resp.content ? final_resp.content : "";
|
||||
if (forced_answer[0] != '\0') {
|
||||
final_response = strdup(forced_answer);
|
||||
}
|
||||
llm_response_free(&final_resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!final_response) {
|
||||
final_response = strdup(exited_on_stall
|
||||
? "Stopped repeated tool calls after detecting a loop, and no final summary was produced."
|
||||
: (tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run."));
|
||||
}
|
||||
|
||||
if (exited_on_stall || hit_max_turn_limit) {
|
||||
notify_admin_limit_diagnostic_http("http_api_tool_loop",
|
||||
exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted",
|
||||
log_sender,
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
g_api_ctx.cfg ? g_api_ctx.cfg->llm.max_tokens : 0,
|
||||
(exited_on_stall && stall_due_to_length) ? "max_tokens_truncation" : NULL,
|
||||
(exited_on_stall && stall_due_to_length) ? "Increase max_tokens (example: /model_set {\"max_tokens\": 4096})" : NULL,
|
||||
final_response ? final_response : "");
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#define DIDACTYL_HTTP_API_H
|
||||
|
||||
#include "config.h"
|
||||
#include "tools.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
|
||||
@@ -289,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);
|
||||
@@ -459,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
148
src/main.c
148
src/main.c
@@ -19,6 +19,7 @@
|
||||
#include "cjson/cJSON.h"
|
||||
#include "http_api.h"
|
||||
#include "setup_wizard.h"
|
||||
#include "cashu_wallet.h"
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
@@ -75,6 +76,8 @@ static void print_usage(const char* prog) {
|
||||
" If file is missing, startup falls back to minimal nsec-only mode.\n"
|
||||
" --nsec <nsec_or_hex>\n"
|
||||
" Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC.\n"
|
||||
" --admin <npub_or_hex>\n"
|
||||
" Administrator pubkey override; accepts npub1... or 64-char hex.\n"
|
||||
" --api-port <port>\n"
|
||||
" Enable HTTP API and bind to this port (1-65535).\n"
|
||||
" --api-bind <addr>\n"
|
||||
@@ -316,10 +319,11 @@ static int query_self_kind10002_relays(const didactyl_config_t* cfg,
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t events_json_len = strlen(events_json);
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
DEBUG_WARN("[didactyl] kind10002 query parse failed or non-array payload (len=%zu)", strlen(events_json));
|
||||
DEBUG_WARN("[didactyl] kind10002 query parse failed or non-array payload (len=%zu)", events_json_len);
|
||||
cJSON_Delete(events);
|
||||
return 0;
|
||||
}
|
||||
@@ -810,24 +814,29 @@ static int publish_encrypted_self_config(const didactyl_config_t* cfg, const cha
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg) {
|
||||
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int persist_llm_config) {
|
||||
if (!cfg) return -1;
|
||||
|
||||
cJSON* llm = cJSON_CreateObject();
|
||||
if (!llm) return -1;
|
||||
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
|
||||
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
|
||||
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
|
||||
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
|
||||
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
|
||||
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
|
||||
int llm_rc = 0;
|
||||
if (persist_llm_config) {
|
||||
cJSON* llm = cJSON_CreateObject();
|
||||
if (!llm) return -1;
|
||||
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
|
||||
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
|
||||
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
|
||||
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
|
||||
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
|
||||
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
|
||||
|
||||
char* llm_json = cJSON_PrintUnformatted(llm);
|
||||
cJSON_Delete(llm);
|
||||
if (!llm_json) return -1;
|
||||
char* llm_json = cJSON_PrintUnformatted(llm);
|
||||
cJSON_Delete(llm);
|
||||
if (!llm_json) return -1;
|
||||
|
||||
int llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
|
||||
free(llm_json);
|
||||
llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
|
||||
free(llm_json);
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] startup phase: skipping llm_config publish because it was recalled from Nostr");
|
||||
}
|
||||
|
||||
cJSON* agent = cJSON_CreateObject();
|
||||
if (!agent) return llm_rc;
|
||||
@@ -843,15 +852,18 @@ static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg) {
|
||||
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1;
|
||||
}
|
||||
|
||||
static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
|
||||
static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
|
||||
if (!cfg) {
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int llm_recalled_from_nostr = 0;
|
||||
|
||||
if (!llm_config_is_complete(&cfg->llm)) {
|
||||
char* llm_plaintext = NULL;
|
||||
if (fetch_self_config_plaintext(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
|
||||
if (apply_recalled_llm_config(cfg, llm_plaintext) == 0) {
|
||||
llm_recalled_from_nostr = 1;
|
||||
DEBUG_INFO("[didactyl] startup phase: recovered llm_config from Nostr");
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled llm_config");
|
||||
@@ -862,24 +874,34 @@ static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
|
||||
free(llm_plaintext);
|
||||
}
|
||||
|
||||
if (!admin_config_is_complete(cfg)) {
|
||||
char* agent_plaintext = NULL;
|
||||
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
|
||||
if (apply_recalled_agent_config(cfg, agent_plaintext) == 0) {
|
||||
char* agent_plaintext = NULL;
|
||||
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
|
||||
didactyl_config_t recalled = *cfg;
|
||||
if (apply_recalled_agent_config(&recalled, agent_plaintext) == 0) {
|
||||
if (!admin_config_is_complete(cfg)) {
|
||||
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", recalled.admin.pubkey);
|
||||
cfg->dm_protocol = recalled.dm_protocol;
|
||||
DEBUG_INFO("[didactyl] startup phase: recovered agent_config from Nostr");
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled agent_config");
|
||||
} else if (recalled.admin.pubkey[0] != '\0' && strcmp(cfg->admin.pubkey, recalled.admin.pubkey) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: agent_config admin pubkey differs from loaded config (loaded=%.16s..., nostr=%.16s...); loaded config wins",
|
||||
cfg->admin.pubkey,
|
||||
recalled.admin.pubkey);
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled agent_config");
|
||||
}
|
||||
free(agent_plaintext);
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
|
||||
}
|
||||
free(agent_plaintext);
|
||||
|
||||
return llm_recalled_from_nostr;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./genesis.jsonc";
|
||||
const char* cli_nsec = NULL;
|
||||
const char* cli_admin_pubkey = NULL;
|
||||
const char* api_bind_override = NULL;
|
||||
int api_port_override = 0;
|
||||
int debug_level = DEBUG_LEVEL_NONE;
|
||||
@@ -943,6 +965,8 @@ int main(int argc, char** argv) {
|
||||
debug_level = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--nsec") == 0 && i + 1 < argc) {
|
||||
cli_nsec = argv[++i];
|
||||
} else if (strcmp(argv[i], "--admin") == 0 && i + 1 < argc) {
|
||||
cli_admin_pubkey = argv[++i];
|
||||
} else if (strcmp(argv[i], "--api-port") == 0 && i + 1 < argc) {
|
||||
api_port_override = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--api-bind") == 0 && i + 1 < argc) {
|
||||
@@ -996,6 +1020,18 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
}
|
||||
|
||||
if (cli_admin_pubkey && cli_admin_pubkey[0] != '\0') {
|
||||
char decoded_admin[65] = {0};
|
||||
if (decode_pubkey_hex_or_npub_local(cli_admin_pubkey, decoded_admin) != 0) {
|
||||
fprintf(stderr, "Invalid admin pubkey provided via --admin (expected npub1... or 64-char hex)\n");
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
snprintf(cfg.admin.pubkey, sizeof(cfg.admin.pubkey), "%s", decoded_admin);
|
||||
DEBUG_INFO("[didactyl] startup phase: admin override applied from --admin (%.16s...)", cfg.admin.pubkey);
|
||||
}
|
||||
|
||||
if (config_ensure_default_skill_startup_events(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to synthesize startup events from default_skill\n");
|
||||
config_free(&cfg);
|
||||
@@ -1038,8 +1074,22 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int wallet_initialized = 0;
|
||||
int exit_code = 0;
|
||||
|
||||
if (cashu_wallet_init(&cfg) == 0) {
|
||||
wallet_initialized = 1;
|
||||
if (cfg.cashu_wallet.enabled) {
|
||||
int wallet_rc = 0;
|
||||
if (cfg.cashu_wallet.auto_load) {
|
||||
wallet_rc = cashu_wallet_load_from_relays();
|
||||
}
|
||||
if (wallet_rc != 0 && cfg.cashu_wallet.mint_count > 0) {
|
||||
(void)cashu_wallet_create_new_from_config();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (test_tool_name) {
|
||||
const int timeout_ms = 15000;
|
||||
if (!wait_for_connected_relays(timeout_ms)) {
|
||||
@@ -1078,6 +1128,9 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
tools_cleanup(&tools_ctx);
|
||||
if (wallet_initialized) {
|
||||
cashu_wallet_cleanup();
|
||||
}
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
llm_cleanup();
|
||||
@@ -1104,8 +1157,8 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
startup_step_begin(2, "Recover runtime config from Nostr");
|
||||
recover_missing_runtime_config_from_nostr(&cfg);
|
||||
startup_step_ok(2, "Recover runtime config from Nostr", NULL);
|
||||
int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg);
|
||||
startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr ? "llm_config recalled" : NULL);
|
||||
|
||||
startup_step_begin(3, "Validate LLM config");
|
||||
if (!llm_config_is_complete(&cfg.llm)) {
|
||||
@@ -1123,7 +1176,7 @@ int main(int argc, char** argv) {
|
||||
if (!admin_config_is_complete(&cfg)) {
|
||||
startup_step_fail(4, "Validate admin config", "missing admin pubkey");
|
||||
fprintf(stderr,
|
||||
"Missing admin config: provide admin.pubkey in genesis or store d=agent_config via config_store\n");
|
||||
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n");
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
@@ -1152,17 +1205,19 @@ int main(int argc, char** argv) {
|
||||
if (nostr_handler_reconcile_startup_events() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: reconcile startup events failed (continuing)");
|
||||
}
|
||||
if (persist_runtime_config_to_nostr(&cfg) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
|
||||
}
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping bootstrap publish on subsequent run");
|
||||
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run");
|
||||
if (nostr_handler_load_system_context_from_adopted_skills() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to load system context from adopted skills (continuing with fallback)");
|
||||
}
|
||||
}
|
||||
|
||||
if (persist_runtime_config_to_nostr(&cfg, llm_recalled_from_nostr ? 0 : 1) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
|
||||
}
|
||||
|
||||
startup_step_ok(7, "Reconcile/persist startup state", NULL);
|
||||
|
||||
const char* system_context = nostr_handler_get_system_context();
|
||||
@@ -1330,6 +1385,28 @@ int main(int argc, char** argv) {
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe DMs end");
|
||||
startup_step_ok(15, "Subscribe DMs", NULL);
|
||||
|
||||
if (cfg.cashu_wallet.enabled) {
|
||||
startup_step_begin(16, "Initialize cashu wallet");
|
||||
if (cashu_wallet_init(&cfg) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: cashu_wallet_init failed (continuing without wallet)");
|
||||
startup_step_fail(16, "Initialize cashu wallet", "cashu_wallet_init failed; wallet features disabled for this run");
|
||||
} else {
|
||||
int wallet_rc = 0;
|
||||
if (cfg.cashu_wallet.auto_load) {
|
||||
wallet_rc = cashu_wallet_load_from_relays();
|
||||
}
|
||||
if (wallet_rc != 0 && cfg.cashu_wallet.mint_count > 0) {
|
||||
wallet_rc = cashu_wallet_create_new_from_config();
|
||||
}
|
||||
if (wallet_rc != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: cashu wallet load/create failed (continuing)");
|
||||
startup_step_fail(16, "Initialize cashu wallet", "wallet load/create failed; tools may return errors until wallet is configured");
|
||||
} else {
|
||||
startup_step_ok(16, "Initialize cashu wallet", cfg.cashu_wallet.auto_load ? "loaded or created" : "initialized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
@@ -1366,7 +1443,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
nostr_handler_refresh_relay_statuses();
|
||||
|
||||
startup_step_ok(16, "READY", "agent online; entering main poll loop");
|
||||
startup_step_ok(17, "READY", "agent online; entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
|
||||
|
||||
@@ -1386,6 +1463,7 @@ int main(int argc, char** argv) {
|
||||
http_api_cleanup();
|
||||
}
|
||||
|
||||
cashu_wallet_cleanup();
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
|
||||
@@ -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 73
|
||||
#define DIDACTYL_VERSION "v0.0.73"
|
||||
#define DIDACTYL_VERSION_MINOR 1
|
||||
#define DIDACTYL_VERSION_PATCH 1
|
||||
#define DIDACTYL_VERSION "v0.1.1"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -416,6 +416,7 @@ static int managed_subscription_register_locked(managed_subscription_id_t id,
|
||||
static int managed_subscriptions_resubscribe_all_locked(void);
|
||||
static int managed_subscription_id_from_name(const char* name, managed_subscription_id_t* out_id);
|
||||
static int apply_runtime_kind10002_relays(cJSON* tags, const char* source_label);
|
||||
static void seed_default_skill_into_cache(void);
|
||||
|
||||
static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url) {
|
||||
if (!relays || relay_count <= 0 || !relay_url || relay_url[0] == '\0') {
|
||||
@@ -1249,6 +1250,50 @@ static void self_skill_cache_upsert_event_locked(cJSON* event) {
|
||||
cJSON_AddItemToArray(g_self_skill_events, dup);
|
||||
}
|
||||
|
||||
static void seed_default_skill_into_cache(void) {
|
||||
if (!g_cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->default_skill.kind != 31123 && g_cfg->default_skill.kind != 31124) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->keys.public_key_hex[0] == '\0' ||
|
||||
!g_cfg->default_skill.content || g_cfg->default_skill.content[0] == '\0' ||
|
||||
!g_cfg->default_skill.tags_json || g_cfg->default_skill.tags_json[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_Parse(g_cfg->default_skill.tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
if (!event) {
|
||||
cJSON_Delete(tags);
|
||||
return;
|
||||
}
|
||||
|
||||
const double now = (double)time(NULL);
|
||||
cJSON_AddNumberToObject(event, "kind", g_cfg->default_skill.kind);
|
||||
cJSON_AddStringToObject(event, "pubkey", g_cfg->keys.public_key_hex);
|
||||
cJSON_AddStringToObject(event, "content", g_cfg->default_skill.content);
|
||||
cJSON_AddItemToObject(event, "tags", tags);
|
||||
cJSON_AddNumberToObject(event, "created_at", now);
|
||||
cJSON_AddStringToObject(event,
|
||||
"id",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000");
|
||||
|
||||
pthread_mutex_lock(&g_self_skill_mutex);
|
||||
self_skill_cache_upsert_event_locked(event);
|
||||
pthread_mutex_unlock(&g_self_skill_mutex);
|
||||
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
|
||||
static int hex_to_pubkey(const char* hex, unsigned char out_pubkey[32]) {
|
||||
if (!hex || !out_pubkey || strlen(hex) != 64U) {
|
||||
return -1;
|
||||
@@ -2969,6 +3014,8 @@ int nostr_handler_reconcile_startup_events(void) {
|
||||
publish_pending_startup_events_for_relay_index(relay_index, "startup_reconcile");
|
||||
}
|
||||
|
||||
seed_default_skill_into_cache();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -3755,6 +3802,55 @@ char* nostr_handler_get_admin_kind1_notes_context(void) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* extract_latest_kind0_content_from_events_json(const char* events_json) {
|
||||
if (!events_json || events_json[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(events_json);
|
||||
if (!root || !cJSON_IsArray(root)) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* best_content = NULL;
|
||||
time_t best_created_at = 0;
|
||||
|
||||
int n = cJSON_GetArraySize(root);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(root, i);
|
||||
if (!ev || !cJSON_IsObject(ev)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(ev, "kind");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev, "content");
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
if (!kind || !cJSON_IsNumber(kind) || (int)kind->valuedouble != 0 ||
|
||||
!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
time_t ev_created_at = 0;
|
||||
if (created_at && cJSON_IsNumber(created_at)) {
|
||||
ev_created_at = (time_t)created_at->valuedouble;
|
||||
}
|
||||
|
||||
if (!best_content || ev_created_at >= best_created_at) {
|
||||
char* dup = strdup(content->valuestring);
|
||||
if (!dup) {
|
||||
continue;
|
||||
}
|
||||
free(best_content);
|
||||
best_content = dup;
|
||||
best_created_at = ev_created_at;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return best_content;
|
||||
}
|
||||
|
||||
char* nostr_handler_get_agent_kind0_context(void) {
|
||||
if (!g_cfg) {
|
||||
return NULL;
|
||||
@@ -3763,7 +3859,46 @@ char* nostr_handler_get_agent_kind0_context(void) {
|
||||
pthread_mutex_lock(&g_admin_ctx_mutex);
|
||||
char* out = g_agent_kind0_json ? strdup(g_agent_kind0_json) : NULL;
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
return out;
|
||||
if (out && out[0] != '\0') {
|
||||
return out;
|
||||
}
|
||||
free(out);
|
||||
|
||||
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(0));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", 8);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 3000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* fetched_kind0 = extract_latest_kind0_content_from_events_json(events_json);
|
||||
free(events_json);
|
||||
if (!fetched_kind0 || fetched_kind0[0] == '\0') {
|
||||
free(fetched_kind0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_admin_ctx_mutex);
|
||||
free(g_agent_kind0_json);
|
||||
g_agent_kind0_json = strdup(fetched_kind0);
|
||||
pthread_mutex_unlock(&g_admin_ctx_mutex);
|
||||
|
||||
return fetched_kind0;
|
||||
}
|
||||
|
||||
char* nostr_handler_get_agent_kind3_context(void) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#define DIDACTYL_PROMPT_TEMPLATE_H
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
|
||||
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,6 @@ char* execute_admin_identity(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "admin_pubkey", ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown");
|
||||
cJSON_AddStringToObject(out, "sender_verification", tier_text);
|
||||
|
||||
char content[768];
|
||||
snprintf(content,
|
||||
@@ -82,7 +80,6 @@ char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "admin_kind0_json", profile_json);
|
||||
|
||||
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);
|
||||
@@ -121,7 +118,6 @@ char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json)
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "admin_kind3_json", contacts_json);
|
||||
|
||||
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);
|
||||
@@ -160,7 +156,6 @@ char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "admin_kind10002_json", relays_json);
|
||||
|
||||
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);
|
||||
@@ -199,7 +194,6 @@ char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "admin_notes_content", notes_text);
|
||||
|
||||
size_t content_len = strlen("## Administrator Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
|
||||
@@ -62,8 +62,6 @@ char* execute_agent_identity(tools_context_t* ctx, const char* args_json) {
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "agent_pubkey", ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown");
|
||||
cJSON_AddStringToObject(out, "agent_npub", npub);
|
||||
|
||||
char content[384];
|
||||
snprintf(content,
|
||||
@@ -95,7 +93,6 @@ char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "agent_kind0_json", profile_json);
|
||||
|
||||
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);
|
||||
@@ -134,7 +131,6 @@ char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json)
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "agent_kind3_json", contacts_json);
|
||||
|
||||
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);
|
||||
@@ -173,7 +169,6 @@ char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "agent_kind10002_json", relays_json);
|
||||
|
||||
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);
|
||||
@@ -212,7 +207,6 @@ char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "agent_notes_content", notes_text);
|
||||
|
||||
size_t content_len = strlen("## Agent Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
|
||||
char* content = (char*)malloc(content_len);
|
||||
@@ -267,7 +261,6 @@ char* execute_adopted_skills(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "content", (events_json && events_json[0]) ? events_json : "[]");
|
||||
cJSON_AddStringToObject(out, "adoption_events_json", (events_json && events_json[0]) ? events_json : "[]");
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
@@ -302,7 +295,6 @@ char* execute_trigger_event(tools_context_t* ctx, const char* args_json) {
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "content", trigger_json);
|
||||
cJSON_AddStringToObject(out, "trigger_event_json", trigger_json);
|
||||
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");
|
||||
}
|
||||
@@ -143,6 +143,9 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json) {
|
||||
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);
|
||||
@@ -180,7 +183,7 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_j->valuestring));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_buf));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
|
||||
@@ -208,7 +211,7 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag_j->valuestring);
|
||||
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);
|
||||
|
||||
@@ -30,113 +30,72 @@ static cJSON* parse_args_local(const char* args_json) {
|
||||
return args;
|
||||
}
|
||||
|
||||
static int json_object_set_string(cJSON* obj, const char* key, const char* value) {
|
||||
if (!obj || !key || !value) return -1;
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(obj, key);
|
||||
cJSON* v = cJSON_CreateString(value);
|
||||
if (!v) return -1;
|
||||
cJSON_AddItemToObject(obj, key, v);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* read_entire_file_local(const char* file_path, size_t* out_bytes) {
|
||||
if (!file_path) return NULL;
|
||||
|
||||
FILE* fp = fopen(file_path, "rb");
|
||||
if (!fp) return NULL;
|
||||
|
||||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
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;
|
||||
}
|
||||
|
||||
long len = ftell(fp);
|
||||
if (len < 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
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;
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_SET) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
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* buf = (char*)malloc((size_t)len + 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
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;
|
||||
}
|
||||
|
||||
size_t n = fread(buf, 1, (size_t)len, fp);
|
||||
fclose(fp);
|
||||
if (n != (size_t)len) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
buf[len] = '\0';
|
||||
if (out_bytes) *out_bytes = (size_t)len;
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int persist_llm_config(tools_context_t* ctx, const llm_config_t* cfg) {
|
||||
if (!ctx || !ctx->cfg || !cfg) return -1;
|
||||
if (ctx->cfg->config_path[0] == '\0') return -1;
|
||||
|
||||
size_t src_len = 0;
|
||||
char* raw = read_entire_file_local(ctx->cfg->config_path, &src_len);
|
||||
if (!raw) return -1;
|
||||
|
||||
char* src = jsonc_strip_comments(raw, src_len);
|
||||
free(raw);
|
||||
if (!src) return -1;
|
||||
|
||||
cJSON* root = cJSON_ParseWithLength(src, strlen(src));
|
||||
free(src);
|
||||
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* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
|
||||
if (!llm || !cJSON_IsObject(llm)) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "llm");
|
||||
llm = cJSON_CreateObject();
|
||||
if (!llm) {
|
||||
cJSON_Delete(root);
|
||||
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_AddItemToObject(root, "llm", llm);
|
||||
}
|
||||
|
||||
if (json_object_set_string(llm, "provider", cfg->provider) != 0 ||
|
||||
json_object_set_string(llm, "api_key", cfg->api_key) != 0 ||
|
||||
json_object_set_string(llm, "model", cfg->model) != 0 ||
|
||||
json_object_set_string(llm, "base_url", cfg->base_url) != 0) {
|
||||
cJSON_Delete(root);
|
||||
free(store_result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(llm, "max_tokens");
|
||||
cJSON_AddNumberToObject(llm, "max_tokens", cfg->max_tokens);
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(llm, "temperature");
|
||||
cJSON_AddNumberToObject(llm, "temperature", cfg->temperature);
|
||||
|
||||
char* out = cJSON_Print(root);
|
||||
cJSON_Delete(root);
|
||||
if (!out) return -1;
|
||||
|
||||
FILE* fp = fopen(ctx->cfg->config_path, "wb");
|
||||
if (!fp) {
|
||||
free(out);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t out_len = strlen(out);
|
||||
size_t n = fwrite(out, 1, out_len, fp);
|
||||
fclose(fp);
|
||||
free(out);
|
||||
return n == out_len ? 0 : -1;
|
||||
free(store_result);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* changed) {
|
||||
@@ -149,6 +108,25 @@ static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* cha
|
||||
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");
|
||||
@@ -161,10 +139,15 @@ char* execute_model_get(const char* args_json) {
|
||||
|
||||
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);
|
||||
|
||||
@@ -231,21 +214,30 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
ctx->cfg->llm = cfg;
|
||||
if (persist_llm_config(ctx, &cfg) != 0) {
|
||||
return json_error_local("failed to persist llm config to config file");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_handler.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define TASKS_KIND 30078
|
||||
#define TASKS_D_TAG "tasks"
|
||||
#define TASKS_APP_TAG "didactyl"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
@@ -31,33 +35,6 @@ static cJSON* parse_args_local(const char* args_json) {
|
||||
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 cJSON* tasks_create_empty_root_local(void) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* tasks = cJSON_CreateArray();
|
||||
@@ -79,47 +56,118 @@ static const char* normalize_task_status_local(const char* status) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static cJSON* tasks_load_root_local(const char* path) {
|
||||
if (!path) return NULL;
|
||||
static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, char** out_ciphertext) {
|
||||
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
|
||||
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp) {
|
||||
if (access(path, F_OK) == 0) {
|
||||
return NULL;
|
||||
}
|
||||
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_tasks_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(TASKS_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(TASKS_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 cJSON* tasks_load_root_nostr_local(tools_context_t* ctx) {
|
||||
char* ciphertext = NULL;
|
||||
if (query_tasks_ciphertext_local(ctx, &ciphertext) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!ciphertext || ciphertext[0] == '\0') {
|
||||
free(ciphertext);
|
||||
return tasks_create_empty_root_local();
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
long len = ftell(fp);
|
||||
if (len < 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
if (fseek(fp, 0, SEEK_SET) != 0) {
|
||||
fclose(fp);
|
||||
char* plaintext = NULL;
|
||||
if (nip44_decrypt_self_local(ctx, ciphertext, &plaintext) != 0) {
|
||||
free(ciphertext);
|
||||
return NULL;
|
||||
}
|
||||
free(ciphertext);
|
||||
|
||||
char* buf = (char*)malloc((size_t)len + 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t n = fread(buf, 1, (size_t)len, fp);
|
||||
fclose(fp);
|
||||
if (n != (size_t)len) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
buf[len] = '\0';
|
||||
|
||||
cJSON* root = cJSON_Parse(buf);
|
||||
free(buf);
|
||||
cJSON* root = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
@@ -140,22 +188,45 @@ static cJSON* tasks_load_root_local(const char* path) {
|
||||
return root;
|
||||
}
|
||||
|
||||
static int tasks_save_root_local(const char* path, cJSON* root) {
|
||||
if (!path || !root) return -1;
|
||||
char* raw = cJSON_PrintUnformatted(root);
|
||||
if (!raw) return -1;
|
||||
static int tasks_save_root_nostr_local(tools_context_t* ctx, cJSON* root) {
|
||||
if (!ctx || !ctx->cfg || !root) return -1;
|
||||
|
||||
FILE* fp = fopen(path, "wb");
|
||||
if (!fp) {
|
||||
free(raw);
|
||||
char* plaintext = cJSON_PrintUnformatted(root);
|
||||
if (!plaintext) return -1;
|
||||
|
||||
char* ciphertext = NULL;
|
||||
if (nip44_encrypt_self_local(ctx, plaintext, &ciphertext) != 0) {
|
||||
free(plaintext);
|
||||
return -1;
|
||||
}
|
||||
free(plaintext);
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
cJSON* d_tag = cJSON_CreateArray();
|
||||
cJSON* app_tag = cJSON_CreateArray();
|
||||
if (!tags || !d_tag || !app_tag) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(d_tag);
|
||||
cJSON_Delete(app_tag);
|
||||
free(ciphertext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t len = strlen(raw);
|
||||
size_t n = fwrite(raw, 1, len, fp);
|
||||
fclose(fp);
|
||||
free(raw);
|
||||
return (n == len) ? 0 : -1;
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(TASKS_D_TAG));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString(TASKS_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(TASKS_KIND, ciphertext, tags, &publish_result);
|
||||
cJSON_Delete(tags);
|
||||
free(ciphertext);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return rc == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
static cJSON* task_find_by_id_local(cJSON* tasks, int id, int* out_index) {
|
||||
@@ -184,16 +255,10 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
|
||||
return json_error_local("task_manage requires string action");
|
||||
}
|
||||
|
||||
char tasks_path[PATH_MAX];
|
||||
if (build_tool_path_local(ctx, "tasks.json", tasks_path, sizeof(tasks_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to resolve tasks file path");
|
||||
}
|
||||
|
||||
cJSON* root = tasks_load_root_local(tasks_path);
|
||||
cJSON* root = tasks_load_root_nostr_local(ctx);
|
||||
if (!root) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to load tasks file");
|
||||
return json_error_local("failed to load tasks memory");
|
||||
}
|
||||
|
||||
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
|
||||
@@ -405,10 +470,10 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
|
||||
if (mutated) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
|
||||
cJSON_AddNumberToObject(root, "next_id", next_id);
|
||||
if (tasks_save_root_local(tasks_path, root) != 0) {
|
||||
if (tasks_save_root_nostr_local(ctx, root) != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error_local("failed to save tasks file");
|
||||
return json_error_local("failed to persist tasks memory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +486,6 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "action", action_s);
|
||||
cJSON_AddStringToObject(out, "path", tasks_path);
|
||||
cJSON_AddBoolToObject(out, "mutated", mutated ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(tasks));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#ifndef DIDACTYL_TOOLS_H
|
||||
#define DIDACTYL_TOOLS_H
|
||||
|
||||
#include "config.h"
|
||||
#include "../config.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
|
||||
@@ -232,6 +232,30 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
|
||||
if (strcmp(tool_name, "trigger_event") == 0) {
|
||||
return execute_trigger_event(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_balance") == 0) {
|
||||
return execute_cashu_wallet_balance(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_info") == 0) {
|
||||
return execute_cashu_wallet_info(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_mint_quote") == 0) {
|
||||
return execute_cashu_wallet_mint_quote(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_mint_check") == 0) {
|
||||
return execute_cashu_wallet_mint_check(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_mint_claim") == 0) {
|
||||
return execute_cashu_wallet_mint_claim(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_melt_quote") == 0) {
|
||||
return execute_cashu_wallet_melt_quote(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_melt_pay") == 0) {
|
||||
return execute_cashu_wallet_melt_pay(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "cashu_wallet_check_proofs") == 0) {
|
||||
return execute_cashu_wallet_check_proofs(ctx, args_json);
|
||||
}
|
||||
|
||||
return json_error("unknown tool");
|
||||
}
|
||||
|
||||
@@ -68,6 +68,16 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_config_recall(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_adopted_skills(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_trigger_event(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_mint_quote(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_mint_check(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_mint_claim(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_melt_quote(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_melt_pay(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_check_proofs(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
int memory_init(tools_context_t* ctx);
|
||||
void memory_cleanup(void);
|
||||
|
||||
|
||||
@@ -687,6 +687,9 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON* p_skill_create_llm = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_llm, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_llm,
|
||||
"description",
|
||||
"Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only.");
|
||||
cJSON_AddItemToObject(t22_props, "llm", p_skill_create_llm);
|
||||
|
||||
cJSON* p_skill_create_tools = cJSON_CreateObject();
|
||||
@@ -840,6 +843,9 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON* p_skill_edit_llm = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_llm, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_edit_llm,
|
||||
"description",
|
||||
"Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only.");
|
||||
cJSON_AddItemToObject(t25b_props, "llm", p_skill_edit_llm);
|
||||
|
||||
cJSON* p_skill_edit_tools = cJSON_CreateObject();
|
||||
@@ -976,7 +982,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON_AddStringToObject(t31, "type", "function");
|
||||
cJSON_AddStringToObject(t31_fn, "name", "model_set");
|
||||
cJSON_AddStringToObject(t31_fn, "description", "Update active LLM configuration and persist it to config.jsonc");
|
||||
cJSON_AddStringToObject(t31_fn, "description", "Update active LLM configuration and persist it to Nostr kind 30078 (d=llm_config)");
|
||||
cJSON_AddStringToObject(t31_params, "type", "object");
|
||||
cJSON_AddItemToObject(t31_params, "properties", t31_props);
|
||||
|
||||
@@ -1276,7 +1282,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON_AddStringToObject(t42, "type", "function");
|
||||
cJSON_AddStringToObject(t42_fn, "name", "task_list");
|
||||
cJSON_AddStringToObject(t42_fn, "description", "Build current task list context block from tasks.json");
|
||||
cJSON_AddStringToObject(t42_fn, "description", "Build current task list context block from agent task memory on Nostr");
|
||||
cJSON_AddStringToObject(t42_params, "type", "object");
|
||||
cJSON_AddItemToObject(t42_params, "properties", t42_props);
|
||||
cJSON_AddItemToObject(t42_fn, "parameters", t42_params);
|
||||
@@ -1305,7 +1311,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON_AddStringToObject(t45, "type", "function");
|
||||
cJSON_AddStringToObject(t45_fn, "name", "task_manage");
|
||||
cJSON_AddStringToObject(t45_fn, "description", "Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)");
|
||||
cJSON_AddStringToObject(t45_fn, "description", "Manage agent short-term task memory stored on Nostr kind 30078 (d=tasks): list/add/update/remove/clear/replace");
|
||||
cJSON_AddStringToObject(t45_params, "type", "object");
|
||||
cJSON_AddItemToObject(t45_params, "properties", t45_props);
|
||||
cJSON_AddItemToObject(t45_params, "required", t45_required);
|
||||
@@ -1471,6 +1477,211 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
cJSON_AddItemToObject(t50, "function", t50_fn);
|
||||
cJSON_AddItemToArray(tools, t50);
|
||||
|
||||
cJSON* t51 = cJSON_CreateObject();
|
||||
cJSON* t51_fn = cJSON_CreateObject();
|
||||
cJSON* t51_params = cJSON_CreateObject();
|
||||
cJSON* t51_props = cJSON_CreateObject();
|
||||
|
||||
cJSON_AddStringToObject(t51, "type", "function");
|
||||
cJSON_AddStringToObject(t51_fn, "name", "cashu_wallet_balance");
|
||||
cJSON_AddStringToObject(t51_fn, "description", "Return current wallet balances aggregated by mint and unit");
|
||||
cJSON_AddStringToObject(t51_params, "type", "object");
|
||||
cJSON_AddItemToObject(t51_params, "properties", t51_props);
|
||||
|
||||
cJSON_AddItemToObject(t51_fn, "parameters", t51_params);
|
||||
cJSON_AddItemToObject(t51, "function", t51_fn);
|
||||
cJSON_AddItemToArray(tools, t51);
|
||||
|
||||
cJSON* t52 = cJSON_CreateObject();
|
||||
cJSON* t52_fn = cJSON_CreateObject();
|
||||
cJSON* t52_params = cJSON_CreateObject();
|
||||
cJSON* t52_props = cJSON_CreateObject();
|
||||
|
||||
cJSON_AddStringToObject(t52, "type", "function");
|
||||
cJSON_AddStringToObject(t52_fn, "name", "cashu_wallet_info");
|
||||
cJSON_AddStringToObject(t52_fn, "description", "Fetch mint info for a specific mint_url or the default configured mint");
|
||||
cJSON_AddStringToObject(t52_params, "type", "object");
|
||||
cJSON_AddItemToObject(t52_params, "properties", t52_props);
|
||||
|
||||
cJSON* p_wallet_info_mint = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_info_mint, "type", "string");
|
||||
cJSON_AddStringToObject(p_wallet_info_mint, "description", "Optional mint base URL; uses configured default when omitted");
|
||||
cJSON_AddItemToObject(t52_props, "mint_url", p_wallet_info_mint);
|
||||
|
||||
cJSON_AddItemToObject(t52_fn, "parameters", t52_params);
|
||||
cJSON_AddItemToObject(t52, "function", t52_fn);
|
||||
cJSON_AddItemToArray(tools, t52);
|
||||
|
||||
cJSON* t53 = cJSON_CreateObject();
|
||||
cJSON* t53_fn = cJSON_CreateObject();
|
||||
cJSON* t53_params = cJSON_CreateObject();
|
||||
cJSON* t53_props = cJSON_CreateObject();
|
||||
cJSON* t53_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t53, "type", "function");
|
||||
cJSON_AddStringToObject(t53_fn, "name", "cashu_wallet_mint_quote");
|
||||
cJSON_AddStringToObject(t53_fn, "description", "Request a mint quote for a target amount");
|
||||
cJSON_AddStringToObject(t53_params, "type", "object");
|
||||
cJSON_AddItemToObject(t53_params, "properties", t53_props);
|
||||
cJSON_AddItemToObject(t53_params, "required", t53_required);
|
||||
|
||||
cJSON* p_wallet_mq_amount = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mq_amount, "type", "integer");
|
||||
cJSON_AddStringToObject(p_wallet_mq_amount, "description", "Amount to mint in the selected unit");
|
||||
cJSON_AddItemToObject(t53_props, "amount", p_wallet_mq_amount);
|
||||
|
||||
cJSON* p_wallet_mq_mint = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mq_mint, "type", "string");
|
||||
cJSON_AddStringToObject(p_wallet_mq_mint, "description", "Optional mint base URL; uses configured default when omitted");
|
||||
cJSON_AddItemToObject(t53_props, "mint_url", p_wallet_mq_mint);
|
||||
|
||||
cJSON* p_wallet_mq_unit = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mq_unit, "type", "string");
|
||||
cJSON_AddStringToObject(p_wallet_mq_unit, "description", "Optional unit, e.g. sat");
|
||||
cJSON_AddItemToObject(t53_props, "unit", p_wallet_mq_unit);
|
||||
|
||||
cJSON_AddItemToArray(t53_required, cJSON_CreateString("amount"));
|
||||
|
||||
cJSON_AddItemToObject(t53_fn, "parameters", t53_params);
|
||||
cJSON_AddItemToObject(t53, "function", t53_fn);
|
||||
cJSON_AddItemToArray(tools, t53);
|
||||
|
||||
cJSON* t54 = cJSON_CreateObject();
|
||||
cJSON* t54_fn = cJSON_CreateObject();
|
||||
cJSON* t54_params = cJSON_CreateObject();
|
||||
cJSON* t54_props = cJSON_CreateObject();
|
||||
cJSON* t54_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t54, "type", "function");
|
||||
cJSON_AddStringToObject(t54_fn, "name", "cashu_wallet_mint_check");
|
||||
cJSON_AddStringToObject(t54_fn, "description", "Check a previously requested mint quote status");
|
||||
cJSON_AddStringToObject(t54_params, "type", "object");
|
||||
cJSON_AddItemToObject(t54_params, "properties", t54_props);
|
||||
cJSON_AddItemToObject(t54_params, "required", t54_required);
|
||||
|
||||
cJSON* p_wallet_mc_quote = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mc_quote, "type", "string");
|
||||
cJSON_AddItemToObject(t54_props, "quote_id", p_wallet_mc_quote);
|
||||
|
||||
cJSON* p_wallet_mc_mint = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mc_mint, "type", "string");
|
||||
cJSON_AddItemToObject(t54_props, "mint_url", p_wallet_mc_mint);
|
||||
|
||||
cJSON_AddItemToArray(t54_required, cJSON_CreateString("quote_id"));
|
||||
|
||||
cJSON_AddItemToObject(t54_fn, "parameters", t54_params);
|
||||
cJSON_AddItemToObject(t54, "function", t54_fn);
|
||||
cJSON_AddItemToArray(tools, t54);
|
||||
|
||||
cJSON* t55 = cJSON_CreateObject();
|
||||
cJSON* t55_fn = cJSON_CreateObject();
|
||||
cJSON* t55_params = cJSON_CreateObject();
|
||||
cJSON* t55_props = cJSON_CreateObject();
|
||||
cJSON* t55_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t55, "type", "function");
|
||||
cJSON_AddStringToObject(t55_fn, "name", "cashu_wallet_mint_claim");
|
||||
cJSON_AddStringToObject(t55_fn, "description", "Claim minted proofs for a paid quote_id and add them to wallet state");
|
||||
cJSON_AddStringToObject(t55_params, "type", "object");
|
||||
cJSON_AddItemToObject(t55_params, "properties", t55_props);
|
||||
cJSON_AddItemToObject(t55_params, "required", t55_required);
|
||||
|
||||
cJSON* p_wallet_mclaim_quote = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mclaim_quote, "type", "string");
|
||||
cJSON_AddItemToObject(t55_props, "quote_id", p_wallet_mclaim_quote);
|
||||
|
||||
cJSON* p_wallet_mclaim_mint = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mclaim_mint, "type", "string");
|
||||
cJSON_AddItemToObject(t55_props, "mint_url", p_wallet_mclaim_mint);
|
||||
|
||||
cJSON* p_wallet_mclaim_amount = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mclaim_amount, "type", "integer");
|
||||
cJSON_AddStringToObject(p_wallet_mclaim_amount, "description", "Optional target amount; if omitted wallet attempts best-effort claim");
|
||||
cJSON_AddItemToObject(t55_props, "amount", p_wallet_mclaim_amount);
|
||||
|
||||
cJSON_AddItemToArray(t55_required, cJSON_CreateString("quote_id"));
|
||||
|
||||
cJSON_AddItemToObject(t55_fn, "parameters", t55_params);
|
||||
cJSON_AddItemToObject(t55, "function", t55_fn);
|
||||
cJSON_AddItemToArray(tools, t55);
|
||||
|
||||
cJSON* t56 = cJSON_CreateObject();
|
||||
cJSON* t56_fn = cJSON_CreateObject();
|
||||
cJSON* t56_params = cJSON_CreateObject();
|
||||
cJSON* t56_props = cJSON_CreateObject();
|
||||
cJSON* t56_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t56, "type", "function");
|
||||
cJSON_AddStringToObject(t56_fn, "name", "cashu_wallet_melt_quote");
|
||||
cJSON_AddStringToObject(t56_fn, "description", "Request a melt quote for a Lightning payment request");
|
||||
cJSON_AddStringToObject(t56_params, "type", "object");
|
||||
cJSON_AddItemToObject(t56_params, "properties", t56_props);
|
||||
cJSON_AddItemToObject(t56_params, "required", t56_required);
|
||||
|
||||
cJSON* p_wallet_meq_pr = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_meq_pr, "type", "string");
|
||||
cJSON_AddItemToObject(t56_props, "payment_request", p_wallet_meq_pr);
|
||||
|
||||
cJSON* p_wallet_meq_mint = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_meq_mint, "type", "string");
|
||||
cJSON_AddItemToObject(t56_props, "mint_url", p_wallet_meq_mint);
|
||||
|
||||
cJSON* p_wallet_meq_unit = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_meq_unit, "type", "string");
|
||||
cJSON_AddItemToObject(t56_props, "unit", p_wallet_meq_unit);
|
||||
|
||||
cJSON_AddItemToArray(t56_required, cJSON_CreateString("payment_request"));
|
||||
|
||||
cJSON_AddItemToObject(t56_fn, "parameters", t56_params);
|
||||
cJSON_AddItemToObject(t56, "function", t56_fn);
|
||||
cJSON_AddItemToArray(tools, t56);
|
||||
|
||||
cJSON* t57 = cJSON_CreateObject();
|
||||
cJSON* t57_fn = cJSON_CreateObject();
|
||||
cJSON* t57_params = cJSON_CreateObject();
|
||||
cJSON* t57_props = cJSON_CreateObject();
|
||||
cJSON* t57_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t57, "type", "function");
|
||||
cJSON_AddStringToObject(t57_fn, "name", "cashu_wallet_melt_pay");
|
||||
cJSON_AddStringToObject(t57_fn, "description", "Pay a previously quoted melt by quote_id");
|
||||
cJSON_AddStringToObject(t57_params, "type", "object");
|
||||
cJSON_AddItemToObject(t57_params, "properties", t57_props);
|
||||
cJSON_AddItemToObject(t57_params, "required", t57_required);
|
||||
|
||||
cJSON* p_wallet_mp_quote = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mp_quote, "type", "string");
|
||||
cJSON_AddItemToObject(t57_props, "quote_id", p_wallet_mp_quote);
|
||||
|
||||
cJSON* p_wallet_mp_mint = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_mp_mint, "type", "string");
|
||||
cJSON_AddItemToObject(t57_props, "mint_url", p_wallet_mp_mint);
|
||||
|
||||
cJSON_AddItemToArray(t57_required, cJSON_CreateString("quote_id"));
|
||||
|
||||
cJSON_AddItemToObject(t57_fn, "parameters", t57_params);
|
||||
cJSON_AddItemToObject(t57, "function", t57_fn);
|
||||
cJSON_AddItemToArray(tools, t57);
|
||||
|
||||
cJSON* t58 = cJSON_CreateObject();
|
||||
cJSON* t58_fn = cJSON_CreateObject();
|
||||
cJSON* t58_params = cJSON_CreateObject();
|
||||
cJSON* t58_props = cJSON_CreateObject();
|
||||
|
||||
cJSON_AddStringToObject(t58, "type", "function");
|
||||
cJSON_AddStringToObject(t58_fn, "name", "cashu_wallet_check_proofs");
|
||||
cJSON_AddStringToObject(t58_fn, "description", "Check current wallet proofs state against mint");
|
||||
cJSON_AddStringToObject(t58_params, "type", "object");
|
||||
cJSON_AddItemToObject(t58_params, "properties", t58_props);
|
||||
|
||||
cJSON* p_wallet_cp_mint = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_wallet_cp_mint, "type", "string");
|
||||
cJSON_AddItemToObject(t58_props, "mint_url", p_wallet_cp_mint);
|
||||
|
||||
cJSON_AddItemToObject(t58_fn, "parameters", t58_params);
|
||||
cJSON_AddItemToObject(t58, "function", t58_fn);
|
||||
cJSON_AddItemToArray(tools, t58);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(tools);
|
||||
cJSON_Delete(tools);
|
||||
return out;
|
||||
|
||||
@@ -204,18 +204,202 @@ static int cron_field_matches(const char* field, int value, int min_v, int max_v
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cron_normalize_expression(const char* expr, char* out, size_t out_sz, int* out_expanded) {
|
||||
if (out_expanded) {
|
||||
*out_expanded = 0;
|
||||
}
|
||||
if (!expr || !out || out_sz == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
while (*expr && isspace((unsigned char)*expr)) {
|
||||
expr++;
|
||||
}
|
||||
if (*expr == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* mapped = NULL;
|
||||
if (strcmp(expr, "*") == 0) {
|
||||
mapped = "* * * * *";
|
||||
} else if (strcmp(expr, "@yearly") == 0 || strcmp(expr, "@annually") == 0) {
|
||||
mapped = "0 0 1 1 *";
|
||||
} else if (strcmp(expr, "@monthly") == 0) {
|
||||
mapped = "0 0 1 * *";
|
||||
} else if (strcmp(expr, "@weekly") == 0) {
|
||||
mapped = "0 0 * * 0";
|
||||
} else if (strcmp(expr, "@daily") == 0 || strcmp(expr, "@midnight") == 0) {
|
||||
mapped = "0 0 * * *";
|
||||
} else if (strcmp(expr, "@hourly") == 0) {
|
||||
mapped = "0 * * * *";
|
||||
}
|
||||
|
||||
const char* src = mapped ? mapped : expr;
|
||||
int n = snprintf(out, out_sz, "%s", src);
|
||||
if (n < 0 || (size_t)n >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mapped && out_expanded) {
|
||||
*out_expanded = 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cron_field_token_valid(const char* token, int min_v, int max_v) {
|
||||
if (!token || token[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(token, "*") == 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char* slash = strchr(token, '/');
|
||||
int step = 0;
|
||||
char base[64];
|
||||
if (slash) {
|
||||
size_t base_len = (size_t)(slash - token);
|
||||
if (base_len == 0 || base_len >= sizeof(base)) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(base, token, base_len);
|
||||
base[base_len] = '\0';
|
||||
|
||||
char* endptr = NULL;
|
||||
long step_l = strtol(slash + 1, &endptr, 10);
|
||||
if (endptr == slash + 1 || *endptr != '\0' || step_l <= 0 || step_l > 1024) {
|
||||
return 0;
|
||||
}
|
||||
step = (int)step_l;
|
||||
} else {
|
||||
int n = snprintf(base, sizeof(base), "%s", token);
|
||||
if (n < 0 || (size_t)n >= sizeof(base)) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (strcmp(base, "*") == 0) {
|
||||
return step == 0 || step <= (max_v - min_v + 1);
|
||||
}
|
||||
|
||||
const char* dash = strchr(base, '-');
|
||||
if (dash) {
|
||||
char left[32];
|
||||
char right[32];
|
||||
size_t left_len = (size_t)(dash - base);
|
||||
size_t right_len = strlen(dash + 1);
|
||||
if (left_len == 0 || right_len == 0 || left_len >= sizeof(left) || right_len >= sizeof(right)) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(left, base, left_len);
|
||||
left[left_len] = '\0';
|
||||
memcpy(right, dash + 1, right_len + 1U);
|
||||
|
||||
char* e1 = NULL;
|
||||
char* e2 = NULL;
|
||||
long a = strtol(left, &e1, 10);
|
||||
long b = strtol(right, &e2, 10);
|
||||
if (!e1 || *e1 != '\0' || !e2 || *e2 != '\0') {
|
||||
return 0;
|
||||
}
|
||||
if ((int)a < min_v || (int)b > max_v || (int)a > (int)b) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
char* e = NULL;
|
||||
long n = strtol(base, &e, 10);
|
||||
if (!e || *e != '\0') {
|
||||
return 0;
|
||||
}
|
||||
if ((int)n < min_v || (int)n > max_v) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int cron_field_valid(const char* field, int min_v, int max_v) {
|
||||
if (!field || field[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char tmp[128];
|
||||
int n = snprintf(tmp, sizeof(tmp), "%s", field);
|
||||
if (n < 0 || (size_t)n >= sizeof(tmp)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int tokens = 0;
|
||||
char* saveptr = NULL;
|
||||
char* token = strtok_r(tmp, ",", &saveptr);
|
||||
while (token) {
|
||||
while (*token == ' ') token++;
|
||||
if (!cron_field_token_valid(token, min_v, max_v)) {
|
||||
return 0;
|
||||
}
|
||||
tokens++;
|
||||
token = strtok_r(NULL, ",", &saveptr);
|
||||
}
|
||||
|
||||
return tokens > 0;
|
||||
}
|
||||
|
||||
static int cron_expr_is_valid(const char* expr, char* normalized_out, size_t normalized_out_size) {
|
||||
if (!normalized_out || normalized_out_size == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (cron_normalize_expression(expr, normalized_out, normalized_out_size, NULL) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char parse_buf[TRIGGER_FILTER_JSON_MAX];
|
||||
int n = snprintf(parse_buf, sizeof(parse_buf), "%s", normalized_out);
|
||||
if (n < 0 || (size_t)n >= sizeof(parse_buf)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* fields[5] = {0};
|
||||
int nf = 0;
|
||||
char* saveptr = NULL;
|
||||
char* tok = strtok_r(parse_buf, " \t", &saveptr);
|
||||
while (tok && nf < 5) {
|
||||
fields[nf++] = tok;
|
||||
tok = strtok_r(NULL, " \t", &saveptr);
|
||||
}
|
||||
if (nf != 5 || tok != NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!cron_field_valid(fields[0], 0, 59)) return 0;
|
||||
if (!cron_field_valid(fields[1], 0, 23)) return 0;
|
||||
if (!cron_field_valid(fields[2], 1, 31)) return 0;
|
||||
if (!cron_field_valid(fields[3], 1, 12)) return 0;
|
||||
if (!cron_field_valid(fields[4], 0, 6)) return 0;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int cron_matches_now(const char* expr, time_t now_ts) {
|
||||
if (!expr || expr[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char buf[TRIGGER_FILTER_JSON_MAX];
|
||||
snprintf(buf, sizeof(buf), "%s", expr);
|
||||
char normalized[TRIGGER_FILTER_JSON_MAX];
|
||||
if (!cron_expr_is_valid(expr, normalized, sizeof(normalized))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char parse_buf[TRIGGER_FILTER_JSON_MAX];
|
||||
snprintf(parse_buf, sizeof(parse_buf), "%s", normalized);
|
||||
|
||||
char* fields[5] = {0};
|
||||
int nf = 0;
|
||||
char* saveptr = NULL;
|
||||
char* tok = strtok_r(buf, " \t", &saveptr);
|
||||
char* tok = strtok_r(parse_buf, " \t", &saveptr);
|
||||
while (tok && nf < 5) {
|
||||
fields[nf++] = tok;
|
||||
tok = strtok_r(NULL, " \t", &saveptr);
|
||||
@@ -612,7 +796,7 @@ int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
mgr->last_poll_at = time(NULL);
|
||||
mgr->last_poll_at = 0;
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager initialized (capacity=%d)", mgr->capacity);
|
||||
return 0;
|
||||
@@ -985,7 +1169,14 @@ int trigger_manager_add(trigger_manager_t* mgr,
|
||||
t->last_seen_created_at = 0;
|
||||
t->last_cron_fire = 0;
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
char normalized_cron[sizeof(t->cron_expr)] = {0};
|
||||
if (!cron_expr_is_valid(filter_json, normalized_cron, sizeof(normalized_cron))) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: invalid cron expression d_tag=%s expr=%s", skill_d_tag, filter_json);
|
||||
memset(t, 0, sizeof(*t));
|
||||
return -1;
|
||||
}
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", normalized_cron);
|
||||
}
|
||||
snprintf(t->llm_spec, sizeof(t->llm_spec), "%s", llm_spec ? llm_spec : "");
|
||||
snprintf(t->tools_policy, sizeof(t->tools_policy), "%s", tools_policy ? tools_policy : "");
|
||||
@@ -1208,7 +1399,13 @@ int trigger_manager_update(trigger_manager_t* mgr,
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
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);
|
||||
char normalized_cron[sizeof(t->cron_expr)] = {0};
|
||||
if (!cron_expr_is_valid(filter_json, normalized_cron, sizeof(normalized_cron))) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger update rejected: invalid cron expression d_tag=%s expr=%s", skill_d_tag, filter_json);
|
||||
return -1;
|
||||
}
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", normalized_cron);
|
||||
} else {
|
||||
t->cron_expr[0] = '\0';
|
||||
}
|
||||
@@ -1261,6 +1458,7 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
}
|
||||
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
|
||||
DEBUG_LOG("[didactyl] trigger cron poll throttled (delta=%ld)", (long)(now - mgr->last_poll_at));
|
||||
return 0;
|
||||
}
|
||||
mgr->last_poll_at = now;
|
||||
@@ -1276,9 +1474,16 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
}
|
||||
|
||||
const char* expr = (t->cron_expr[0] != '\0') ? t->cron_expr : t->filter_json;
|
||||
if (!cron_matches_now(expr, now)) {
|
||||
char normalized_expr[64] = {0};
|
||||
if (!cron_expr_is_valid(expr, normalized_expr, sizeof(normalized_expr))) {
|
||||
DEBUG_WARN("[didactyl] cron trigger skipped: invalid expression d_tag=%s expr=%s", t->skill_d_tag, expr ? expr : "");
|
||||
continue;
|
||||
}
|
||||
if (!cron_matches_now(normalized_expr, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] cron trigger matched d_tag=%s expr=%s", t->skill_d_tag, normalized_expr);
|
||||
|
||||
if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) {
|
||||
continue;
|
||||
@@ -1296,7 +1501,7 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (event) {
|
||||
cJSON_AddStringToObject(event, "type", "cron");
|
||||
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
|
||||
cJSON_AddStringToObject(event, "cron_expr", expr);
|
||||
cJSON_AddStringToObject(event, "cron_expr", normalized_expr);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
(void)mgr;
|
||||
execute_llm_action(&trigger_copy, event, "cron");
|
||||
@@ -1438,6 +1643,10 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
if (t->has_seed) cJSON_AddNumberToObject(item, "seed", t->seed);
|
||||
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
|
||||
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
|
||||
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);
|
||||
}
|
||||
cJSON_AddItemToArray(arr, item);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user