Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c69a581d9 | ||
|
|
76842627dc | ||
|
|
2209629f23 | ||
|
|
5d7ddb5b78 | ||
|
|
d83c93eb32 | ||
|
|
01a4cad35c |
9
.roo/commands/build.md
Normal file
9
.roo/commands/build.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: "Build Didactyl for end-of-change validation"
|
||||
---
|
||||
|
||||
After making code changes, run [`build_static.sh`](build_static.sh) instead of `make` for final compile validation.
|
||||
|
||||
Example:
|
||||
|
||||
./build_static.sh
|
||||
238
README.md
238
README.md
@@ -10,48 +10,57 @@ Didactyl boots on any internet-connected machine, connects to Nostr relays, list
|
||||
|
||||
Because all identity, communication, and memory live on Nostr, the agent is **portable** (start it anywhere) and **sovereign** (no single entity can erase its memory).
|
||||
|
||||
**Skills are the new apps.** Agents learn capabilities through skills — public Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. If someone publishes a useful skill, your agent can find it through your web of trust and start using it. Popularity is measured by adoption, not by a rating algorithm. The best skills spread because agents actually use them.
|
||||
|
||||
## Current Status
|
||||
|
||||
**MVP — Working chat agent with relay connectivity and LLM integration.**
|
||||
**Active build — relay-aware autonomous agent with tool-use and Nostr-native startup memory.**
|
||||
|
||||
- Connects to configured Nostr relays with auto-reconnect
|
||||
- Publishes agent profile (kind 0 metadata)
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
- Uses kind `31120` startup content as live Soul at boot
|
||||
- Listens for NIP-04 encrypted DMs from authorized admin
|
||||
- Forwards messages to an OpenAI-compatible LLM API
|
||||
- Sends LLM responses back as encrypted DMs
|
||||
- Runtime logging: relay status, connection health, message flow
|
||||
|
||||
**Next: Agentic tool-use system** — see [plans/didactyl_agentic.md](plans/didactyl_agentic.md).
|
||||
- Builds LLM context from system prompt + startup events + last 12 DM turns
|
||||
- Supports tool-calling loop with configurable max turns and local safety limits
|
||||
- Appends every outbound LLM context payload to [`context.log`](context.log)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
### Download binary (recommended)
|
||||
|
||||
- GCC with C99 support
|
||||
- libcurl, libssl, libcrypto, libsecp256k1
|
||||
1. Download the latest release binary from GitLab: <https://git.laantungir.net/laantungir/didactyl/-/releases>
|
||||
2. Make it executable and run it:
|
||||
|
||||
```bash
|
||||
chmod +x ./didactyl_static_x86_64
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
```
|
||||
|
||||
### Build from source (optional)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Docker (for static binary build)
|
||||
- An OpenAI-compatible LLM API key (OpenAI, PPQ, Ollama, etc.)
|
||||
- A Nostr keypair (nsec)
|
||||
|
||||
### Build
|
||||
#### Build
|
||||
|
||||
```bash
|
||||
make deps # builds nostr_core_lib
|
||||
make # builds didactyl
|
||||
./build_static.sh # builds a fully static MUSL binary via Docker
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
Edit `config.json`:
|
||||
Edit [`config.json`](config.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"name": "Didactyl Agent",
|
||||
"display_name": "Didactyl",
|
||||
"about": "A sovereign AI agent on Nostr"
|
||||
},
|
||||
"keys": {
|
||||
"nsec": "nsec1..."
|
||||
"nsec": "nsec1...",
|
||||
"npub": "npub1...",
|
||||
"npubHex": "<optional helper>",
|
||||
"nsecHex": "<optional helper>"
|
||||
},
|
||||
"admin": {
|
||||
"pubkey": "npub1... or hex pubkey"
|
||||
@@ -61,28 +70,55 @@ Edit `config.json`:
|
||||
"wss://nos.lol"
|
||||
],
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"provider": "openai|ppq|...",
|
||||
"api_key": "sk-...",
|
||||
"model": "gpt-4o-mini",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"enabled": true,
|
||||
"max_turns": 8,
|
||||
"shell": {
|
||||
"enabled": true,
|
||||
"timeout_seconds": 30,
|
||||
"max_output_bytes": 65536,
|
||||
"working_directory": "."
|
||||
}
|
||||
},
|
||||
"startup_events": [
|
||||
{
|
||||
"kind": 31120,
|
||||
"content": "You are Didactyl...",
|
||||
"tags": [["d", "soul"], ["app", "didactyl"], ["scope", "private"]]
|
||||
},
|
||||
{
|
||||
"kind": 31123,
|
||||
"content_fields": {"name": "long_form_note", "description": "..."},
|
||||
"tags": [["d", "long_form_note"], ["app", "didactyl"], ["scope", "public"], ["slug", "long_form_note"]]
|
||||
},
|
||||
{
|
||||
"kind": 10123,
|
||||
"content": "",
|
||||
"tags": [["a", "31123:<author-pubkey>:long_form_note"], ["app", "didactyl"], ["scope", "public"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Edit `SYSTEM.md` to define the agent's personality and instructions.
|
||||
`startup_events[].content_fields` is accepted for human-readable authoring and encoded to JSON string content at runtime.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./didactyl
|
||||
./didactyl_static_x86_64 --config ./config.json
|
||||
```
|
||||
|
||||
Options:
|
||||
```
|
||||
./didactyl --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl --context <path> # custom context file (default: ./SYSTEM.md)
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
@@ -92,45 +128,116 @@ Send an encrypted DM to the agent's pubkey from the admin account using any Nost
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Didactyl │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
|
||||
│ │ config │ │ context │ │ agent │ │
|
||||
│ │ loader │ │ loader │ │ loop │ │
|
||||
│ └────┬────┘ └────┬────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ nostr_handler │ │
|
||||
│ │ relay pool · subscribe · publish │ │
|
||||
│ └──────────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┴──────────────────┐ │
|
||||
│ │ LLM client │ │
|
||||
│ │ OpenAI-compatible chat API │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Didactyl │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ config │ │ skills │ │ agent │ │
|
||||
│ │ loader │ │ loader │ │ loop │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ nostr_handler │ │
|
||||
│ │ relay pool · subscribe · publish │ │
|
||||
│ └──────────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┴──────────────────┐ │
|
||||
│ │ LLM client │ │
|
||||
│ │ OpenAI-compatible chat API │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
Nostr Relays LLM API
|
||||
```
|
||||
|
||||
## Didactyl Kinds (Nostr)
|
||||
|
||||
Didactyl uses a two-layer skill model: authors publish public skill definitions, and adopters publish which skills they use.
|
||||
|
||||
- `31120` — **Soul** (private instruction baseline)
|
||||
- `d=soul`
|
||||
- `31123` — **Public Skill Definition** (markdown skill body in `content` or structured JSON in `content_fields`)
|
||||
- `d=<skill_slug>` (example: `d=long_form_note`)
|
||||
- `31124` — **Private Skill Definition** (private/internal procedures)
|
||||
- `d=<skill_slug>` (example: `d=admin_ops`)
|
||||
- `10123` — **Public Skill Adoption List**
|
||||
- tags contain one or more `a` references to selected `31123` skills
|
||||
|
||||
## Skill Sharing & Discovery
|
||||
|
||||
Skills are shared across Nostr without any centralized registry or approval process.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Publish**: An author publishes a skill as a kind `31123` event. The `content` field contains the skill body (markdown or structured JSON). The `d` tag is the skill's slug (e.g. `long_form_note`).
|
||||
|
||||
2. **Adopt**: An agent that wants to use a skill adds an `a`-tag reference to its kind `10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses.
|
||||
|
||||
3. **Discover**: A new user queries `{"kinds": [10123], "authors": [<my-follows>]}` to see which skills their web of trust has adopted. The most-referenced `31123` addresses are the most popular skills — no rating system needed.
|
||||
|
||||
4. **Improve**: Anyone can publish their own `31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking.
|
||||
|
||||
### Why this works
|
||||
|
||||
- **No gatekeeper**: Skills are just Nostr events. Anyone can publish one.
|
||||
- **WoT as curation**: You see what people you trust actually use, not what an algorithm promotes.
|
||||
- **Visible adoption**: The `10123` list is public. Popularity is a countable fact, not a manipulable score.
|
||||
- **Censorship resistant**: Skills live on relays. No single entity can remove a skill from the network.
|
||||
|
||||
## Startup
|
||||
|
||||
Didactyl startup behavior is configured in [`config.json`](config.json) under `startup_events`.
|
||||
|
||||
Also used at startup:
|
||||
|
||||
- `0` — profile metadata
|
||||
- `10002` — relay list
|
||||
- `1` — optional startup note/status
|
||||
- `3` — contacts/follows (optional placeholder)
|
||||
|
||||
On boot, Didactyl attempts startup publishes to each relay as that relay transitions to connected state.
|
||||
|
||||
## Runtime Context Model
|
||||
|
||||
For each admin DM request, Didactyl builds message context in this order:
|
||||
|
||||
1. Soul message from kind `31120` (or fallback default)
|
||||
2. Startup events memory block (`kinds/content/tags` snapshot)
|
||||
3. Last 12 decrypted DM turns between admin and agent
|
||||
4. Current user message
|
||||
|
||||
Every serialized LLM context payload is appended to [`context.log`](context.log).
|
||||
|
||||
## Tooling Interface
|
||||
|
||||
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:72):
|
||||
|
||||
- `nostr_post`
|
||||
- `nostr_query`
|
||||
- `shell_exec`
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
|
||||
Execution entrypoint: [`tools_execute()`](src/tools.c:434).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── config.json # Agent configuration
|
||||
├── SYSTEM.md # Agent personality/instructions for LLM
|
||||
├── config.json # Agent/runtime config including startup_events + tools
|
||||
├── context.log # Appended outbound LLM context payloads
|
||||
├── Makefile # Build system
|
||||
├── build_static.sh # Preferred final build validation
|
||||
├── src/
|
||||
│ ├── main.c # Entry point, signal handling, daemon loop
|
||||
│ ├── config.c / .h # JSON config parsing, key decoding
|
||||
│ ├── context.c / .h # SYSTEM.md file loader
|
||||
│ ├── agent.c / .h # Core agent logic: receive → LLM → respond
|
||||
│ ├── main.c # Entry point, args (--config/--debug), lifecycle
|
||||
│ ├── config.c / .h # JSON config parsing, key decode, startup events
|
||||
│ ├── agent.c / .h # Context assembly, tool loop, DM response flow
|
||||
│ ├── tools.c / .h # LLM tool schema and tool execution
|
||||
│ ├── llm.c / .h # LLM HTTP API client (OpenAI-compatible)
|
||||
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, DMs
|
||||
│ └── secp_compat.c # secp256k1 API compatibility shim
|
||||
│ ├── nostr_handler.c / .h # Relay pool, subscriptions, publish, startup reconcile
|
||||
│ └── debug.c / .h # Runtime log levels/macros
|
||||
├── plans/ # Architecture and planning documents
|
||||
│ ├── didactyl_mvp.md
|
||||
│ └── didactyl_agentic.md
|
||||
@@ -139,26 +246,33 @@ Send an encrypted DM to the agent's pubkey from the admin account using any Nost
|
||||
|
||||
## Dependencies
|
||||
|
||||
All dependencies are statically linked into the binary at build time. No system libraries are required at runtime.
|
||||
|
||||
| Dependency | Purpose | Source |
|
||||
|---|---|---|
|
||||
| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) |
|
||||
| cJSON | JSON parsing | Bundled in nostr_core_lib |
|
||||
| libcurl | HTTPS for LLM API calls | System package |
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | System package |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | System package |
|
||||
| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) |
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] MVP chat agent — DM in, LLM response out
|
||||
- [x] Relay pool with auto-reconnect and status logging
|
||||
- [x] Runtime diagnostics — relay health, message flow, LLM calls
|
||||
- [ ] **Agentic tool-use** — LLM can call tools (nostr_post, nostr_query, shell_exec)
|
||||
- [x] Per-relay startup publish on relay-connected transitions
|
||||
- [x] Runtime diagnostics — relay health, message flow, event kind publish logs
|
||||
- [x] Tool-calling loop (nostr_post, nostr_query, shell_exec, file_read, file_write)
|
||||
- [x] Context assembly with startup events + recent DM history
|
||||
- [x] Context payload logging to [`context.log`](context.log)
|
||||
- [x] Skill kind definitions (`31120` Soul, `31123` Public Skill, `31124` Private Skill)
|
||||
- [x] Skill adoption list (`10123`) for WoT-driven discovery
|
||||
- [ ] Runtime skill loading from adopted `31123` events on relays
|
||||
- [ ] Skill discovery CLI/tool (query WoT adoption lists)
|
||||
- [ ] Upgrade to NIP-17 gift-wrapped DMs
|
||||
- [ ] NIP-44 encrypted private skills (`31124`)
|
||||
- [ ] Nostr-native data storage (kind 30078 app-specific events)
|
||||
- [ ] Blossom blob storage integration
|
||||
- [ ] Conversation memory on Nostr
|
||||
- [ ] Config and SYSTEM.md stored as Nostr events
|
||||
- [ ] Multi-turn conversation context window
|
||||
- [ ] Agent-to-agent communication
|
||||
|
||||
## License
|
||||
|
||||
16
SYSTEM.md
16
SYSTEM.md
@@ -1,16 +0,0 @@
|
||||
# Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
## Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
## Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
|
||||
## Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- Do not reveal secrets from configuration or keys.
|
||||
@@ -6,7 +6,7 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
OUTPUT_DIR="$SCRIPT_DIR"
|
||||
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
|
||||
|
||||
# Parse command line arguments
|
||||
@@ -22,12 +22,15 @@ else
|
||||
echo "=========================================="
|
||||
fi
|
||||
echo "Project directory: $SCRIPT_DIR"
|
||||
echo "Build directory: $BUILD_DIR"
|
||||
echo "Output directory: $OUTPUT_DIR"
|
||||
echo "Debug build: $DEBUG_BUILD"
|
||||
echo ""
|
||||
|
||||
# Create build directory
|
||||
mkdir -p "$BUILD_DIR"
|
||||
# Remove legacy build directory if present
|
||||
if [ -d "$SCRIPT_DIR/build" ]; then
|
||||
rm -rf "$SCRIPT_DIR/build"
|
||||
echo "Removed legacy build directory: $SCRIPT_DIR/build"
|
||||
fi
|
||||
|
||||
# Check if Docker is available
|
||||
if ! command -v docker &> /dev/null; then
|
||||
@@ -136,20 +139,12 @@ echo "=========================================="
|
||||
echo "Step 2: Extracting static binary"
|
||||
echo "=========================================="
|
||||
|
||||
# Build the builder stage to extract the binary
|
||||
$DOCKER_CMD build \
|
||||
--platform "$PLATFORM" \
|
||||
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
|
||||
--target builder \
|
||||
-f "$DOCKERFILE" \
|
||||
-t didactyl-static-builder-stage:latest \
|
||||
. > /dev/null 2>&1
|
||||
# Reuse the already-built final image and extract binary directly
|
||||
# (avoids a second docker build pass)
|
||||
CONTAINER_ID=$($DOCKER_CMD create didactyl-musl-builder:latest /didactyl_static)
|
||||
|
||||
# Create a temporary container to copy the binary
|
||||
CONTAINER_ID=$($DOCKER_CMD create didactyl-static-builder-stage:latest)
|
||||
|
||||
# Copy binary from container
|
||||
$DOCKER_CMD cp "$CONTAINER_ID:/build/didactyl_static" "$BUILD_DIR/$OUTPUT_NAME" || {
|
||||
# Copy binary from final image
|
||||
$DOCKER_CMD cp "$CONTAINER_ID:/didactyl_static" "$OUTPUT_DIR/$OUTPUT_NAME" || {
|
||||
echo "ERROR: Failed to extract binary from container"
|
||||
$DOCKER_CMD rm "$CONTAINER_ID" 2>/dev/null
|
||||
exit 1
|
||||
@@ -158,11 +153,11 @@ $DOCKER_CMD cp "$CONTAINER_ID:/build/didactyl_static" "$BUILD_DIR/$OUTPUT_NAME"
|
||||
# Clean up container
|
||||
$DOCKER_CMD rm "$CONTAINER_ID" > /dev/null
|
||||
|
||||
echo "✓ Binary extracted to: $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo "✓ Binary extracted to: $OUTPUT_DIR/$OUTPUT_NAME"
|
||||
echo ""
|
||||
|
||||
# Make binary executable
|
||||
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
|
||||
chmod +x "$OUTPUT_DIR/$OUTPUT_NAME"
|
||||
|
||||
# Verify the binary
|
||||
echo "=========================================="
|
||||
@@ -171,7 +166,7 @@ echo "=========================================="
|
||||
echo ""
|
||||
|
||||
echo "Checking for dynamic dependencies:"
|
||||
if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then
|
||||
if LDD_OUTPUT=$(timeout 5 ldd "$OUTPUT_DIR/$OUTPUT_NAME" 2>&1); then
|
||||
if echo "$LDD_OUTPUT" | grep -q "not a dynamic executable"; then
|
||||
echo "✓ Binary is fully static (no dynamic dependencies)"
|
||||
TRULY_STATIC=true
|
||||
@@ -185,7 +180,7 @@ if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then
|
||||
fi
|
||||
else
|
||||
# ldd failed or timed out - check with file command instead
|
||||
if file "$BUILD_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then
|
||||
if file "$OUTPUT_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then
|
||||
echo "✓ Binary is statically linked (verified with file command)"
|
||||
TRULY_STATIC=true
|
||||
else
|
||||
@@ -198,8 +193,8 @@ echo ""
|
||||
echo "=========================================="
|
||||
echo "Build Summary"
|
||||
echo "=========================================="
|
||||
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
|
||||
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
|
||||
echo "Binary: $OUTPUT_DIR/$OUTPUT_NAME"
|
||||
echo "Size: $(du -h "$OUTPUT_DIR/$OUTPUT_NAME" | cut -f1)"
|
||||
echo "Static: $TRULY_STATIC"
|
||||
echo "Debug: $DEBUG_BUILD"
|
||||
echo "Platform: $PLATFORM"
|
||||
|
||||
510
src/agent.c
510
src/agent.c
@@ -5,15 +5,410 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
static char* g_system_context = NULL;
|
||||
static tools_context_t g_tools_ctx;
|
||||
|
||||
#define AGENT_DEBOUNCE_WINDOW_SECONDS 5
|
||||
#define AGENT_DEBOUNCE_CACHE_SIZE 256
|
||||
#define AGENT_HISTORY_TURNS 12
|
||||
#define AGENT_HISTORY_QUERY_LIMIT 200
|
||||
|
||||
typedef struct {
|
||||
uint64_t fingerprint;
|
||||
time_t seen_at;
|
||||
} agent_seen_msg_t;
|
||||
|
||||
static agent_seen_msg_t g_seen_msgs[AGENT_DEBOUNCE_CACHE_SIZE];
|
||||
static int g_seen_msgs_count = 0;
|
||||
static int g_seen_msgs_next = 0;
|
||||
static pthread_mutex_t g_seen_msgs_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static uint64_t fnv1a64(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
if (!s) return h;
|
||||
while (*s) {
|
||||
h ^= (unsigned char)(*s++);
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
static uint64_t message_fingerprint(const char* sender_pubkey_hex, const char* message) {
|
||||
uint64_t a = fnv1a64(sender_pubkey_hex);
|
||||
uint64_t b = fnv1a64(message);
|
||||
return a ^ (b + 0x9e3779b97f4a7c15ULL + (a << 6) + (a >> 2));
|
||||
}
|
||||
|
||||
static int agent_message_is_debounced(const char* sender_pubkey_hex, const char* message) {
|
||||
time_t now = time(NULL);
|
||||
uint64_t fp = message_fingerprint(sender_pubkey_hex, message);
|
||||
int duplicate = 0;
|
||||
|
||||
pthread_mutex_lock(&g_seen_msgs_mutex);
|
||||
|
||||
for (int i = 0; i < g_seen_msgs_count; i++) {
|
||||
if (g_seen_msgs[i].fingerprint == fp &&
|
||||
g_seen_msgs[i].seen_at > 0 &&
|
||||
(now - g_seen_msgs[i].seen_at) <= AGENT_DEBOUNCE_WINDOW_SECONDS) {
|
||||
duplicate = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!duplicate) {
|
||||
int slot = 0;
|
||||
if (g_seen_msgs_count < AGENT_DEBOUNCE_CACHE_SIZE) {
|
||||
slot = g_seen_msgs_count;
|
||||
g_seen_msgs_count++;
|
||||
} else {
|
||||
slot = g_seen_msgs_next;
|
||||
g_seen_msgs_next = (g_seen_msgs_next + 1) % AGENT_DEBOUNCE_CACHE_SIZE;
|
||||
}
|
||||
g_seen_msgs[slot].fingerprint = fp;
|
||||
g_seen_msgs[slot].seen_at = now;
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_seen_msgs_mutex);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
static int append_simple_message(cJSON* messages, const char* role, const char* content) {
|
||||
if (!messages || !role) return -1;
|
||||
|
||||
cJSON* msg = cJSON_CreateObject();
|
||||
if (!msg) return -1;
|
||||
|
||||
cJSON_AddStringToObject(msg, "role", role);
|
||||
cJSON_AddStringToObject(msg, "content", content ? content : "");
|
||||
cJSON_AddItemToArray(messages, msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_assistant_tool_calls_message(cJSON* messages, const llm_response_t* resp) {
|
||||
if (!messages || !resp || resp->tool_call_count <= 0) return -1;
|
||||
|
||||
cJSON* assistant = cJSON_CreateObject();
|
||||
cJSON* tool_calls = cJSON_CreateArray();
|
||||
if (!assistant || !tool_calls) {
|
||||
cJSON_Delete(assistant);
|
||||
cJSON_Delete(tool_calls);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(assistant, "role", "assistant");
|
||||
if (resp->content) {
|
||||
cJSON_AddStringToObject(assistant, "content", resp->content);
|
||||
} else {
|
||||
cJSON_AddNullToObject(assistant, "content");
|
||||
}
|
||||
|
||||
for (int i = 0; i < resp->tool_call_count; i++) {
|
||||
const llm_tool_call_t* tc = &resp->tool_calls[i];
|
||||
cJSON* tc_obj = cJSON_CreateObject();
|
||||
cJSON* fn_obj = cJSON_CreateObject();
|
||||
if (!tc_obj || !fn_obj) {
|
||||
cJSON_Delete(tc_obj);
|
||||
cJSON_Delete(fn_obj);
|
||||
cJSON_Delete(assistant);
|
||||
cJSON_Delete(tool_calls);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(tc_obj, "id", tc->id ? tc->id : "");
|
||||
cJSON_AddStringToObject(tc_obj, "type", "function");
|
||||
cJSON_AddStringToObject(fn_obj, "name", tc->name ? tc->name : "");
|
||||
cJSON_AddStringToObject(fn_obj, "arguments", tc->arguments_json ? tc->arguments_json : "{}");
|
||||
cJSON_AddItemToObject(tc_obj, "function", fn_obj);
|
||||
cJSON_AddItemToArray(tool_calls, tc_obj);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(assistant, "tool_calls", tool_calls);
|
||||
cJSON_AddItemToArray(messages, assistant);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_tool_result_message(cJSON* messages, const char* tool_call_id, const char* tool_result_json) {
|
||||
if (!messages || !tool_call_id) return -1;
|
||||
|
||||
cJSON* msg = cJSON_CreateObject();
|
||||
if (!msg) return -1;
|
||||
|
||||
cJSON_AddStringToObject(msg, "role", "tool");
|
||||
cJSON_AddStringToObject(msg, "tool_call_id", tool_call_id);
|
||||
cJSON_AddStringToObject(msg, "content", tool_result_json ? tool_result_json : "{\"success\":false,\"error\":\"empty tool result\"}");
|
||||
cJSON_AddItemToArray(messages, msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload) {
|
||||
FILE* fp = fopen("context.log", "a");
|
||||
if (!fp) {
|
||||
return;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm tm_info;
|
||||
localtime_r(&now, &tm_info);
|
||||
char timestamp[32] = {0};
|
||||
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", &tm_info);
|
||||
|
||||
fprintf(fp,
|
||||
"[%s] phase=%s sender=%s\n%s\n\n---\n\n",
|
||||
timestamp,
|
||||
phase ? phase : "unknown",
|
||||
sender_pubkey_hex ? sender_pubkey_hex : "unknown",
|
||||
context_payload ? context_payload : "");
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
time_t created_at;
|
||||
int role_is_user;
|
||||
char* content;
|
||||
} agent_history_item_t;
|
||||
|
||||
static int extract_first_p_tag_local(cJSON* tags, char out_pubkey_hex[65]) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !out_pubkey_hex) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !val->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(key->valuestring, "p") == 0 && strlen(val->valuestring) == 64U) {
|
||||
memcpy(out_pubkey_hex, val->valuestring, 64U);
|
||||
out_pubkey_hex[64] = '\0';
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int history_item_cmp_created_at(const void* a, const void* b) {
|
||||
const agent_history_item_t* ia = (const agent_history_item_t*)a;
|
||||
const agent_history_item_t* ib = (const agent_history_item_t*)b;
|
||||
if (ia->created_at < ib->created_at) return -1;
|
||||
if (ia->created_at > ib->created_at) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void free_history_items(agent_history_item_t* items, int count) {
|
||||
if (!items) return;
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(items[i].content);
|
||||
}
|
||||
free(items);
|
||||
}
|
||||
|
||||
static int append_startup_events_context(cJSON* messages) {
|
||||
if (!messages || !g_cfg || g_cfg->startup_event_count <= 0 || !g_cfg->startup_events) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_CreateArray();
|
||||
if (!arr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < g_cfg->startup_event_count; i++) {
|
||||
startup_event_t* se = &g_cfg->startup_events[i];
|
||||
cJSON* item = cJSON_CreateObject();
|
||||
if (!item) {
|
||||
cJSON_Delete(arr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(item, "kind", se->kind);
|
||||
cJSON_AddStringToObject(item, "content", se->content ? se->content : "");
|
||||
|
||||
cJSON* tags = NULL;
|
||||
if (se->tags_json) {
|
||||
tags = cJSON_Parse(se->tags_json);
|
||||
}
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
tags = cJSON_CreateArray();
|
||||
}
|
||||
cJSON_AddItemToObject(item, "tags", tags);
|
||||
cJSON_AddItemToArray(arr, item);
|
||||
}
|
||||
|
||||
char* events_json = cJSON_PrintUnformatted(arr);
|
||||
cJSON_Delete(arr);
|
||||
if (!events_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* prefix = "Startup events memory (kinds/content/tags): ";
|
||||
size_t out_len = strlen(prefix) + strlen(events_json) + 1U;
|
||||
char* payload = (char*)malloc(out_len);
|
||||
if (!payload) {
|
||||
free(events_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(payload, out_len, "%s%s", prefix, events_json);
|
||||
free(events_json);
|
||||
|
||||
int rc = append_simple_message(messages, "system", payload);
|
||||
free(payload);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int append_recent_admin_dm_history(cJSON* messages, const char* current_message) {
|
||||
if (!messages || !g_cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
unsigned char admin_pubkey[32];
|
||||
if (nostr_hex_to_bytes(g_cfg->admin.pubkey, admin_pubkey, 32) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->admin.pubkey));
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", AGENT_HISTORY_QUERY_LIMIT);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 8000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
agent_history_item_t* items = NULL;
|
||||
int item_count = 0;
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, i);
|
||||
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
|
||||
cJSON* pubkey = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL;
|
||||
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
|
||||
cJSON* tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
|
||||
cJSON* created_at = ev ? cJSON_GetObjectItemCaseSensitive(ev, "created_at") : NULL;
|
||||
|
||||
if (!kind || !pubkey || !content || !tags || !created_at ||
|
||||
!cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) ||
|
||||
!cJSON_IsString(content) || !cJSON_IsArray(tags) || !cJSON_IsNumber(created_at) ||
|
||||
(int)kind->valuedouble != 4 || !pubkey->valuestring || !content->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
char p_tag_pubkey[65] = {0};
|
||||
if (extract_first_p_tag_local(tags, p_tag_pubkey) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int role_is_user = 0;
|
||||
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0 &&
|
||||
strcmp(p_tag_pubkey, g_cfg->keys.public_key_hex) == 0) {
|
||||
role_is_user = 1;
|
||||
} else if (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) == 0 &&
|
||||
strcmp(p_tag_pubkey, g_cfg->admin.pubkey) == 0) {
|
||||
role_is_user = 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
char* plaintext = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
|
||||
if (!plaintext) {
|
||||
continue;
|
||||
}
|
||||
plaintext[0] = '\0';
|
||||
|
||||
if (nostr_nip04_decrypt(g_cfg->keys.private_key,
|
||||
admin_pubkey,
|
||||
content->valuestring,
|
||||
plaintext,
|
||||
NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
|
||||
free(plaintext);
|
||||
continue;
|
||||
}
|
||||
|
||||
agent_history_item_t* grown = (agent_history_item_t*)realloc(items, (size_t)(item_count + 1) * sizeof(agent_history_item_t));
|
||||
if (!grown) {
|
||||
free(plaintext);
|
||||
free_history_items(items, item_count);
|
||||
cJSON_Delete(events);
|
||||
return -1;
|
||||
}
|
||||
|
||||
items = grown;
|
||||
items[item_count].created_at = (time_t)created_at->valuedouble;
|
||||
items[item_count].role_is_user = role_is_user;
|
||||
items[item_count].content = plaintext;
|
||||
item_count++;
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
|
||||
if (item_count > 1) {
|
||||
qsort(items, (size_t)item_count, sizeof(agent_history_item_t), history_item_cmp_created_at);
|
||||
}
|
||||
|
||||
int start = item_count > AGENT_HISTORY_TURNS ? item_count - AGENT_HISTORY_TURNS : 0;
|
||||
for (int i = start; i < item_count; i++) {
|
||||
if (i == item_count - 1 &&
|
||||
items[i].role_is_user &&
|
||||
current_message &&
|
||||
strcmp(items[i].content, current_message) == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (append_simple_message(messages,
|
||||
items[i].role_is_user ? "user" : "assistant",
|
||||
items[i].content ? items[i].content : "") != 0) {
|
||||
free_history_items(items, item_count);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
free_history_items(items, item_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
if (!config || !system_context) {
|
||||
return -1;
|
||||
@@ -32,6 +427,10 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
|
||||
g_seen_msgs_count = 0;
|
||||
g_seen_msgs_next = 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -43,9 +442,23 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void*
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] incoming message from %.16s...\n", sender_pubkey_hex);
|
||||
|
||||
if (agent_message_is_debounced(sender_pubkey_hex, message)) {
|
||||
fprintf(stdout, "[didactyl] debounced duplicate inbound message from %.16s...\n", sender_pubkey_hex);
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex);
|
||||
|
||||
if (!g_cfg->tools.enabled) {
|
||||
size_t context_len = strlen("system:\n\nuser:\n") + strlen(g_system_context) + strlen(message) + 1U;
|
||||
char* plain_context = (char*)malloc(context_len);
|
||||
if (plain_context) {
|
||||
snprintf(plain_context, context_len, "system:\n%s\n\nuser:\n%s", g_system_context, message);
|
||||
append_context_log(sender_pubkey_hex, "llm_chat", plain_context);
|
||||
free(plain_context);
|
||||
}
|
||||
|
||||
char* response = llm_chat(g_system_context, message);
|
||||
if (!response) {
|
||||
const char* fallback = "I could not get a response from the LLM right now.";
|
||||
@@ -68,59 +481,97 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void*
|
||||
return;
|
||||
}
|
||||
|
||||
llm_response_t resp;
|
||||
if (llm_chat_with_tools(g_system_context, message, tools_json, &resp) != 0) {
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
if (!messages) {
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "LLM request failed.");
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation state.");
|
||||
return;
|
||||
}
|
||||
free(tools_json);
|
||||
|
||||
if (resp.tool_call_count <= 0) {
|
||||
const char* answer = resp.content ? resp.content : "No response content.";
|
||||
fprintf(stdout, "[didactyl] llm response (no tool call): %.240s%s\n",
|
||||
answer,
|
||||
strlen(answer) > 240 ? "..." : "");
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, answer);
|
||||
llm_response_free(&resp);
|
||||
if (append_simple_message(messages, "system", g_system_context) != 0 ||
|
||||
append_startup_events_context(messages) != 0 ||
|
||||
append_recent_admin_dm_history(messages, message) != 0 ||
|
||||
append_simple_message(messages, "user", message) != 0) {
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages.");
|
||||
return;
|
||||
}
|
||||
|
||||
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8;
|
||||
llm_response_t current = resp;
|
||||
char* final_answer_owned = NULL;
|
||||
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
if (current.tool_call_count <= 0) {
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (!messages_json) {
|
||||
break;
|
||||
}
|
||||
|
||||
llm_tool_call_t* tc = ¤t.tool_calls[0];
|
||||
fprintf(stdout, "[didactyl] executing tool call: %s\n", tc->name ? tc->name : "<null>");
|
||||
append_context_log(sender_pubkey_hex, "llm_chat_with_tools_messages", messages_json);
|
||||
|
||||
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
|
||||
if (!tool_result) {
|
||||
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
|
||||
}
|
||||
|
||||
llm_response_free(¤t);
|
||||
if (llm_chat_with_tools(g_system_context, tool_result, NULL, ¤t) != 0) {
|
||||
free(tool_result);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "LLM failed after tool execution.");
|
||||
llm_response_t resp;
|
||||
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
|
||||
free(messages_json);
|
||||
if (rc != 0) {
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "LLM request failed.");
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
return;
|
||||
}
|
||||
free(tool_result);
|
||||
|
||||
if (current.content && current.tool_call_count == 0) {
|
||||
if (resp.tool_call_count <= 0) {
|
||||
const char* answer = resp.content ? resp.content : "No response content.";
|
||||
fprintf(stdout, "[didactyl] llm response (no tool call): %.240s%s\n",
|
||||
answer,
|
||||
strlen(answer) > 240 ? "..." : "");
|
||||
final_answer_owned = strdup(answer);
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
if (append_assistant_tool_calls_message(messages, &resp) != 0) {
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
for (int i = 0; i < resp.tool_call_count; i++) {
|
||||
llm_tool_call_t* tc = &resp.tool_calls[i];
|
||||
fprintf(stdout, "[didactyl] executing tool call: %s\n", tc->name ? tc->name : "<null>");
|
||||
|
||||
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
|
||||
if (!tool_result) {
|
||||
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
|
||||
}
|
||||
|
||||
if (append_tool_result_message(messages,
|
||||
tc->id ? tc->id : "",
|
||||
tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) {
|
||||
free(tool_result);
|
||||
llm_response_free(&resp);
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to append tool result.");
|
||||
return;
|
||||
}
|
||||
free(tool_result);
|
||||
}
|
||||
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
const char* final_answer = current.content ? current.content : "I hit my tool-use limit for this request.";
|
||||
if (!final_answer_owned) {
|
||||
final_answer_owned = strdup("I hit my tool-use limit for this request.");
|
||||
}
|
||||
|
||||
const char* final_answer = final_answer_owned ? final_answer_owned : "I hit my tool-use limit for this request.";
|
||||
fprintf(stdout, "[didactyl] final response: %.240s%s\n",
|
||||
final_answer,
|
||||
strlen(final_answer) > 240 ? "..." : "");
|
||||
(void)nostr_handler_send_dm(sender_pubkey_hex, final_answer);
|
||||
llm_response_free(¤t);
|
||||
|
||||
free(final_answer_owned);
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
}
|
||||
|
||||
void agent_cleanup(void) {
|
||||
@@ -128,4 +579,7 @@ void agent_cleanup(void) {
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
g_cfg = NULL;
|
||||
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
|
||||
g_seen_msgs_count = 0;
|
||||
g_seen_msgs_next = 0;
|
||||
}
|
||||
|
||||
204
src/config.c
204
src/config.c
@@ -161,6 +161,178 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(key->valuestring, tag_key) == 0) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int set_tag_value_string(cJSON* tags, const char* tag_key, const char* tag_value) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !tag_key || !tag_value || tag_value[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !key->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(key->valuestring, tag_key) == 0) {
|
||||
if (cJSON_IsString(val)) {
|
||||
if (!cJSON_SetValuestring(val, tag_value)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* new_val = cJSON_CreateString(tag_value);
|
||||
if (!new_val) {
|
||||
return -1;
|
||||
}
|
||||
cJSON_ReplaceItemInArray(tag, 1, new_val);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* new_tag = cJSON_CreateArray();
|
||||
if (!new_tag) {
|
||||
return -1;
|
||||
}
|
||||
cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_key));
|
||||
cJSON_AddItemToArray(new_tag, cJSON_CreateString(tag_value));
|
||||
cJSON_AddItemToArray(tags, new_tag);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
|
||||
if (!item || !tags || !cJSON_IsArray(tags)) {
|
||||
return 0;
|
||||
}
|
||||
if (event_kind != 31123 && event_kind != 31124) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* d_val = find_tag_value_string(tags, "d");
|
||||
if (!d_val || !cJSON_IsString(d_val) || !d_val->valuestring) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int needs_normalize =
|
||||
(strcmp(d_val->valuestring, "skill") == 0 || strcmp(d_val->valuestring, "private_skill") == 0);
|
||||
if (!needs_normalize) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char* slug = NULL;
|
||||
cJSON* slug_val = find_tag_value_string(tags, "slug");
|
||||
if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') {
|
||||
slug = slug_val->valuestring;
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
|
||||
if (content_fields && cJSON_IsObject(content_fields)) {
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name");
|
||||
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
|
||||
slug = name->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return set_tag_value_string(tags, "d", slug);
|
||||
}
|
||||
|
||||
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "startup_events");
|
||||
if (!arr || !cJSON_IsArray(arr)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = cJSON_GetArraySize(arr);
|
||||
if (count <= 0) return 0;
|
||||
|
||||
config->startup_events = (startup_event_t*)calloc((size_t)count, sizeof(startup_event_t));
|
||||
if (!config->startup_events) return -1;
|
||||
config->startup_event_count = count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON* item = cJSON_GetArrayItem(arr, i);
|
||||
if (!item || !cJSON_IsObject(item)) return -1;
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(item, "kind");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(item, "content");
|
||||
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(item, "tags");
|
||||
|
||||
if (!kind || !cJSON_IsNumber(kind)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
config->startup_events[i].kind = (int)kind->valuedouble;
|
||||
|
||||
if (content_fields) {
|
||||
if (!cJSON_IsObject(content_fields)) {
|
||||
return -1;
|
||||
}
|
||||
config->startup_events[i].content = cJSON_PrintUnformatted(content_fields);
|
||||
if (!config->startup_events[i].content) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring) {
|
||||
return -1;
|
||||
}
|
||||
config->startup_events[i].content = strdup(content->valuestring);
|
||||
if (!config->startup_events[i].content) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (tags) {
|
||||
if (!cJSON_IsArray(tags)) return -1;
|
||||
if (normalize_skill_d_tag(config->startup_events[i].kind, item, tags) != 0) {
|
||||
return -1;
|
||||
}
|
||||
config->startup_events[i].tags_json = cJSON_PrintUnformatted(tags);
|
||||
if (!config->startup_events[i].tags_json) return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_relays(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* relays = cJSON_GetObjectItemCaseSensitive(root, "relays");
|
||||
if (!relays || !cJSON_IsArray(relays)) {
|
||||
@@ -206,6 +378,14 @@ void config_free(didactyl_config_t* config) {
|
||||
free(config->relays);
|
||||
}
|
||||
|
||||
if (config->startup_events) {
|
||||
for (int i = 0; i < config->startup_event_count; i++) {
|
||||
free(config->startup_events[i].content);
|
||||
free(config->startup_events[i].tags_json);
|
||||
}
|
||||
free(config->startup_events);
|
||||
}
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
}
|
||||
|
||||
@@ -237,34 +417,16 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
|
||||
int rc = -1;
|
||||
|
||||
cJSON* agent = cJSON_GetObjectItemCaseSensitive(root, "agent");
|
||||
cJSON* keys = cJSON_GetObjectItemCaseSensitive(root, "keys");
|
||||
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
|
||||
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
|
||||
|
||||
if (!agent || !cJSON_IsObject(agent) ||
|
||||
!keys || !cJSON_IsObject(keys) ||
|
||||
if (!keys || !cJSON_IsObject(keys) ||
|
||||
!admin || !cJSON_IsObject(admin) ||
|
||||
!llm || !cJSON_IsObject(llm)) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(agent, "name", config->profile.name, sizeof(config->profile.name), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "display_name", config->profile.display_name, sizeof(config->profile.display_name), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "about", config->profile.about, sizeof(config->profile.about), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "picture", config->profile.picture, sizeof(config->profile.picture), 0) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (copy_json_string(agent, "nip05", config->profile.nip05, sizeof(config->profile.nip05), 0) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (copy_json_string(keys, "nsec", config->keys.nsec, sizeof(config->keys.nsec), 1) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
@@ -308,6 +470,10 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_startup_events(root, config) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
17
src/config.h
17
src/config.h
@@ -9,14 +9,6 @@
|
||||
#define OW_MAX_KEY_LEN 256
|
||||
#define OW_MAX_MODEL_LEN 128
|
||||
|
||||
typedef struct {
|
||||
char name[OW_MAX_NAME_LEN];
|
||||
char display_name[OW_MAX_NAME_LEN];
|
||||
char about[OW_MAX_ABOUT_LEN];
|
||||
char picture[OW_MAX_URL_LEN];
|
||||
char nip05[OW_MAX_URL_LEN];
|
||||
} agent_profile_t;
|
||||
|
||||
typedef struct {
|
||||
char nsec[OW_MAX_KEY_LEN];
|
||||
unsigned char private_key[32];
|
||||
@@ -51,13 +43,20 @@ typedef struct {
|
||||
} tools_config_t;
|
||||
|
||||
typedef struct {
|
||||
agent_profile_t profile;
|
||||
int kind;
|
||||
char* content;
|
||||
char* tags_json; // JSON array string for tags, optional
|
||||
} startup_event_t;
|
||||
|
||||
typedef struct {
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
char** relays;
|
||||
int relay_count;
|
||||
llm_config_t llm;
|
||||
tools_config_t tools;
|
||||
startup_event_t* startup_events;
|
||||
int startup_event_count;
|
||||
} didactyl_config_t;
|
||||
|
||||
int config_load(const char* path, didactyl_config_t* config);
|
||||
|
||||
122
src/llm.c
122
src/llm.c
@@ -6,6 +6,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
@@ -41,6 +42,28 @@ static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
return total;
|
||||
}
|
||||
|
||||
static const char* detect_ca_bundle_path(void) {
|
||||
const char* env = getenv("SSL_CERT_FILE");
|
||||
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
|
||||
return env;
|
||||
}
|
||||
|
||||
static const char* candidates[] = {
|
||||
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
|
||||
"/etc/ssl/cert.pem", // Alpine
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
|
||||
"/etc/ssl/ca-bundle.pem" // openSUSE
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
|
||||
if (access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !body) {
|
||||
@@ -67,6 +90,11 @@ static char* perform_chat_request(const char* body) {
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
|
||||
const char* ca_bundle = detect_ca_bundle_path();
|
||||
if (ca_bundle) {
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
@@ -74,11 +102,33 @@ static char* perform_chat_request(const char* body) {
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK || status < 200 || status >= 300 || !rb.data) {
|
||||
if (res != CURLE_OK) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: curl=%s\n", curl_easy_strerror(res));
|
||||
if (rb.data && rb.len > 0) {
|
||||
fprintf(stderr, "[didactyl] llm partial response: %.600s%s\n",
|
||||
rb.data,
|
||||
rb.len > 600 ? "..." : "");
|
||||
}
|
||||
free(rb.data);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
|
||||
if (rb.data && rb.len > 0) {
|
||||
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
|
||||
rb.data,
|
||||
rb.len > 1200 ? "..." : "");
|
||||
}
|
||||
free(rb.data);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!rb.data) {
|
||||
fprintf(stderr, "[didactyl] llm http request failed: empty response body\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return rb.data;
|
||||
}
|
||||
|
||||
@@ -228,6 +278,9 @@ char* llm_chat(const char* system_prompt, const char* user_message) {
|
||||
|
||||
llm_response_t parsed;
|
||||
if (parse_llm_response(raw, &parsed) != 0) {
|
||||
fprintf(stderr, "[didactyl] failed to parse llm response (non-tool path): %.1200s%s\n",
|
||||
raw,
|
||||
strlen(raw) > 1200 ? "..." : "");
|
||||
free(raw);
|
||||
return NULL;
|
||||
}
|
||||
@@ -238,19 +291,16 @@ char* llm_chat(const char* system_prompt, const char* user_message) {
|
||||
return answer;
|
||||
}
|
||||
|
||||
int llm_chat_with_tools(const char* system_prompt,
|
||||
const char* user_message,
|
||||
const char* tools_json,
|
||||
llm_response_t* out_response) {
|
||||
if (!g_initialized || !out_response) {
|
||||
int llm_chat_with_tools_messages(const char* messages_json,
|
||||
const char* tools_json,
|
||||
const char* tool_choice,
|
||||
llm_response_t* out_response) {
|
||||
if (!g_initialized || !out_response || !messages_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
if (!root || !messages) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(messages);
|
||||
if (!root) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -258,20 +308,19 @@ int llm_chat_with_tools(const char* system_prompt,
|
||||
cJSON_AddNumberToObject(root, "max_tokens", g_cfg.max_tokens);
|
||||
cJSON_AddNumberToObject(root, "temperature", g_cfg.temperature);
|
||||
|
||||
cJSON* system_msg = cJSON_CreateObject();
|
||||
cJSON* user_msg = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(system_msg, "role", "system");
|
||||
cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : "");
|
||||
cJSON_AddStringToObject(user_msg, "role", "user");
|
||||
cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : "");
|
||||
cJSON_AddItemToArray(messages, system_msg);
|
||||
cJSON_AddItemToArray(messages, user_msg);
|
||||
cJSON* messages = cJSON_Parse(messages_json);
|
||||
if (!messages || !cJSON_IsArray(messages)) {
|
||||
cJSON_Delete(messages);
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
cJSON_AddItemToObject(root, "messages", messages);
|
||||
|
||||
if (tools_json) {
|
||||
cJSON* tools = cJSON_Parse(tools_json);
|
||||
if (tools && cJSON_IsArray(tools)) {
|
||||
cJSON_AddItemToObject(root, "tools", tools);
|
||||
cJSON_AddStringToObject(root, "tool_choice", tool_choice ? tool_choice : "auto");
|
||||
} else {
|
||||
cJSON_Delete(tools);
|
||||
}
|
||||
@@ -286,10 +335,47 @@ int llm_chat_with_tools(const char* system_prompt,
|
||||
if (!raw) return -1;
|
||||
|
||||
int rc = parse_llm_response(raw, out_response);
|
||||
if (rc != 0) {
|
||||
fprintf(stderr, "[didactyl] failed to parse llm response (tools path): %.1200s%s\n",
|
||||
raw,
|
||||
strlen(raw) > 1200 ? "..." : "");
|
||||
}
|
||||
free(raw);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int llm_chat_with_tools(const char* system_prompt,
|
||||
const char* user_message,
|
||||
const char* tools_json,
|
||||
llm_response_t* out_response) {
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
cJSON* system_msg = cJSON_CreateObject();
|
||||
cJSON* user_msg = cJSON_CreateObject();
|
||||
if (!messages || !system_msg || !user_msg) {
|
||||
cJSON_Delete(messages);
|
||||
cJSON_Delete(system_msg);
|
||||
cJSON_Delete(user_msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(system_msg, "role", "system");
|
||||
cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : "");
|
||||
cJSON_AddStringToObject(user_msg, "role", "user");
|
||||
cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : "");
|
||||
cJSON_AddItemToArray(messages, system_msg);
|
||||
cJSON_AddItemToArray(messages, user_msg);
|
||||
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
cJSON_Delete(messages);
|
||||
if (!messages_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", out_response);
|
||||
free(messages_json);
|
||||
return rc;
|
||||
}
|
||||
|
||||
void llm_response_free(llm_response_t* response) {
|
||||
if (!response) return;
|
||||
free(response->content);
|
||||
|
||||
@@ -21,6 +21,10 @@ int llm_chat_with_tools(const char* system_prompt,
|
||||
const char* user_message,
|
||||
const char* tools_json,
|
||||
llm_response_t* out_response);
|
||||
int llm_chat_with_tools_messages(const char* messages_json,
|
||||
const char* tools_json,
|
||||
const char* tool_choice,
|
||||
llm_response_t* out_response);
|
||||
void llm_response_free(llm_response_t* response);
|
||||
void llm_cleanup(void);
|
||||
|
||||
|
||||
37
src/main.c
37
src/main.c
@@ -9,7 +9,6 @@
|
||||
#include "main.h"
|
||||
#include "agent.h"
|
||||
#include "config.h"
|
||||
#include "context.h"
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "debug.h"
|
||||
@@ -23,19 +22,15 @@ static void signal_handler(int signum) {
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./config.json";
|
||||
const char* context_path = "./SYSTEM.md";
|
||||
|
||||
int debug_level = DEBUG_LEVEL_INFO;
|
||||
int debug_level = DEBUG_LEVEL_TRACE;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
|
||||
config_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "--context") == 0 && i + 1 < argc) {
|
||||
context_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "--debug") == 0 && i + 1 < argc) {
|
||||
debug_level = atoi(argv[++i]);
|
||||
} else {
|
||||
fprintf(stderr, "Usage: %s [--config <path>] [--context <path>] [--debug <0-5>]\n", argv[0]);
|
||||
fprintf(stderr, "Usage: %s [--config <path>] [--debug <0-5>]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -56,17 +51,8 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
char* system_context = context_load(context_path);
|
||||
if (!system_context) {
|
||||
fprintf(stderr, "Failed to load context file: %s\n", context_path);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
@@ -75,28 +61,29 @@ int main(int argc, char** argv) {
|
||||
if (nostr_handler_init(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize nostr handler\n");
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (nostr_handler_reconcile_startup_events() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: reconcile startup events failed (continuing)");
|
||||
}
|
||||
|
||||
const char* system_context = nostr_handler_get_system_context();
|
||||
if (!system_context || system_context[0] == '\0') {
|
||||
system_context = "You are Didactyl, a sovereign AI agent living on Nostr.";
|
||||
}
|
||||
|
||||
if (agent_init(&cfg, system_context) != 0) {
|
||||
fprintf(stderr, "Failed to initialize agent\n");
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: publish profile begin");
|
||||
if (nostr_handler_publish_profile() != 0) {
|
||||
DEBUG_WARN("[didactyl] publish profile deferred/failed (will continue startup)");
|
||||
}
|
||||
DEBUG_INFO("[didactyl] startup phase: publish profile end");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
|
||||
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
|
||||
DEBUG_ERROR("[didactyl] startup phase: subscribe DMs failed");
|
||||
@@ -104,7 +91,6 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
@@ -129,7 +115,6 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
context_free(system_context);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||
#define DIDACTYL_VERSION_MAJOR 0
|
||||
#define DIDACTYL_VERSION_MINOR 0
|
||||
#define DIDACTYL_VERSION_PATCH 1
|
||||
#define DIDACTYL_VERSION "v0.0.1"
|
||||
#define DIDACTYL_VERSION_PATCH 6
|
||||
#define DIDACTYL_VERSION "v0.0.6"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include "../../nostr_core_lib/cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
@@ -19,6 +20,50 @@ static int g_poll_counter = 0;
|
||||
static time_t g_start_time = 0;
|
||||
static time_t g_last_status_log_time = 0;
|
||||
static nostr_pool_relay_status_t* g_last_relay_statuses = NULL;
|
||||
static char* g_system_context = NULL;
|
||||
static unsigned char* g_startup_published = NULL;
|
||||
static int g_startup_publish_tracking_enabled = 0;
|
||||
|
||||
#define DM_DEDUP_CACHE_SIZE 256
|
||||
|
||||
static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65];
|
||||
static int g_seen_dm_count = 0;
|
||||
static int g_seen_dm_next = 0;
|
||||
|
||||
static pthread_mutex_t g_dm_dedup_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int dm_id_seen_or_remember(const char* event_id_hex) {
|
||||
if (!event_id_hex || strlen(event_id_hex) != 64U) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int seen = 0;
|
||||
pthread_mutex_lock(&g_dm_dedup_mutex);
|
||||
|
||||
for (int i = 0; i < g_seen_dm_count; i++) {
|
||||
if (strncmp(g_seen_dm_ids[i], event_id_hex, 64U) == 0) {
|
||||
seen = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!seen) {
|
||||
int slot = 0;
|
||||
if (g_seen_dm_count < DM_DEDUP_CACHE_SIZE) {
|
||||
slot = g_seen_dm_count;
|
||||
g_seen_dm_count++;
|
||||
} else {
|
||||
slot = g_seen_dm_next;
|
||||
g_seen_dm_next = (g_seen_dm_next + 1) % DM_DEDUP_CACHE_SIZE;
|
||||
}
|
||||
|
||||
memcpy(g_seen_dm_ids[slot], event_id_hex, 64U);
|
||||
g_seen_dm_ids[slot][64] = '\0';
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&g_dm_dedup_mutex);
|
||||
return seen;
|
||||
}
|
||||
|
||||
static const char* relay_status_str(nostr_pool_relay_status_t status) {
|
||||
switch (status) {
|
||||
@@ -35,25 +80,50 @@ static const char* relay_status_str(nostr_pool_relay_status_t status) {
|
||||
}
|
||||
}
|
||||
|
||||
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason);
|
||||
static int publish_kind_event_to_relays(int kind,
|
||||
const char* content,
|
||||
cJSON* tags,
|
||||
const char** relay_urls,
|
||||
int relay_count,
|
||||
const char* reason_label);
|
||||
|
||||
static void log_relay_statuses(const char* reason) {
|
||||
if (!g_pool || !g_cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] relay status snapshot (%s)", reason ? reason : "periodic");
|
||||
int info_level = (reason && strcmp(reason, "after init") == 0) ? 1 : 0;
|
||||
if (info_level) {
|
||||
DEBUG_INFO("[didactyl] relay status snapshot (%s)", reason ? reason : "periodic");
|
||||
} else {
|
||||
DEBUG_TRACE("[didactyl] relay status snapshot (%s)", reason ? reason : "periodic");
|
||||
}
|
||||
|
||||
for (int i = 0; i < g_cfg->relay_count; i++) {
|
||||
const char* relay = g_cfg->relays[i];
|
||||
nostr_pool_relay_status_t status = nostr_relay_pool_get_relay_status(g_pool, relay);
|
||||
const char* last_err = nostr_relay_pool_get_relay_last_connection_error(g_pool, relay);
|
||||
double ping_ms = nostr_relay_pool_get_relay_ping_latency(g_pool, relay);
|
||||
|
||||
if (ping_ms > 0.0) {
|
||||
DEBUG_INFO("[didactyl] - %s => %s (ping %.1f ms)",
|
||||
relay,
|
||||
relay_status_str(status),
|
||||
ping_ms);
|
||||
if (info_level) {
|
||||
if (ping_ms > 0.0) {
|
||||
DEBUG_INFO("[didactyl] - %s => %s (ping %.1f ms)",
|
||||
relay,
|
||||
relay_status_str(status),
|
||||
ping_ms);
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] - %s => %s", relay, relay_status_str(status));
|
||||
}
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] - %s => %s", relay, relay_status_str(status));
|
||||
if (ping_ms > 0.0) {
|
||||
DEBUG_TRACE("[didactyl] - %s => %s (ping %.1f ms)",
|
||||
relay,
|
||||
relay_status_str(status),
|
||||
ping_ms);
|
||||
} else {
|
||||
DEBUG_TRACE("[didactyl] - %s => %s", relay, relay_status_str(status));
|
||||
}
|
||||
}
|
||||
|
||||
if (last_err && last_err[0] != '\0') {
|
||||
@@ -88,6 +158,10 @@ static void log_relay_state_changes(void) {
|
||||
relay_status_str(prev),
|
||||
relay_status_str(now));
|
||||
g_last_relay_statuses[i] = now;
|
||||
|
||||
if (now == NOSTR_POOL_RELAY_CONNECTED) {
|
||||
publish_pending_startup_events_for_relay_index(i, "relay_connected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +229,28 @@ static int extract_first_p_tag(cJSON* tags, char out_pubkey_hex[65]) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void trace_event_json(const char* prefix, cJSON* event) {
|
||||
if (g_debug_level < DEBUG_LEVEL_TRACE || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* event_json = cJSON_PrintUnformatted(event);
|
||||
if (!event_json) {
|
||||
DEBUG_TRACE("[didactyl] %s <failed to serialize event>", prefix ? prefix : "event");
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_TRACE("[didactyl] %s %s", prefix ? prefix : "event", event_json);
|
||||
free(event_json);
|
||||
}
|
||||
|
||||
static void trace_plaintext_dm(const char* prefix, const char* plaintext) {
|
||||
if (g_debug_level < DEBUG_LEVEL_TRACE) {
|
||||
return;
|
||||
}
|
||||
DEBUG_TRACE("[didactyl] %s %s", prefix ? prefix : "dm plaintext", plaintext ? plaintext : "");
|
||||
}
|
||||
|
||||
static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
(void)user_data;
|
||||
|
||||
@@ -162,6 +258,7 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
@@ -172,6 +269,10 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* event_id_hex = (id && cJSON_IsString(id) && id->valuestring && strlen(id->valuestring) == 64U)
|
||||
? id->valuestring
|
||||
: NULL;
|
||||
|
||||
if ((int)kind->valuedouble != 4) {
|
||||
return;
|
||||
}
|
||||
@@ -204,13 +305,28 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
}
|
||||
decrypted[0] = '\0';
|
||||
|
||||
trace_event_json("received encrypted DM event:", event);
|
||||
|
||||
if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
|
||||
fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", pubkey->valuestring);
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] received DM from %.16s... via %s",
|
||||
trace_plaintext_dm("received decrypted DM content:", decrypted);
|
||||
|
||||
if (event_id_hex && dm_id_seen_or_remember(event_id_hex)) {
|
||||
DEBUG_LOG("[didactyl] skipped duplicate DM event %.16s... from %.16s... via %s",
|
||||
event_id_hex,
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
free(decrypted);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s",
|
||||
(int)kind->valuedouble,
|
||||
event_id_hex ? event_id_hex : "<no-id>",
|
||||
pubkey->valuestring,
|
||||
relay_url ? relay_url : "unknown relay");
|
||||
g_dm_callback(pubkey->valuestring, decrypted, g_dm_user_data);
|
||||
@@ -231,6 +347,9 @@ int nostr_handler_init(didactyl_config_t* config) {
|
||||
g_cfg = config;
|
||||
g_poll_counter = 0;
|
||||
g_start_time = time(NULL);
|
||||
memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids));
|
||||
g_seen_dm_count = 0;
|
||||
g_seen_dm_next = 0;
|
||||
|
||||
DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count);
|
||||
|
||||
@@ -263,77 +382,14 @@ int nostr_handler_init(didactyl_config_t* config) {
|
||||
|
||||
g_last_status_log_time = time(NULL);
|
||||
log_relay_statuses("after init");
|
||||
|
||||
free(g_startup_published);
|
||||
g_startup_published = NULL;
|
||||
g_startup_publish_tracking_enabled = 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int nostr_handler_publish_profile(void) {
|
||||
if (!g_cfg || !g_pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] publish_profile: build metadata payload");
|
||||
cJSON* profile = cJSON_CreateObject();
|
||||
if (!profile) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(profile, "name", g_cfg->profile.name);
|
||||
cJSON_AddStringToObject(profile, "display_name", g_cfg->profile.display_name);
|
||||
cJSON_AddStringToObject(profile, "about", g_cfg->profile.about);
|
||||
cJSON_AddStringToObject(profile, "picture", g_cfg->profile.picture);
|
||||
if (g_cfg->profile.nip05[0] != '\0') {
|
||||
cJSON_AddStringToObject(profile, "nip05", g_cfg->profile.nip05);
|
||||
}
|
||||
|
||||
char* content = cJSON_PrintUnformatted(profile);
|
||||
cJSON_Delete(profile);
|
||||
if (!content) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] publish_profile: sign kind-0 event");
|
||||
cJSON* event = nostr_create_and_sign_event(0, content, NULL, g_cfg->keys.private_key, time(NULL));
|
||||
free(content);
|
||||
|
||||
if (!event) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*));
|
||||
if (!connected_relays) {
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int connected_count = 0;
|
||||
for (int i = 0; i < g_cfg->relay_count; i++) {
|
||||
if (nostr_relay_pool_get_relay_status(g_pool, g_cfg->relays[i]) == NOSTR_POOL_RELAY_CONNECTED) {
|
||||
connected_relays[connected_count++] = g_cfg->relays[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (connected_count == 0) {
|
||||
DEBUG_WARN("[didactyl] publish_profile: no connected relays yet, deferring publish");
|
||||
free(connected_relays);
|
||||
cJSON_Delete(event);
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] publish_profile: publishing to %d connected relay(s)", connected_count);
|
||||
int sent = nostr_relay_pool_publish_async(
|
||||
g_pool,
|
||||
connected_relays,
|
||||
connected_count,
|
||||
event,
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
free(connected_relays);
|
||||
cJSON_Delete(event);
|
||||
DEBUG_INFO("[didactyl] publish profile result: sent_to=%d connected relay(s)", sent);
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
if (!g_cfg || !g_pool || !callback) {
|
||||
return -1;
|
||||
@@ -393,6 +449,8 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
|
||||
return -1;
|
||||
}
|
||||
|
||||
trace_plaintext_dm("sending plaintext DM content:", message);
|
||||
|
||||
char* encrypted = (char*)malloc(NOSTR_NIP04_MAX_ENCRYPTED_SIZE);
|
||||
if (!encrypted) {
|
||||
fprintf(stderr, "[didactyl] failed to allocate DM encrypt buffer\n");
|
||||
@@ -418,6 +476,8 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
|
||||
return -1;
|
||||
}
|
||||
|
||||
trace_event_json("sending encrypted DM event:", event);
|
||||
|
||||
log_publish_targets("publish DM");
|
||||
|
||||
const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*));
|
||||
@@ -442,16 +502,34 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
|
||||
event,
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
for (int i = 0; i < connected_count; i++) {
|
||||
DEBUG_INFO("[didactyl] kind 4 event published to %s (async)", connected_relays[i]);
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] kind 4 event not queued: no connected relays");
|
||||
}
|
||||
|
||||
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
const char* out_event_id_hex = (event_id && cJSON_IsString(event_id) && event_id->valuestring) ? event_id->valuestring : "<no-id>";
|
||||
|
||||
DEBUG_INFO("[didactyl] sent DM %.16s... to %.16s... via %d connected relay(s)",
|
||||
out_event_id_hex,
|
||||
recipient_pubkey_hex,
|
||||
sent);
|
||||
|
||||
free(connected_relays);
|
||||
cJSON_Delete(event);
|
||||
DEBUG_INFO("[didactyl] sent DM to %.16s... via %d connected relay(s)", recipient_pubkey_hex, sent);
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags) {
|
||||
if (!g_cfg || !g_pool || !content) {
|
||||
static int publish_kind_event_to_relays(int kind,
|
||||
const char* content,
|
||||
cJSON* tags,
|
||||
const char** relay_urls,
|
||||
int relay_count,
|
||||
const char* reason_label) {
|
||||
if (!g_cfg || !g_pool || !content || !relay_urls || relay_count <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -471,11 +549,35 @@ int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags)
|
||||
return -1;
|
||||
}
|
||||
|
||||
int sent = nostr_relay_pool_publish_async(
|
||||
g_pool,
|
||||
relay_urls,
|
||||
relay_count,
|
||||
event,
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
for (int i = 0; i < relay_count; i++) {
|
||||
DEBUG_INFO("[didactyl] kind %d event published to %s (async%s%s)",
|
||||
kind,
|
||||
relay_urls[i],
|
||||
reason_label ? ", reason=" : "",
|
||||
reason_label ? reason_label : "");
|
||||
}
|
||||
|
||||
cJSON_Delete(event);
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags) {
|
||||
if (!g_cfg || !g_pool || !content) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
log_publish_targets("publish kind event");
|
||||
|
||||
const char** connected_relays = (const char**)calloc((size_t)g_cfg->relay_count, sizeof(char*));
|
||||
if (!connected_relays) {
|
||||
cJSON_Delete(event);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -486,21 +588,17 @@ int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags)
|
||||
}
|
||||
}
|
||||
|
||||
int sent = 0;
|
||||
if (connected_count > 0) {
|
||||
sent = nostr_relay_pool_publish_async(
|
||||
g_pool,
|
||||
connected_relays,
|
||||
connected_count,
|
||||
event,
|
||||
NULL,
|
||||
NULL);
|
||||
if (connected_count <= 0) {
|
||||
DEBUG_WARN("[didactyl] kind %d event not queued: no connected relays", kind);
|
||||
free(connected_relays);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = publish_kind_event_to_relays(kind, content, tags, connected_relays, connected_count, "manual_publish");
|
||||
free(connected_relays);
|
||||
cJSON_Delete(event);
|
||||
DEBUG_INFO("[didactyl] published kind %d event via %d connected relay(s)", kind, sent);
|
||||
return sent > 0 ? 0 : -1;
|
||||
|
||||
DEBUG_INFO("[didactyl] published kind %d event via %d connected relay(s)", kind, connected_count);
|
||||
return rc;
|
||||
}
|
||||
|
||||
char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
|
||||
@@ -541,6 +639,98 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason) {
|
||||
if (!g_cfg || !g_pool || !g_startup_publish_tracking_enabled || !g_startup_published) {
|
||||
return;
|
||||
}
|
||||
if (relay_index < 0 || relay_index >= g_cfg->relay_count) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* relay_url = g_cfg->relays[relay_index];
|
||||
if (nostr_relay_pool_get_relay_status(g_pool, relay_url) != NOSTR_POOL_RELAY_CONNECTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < g_cfg->startup_event_count; i++) {
|
||||
startup_event_t* se = &g_cfg->startup_events[i];
|
||||
if (!se->content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t slot = (size_t)i * (size_t)g_cfg->relay_count + (size_t)relay_index;
|
||||
if (g_startup_published[slot]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* tags = NULL;
|
||||
if (se->tags_json) {
|
||||
tags = cJSON_Parse(se->tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
tags = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
const char* one_relay[1] = { relay_url };
|
||||
if (publish_kind_event_to_relays(se->kind, se->content, tags, one_relay, 1, reason) == 0) {
|
||||
g_startup_published[slot] = 1;
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup event publish failed for kind=%d relay=%s", se->kind, relay_url);
|
||||
}
|
||||
|
||||
if (se->kind == 31120 && !g_system_context) {
|
||||
g_system_context = strdup(se->content);
|
||||
}
|
||||
|
||||
cJSON_Delete(tags);
|
||||
}
|
||||
}
|
||||
|
||||
int nostr_handler_reconcile_startup_events(void) {
|
||||
if (!g_cfg || !g_pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
|
||||
free(g_startup_published);
|
||||
g_startup_published = NULL;
|
||||
g_startup_publish_tracking_enabled = 0;
|
||||
|
||||
if (g_cfg->startup_event_count > 0 && g_cfg->relay_count > 0) {
|
||||
size_t total = (size_t)g_cfg->startup_event_count * (size_t)g_cfg->relay_count;
|
||||
g_startup_published = (unsigned char*)calloc(total, 1U);
|
||||
if (!g_startup_published) {
|
||||
return -1;
|
||||
}
|
||||
g_startup_publish_tracking_enabled = 1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < g_cfg->startup_event_count; i++) {
|
||||
startup_event_t* se = &g_cfg->startup_events[i];
|
||||
if (se->kind == 31120 && se->content && !g_system_context) {
|
||||
g_system_context = strdup(se->content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!g_system_context) {
|
||||
g_system_context = strdup("You are Didactyl, a sovereign AI agent living on Nostr.");
|
||||
}
|
||||
|
||||
for (int relay_index = 0; relay_index < g_cfg->relay_count; relay_index++) {
|
||||
publish_pending_startup_events_for_relay_index(relay_index, "startup_reconcile");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char* nostr_handler_get_system_context(void) {
|
||||
return g_system_context;
|
||||
}
|
||||
|
||||
int nostr_handler_poll(int timeout_ms) {
|
||||
if (!g_pool) {
|
||||
return -1;
|
||||
@@ -569,8 +759,17 @@ void nostr_handler_cleanup(void) {
|
||||
g_last_relay_statuses = NULL;
|
||||
g_last_status_log_time = 0;
|
||||
|
||||
free(g_startup_published);
|
||||
g_startup_published = NULL;
|
||||
g_startup_publish_tracking_enabled = 0;
|
||||
|
||||
g_pool = NULL;
|
||||
g_cfg = NULL;
|
||||
g_dm_callback = NULL;
|
||||
g_dm_user_data = NULL;
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids));
|
||||
g_seen_dm_count = 0;
|
||||
g_seen_dm_next = 0;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
typedef void (*dm_callback_t)(const char* sender_pubkey_hex, const char* message, void* user_data);
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config);
|
||||
int nostr_handler_publish_profile(void);
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags);
|
||||
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
|
||||
int nostr_handler_poll(int timeout_ms);
|
||||
int nostr_handler_reconcile_startup_events(void);
|
||||
const char* nostr_handler_get_system_context(void);
|
||||
void nostr_handler_cleanup(void);
|
||||
|
||||
#endif
|
||||
188
src/tools.c
188
src/tools.c
@@ -5,6 +5,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <limits.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "nostr_handler.h"
|
||||
@@ -29,6 +30,33 @@ static char* json_success_with_message(const char* msg) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static int is_safe_relative_path(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(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(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;
|
||||
}
|
||||
|
||||
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) {
|
||||
if (!ctx || !cfg) return -1;
|
||||
memset(ctx, 0, sizeof(*ctx));
|
||||
@@ -120,6 +148,60 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON_AddItemToObject(t3, "function", t3_fn);
|
||||
cJSON_AddItemToArray(tools, t3);
|
||||
|
||||
cJSON* t4 = cJSON_CreateObject();
|
||||
cJSON* t4_fn = cJSON_CreateObject();
|
||||
cJSON* t4_params = cJSON_CreateObject();
|
||||
cJSON* t4_props = cJSON_CreateObject();
|
||||
cJSON* t4_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t4, "type", "function");
|
||||
cJSON_AddStringToObject(t4_fn, "name", "file_read");
|
||||
cJSON_AddStringToObject(t4_fn, "description", "Read a local file as text from the configured working directory");
|
||||
cJSON_AddStringToObject(t4_params, "type", "object");
|
||||
cJSON_AddItemToObject(t4_params, "properties", t4_props);
|
||||
cJSON_AddItemToObject(t4_params, "required", t4_required);
|
||||
|
||||
cJSON* p_fr_path = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_fr_path, "type", "string");
|
||||
cJSON_AddItemToObject(t4_props, "path", p_fr_path);
|
||||
cJSON* p_fr_max = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_fr_max, "type", "integer");
|
||||
cJSON_AddItemToObject(t4_props, "max_bytes", p_fr_max);
|
||||
cJSON_AddItemToArray(t4_required, cJSON_CreateString("path"));
|
||||
|
||||
cJSON_AddItemToObject(t4_fn, "parameters", t4_params);
|
||||
cJSON_AddItemToObject(t4, "function", t4_fn);
|
||||
cJSON_AddItemToArray(tools, t4);
|
||||
|
||||
cJSON* t5 = cJSON_CreateObject();
|
||||
cJSON* t5_fn = cJSON_CreateObject();
|
||||
cJSON* t5_params = cJSON_CreateObject();
|
||||
cJSON* t5_props = cJSON_CreateObject();
|
||||
cJSON* t5_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t5, "type", "function");
|
||||
cJSON_AddStringToObject(t5_fn, "name", "file_write");
|
||||
cJSON_AddStringToObject(t5_fn, "description", "Write text content to a local file in the configured working directory");
|
||||
cJSON_AddStringToObject(t5_params, "type", "object");
|
||||
cJSON_AddItemToObject(t5_params, "properties", t5_props);
|
||||
cJSON_AddItemToObject(t5_params, "required", t5_required);
|
||||
|
||||
cJSON* p_fw_path = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_fw_path, "type", "string");
|
||||
cJSON_AddItemToObject(t5_props, "path", p_fw_path);
|
||||
cJSON* p_fw_content = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_fw_content, "type", "string");
|
||||
cJSON_AddItemToObject(t5_props, "content", p_fw_content);
|
||||
cJSON* p_fw_append = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_fw_append, "type", "boolean");
|
||||
cJSON_AddItemToObject(t5_props, "append", p_fw_append);
|
||||
cJSON_AddItemToArray(t5_required, cJSON_CreateString("path"));
|
||||
cJSON_AddItemToArray(t5_required, cJSON_CreateString("content"));
|
||||
|
||||
cJSON_AddItemToObject(t5_fn, "parameters", t5_params);
|
||||
cJSON_AddItemToObject(t5, "function", t5_fn);
|
||||
cJSON_AddItemToArray(tools, t5);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(tools);
|
||||
cJSON_Delete(tools);
|
||||
return out;
|
||||
@@ -249,6 +331,106 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_file_read(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
|
||||
|
||||
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
|
||||
cJSON* maxb = cJSON_GetObjectItemCaseSensitive(args, "max_bytes");
|
||||
if (!path || !cJSON_IsString(path) || !path->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("file_read requires string path");
|
||||
}
|
||||
|
||||
int hard_max = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
|
||||
int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max;
|
||||
if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max;
|
||||
|
||||
char file_path[PATH_MAX];
|
||||
if (build_tool_path(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("file_read path is not allowed");
|
||||
}
|
||||
|
||||
FILE* fp = fopen(file_path, "rb");
|
||||
cJSON_Delete(args);
|
||||
if (!fp) return json_error("file_read failed to open file");
|
||||
|
||||
char* buf = (char*)calloc((size_t)max_bytes + 1U, 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return json_error("allocation failure");
|
||||
}
|
||||
|
||||
size_t n = fread(buf, 1, (size_t)max_bytes, fp);
|
||||
int truncated = !feof(fp) ? 1 : 0;
|
||||
fclose(fp);
|
||||
buf[n] = '\0';
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "path", file_path);
|
||||
cJSON_AddNumberToObject(out, "bytes_read", (double)n);
|
||||
cJSON_AddBoolToObject(out, "truncated", truncated);
|
||||
cJSON_AddStringToObject(out, "content", buf);
|
||||
free(buf);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
|
||||
|
||||
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* path = cJSON_GetObjectItemCaseSensitive(args, "path");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
|
||||
cJSON* append = cJSON_GetObjectItemCaseSensitive(args, "append");
|
||||
if (!path || !cJSON_IsString(path) || !path->valuestring ||
|
||||
!content || !cJSON_IsString(content) || !content->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("file_write requires string path and content");
|
||||
}
|
||||
|
||||
char file_path[PATH_MAX];
|
||||
if (build_tool_path(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("file_write path is not allowed");
|
||||
}
|
||||
|
||||
const char* content_str = content->valuestring;
|
||||
size_t len = strlen(content_str);
|
||||
int do_append = (append && cJSON_IsBool(append) && cJSON_IsTrue(append)) ? 1 : 0;
|
||||
|
||||
FILE* fp = fopen(file_path, do_append ? "ab" : "wb");
|
||||
cJSON_Delete(args);
|
||||
if (!fp) return json_error("file_write failed to open file");
|
||||
|
||||
size_t n = fwrite(content_str, 1, len, fp);
|
||||
fclose(fp);
|
||||
if (n != len) return json_error("file_write failed to write all bytes");
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "path", file_path);
|
||||
cJSON_AddNumberToObject(out, "bytes_written", (double)n);
|
||||
cJSON_AddBoolToObject(out, "append", do_append);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) {
|
||||
if (!tool_name) return json_error("missing tool name");
|
||||
|
||||
@@ -261,6 +443,12 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
|
||||
if (strcmp(tool_name, "shell_exec") == 0) {
|
||||
return execute_shell_exec(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "file_read") == 0) {
|
||||
return execute_file_read(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "file_write") == 0) {
|
||||
return execute_file_write(ctx, args_json);
|
||||
}
|
||||
|
||||
return json_error("unknown tool");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user