Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f31e4ceb7 | ||
|
|
052c11863f |
@@ -94,7 +94,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
src/main.c src/config.c src/context.c src/llm.c \
|
||||
src/nostr_handler.c src/agent.c src/tools.c src/debug.c \
|
||||
src/nostr_handler.c src/agent.c src/tools.c src/trigger_manager.c src/debug.c \
|
||||
-o /build/didactyl_static \
|
||||
nostr_core_lib/libnostr_core_x64.a \
|
||||
-lsecp256k1 \
|
||||
|
||||
@@ -12,6 +12,7 @@ SRCS = \
|
||||
$(SRC_DIR)/nostr_handler.c \
|
||||
$(SRC_DIR)/agent.c \
|
||||
$(SRC_DIR)/tools.c \
|
||||
$(SRC_DIR)/trigger_manager.c \
|
||||
$(SRC_DIR)/debug.c
|
||||
|
||||
INCLUDES = \
|
||||
|
||||
@@ -51,11 +51,11 @@ Agents learn capabilities through skills — Nostr events that any agent can di
|
||||
|
||||
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
|
||||
|
||||
## Current Status — v0.0.21
|
||||
## Current Status — v0.0.23
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.21 — Add full skill_* tool family with schema, execution, dispatch, and README updates
|
||||
> Last release update: v0.0.23 — Add tool_list runtime tool introspection (name/description/schema) and validate via CLI test mode
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
@@ -179,8 +179,20 @@ Relays are sourced exclusively from startup kind `10002` `r` tags.
|
||||
Options:
|
||||
|
||||
```
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./didactyl_static_x86_64 --config <path> # custom config file (default: ./config.json)
|
||||
./didactyl_static_x86_64 --debug <0-5> # log verbosity (0 none, 3 info, 5 trace)
|
||||
./didactyl_static_x86_64 --dump-schemas # print tool JSON schemas and exit
|
||||
./didactyl_static_x86_64 --test-tool <name> <args_json> # run one tool directly and print JSON result
|
||||
```
|
||||
|
||||
CLI debugger notes:
|
||||
|
||||
- `--test-tool` initializes Nostr, waits for at least one relay connection (up to 15s), then executes the selected tool.
|
||||
- Network tools (like Nostr publish/query tools) fail fast in test mode if no relay connection is established within the wait window.
|
||||
- Example:
|
||||
|
||||
```bash
|
||||
./didactyl_static_x86_64 --config ./config.json --test-tool nostr_file_md_to_longform_post '{"file":"docs/TOOLS_AND_SKILLS.md","title":"TOOLS_AND_SKILLS"}'
|
||||
```
|
||||
|
||||
### Talk to it
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
# Didactyl — Tools & Skills
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent**. It receives commands via encrypted DMs, reasons about them with an LLM, and takes actions through **tools**. It stores learned behaviors as **skills** — Nostr events that define reusable capabilities. Skills can optionally carry **triggers** — Nostr subscription filters that activate the skill automatically when matching events arrive.
|
||||
|
||||
This document describes the complete tools and skills architecture: what they are, how they work, and how they compose into a dynamic, self-modifying agent.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
Tools are the agent's hands. They are hardcoded C functions that the LLM can invoke during a conversation to take actions in the world.
|
||||
|
||||
### How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### Nostr Core Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_post` | Publish any kind event to relays |
|
||||
| `nostr_query` | Query relays with filters, return matching events |
|
||||
| `nostr_dm` | Send a DM via NIP-04 |
|
||||
| `nostr_dm_nip17` | Send a DM via NIP-17 gift wrap |
|
||||
| `nostr_profile` | Update the agent's kind 0 metadata |
|
||||
| `nostr_list_manage` | Add/remove items from replaceable list events |
|
||||
| `nostr_relay_status` | Get connection status of all relays |
|
||||
| `nostr_relay_info` | Get NIP-11 relay information document |
|
||||
|
||||
#### Identity Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `nostr_resolve_identifier` | Resolve NIP-05, npub, nprofile, or note identifiers |
|
||||
| `nostr_verify_nip05` | Verify a NIP-05 identifier |
|
||||
|
||||
#### Skill Management Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `skill_create` | Create or update a skill definition |
|
||||
| `skill_list` | List the agent's published skills |
|
||||
| `skill_adopt` | Add a skill to the adoption list |
|
||||
| `skill_remove` | Remove a skill from the adoption list |
|
||||
| `skill_search` | Search for skills across the Web of Trust |
|
||||
|
||||
#### System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `shell_exec` | Execute a shell command with sandboxing |
|
||||
| `http_request` | Make an HTTP request |
|
||||
| `get_time` | Get the current UTC time |
|
||||
|
||||
### Security Model
|
||||
|
||||
Tools are gated by sender tier:
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
| **ADMIN** | Configured admin pubkey | All tools | Full LLM with context |
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are the agent's learned behaviors. They are **Nostr events** — stored on relays, portable, shareable, and discoverable by other agents.
|
||||
|
||||
### Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
A skill event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "When asked to summarize a thread, query the root event and all replies, then produce a concise summary with key points and sentiment.",
|
||||
"tags": [
|
||||
["d", "summarize-thread"],
|
||||
["app", "didactyl"],
|
||||
["scope", "public"],
|
||||
["description", "Summarize a Nostr thread given a root event ID"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Skill Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CREATE[Admin asks didactyl to create a skill] --> PUBLISH[skill_create publishes kind 31123/31124]
|
||||
PUBLISH --> ADOPT[Auto-adopted into kind 10123 list]
|
||||
ADOPT --> AVAILABLE[Skill available for use]
|
||||
AVAILABLE --> DISCOVER[Other agents can discover via skill_search]
|
||||
DISCOVER --> ADOPT_OTHER[Other agents can skill_adopt]
|
||||
```
|
||||
|
||||
### How Skills Are Used Today
|
||||
|
||||
Currently, skills are **passive knowledge**. They exist on Nostr and are loaded into the LLM context when relevant. The admin might say "use your summarize-thread skill" and the LLM retrieves and follows the skill's instructions.
|
||||
|
||||
---
|
||||
|
||||
## Triggered Skills — The Activation System
|
||||
|
||||
This is where skills become **active**. A triggered skill is a skill with a Nostr subscription filter attached. When matching events arrive on the relay, didactyl wakes up and executes the skill automatically — no admin DM required.
|
||||
|
||||
### Anatomy of a Triggered Skill
|
||||
|
||||
A triggered skill extends the standard skill event with trigger-related tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31124,
|
||||
"content": "DM admin: '{author_display_name} just posted: {content_preview}'",
|
||||
"tags": [
|
||||
["d", "watch-jack"],
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "Notify admin when @jack posts a note"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"authors\":[\"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2\"],\"kinds\":[1]}"],
|
||||
["action", "template"],
|
||||
["enabled", "true"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type. Currently: `nostr-subscription` |
|
||||
| `filter` | Yes | JSON-encoded Nostr subscription filter |
|
||||
| `action` | No | Action type: `template` or `llm`. Default: `llm` |
|
||||
| `enabled` | No | Whether the trigger is active. Default: `true` |
|
||||
|
||||
### Action Types
|
||||
|
||||
#### Template Actions
|
||||
|
||||
The skill content is a string template with placeholders that get interpolated from the triggering event:
|
||||
|
||||
```
|
||||
DM admin: '{author_display_name} posted: {content_preview}'
|
||||
```
|
||||
|
||||
Available placeholders:
|
||||
|
||||
| Placeholder | Source |
|
||||
|---|---|
|
||||
| `{event_id}` | Triggering event ID hex |
|
||||
| `{pubkey}` | Author pubkey hex |
|
||||
| `{author_display_name}` | Resolved display name, falls back to truncated pubkey |
|
||||
| `{kind}` | Event kind number |
|
||||
| `{content}` | Full event content |
|
||||
| `{content_preview}` | First 280 characters of content |
|
||||
| `{created_at}` | Unix timestamp |
|
||||
| `{relay_url}` | Relay the event arrived from |
|
||||
|
||||
Template actions execute **without LLM involvement** — they are fast, cheap, and deterministic. The interpolated string is then acted upon based on a simple action prefix:
|
||||
|
||||
- `DM admin: ...` — send a DM to the admin
|
||||
- `DM <pubkey>: ...` — send a DM to a specific pubkey
|
||||
- `POST: ...` — publish as a kind 1 note
|
||||
- `LOG: ...` — write to debug log only
|
||||
|
||||
#### LLM-Mediated Actions
|
||||
|
||||
The skill content is a prompt. The triggering event is injected as context, and the LLM decides what to do:
|
||||
|
||||
```
|
||||
You received a note from a watched author. Analyze the note content.
|
||||
If it mentions Bitcoin or Lightning, summarize it and DM the admin.
|
||||
If it's a repost or low-effort content, ignore it silently.
|
||||
Use your tools to take action.
|
||||
```
|
||||
|
||||
LLM-mediated actions go through the full agent loop — the LLM can call tools, reason about the event, and produce complex multi-step responses.
|
||||
|
||||
### Trigger Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Creation
|
||||
ADMIN_CMD[Admin: 'Warn me when @jack posts'] --> LLM_REASON[LLM resolves pubkey + builds skill]
|
||||
LLM_REASON --> SKILL_CREATE[skill_create with trigger tags]
|
||||
SKILL_CREATE --> PUBLISHED[Skill published to Nostr]
|
||||
end
|
||||
|
||||
subgraph Activation
|
||||
STARTUP[Didactyl starts up] --> LOAD_SKILLS[Load adopted skills from kind 10123]
|
||||
LOAD_SKILLS --> FIND_TRIGGERS[Find skills with trigger tags]
|
||||
FIND_TRIGGERS --> SUBSCRIBE[Create Nostr subscriptions for each filter]
|
||||
end
|
||||
|
||||
subgraph Execution
|
||||
EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill]
|
||||
LOOKUP --> CHECK_TYPE{Action type?}
|
||||
CHECK_TYPE -->|template| INTERPOLATE[Interpolate placeholders]
|
||||
CHECK_TYPE -->|llm| LLM_LOOP[Run agent loop with event as context]
|
||||
INTERPOLATE --> EXECUTE_TPL[Execute action prefix]
|
||||
LLM_LOOP --> EXECUTE_LLM[LLM uses tools to respond]
|
||||
end
|
||||
|
||||
PUBLISHED --> LOAD_SKILLS
|
||||
```
|
||||
|
||||
### Dynamic Subscription Management
|
||||
|
||||
Didactyl manages its trigger subscriptions dynamically:
|
||||
|
||||
1. **On startup**: Load all adopted skills, find triggered ones, create subscriptions
|
||||
2. **On skill_create with trigger**: Immediately create a new subscription (no restart needed)
|
||||
3. **On skill_remove with trigger**: Tear down the associated subscription
|
||||
4. **On skill update**: Tear down old subscription, create new one if trigger changed
|
||||
|
||||
This requires a **trigger manager** component that:
|
||||
- Maintains a registry of active trigger subscriptions
|
||||
- Maps subscription callbacks back to their source skills
|
||||
- Handles subscription lifecycle (create, update, destroy)
|
||||
- Enforces limits on concurrent triggers
|
||||
|
||||
### Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion from too many subscriptions |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution from high-volume filters |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs from triggered skills |
|
||||
| Template action rate limit | 60/min | Prevents DM spam from template actions |
|
||||
|
||||
---
|
||||
|
||||
## How It All Fits Together
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Activation Sources
|
||||
DM_IN[DM from Admin/WoT]
|
||||
TRIGGER_EVENT[Nostr event matching a trigger filter]
|
||||
end
|
||||
|
||||
subgraph Agent Core
|
||||
DISPATCHER{Dispatcher}
|
||||
AGENT_LOOP[Agent Loop - LLM + Tools]
|
||||
TEMPLATE_ENGINE[Template Engine]
|
||||
end
|
||||
|
||||
subgraph Nostr
|
||||
RELAYS[Relays]
|
||||
SKILLS_STORE[Skills - kind 31123/31124]
|
||||
ADOPTION[Adoption List - kind 10123]
|
||||
end
|
||||
|
||||
subgraph Actions
|
||||
DM_OUT[Send DM]
|
||||
POST[Publish Note]
|
||||
TOOL_EXEC[Execute Tool]
|
||||
end
|
||||
|
||||
DM_IN --> DISPATCHER
|
||||
TRIGGER_EVENT --> DISPATCHER
|
||||
|
||||
DISPATCHER -->|DM message| AGENT_LOOP
|
||||
DISPATCHER -->|template trigger| TEMPLATE_ENGINE
|
||||
DISPATCHER -->|llm trigger| AGENT_LOOP
|
||||
|
||||
AGENT_LOOP --> TOOL_EXEC
|
||||
AGENT_LOOP --> DM_OUT
|
||||
TEMPLATE_ENGINE --> DM_OUT
|
||||
TEMPLATE_ENGINE --> POST
|
||||
|
||||
TOOL_EXEC -->|skill_create| SKILLS_STORE
|
||||
TOOL_EXEC -->|skill_adopt| ADOPTION
|
||||
TOOL_EXEC -->|nostr_post| RELAYS
|
||||
TOOL_EXEC -->|nostr_dm| DM_OUT
|
||||
```
|
||||
|
||||
### The Activation Flow
|
||||
|
||||
Today, didactyl has one activation source: **DMs**. With triggered skills, it gains a second: **any Nostr event matching a trigger filter**.
|
||||
|
||||
Both paths converge at the dispatcher, which routes to either:
|
||||
- The **agent loop** (for DMs and LLM-mediated triggers)
|
||||
- The **template engine** (for template triggers — fast path, no LLM)
|
||||
|
||||
### Self-Modification
|
||||
|
||||
The most powerful aspect: **didactyl can create its own triggers**. The admin says "watch for mentions of me on Nostr" and the LLM:
|
||||
|
||||
1. Resolves the admin's pubkey
|
||||
2. Crafts a Nostr filter: `{"#p": ["<admin_pubkey>"], "kinds": [1]}`
|
||||
3. Writes a skill with trigger tags via `skill_create`
|
||||
4. The trigger manager picks it up and creates the subscription
|
||||
5. From now on, didactyl monitors mentions autonomously
|
||||
|
||||
The admin can later say "stop watching for mentions" and didactyl removes the skill, tearing down the subscription.
|
||||
|
||||
---
|
||||
|
||||
## Storage — Everything on Nostr
|
||||
|
||||
All state lives on Nostr:
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Agent identity | Kind 0 profile event |
|
||||
| Agent relay list | Kind 10002 event |
|
||||
| Agent contact list | Kind 3 event |
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions | Tags on skill events |
|
||||
| Conversation history | Kind 4 DM events on relays |
|
||||
| Agent soul/personality | Startup event content in config |
|
||||
|
||||
The only local state is `config.json` (keys, relay URLs, LLM config) and the runtime in-memory state (active subscriptions, LLM context).
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### Trigger Types Beyond Nostr Subscriptions
|
||||
|
||||
The `trigger` tag is designed to be extensible:
|
||||
|
||||
| Trigger Type | Description |
|
||||
|---|---|
|
||||
| `nostr-subscription` | Match events via Nostr filter (implemented first) |
|
||||
| `cron` | Time-based triggers — "every day at 9am, post a GM" |
|
||||
| `webhook` | HTTP webhook triggers — external systems wake didactyl |
|
||||
| `chain` | Output of one skill triggers another skill |
|
||||
|
||||
### Skill Composition
|
||||
|
||||
Skills could reference other skills, building complex behaviors from simple primitives:
|
||||
|
||||
```
|
||||
When triggered, run skill 'translate-to-english' on the note content,
|
||||
then run skill 'sentiment-analysis' on the translation,
|
||||
then DM admin with the result if sentiment is negative.
|
||||
```
|
||||
|
||||
### Agent-to-Agent Skill Sharing
|
||||
|
||||
Since skills are Nostr events, agents can:
|
||||
- Discover skills published by other agents via `skill_search`
|
||||
- Adopt skills from other agents via `skill_adopt`
|
||||
- Share triggered skill patterns across a network of agents
|
||||
|
||||
### Trigger Marketplace
|
||||
|
||||
With kind 10123 adoption lists being public, a natural marketplace emerges:
|
||||
- Agents publish useful triggered skills
|
||||
- Other agents discover and adopt them
|
||||
- Popular triggers rise to the top via adoption count
|
||||
@@ -0,0 +1,203 @@
|
||||
# Triggered Skills — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing skill system so that skills can carry Nostr subscription triggers. When matching events arrive, didactyl executes the skill automatically — either via template interpolation (fast, no LLM) or LLM-mediated reasoning (full agent loop).
|
||||
|
||||
See [docs/TOOLS_AND_SKILLS.md](../docs/TOOLS_AND_SKILLS.md) for the full architecture.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Trigger Manager Module
|
||||
|
||||
Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core component that manages dynamic Nostr subscriptions tied to skills.
|
||||
|
||||
**Data structures:**
|
||||
|
||||
```c
|
||||
#define TRIGGER_MAX_ACTIVE 16
|
||||
#define TRIGGER_COOLDOWN_SECONDS 60
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[65];
|
||||
char skill_content[4096]; // the action template or LLM prompt
|
||||
int action_type; // 0 = llm, 1 = template
|
||||
char filter_json[2048]; // the Nostr subscription filter
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
nostr_pool_subscription_t* subscription;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct {
|
||||
active_trigger_t triggers[TRIGGER_MAX_ACTIVE];
|
||||
int count;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
} trigger_manager_t;
|
||||
```
|
||||
|
||||
**API:**
|
||||
|
||||
```c
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_slug,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_slug,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
```
|
||||
|
||||
### 2. Template Engine
|
||||
|
||||
Create a simple template interpolation engine in `src/trigger_manager.c` (or a separate `src/template.c` if it grows).
|
||||
|
||||
**Functionality:**
|
||||
- Parse placeholders like `{content}`, `{pubkey}`, `{author_display_name}` from a template string
|
||||
- Extract values from a triggering Nostr event (cJSON object)
|
||||
- Produce an interpolated output string
|
||||
- Parse action prefixes: `DM admin:`, `DM <pubkey>:`, `POST:`, `LOG:`
|
||||
|
||||
### 3. Trigger Event Callback
|
||||
|
||||
When a subscribed event arrives:
|
||||
|
||||
```c
|
||||
static void on_trigger_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
active_trigger_t* trigger = (active_trigger_t*)user_data;
|
||||
|
||||
// Check cooldown
|
||||
if (time(NULL) - trigger->last_fired < TRIGGER_COOLDOWN_SECONDS) return;
|
||||
trigger->last_fired = time(NULL);
|
||||
|
||||
if (trigger->action_type == 1) {
|
||||
// Template: interpolate and execute
|
||||
char* output = template_interpolate(trigger->skill_content, event);
|
||||
template_execute_action(output); // parse prefix, DM/POST/LOG
|
||||
free(output);
|
||||
} else {
|
||||
// LLM: build context and run agent loop
|
||||
trigger_run_llm_action(trigger, event, relay_url);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Skill Loading on Startup
|
||||
|
||||
In `main.c`, after `agent_init()` and skill adoption list is available:
|
||||
|
||||
1. Query own kind 10123 adoption list
|
||||
2. For each adopted skill address, query the skill event
|
||||
3. Check for `trigger` tag — if present, extract `filter`, `action`, `enabled` tags
|
||||
4. Register with trigger manager
|
||||
5. Trigger manager creates Nostr subscriptions
|
||||
|
||||
### 5. Extend skill_create for Live Trigger Registration
|
||||
|
||||
When `skill_create` is called with trigger tags:
|
||||
1. Publish the skill event as normal
|
||||
2. If trigger tags are present, also register with the trigger manager immediately
|
||||
3. No restart required — the subscription goes live right away
|
||||
|
||||
When `skill_remove` is called for a triggered skill:
|
||||
1. Remove from adoption list as normal
|
||||
2. Also unregister from trigger manager, tearing down the subscription
|
||||
|
||||
### 6. Extend Agent for Trigger-Initiated Conversations
|
||||
|
||||
The agent currently only handles DM-initiated conversations via `agent_on_message()`. Add a new entry point:
|
||||
|
||||
```c
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
```
|
||||
|
||||
This builds an LLM conversation with:
|
||||
- System context (soul)
|
||||
- A system message explaining this is a triggered skill execution
|
||||
- The skill content as instructions
|
||||
- The triggering event as user context
|
||||
- Full tool access (same as admin tier)
|
||||
|
||||
The LLM response actions (DMs, posts, etc.) are executed via tools as normal.
|
||||
|
||||
### 7. New Tool: trigger_list
|
||||
|
||||
Add a tool so the LLM can inspect active triggers:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "trigger_list",
|
||||
"description": "List all active triggered skills with their filters and status",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Config Extension
|
||||
|
||||
Add trigger-related limits to config:
|
||||
|
||||
```json
|
||||
{
|
||||
"triggers": {
|
||||
"enabled": true,
|
||||
"max_active": 16,
|
||||
"cooldown_seconds": 60,
|
||||
"llm_rate_limit_per_minute": 10,
|
||||
"template_rate_limit_per_minute": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Integration into Main Loop
|
||||
|
||||
The trigger manager subscriptions are serviced by the same `nostr_handler_poll()` call in the main loop — no changes needed to the poll loop itself, since all subscriptions share the relay pool.
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/trigger_manager.c` | **NEW** — trigger manager, template engine, event callbacks |
|
||||
| `src/trigger_manager.h` | **NEW** — trigger manager API |
|
||||
| `src/main.c` | Add trigger_manager_init, trigger_manager_load_from_skills after agent_init |
|
||||
| `src/agent.c` | Add agent_on_trigger entry point for LLM-mediated trigger actions |
|
||||
| `src/agent.h` | Declare agent_on_trigger |
|
||||
| `src/tools.c` | Extend skill_create/skill_remove to register/unregister triggers; add trigger_list tool |
|
||||
| `src/config.h` | Add triggers_config_t struct |
|
||||
| `src/config.c` | Parse triggers config section |
|
||||
| `docs/TOOLS_AND_SKILLS.md` | Already written — full architecture reference |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `trigger_manager.h` / `trigger_manager.c` — core module with data structures and API stubs
|
||||
2. Template engine — interpolation and action prefix parsing
|
||||
3. Trigger event callback — on_trigger_event with cooldown
|
||||
4. Startup loading — query adoption list, find triggered skills, create subscriptions
|
||||
5. agent_on_trigger — LLM-mediated trigger execution path
|
||||
6. Live registration — extend skill_create/skill_remove for immediate trigger management
|
||||
7. trigger_list tool — LLM visibility into active triggers
|
||||
8. Config parsing — triggers section with limits
|
||||
9. Rate limiting — enforce LLM and template rate limits
|
||||
10. Testing — manual trigger creation and verification
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing `nostr_handler_query_json()` for loading skills
|
||||
- Existing `nostr_relay_pool_subscribe()` for creating trigger subscriptions
|
||||
- Existing `agent_on_message()` pattern for the LLM-mediated path
|
||||
- Existing `skill_create` / `skill_remove` tools for lifecycle hooks
|
||||
+75
@@ -19,6 +19,7 @@
|
||||
static didactyl_config_t* g_cfg = NULL;
|
||||
static char* g_system_context = NULL;
|
||||
static tools_context_t g_tools_ctx;
|
||||
static struct trigger_manager* g_trigger_manager = NULL;
|
||||
|
||||
#define AGENT_DEBOUNCE_WINDOW_SECONDS 5
|
||||
#define AGENT_DEBOUNCE_CACHE_SIZE 256
|
||||
@@ -488,6 +489,79 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager) {
|
||||
g_trigger_manager = trigger_manager;
|
||||
g_tools_ctx.trigger_manager = trigger_manager;
|
||||
}
|
||||
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url) {
|
||||
if (!g_cfg || !g_system_context || !skill_slug || !skill_content || !triggering_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* event_json = cJSON_PrintUnformatted(triggering_event);
|
||||
if (!event_json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* relay = (relay_url && relay_url[0] != '\0') ? relay_url : "unknown";
|
||||
|
||||
const char* trigger_prefix =
|
||||
"Triggered-skill execution context:\n"
|
||||
"- You are executing a skill because a Nostr event matched its trigger filter.\n"
|
||||
"- Execute the skill instructions below against the triggering event.\n"
|
||||
"- Keep output concise and actionable for the administrator.\n"
|
||||
"- If no action is needed, explicitly say why.\n\n"
|
||||
"Skill slug: ";
|
||||
|
||||
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_slug) +
|
||||
strlen("\nRelay: ") + strlen(relay) + strlen("\n\nSkill instructions:\n") +
|
||||
strlen(skill_content) + 1U;
|
||||
char* system_prompt = (char*)malloc(system_len);
|
||||
if (!system_prompt) {
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(system_prompt,
|
||||
system_len,
|
||||
"%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
|
||||
g_system_context,
|
||||
trigger_prefix,
|
||||
skill_slug,
|
||||
relay,
|
||||
skill_content);
|
||||
|
||||
size_t user_len = strlen("Triggering event JSON:\n") + strlen(event_json) + 1U;
|
||||
char* user_prompt = (char*)malloc(user_len);
|
||||
if (!user_prompt) {
|
||||
free(system_prompt);
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(user_prompt, user_len, "Triggering event JSON:\n%s", event_json);
|
||||
free(event_json);
|
||||
|
||||
append_context_log(g_cfg->admin.pubkey, "llm_trigger", user_prompt);
|
||||
|
||||
char* response = llm_chat(system_prompt, user_prompt);
|
||||
free(system_prompt);
|
||||
free(user_prompt);
|
||||
|
||||
if (!response) {
|
||||
const char* fallback = "Triggered skill execution failed: LLM unavailable.";
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
(void)nostr_handler_send_dm(g_cfg->admin.pubkey, response);
|
||||
free(response);
|
||||
}
|
||||
|
||||
void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
@@ -669,6 +743,7 @@ void agent_cleanup(void) {
|
||||
free(g_system_context);
|
||||
g_system_context = NULL;
|
||||
g_cfg = NULL;
|
||||
g_trigger_manager = NULL;
|
||||
memset(g_seen_msgs, 0, sizeof(g_seen_msgs));
|
||||
g_seen_msgs_count = 0;
|
||||
g_seen_msgs_next = 0;
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context);
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
void agent_on_message(const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
|
||||
@@ -271,6 +271,50 @@ static int parse_admin_context_config(cJSON* root, didactyl_config_t* config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_triggers_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* triggers = cJSON_GetObjectItemCaseSensitive(root, "triggers");
|
||||
if (!triggers || !cJSON_IsObject(triggers)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(triggers, "enabled");
|
||||
cJSON* max_active = cJSON_GetObjectItemCaseSensitive(triggers, "max_active");
|
||||
cJSON* cooldown_seconds = cJSON_GetObjectItemCaseSensitive(triggers, "cooldown_seconds");
|
||||
cJSON* llm_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "llm_rate_limit_per_minute");
|
||||
cJSON* template_rate_limit = cJSON_GetObjectItemCaseSensitive(triggers, "template_rate_limit_per_minute");
|
||||
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->triggers.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
if (max_active && cJSON_IsNumber(max_active)) {
|
||||
config->triggers.max_active = (int)max_active->valuedouble;
|
||||
}
|
||||
if (cooldown_seconds && cJSON_IsNumber(cooldown_seconds)) {
|
||||
config->triggers.cooldown_seconds = (int)cooldown_seconds->valuedouble;
|
||||
}
|
||||
if (llm_rate_limit && cJSON_IsNumber(llm_rate_limit)) {
|
||||
config->triggers.llm_rate_limit_per_minute = (int)llm_rate_limit->valuedouble;
|
||||
}
|
||||
if (template_rate_limit && cJSON_IsNumber(template_rate_limit)) {
|
||||
config->triggers.template_rate_limit_per_minute = (int)template_rate_limit->valuedouble;
|
||||
}
|
||||
|
||||
if (config->triggers.max_active < 1) {
|
||||
config->triggers.max_active = 1;
|
||||
}
|
||||
if (config->triggers.cooldown_seconds < 0) {
|
||||
config->triggers.cooldown_seconds = 0;
|
||||
}
|
||||
if (config->triggers.llm_rate_limit_per_minute < 1) {
|
||||
config->triggers.llm_rate_limit_per_minute = 1;
|
||||
}
|
||||
if (config->triggers.template_rate_limit_per_minute < 1) {
|
||||
config->triggers.template_rate_limit_per_minute = 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
|
||||
return NULL;
|
||||
@@ -581,6 +625,12 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->admin_context.track_kind_1 = 1;
|
||||
config->admin_context.kind_1_limit = 10;
|
||||
|
||||
config->triggers.enabled = 1;
|
||||
config->triggers.max_active = 16;
|
||||
config->triggers.cooldown_seconds = 60;
|
||||
config->triggers.llm_rate_limit_per_minute = 10;
|
||||
config->triggers.template_rate_limit_per_minute = 60;
|
||||
|
||||
char* json_buf = NULL;
|
||||
size_t json_len = 0;
|
||||
if (read_file_to_buffer(path, &json_buf, &json_len) != 0) {
|
||||
@@ -680,6 +730,11 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (parse_triggers_config(root, config) != 0) {
|
||||
config_set_error("invalid triggers configuration");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
|
||||
config_set_error("keys.nsec must be valid nsec1... or 64-char hex private key");
|
||||
goto cleanup;
|
||||
|
||||
@@ -72,6 +72,14 @@ typedef struct {
|
||||
int kind_1_limit;
|
||||
} admin_context_config_t;
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int max_active;
|
||||
int cooldown_seconds;
|
||||
int llm_rate_limit_per_minute;
|
||||
int template_rate_limit_per_minute;
|
||||
} triggers_config_t;
|
||||
|
||||
typedef struct {
|
||||
agent_keys_t keys;
|
||||
admin_config_t admin;
|
||||
@@ -81,6 +89,7 @@ typedef struct {
|
||||
tools_config_t tools;
|
||||
security_config_t security;
|
||||
admin_context_config_t admin_context;
|
||||
triggers_config_t triggers;
|
||||
startup_event_t* startup_events;
|
||||
int startup_event_count;
|
||||
} didactyl_config_t;
|
||||
|
||||
+131
-1
@@ -4,6 +4,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "main.h"
|
||||
@@ -12,6 +13,9 @@
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "debug.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "tools.h"
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
@@ -22,13 +26,53 @@ static void signal_handler(int signum) {
|
||||
|
||||
static void print_usage(const char* prog) {
|
||||
fprintf(stderr,
|
||||
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>]\n",
|
||||
"Usage: %s [-h|--help] [-v|--version] [--config <path>] [--debug <0-5>] [--dump-schemas] [--test-tool <name> <args_json>]\n",
|
||||
prog ? prog : "didactyl");
|
||||
}
|
||||
|
||||
static int tool_result_success(const char* json) {
|
||||
if (!json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(json);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
int ok = (success && cJSON_IsBool(success) && cJSON_IsTrue(success)) ? 1 : 0;
|
||||
cJSON_Delete(root);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static int wait_for_connected_relays(int timeout_ms) {
|
||||
if (timeout_ms <= 0) {
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const int step_ms = 100;
|
||||
int elapsed_ms = 0;
|
||||
|
||||
while (elapsed_ms < timeout_ms) {
|
||||
if (nostr_handler_connected_relay_count() > 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
(void)nostr_handler_poll(step_ms);
|
||||
elapsed_ms += step_ms;
|
||||
}
|
||||
|
||||
return nostr_handler_connected_relay_count() > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./config.json";
|
||||
int debug_level = DEBUG_LEVEL_TRACE;
|
||||
int dump_schemas = 0;
|
||||
const char* test_tool_name = NULL;
|
||||
const char* test_tool_args = "{}";
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||
@@ -41,6 +85,11 @@ int main(int argc, char** argv) {
|
||||
config_path = argv[++i];
|
||||
} else if (strcmp(argv[i], "--debug") == 0 && i + 1 < argc) {
|
||||
debug_level = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--dump-schemas") == 0) {
|
||||
dump_schemas = 1;
|
||||
} else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) {
|
||||
test_tool_name = argv[++i];
|
||||
test_tool_args = argv[++i];
|
||||
} else {
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
@@ -64,6 +113,68 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (dump_schemas || test_tool_name) {
|
||||
if (nostr_handler_init(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize nostr handler\n");
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
tools_context_t tools_ctx;
|
||||
if (tools_init(&tools_ctx, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize tools context\n");
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int exit_code = 0;
|
||||
|
||||
if (test_tool_name) {
|
||||
const int timeout_ms = 15000;
|
||||
if (!wait_for_connected_relays(timeout_ms)) {
|
||||
fprintf(stderr,
|
||||
"No relay connection established within %d ms; refusing to run --test-tool '%s'\n",
|
||||
timeout_ms,
|
||||
test_tool_name);
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (dump_schemas) {
|
||||
char* schemas = tools_build_openai_schema_json(&tools_ctx);
|
||||
if (!schemas) {
|
||||
fprintf(stderr, "Failed to build tool schemas\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", schemas);
|
||||
free(schemas);
|
||||
}
|
||||
} else {
|
||||
char* result = tools_execute(&tools_ctx, test_tool_name, test_tool_args);
|
||||
if (!result) {
|
||||
fprintf(stderr, "Failed to execute tool\n");
|
||||
exit_code = 1;
|
||||
} else {
|
||||
printf("%s\n", result);
|
||||
exit_code = tool_result_success(result) ? 0 : 1;
|
||||
free(result);
|
||||
}
|
||||
}
|
||||
|
||||
tools_cleanup(&tools_ctx);
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
config_free(&cfg);
|
||||
@@ -97,6 +208,22 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
trigger_manager_t trigger_manager;
|
||||
if (trigger_manager_init(&trigger_manager, &cfg) != 0) {
|
||||
fprintf(stderr, "Failed to initialize trigger manager\n");
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
agent_set_trigger_manager(&trigger_manager);
|
||||
|
||||
if (trigger_manager_load_from_skills(&trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: trigger manager load from skills failed (continuing)");
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
|
||||
if (nostr_handler_subscribe_admin_context() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: subscribe admin context failed (continuing)");
|
||||
@@ -124,6 +251,7 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
@@ -139,6 +267,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
while (g_running) {
|
||||
(void)nostr_handler_poll(100);
|
||||
(void)trigger_manager_poll(&trigger_manager);
|
||||
struct timespec ts = {0, 10 * 1000 * 1000};
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
@@ -148,6 +277,7 @@ int main(int argc, char** argv) {
|
||||
agent_cleanup();
|
||||
nostr_handler_cleanup();
|
||||
llm_cleanup();
|
||||
trigger_manager_cleanup(&trigger_manager);
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
|
||||
|
||||
+2
-2
@@ -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 21
|
||||
#define DIDACTYL_VERSION "v0.0.21"
|
||||
#define DIDACTYL_VERSION_PATCH 23
|
||||
#define DIDACTYL_VERSION "v0.0.23"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+534
-11
@@ -11,10 +11,12 @@
|
||||
#include <ctype.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "main.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static char* json_error(const char* msg) {
|
||||
@@ -263,6 +265,40 @@ static char* trim_copy(const char* start, size_t len) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* shell_quote_single(const char* in) {
|
||||
if (!in) return NULL;
|
||||
|
||||
size_t len = strlen(in);
|
||||
size_t extra = 2U;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (in[i] == '\'') {
|
||||
extra += 4U;
|
||||
} else {
|
||||
extra += 1U;
|
||||
}
|
||||
}
|
||||
|
||||
char* out = (char*)malloc(extra + 1U);
|
||||
if (!out) return NULL;
|
||||
|
||||
size_t j = 0;
|
||||
out[j++] = '\'';
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (in[i] == '\'') {
|
||||
out[j++] = '\'';
|
||||
out[j++] = '\\';
|
||||
out[j++] = '\'';
|
||||
out[j++] = '\'';
|
||||
} else {
|
||||
out[j++] = in[i];
|
||||
}
|
||||
}
|
||||
out[j++] = '\'';
|
||||
out[j] = '\0';
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* first_markdown_h1(const char* content) {
|
||||
if (!content) return NULL;
|
||||
|
||||
@@ -505,6 +541,7 @@ int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) {
|
||||
if (!ctx || !cfg) return -1;
|
||||
memset(ctx, 0, sizeof(*ctx));
|
||||
ctx->cfg = cfg;
|
||||
ctx->trigger_manager = NULL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1605,6 +1642,71 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON_AddItemToObject(t26, "function", t26_fn);
|
||||
cJSON_AddItemToArray(tools, t26);
|
||||
|
||||
cJSON* t27 = cJSON_CreateObject();
|
||||
cJSON* t27_fn = cJSON_CreateObject();
|
||||
cJSON* t27_params = cJSON_CreateObject();
|
||||
cJSON* t27_props = cJSON_CreateObject();
|
||||
|
||||
cJSON_AddStringToObject(t27, "type", "function");
|
||||
cJSON_AddStringToObject(t27_fn, "name", "trigger_list");
|
||||
cJSON_AddStringToObject(t27_fn, "description", "List active triggered skills and their runtime status");
|
||||
cJSON_AddStringToObject(t27_params, "type", "object");
|
||||
cJSON_AddItemToObject(t27_params, "properties", t27_props);
|
||||
|
||||
cJSON_AddItemToObject(t27_fn, "parameters", t27_params);
|
||||
cJSON_AddItemToObject(t27, "function", t27_fn);
|
||||
cJSON_AddItemToArray(tools, t27);
|
||||
|
||||
cJSON* t28 = cJSON_CreateObject();
|
||||
cJSON* t28_fn = cJSON_CreateObject();
|
||||
cJSON* t28_params = cJSON_CreateObject();
|
||||
cJSON* t28_props = cJSON_CreateObject();
|
||||
cJSON* t28_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t28, "type", "function");
|
||||
cJSON_AddStringToObject(t28_fn, "name", "nostr_file_md_to_longform_post");
|
||||
cJSON_AddStringToObject(t28_fn, "description", "Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename");
|
||||
cJSON_AddStringToObject(t28_params, "type", "object");
|
||||
cJSON_AddItemToObject(t28_params, "properties", t28_props);
|
||||
cJSON_AddItemToObject(t28_params, "required", t28_required);
|
||||
|
||||
cJSON* p_lf_file = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_lf_file, "type", "string");
|
||||
cJSON_AddItemToObject(t28_props, "file", p_lf_file);
|
||||
|
||||
cJSON* p_lf_title = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_lf_title, "type", "string");
|
||||
cJSON_AddItemToObject(t28_props, "title", p_lf_title);
|
||||
|
||||
cJSON* p_lf_image = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_lf_image, "type", "string");
|
||||
cJSON_AddItemToObject(t28_props, "image", p_lf_image);
|
||||
|
||||
cJSON* p_lf_summary = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_lf_summary, "type", "string");
|
||||
cJSON_AddItemToObject(t28_props, "summary", p_lf_summary);
|
||||
|
||||
cJSON_AddItemToArray(t28_required, cJSON_CreateString("file"));
|
||||
|
||||
cJSON_AddItemToObject(t28_fn, "parameters", t28_params);
|
||||
cJSON_AddItemToObject(t28, "function", t28_fn);
|
||||
cJSON_AddItemToArray(tools, t28);
|
||||
|
||||
cJSON* t29 = cJSON_CreateObject();
|
||||
cJSON* t29_fn = cJSON_CreateObject();
|
||||
cJSON* t29_params = cJSON_CreateObject();
|
||||
cJSON* t29_props = cJSON_CreateObject();
|
||||
|
||||
cJSON_AddStringToObject(t29, "type", "function");
|
||||
cJSON_AddStringToObject(t29_fn, "name", "tool_list");
|
||||
cJSON_AddStringToObject(t29_fn, "description", "List available tools with name, description, and JSON parameter schema");
|
||||
cJSON_AddStringToObject(t29_params, "type", "object");
|
||||
cJSON_AddItemToObject(t29_params, "properties", t29_props);
|
||||
|
||||
cJSON_AddItemToObject(t29_fn, "parameters", t29_params);
|
||||
cJSON_AddItemToObject(t29, "function", t29_fn);
|
||||
cJSON_AddItemToArray(tools, t29);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(tools);
|
||||
cJSON_Delete(tools);
|
||||
return out;
|
||||
@@ -1764,6 +1866,23 @@ static char* read_entire_file(const char* file_path, size_t* out_bytes) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
static char* basename_lowercase_dup(const char* path) {
|
||||
if (!path || path[0] == '\0') return NULL;
|
||||
|
||||
const char* base = strrchr(path, '/');
|
||||
base = base ? (base + 1) : path;
|
||||
if (base[0] == '\0') return NULL;
|
||||
|
||||
char* out = strdup(base);
|
||||
if (!out) return NULL;
|
||||
|
||||
for (size_t i = 0; out[i] != '\0'; i++) {
|
||||
out[i] = (char)tolower((unsigned char)out[i]);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
|
||||
|
||||
@@ -1892,6 +2011,160 @@ static char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_js
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
|
||||
|
||||
cJSON* args = parse_tool_args_json(args_json);
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* file = cJSON_GetObjectItemCaseSensitive(args, "file");
|
||||
cJSON* title = cJSON_GetObjectItemCaseSensitive(args, "title");
|
||||
cJSON* image = cJSON_GetObjectItemCaseSensitive(args, "image");
|
||||
cJSON* summary = cJSON_GetObjectItemCaseSensitive(args, "summary");
|
||||
|
||||
if (!file || !cJSON_IsString(file) || !file->valuestring || file->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error("nostr_file_md_to_longform_post requires string file");
|
||||
}
|
||||
if (title && !cJSON_IsString(title)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("nostr_file_md_to_longform_post title must be string when provided");
|
||||
}
|
||||
if (image && !cJSON_IsString(image)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("nostr_file_md_to_longform_post image must be string when provided");
|
||||
}
|
||||
if (summary && !cJSON_IsString(summary)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("nostr_file_md_to_longform_post summary must be string when provided");
|
||||
}
|
||||
|
||||
char file_path[PATH_MAX];
|
||||
if (build_tool_path(ctx, file->valuestring, file_path, sizeof(file_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to resolve markdown file path");
|
||||
}
|
||||
|
||||
size_t bytes_read = 0;
|
||||
char* content = read_entire_file(file_path, &bytes_read);
|
||||
if (!content) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to read markdown file");
|
||||
}
|
||||
|
||||
char* d_tag = basename_lowercase_dup(file->valuestring);
|
||||
if (!d_tag || d_tag[0] == '\0') {
|
||||
free(d_tag);
|
||||
free(content);
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to derive d tag from filename");
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
if (!tags) {
|
||||
free(d_tag);
|
||||
free(content);
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to create tags");
|
||||
}
|
||||
|
||||
if (add_string_tag(tags, "d", d_tag) != 0) {
|
||||
free(d_tag);
|
||||
cJSON_Delete(tags);
|
||||
free(content);
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to set d tag");
|
||||
}
|
||||
|
||||
if (title && title->valuestring && title->valuestring[0] != '\0') {
|
||||
if (add_string_tag(tags, "title", title->valuestring) != 0) {
|
||||
free(d_tag);
|
||||
cJSON_Delete(tags);
|
||||
free(content);
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to set title tag");
|
||||
}
|
||||
}
|
||||
|
||||
if (image && image->valuestring && image->valuestring[0] != '\0') {
|
||||
if (add_string_tag(tags, "image", image->valuestring) != 0) {
|
||||
free(d_tag);
|
||||
cJSON_Delete(tags);
|
||||
free(content);
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to set image tag");
|
||||
}
|
||||
}
|
||||
|
||||
if (summary && summary->valuestring && summary->valuestring[0] != '\0') {
|
||||
if (add_string_tag(tags, "summary", summary->valuestring) != 0) {
|
||||
free(d_tag);
|
||||
cJSON_Delete(tags);
|
||||
free(content);
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to set summary tag");
|
||||
}
|
||||
}
|
||||
|
||||
ensure_nip23_metadata_tags(30023, content, &tags);
|
||||
|
||||
nostr_publish_result_t publish_result;
|
||||
memset(&publish_result, 0, sizeof(publish_result));
|
||||
|
||||
int rc = nostr_handler_publish_kind_event(30023, content, tags, &publish_result);
|
||||
cJSON_Delete(tags);
|
||||
free(content);
|
||||
cJSON_Delete(args);
|
||||
|
||||
if (rc != 0) {
|
||||
free(d_tag);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json_error("nostr_file_md_to_longform_post failed");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(d_tag);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
|
||||
cJSON_AddStringToObject(out, "message", "nostr_file_md_to_longform_post published");
|
||||
cJSON_AddStringToObject(out, "path", file_path);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag);
|
||||
cJSON_AddNumberToObject(out, "bytes_read", (double)bytes_read);
|
||||
cJSON_AddNumberToObject(out, "kind", publish_result.kind);
|
||||
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
|
||||
cJSON_AddNumberToObject(out, "relay_count", publish_result.relay_count);
|
||||
cJSON_AddNumberToObject(out, "accepted_by_pool_count", publish_result.accepted_by_pool_count);
|
||||
|
||||
if (publish_result.note_uri[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "note_uri", publish_result.note_uri);
|
||||
}
|
||||
if (publish_result.naddr_uri[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
|
||||
}
|
||||
|
||||
cJSON* relays = cJSON_CreateArray();
|
||||
if (!relays) {
|
||||
free(d_tag);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
cJSON_Delete(out);
|
||||
return NULL;
|
||||
}
|
||||
for (int i = 0; i < publish_result.relay_count; i++) {
|
||||
cJSON_AddItemToArray(relays, cJSON_CreateString(publish_result.relays[i] ? publish_result.relays[i] : ""));
|
||||
}
|
||||
cJSON_AddItemToObject(out, "relays", relays);
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(d_tag);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_nostr_delete(const char* args_json) {
|
||||
cJSON* args = parse_tool_args_json(args_json);
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
@@ -2762,6 +3035,10 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
cJSON* scope = cJSON_GetObjectItemCaseSensitive(args, "scope");
|
||||
cJSON* description = cJSON_GetObjectItemCaseSensitive(args, "description");
|
||||
cJSON* auto_adopt = cJSON_GetObjectItemCaseSensitive(args, "auto_adopt");
|
||||
cJSON* trigger = cJSON_GetObjectItemCaseSensitive(args, "trigger");
|
||||
cJSON* filter = cJSON_GetObjectItemCaseSensitive(args, "filter");
|
||||
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(args, "enabled");
|
||||
|
||||
if (!slug || !cJSON_IsString(slug) || !slug->valuestring ||
|
||||
!content || !cJSON_IsString(content) || !content->valuestring) {
|
||||
@@ -2781,6 +3058,23 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
return json_error("skill_create description must be string when provided");
|
||||
}
|
||||
|
||||
if (trigger && (!cJSON_IsString(trigger) || !trigger->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create trigger must be string when provided");
|
||||
}
|
||||
if (filter && (!cJSON_IsString(filter) || !filter->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create filter must be string when provided");
|
||||
}
|
||||
if (action && (!cJSON_IsString(action) || !action->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create action must be string when provided");
|
||||
}
|
||||
if (enabled && !cJSON_IsBool(enabled)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create enabled must be boolean when provided");
|
||||
}
|
||||
|
||||
const char* scope_str = (scope && scope->valuestring && scope->valuestring[0] != '\0') ? scope->valuestring : "public";
|
||||
int kind = 0;
|
||||
if (strcmp(scope_str, "public") == 0) {
|
||||
@@ -2816,6 +3110,38 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
}
|
||||
|
||||
const char* trigger_str = (trigger && trigger->valuestring && trigger->valuestring[0] != '\0')
|
||||
? trigger->valuestring
|
||||
: NULL;
|
||||
const char* filter_str = (filter && filter->valuestring && filter->valuestring[0] != '\0')
|
||||
? filter->valuestring
|
||||
: NULL;
|
||||
const char* action_str = (action && action->valuestring && action->valuestring[0] != '\0')
|
||||
? action->valuestring
|
||||
: "llm";
|
||||
int enabled_int = (!enabled || cJSON_IsTrue(enabled)) ? 1 : 0;
|
||||
|
||||
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create trigger must be nostr-subscription when provided");
|
||||
}
|
||||
if ((trigger_str && !filter_str) || (!trigger_str && filter_str)) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create trigger and filter must both be provided together");
|
||||
}
|
||||
if (trigger_str) {
|
||||
if (add_string_tag(tags, "trigger", trigger_str) != 0 ||
|
||||
add_string_tag(tags, "filter", filter_str) != 0 ||
|
||||
add_string_tag(tags, "action", action_str) != 0 ||
|
||||
add_string_tag(tags, "enabled", enabled_int ? "true" : "false") != 0) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error("failed to add trigger tags");
|
||||
}
|
||||
}
|
||||
|
||||
nostr_publish_result_t skill_publish;
|
||||
memset(&skill_publish, 0, sizeof(skill_publish));
|
||||
|
||||
@@ -2865,6 +3191,21 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
}
|
||||
|
||||
int trigger_registered = 0;
|
||||
if (trigger_str && ctx->trigger_manager && enabled_int) {
|
||||
trigger_action_type_t at = (strcmp(action_str, "template") == 0)
|
||||
? TRIGGER_ACTION_TEMPLATE
|
||||
: TRIGGER_ACTION_LLM;
|
||||
if (trigger_manager_add(ctx->trigger_manager,
|
||||
slug->valuestring,
|
||||
content->valuestring,
|
||||
filter_str,
|
||||
at,
|
||||
enabled_int) == 0) {
|
||||
trigger_registered = 1;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(args);
|
||||
@@ -2881,6 +3222,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
cJSON_AddBoolToObject(out, "auto_adopt", do_auto_adopt ? 1 : 0);
|
||||
cJSON_AddBoolToObject(out, "adopted", adopted ? 1 : 0);
|
||||
cJSON_AddBoolToObject(out, "already_adopted", already_adopted ? 1 : 0);
|
||||
cJSON_AddBoolToObject(out, "trigger_registered", trigger_registered ? 1 : 0);
|
||||
|
||||
if (adoption_publish.event_id[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "adoption_event_id", adoption_publish.event_id);
|
||||
@@ -2939,7 +3281,7 @@ static char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", 200);
|
||||
cJSON_AddNumberToObject(filter, "limit", 300);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 8000);
|
||||
cJSON_Delete(filter);
|
||||
@@ -2954,6 +3296,39 @@ static char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
|
||||
return json_error("skill_list returned invalid JSON");
|
||||
}
|
||||
|
||||
int fallback_used = 0;
|
||||
if (cJSON_GetArraySize(events) == 0) {
|
||||
cJSON_Delete(events);
|
||||
events = NULL;
|
||||
|
||||
cJSON* fallback_filter = cJSON_CreateObject();
|
||||
cJSON* fallback_kinds = cJSON_CreateArray();
|
||||
if (!fallback_filter || !fallback_kinds) {
|
||||
cJSON_Delete(fallback_filter);
|
||||
cJSON_Delete(fallback_kinds);
|
||||
return json_error("failed to create fallback skill_list filter");
|
||||
}
|
||||
|
||||
if (include_public) cJSON_AddItemToArray(fallback_kinds, cJSON_CreateNumber(31123));
|
||||
if (include_private) cJSON_AddItemToArray(fallback_kinds, cJSON_CreateNumber(31124));
|
||||
cJSON_AddItemToObject(fallback_filter, "kinds", fallback_kinds);
|
||||
cJSON_AddNumberToObject(fallback_filter, "limit", 600);
|
||||
|
||||
char* fallback_json = nostr_handler_query_json(fallback_filter, 8000);
|
||||
cJSON_Delete(fallback_filter);
|
||||
if (!fallback_json) {
|
||||
return json_error("skill_list fallback query failed");
|
||||
}
|
||||
|
||||
events = cJSON_Parse(fallback_json);
|
||||
free(fallback_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
return json_error("skill_list fallback returned invalid JSON");
|
||||
}
|
||||
fallback_used = 1;
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
cJSON* skills = cJSON_CreateArray();
|
||||
if (!out || !skills) {
|
||||
@@ -2966,11 +3341,22 @@ static char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
|
||||
int event_count = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < event_count; i++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, i);
|
||||
if (!ev || !cJSON_IsObject(ev)) continue;
|
||||
|
||||
if (fallback_used) {
|
||||
cJSON* ev_pubkey = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
|
||||
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
|
||||
strcmp(ev_pubkey->valuestring, ctx->cfg->keys.public_key_hex) != 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* summary = extract_skill_summary(ev);
|
||||
if (summary) cJSON_AddItemToArray(skills, summary);
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddBoolToObject(out, "fallback_used", fallback_used ? 1 : 0);
|
||||
cJSON_AddItemToObject(out, "skills", skills);
|
||||
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(skills));
|
||||
|
||||
@@ -3138,6 +3524,10 @@ static char* execute_skill_remove(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx->trigger_manager) {
|
||||
(void)trigger_manager_remove(ctx->trigger_manager, slug->valuestring);
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(tuple);
|
||||
@@ -3604,11 +3994,11 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
|
||||
if (!ctx->cfg->tools.shell.enabled) return json_error("shell tool disabled");
|
||||
|
||||
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
|
||||
cJSON* args = parse_tool_args_json(args_json);
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* command = cJSON_GetObjectItemCaseSensitive(args, "command");
|
||||
if (!command || !cJSON_IsString(command) || !command->valuestring) {
|
||||
if (!command || !cJSON_IsString(command) || !command->valuestring || command->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error("shell_exec requires string command");
|
||||
}
|
||||
@@ -3618,16 +4008,47 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
|
||||
: ".";
|
||||
int timeout_s = ctx->cfg->tools.shell.timeout_seconds > 0 ? ctx->cfg->tools.shell.timeout_seconds : 30;
|
||||
|
||||
char cmd[4096];
|
||||
char* quoted_cwd = shell_quote_single(cwd);
|
||||
char* quoted_cmd = shell_quote_single(command->valuestring);
|
||||
cJSON_Delete(args);
|
||||
|
||||
if (!quoted_cwd || !quoted_cmd) {
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
return json_error("allocation failure");
|
||||
}
|
||||
|
||||
int needed = snprintf(NULL,
|
||||
0,
|
||||
"cd %s && timeout %ds sh -lc %s 2>&1",
|
||||
quoted_cwd,
|
||||
timeout_s,
|
||||
quoted_cmd);
|
||||
if (needed <= 0) {
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
return json_error("failed to build shell command");
|
||||
}
|
||||
|
||||
char* cmd = (char*)malloc((size_t)needed + 1U);
|
||||
if (!cmd) {
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
return json_error("allocation failure");
|
||||
}
|
||||
|
||||
snprintf(cmd,
|
||||
sizeof(cmd),
|
||||
(size_t)needed + 1U,
|
||||
"cd %s && timeout %ds sh -lc %s 2>&1",
|
||||
cwd,
|
||||
quoted_cwd,
|
||||
timeout_s,
|
||||
command->valuestring);
|
||||
quoted_cmd);
|
||||
|
||||
free(quoted_cwd);
|
||||
free(quoted_cmd);
|
||||
|
||||
FILE* fp = popen(cmd, "r");
|
||||
cJSON_Delete(args);
|
||||
free(cmd);
|
||||
if (!fp) return json_error("failed to execute command");
|
||||
|
||||
int max_bytes = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
|
||||
@@ -3644,7 +4065,15 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
|
||||
if (n == 0) break;
|
||||
}
|
||||
|
||||
int status = pclose(fp);
|
||||
int raw_status = pclose(fp);
|
||||
int exit_status = raw_status;
|
||||
if (raw_status != -1) {
|
||||
if (WIFEXITED(raw_status)) {
|
||||
exit_status = WEXITSTATUS(raw_status);
|
||||
} else if (WIFSIGNALED(raw_status)) {
|
||||
exit_status = 128 + WTERMSIG(raw_status);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
@@ -3652,8 +4081,8 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", status == 0 ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "exit_status", status);
|
||||
cJSON_AddBoolToObject(out, "success", exit_status == 0 ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "exit_status", exit_status);
|
||||
cJSON_AddStringToObject(out, "output", output);
|
||||
free(output);
|
||||
|
||||
@@ -3762,6 +4191,91 @@ static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_tool_list(tools_context_t* ctx, const char* args_json) {
|
||||
(void)args_json;
|
||||
if (!ctx) {
|
||||
return json_error("tool context unavailable");
|
||||
}
|
||||
|
||||
char* schema_json = tools_build_openai_schema_json(ctx);
|
||||
if (!schema_json) {
|
||||
return json_error("failed to build tool schemas");
|
||||
}
|
||||
|
||||
cJSON* schema_arr = cJSON_Parse(schema_json);
|
||||
free(schema_json);
|
||||
if (!schema_arr || !cJSON_IsArray(schema_arr)) {
|
||||
cJSON_Delete(schema_arr);
|
||||
return json_error("failed to parse tool schemas");
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
cJSON* tools = cJSON_CreateArray();
|
||||
if (!out || !tools) {
|
||||
cJSON_Delete(schema_arr);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(tools);
|
||||
return json_error("out of memory");
|
||||
}
|
||||
|
||||
cJSON* item = NULL;
|
||||
cJSON_ArrayForEach(item, schema_arr) {
|
||||
cJSON* fn = cJSON_GetObjectItemCaseSensitive(item, "function");
|
||||
if (!fn || !cJSON_IsObject(fn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(fn, "name");
|
||||
if (!name || !cJSON_IsString(name) || !name->valuestring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* row = cJSON_CreateObject();
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(row, "name", name->valuestring);
|
||||
|
||||
cJSON* description = cJSON_GetObjectItemCaseSensitive(fn, "description");
|
||||
if (description && cJSON_IsString(description) && description->valuestring) {
|
||||
cJSON_AddStringToObject(row, "description", description->valuestring);
|
||||
} else {
|
||||
cJSON_AddStringToObject(row, "description", "");
|
||||
}
|
||||
|
||||
cJSON* parameters = cJSON_GetObjectItemCaseSensitive(fn, "parameters");
|
||||
if (parameters) {
|
||||
cJSON_AddItemToObject(row, "parameters", cJSON_Duplicate(parameters, 1));
|
||||
} else {
|
||||
cJSON_AddItemToObject(row, "parameters", cJSON_CreateObject());
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(tools, row);
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddNumberToObject(out, "count", (double)cJSON_GetArraySize(tools));
|
||||
cJSON_AddItemToObject(out, "tools", tools);
|
||||
|
||||
char* result = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(schema_arr);
|
||||
return result;
|
||||
}
|
||||
|
||||
static char* execute_trigger_list(tools_context_t* ctx, const char* args_json) {
|
||||
(void)args_json;
|
||||
if (!ctx || !ctx->trigger_manager) {
|
||||
return json_error("trigger manager unavailable");
|
||||
}
|
||||
char* status = trigger_manager_status_json(ctx->trigger_manager);
|
||||
if (!status) {
|
||||
return json_error("failed to build trigger status");
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) {
|
||||
if (!tool_name) return json_error("missing tool name");
|
||||
|
||||
@@ -3840,10 +4354,19 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
|
||||
if (strcmp(tool_name, "skill_search") == 0) {
|
||||
return execute_skill_search(args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "trigger_list") == 0) {
|
||||
return execute_trigger_list(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "tool_list") == 0) {
|
||||
return execute_tool_list(ctx, args_json);
|
||||
}
|
||||
|
||||
if (strcmp(tool_name, "nostr_post_readme") == 0) {
|
||||
return execute_nostr_post_readme(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "nostr_file_md_to_longform_post") == 0) {
|
||||
return execute_nostr_file_md_to_longform_post(ctx, args_json);
|
||||
}
|
||||
|
||||
return json_error("unknown tool");
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef struct {
|
||||
didactyl_config_t* cfg;
|
||||
struct trigger_manager* trigger_manager;
|
||||
} tools_context_t;
|
||||
|
||||
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "trigger_manager.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
static int clamp_enabled(int enabled) {
|
||||
return enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
static int ensure_capacity(trigger_manager_t* mgr, int needed) {
|
||||
if (!mgr || needed <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (mgr->capacity >= needed) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int new_cap = mgr->capacity > 0 ? mgr->capacity : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
while (new_cap < needed) {
|
||||
if (new_cap > 1024) {
|
||||
return -1;
|
||||
}
|
||||
new_cap *= 2;
|
||||
}
|
||||
|
||||
active_trigger_t* grown = (active_trigger_t*)realloc(mgr->triggers, (size_t)new_cap * sizeof(active_trigger_t));
|
||||
if (!grown) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (new_cap > mgr->capacity) {
|
||||
memset(&grown[mgr->capacity], 0, (size_t)(new_cap - mgr->capacity) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->triggers = grown;
|
||||
mgr->capacity = new_cap;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (strncmp(mgr->triggers[i].skill_slug, skill_slug, TRIGGER_SKILL_SLUG_MAX) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
|
||||
if (!tags || !key || !cJSON_IsArray(tags)) return NULL;
|
||||
|
||||
int n = cJSON_GetArraySize(tags);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
|
||||
|
||||
cJSON* k = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* v = cJSON_GetArrayItem(tag, 1);
|
||||
if (k && v && cJSON_IsString(k) && cJSON_IsString(v) &&
|
||||
k->valuestring && v->valuestring && strcmp(k->valuestring, key) == 0) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_slug[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey || !out_slug) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* p1 = strchr(addr, ':');
|
||||
if (!p1) return -1;
|
||||
const char* p2 = strchr(p1 + 1, ':');
|
||||
if (!p2) return -1;
|
||||
|
||||
char kind_buf[16] = {0};
|
||||
size_t kind_len = (size_t)(p1 - addr);
|
||||
if (kind_len == 0 || kind_len >= sizeof(kind_buf)) return -1;
|
||||
memcpy(kind_buf, addr, kind_len);
|
||||
|
||||
int kind = atoi(kind_buf);
|
||||
if (kind != 31123 && kind != 31124) return -1;
|
||||
|
||||
size_t pub_len = (size_t)(p2 - (p1 + 1));
|
||||
if (pub_len != 64U) return -1;
|
||||
memcpy(out_pubkey, p1 + 1, 64U);
|
||||
out_pubkey[64] = '\0';
|
||||
|
||||
size_t slug_len = strlen(p2 + 1);
|
||||
if (slug_len == 0 || slug_len >= 65U) return -1;
|
||||
memcpy(out_slug, p2 + 1, slug_len + 1U);
|
||||
|
||||
*out_kind = kind;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* build_template_output(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
(void)relay_url;
|
||||
|
||||
if (!t || !event) return NULL;
|
||||
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
|
||||
const char* content_s = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
const char* pubkey_s = (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring) ? pubkey->valuestring : "unknown";
|
||||
const char* id_s = (id && cJSON_IsString(id) && id->valuestring) ? id->valuestring : "";
|
||||
int kind_i = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
|
||||
|
||||
const char* tmpl = t->skill_content;
|
||||
if (!tmpl || tmpl[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char out[TRIGGER_SKILL_CONTENT_MAX + 256];
|
||||
snprintf(out,
|
||||
sizeof(out),
|
||||
"%s\n\n[event id=%s kind=%d pubkey=%s]\n%s",
|
||||
tmpl,
|
||||
id_s,
|
||||
kind_i,
|
||||
pubkey_s,
|
||||
content_s);
|
||||
|
||||
return strdup(out);
|
||||
}
|
||||
|
||||
static void execute_template_action(trigger_manager_t* mgr,
|
||||
const active_trigger_t* t,
|
||||
cJSON* event,
|
||||
const char* relay_url) {
|
||||
if (!mgr || !mgr->cfg || !t || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
char* rendered = build_template_output(t, event, relay_url);
|
||||
if (!rendered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(rendered, "DM admin:", 9) == 0) {
|
||||
const char* body = rendered + 9;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, body);
|
||||
} else if (strncmp(rendered, "POST:", 5) == 0) {
|
||||
const char* body = rendered + 5;
|
||||
while (*body == ' ') body++;
|
||||
(void)nostr_handler_publish_kind_event(1, body, NULL, NULL);
|
||||
} else if (strncmp(rendered, "LOG:", 4) == 0) {
|
||||
const char* body = rendered + 4;
|
||||
while (*body == ' ') body++;
|
||||
DEBUG_INFO("[didactyl] trigger template log (%s): %s", t->skill_slug, body);
|
||||
} else {
|
||||
(void)nostr_handler_send_dm(mgr->cfg->admin.pubkey, rendered);
|
||||
}
|
||||
|
||||
free(rendered);
|
||||
}
|
||||
|
||||
static void execute_llm_action(const active_trigger_t* t, cJSON* event, const char* relay_url) {
|
||||
if (!t || !event) {
|
||||
return;
|
||||
}
|
||||
agent_on_trigger(t->skill_slug, t->skill_content, event, relay_url);
|
||||
}
|
||||
|
||||
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
|
||||
active_trigger_t* t = &mgr->triggers[index];
|
||||
if (!t->enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
|
||||
time_t created_ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : 0;
|
||||
if (created_ts > 0 && created_ts <= t->last_seen_created_at) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg->triggers.cooldown_seconds;
|
||||
if (cooldown < 0) cooldown = 0;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
t->last_fired = now;
|
||||
if (created_ts > t->last_seen_created_at) {
|
||||
t->last_seen_created_at = created_ts;
|
||||
}
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, relay_url);
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, relay_url);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
|
||||
if (!mgr || !cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
mgr->cfg = cfg;
|
||||
|
||||
if (pthread_mutex_init(&mgr->mutex, NULL) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int initial_cap = cfg->triggers.max_active > 0 ? cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
mgr->capacity = initial_cap;
|
||||
mgr->triggers = (active_trigger_t*)calloc((size_t)mgr->capacity, sizeof(active_trigger_t));
|
||||
if (!mgr->triggers) {
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
return -1;
|
||||
}
|
||||
|
||||
mgr->last_poll_at = time(NULL);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager initialized (capacity=%d)", mgr->capacity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* adopt_filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!adopt_filter || !kinds || !authors) {
|
||||
cJSON_Delete(adopt_filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
|
||||
cJSON_AddItemToObject(adopt_filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(mgr->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(adopt_filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(adopt_filter, "limit", 1);
|
||||
|
||||
char* adoption_json = nostr_handler_query_json(adopt_filter, 8000);
|
||||
cJSON_Delete(adopt_filter);
|
||||
if (!adoption_json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* adoption_events = cJSON_Parse(adoption_json);
|
||||
free(adoption_json);
|
||||
if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0);
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
if (!list_tags || !cJSON_IsArray(list_tags)) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int tn = cJSON_GetArraySize(list_tags);
|
||||
for (int i = 0; i < tn; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(list_tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) ||
|
||||
!key->valuestring || !val->valuestring || strcmp(key->valuestring, "a") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int kind = 0;
|
||||
char pubkey[65] = {0};
|
||||
char slug[65] = {0};
|
||||
if (parse_address_tag(val->valuestring, &kind, pubkey, slug) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_filter = cJSON_CreateObject();
|
||||
cJSON* sk_kinds = cJSON_CreateArray();
|
||||
cJSON* sk_authors = cJSON_CreateArray();
|
||||
cJSON* d_values = cJSON_CreateArray();
|
||||
if (!skill_filter || !sk_kinds || !sk_authors || !d_values) {
|
||||
cJSON_Delete(skill_filter);
|
||||
cJSON_Delete(sk_kinds);
|
||||
cJSON_Delete(sk_authors);
|
||||
cJSON_Delete(d_values);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(sk_kinds, cJSON_CreateNumber(kind));
|
||||
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
|
||||
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(pubkey));
|
||||
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(slug));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
|
||||
char* skill_json = nostr_handler_query_json(skill_filter, 8000);
|
||||
cJSON_Delete(skill_filter);
|
||||
if (!skill_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
cJSON* enabled = find_tag_value_string(tags, "enabled");
|
||||
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
|
||||
|
||||
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
|
||||
trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM;
|
||||
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
if (trigger_manager_add(mgr, slug, content->valuestring, filter_s, at, is_enabled) == 0) {
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(skill_events);
|
||||
}
|
||||
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from skills", loaded);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int existing = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (existing >= 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_update(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (max_active < 1) max_active = TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
if (mgr->count >= max_active) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: max_active reached (%d)", max_active);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ensure_capacity(mgr, mgr->count + 1) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[mgr->count];
|
||||
memset(t, 0, sizeof(*t));
|
||||
snprintf(t->skill_slug, sizeof(t->skill_slug), "%s", skill_slug);
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
t->last_fired = 0;
|
||||
t->last_seen_created_at = 0;
|
||||
|
||||
mgr->count++;
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger added slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (idx < mgr->count - 1) {
|
||||
memmove(&mgr->triggers[idx],
|
||||
&mgr->triggers[idx + 1],
|
||||
(size_t)(mgr->count - idx - 1) * sizeof(active_trigger_t));
|
||||
}
|
||||
|
||||
mgr->count--;
|
||||
memset(&mgr->triggers[mgr->count], 0, sizeof(active_trigger_t));
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger removed slug=%s", skill_slug);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (action_type != TRIGGER_ACTION_LLM && action_type != TRIGGER_ACTION_TEMPLATE) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_add(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[idx];
|
||||
snprintf(t->skill_content, sizeof(t->skill_content), "%s", content);
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger updated slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int active = 0;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
if (mgr->triggers[i].enabled) {
|
||||
active++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg || !mgr->cfg->triggers.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 2) {
|
||||
return 0;
|
||||
}
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int count = mgr->count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
active_trigger_t snapshot = mgr->triggers[i];
|
||||
if (!snapshot.enabled || snapshot.filter_json[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_Parse(snapshot.filter_json);
|
||||
if (!filter || !cJSON_IsObject(filter)) {
|
||||
cJSON_Delete(filter);
|
||||
continue;
|
||||
}
|
||||
|
||||
time_t since = snapshot.last_seen_created_at > 0 ? snapshot.last_seen_created_at + 1 : now - 30;
|
||||
if (since < 0) since = 0;
|
||||
cJSON_AddNumberToObject(filter, "since", (double)since);
|
||||
cJSON_AddNumberToObject(filter, "limit", 8);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
continue;
|
||||
}
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int e = 0; e < n; e++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, e);
|
||||
if (!ev || !cJSON_IsObject(ev)) continue;
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, snapshot.skill_slug);
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(void)maybe_fire_trigger_locked(mgr, idx, ev, NULL);
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
cJSON* err = cJSON_CreateObject();
|
||||
if (!err) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON_AddBoolToObject(err, "success", 0);
|
||||
cJSON_AddStringToObject(err, "error", "trigger manager unavailable");
|
||||
char* out = cJSON_PrintUnformatted(err);
|
||||
cJSON_Delete(err);
|
||||
return out;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* arr = cJSON_CreateArray();
|
||||
if (!root || !arr) {
|
||||
cJSON_Delete(root);
|
||||
cJSON_Delete(arr);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddNumberToObject(root, "count", mgr->count);
|
||||
|
||||
int active = 0;
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (t->enabled) {
|
||||
active++;
|
||||
}
|
||||
|
||||
cJSON* item = cJSON_CreateObject();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(item, "skill_slug", t->skill_slug);
|
||||
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
|
||||
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
|
||||
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
|
||||
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
|
||||
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
|
||||
cJSON_AddItemToArray(arr, item);
|
||||
}
|
||||
|
||||
cJSON_AddNumberToObject(root, "active", active);
|
||||
cJSON_AddItemToObject(root, "triggers", arr);
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr) {
|
||||
if (!mgr) {
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
free(mgr->triggers);
|
||||
mgr->triggers = NULL;
|
||||
mgr->count = 0;
|
||||
mgr->capacity = 0;
|
||||
mgr->cfg = NULL;
|
||||
mgr->last_poll_at = 0;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
pthread_mutex_destroy(&mgr->mutex);
|
||||
memset(mgr, 0, sizeof(*mgr));
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager cleaned up");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#ifndef DIDACTYL_TRIGGER_MANAGER_H
|
||||
#define DIDACTYL_TRIGGER_MANAGER_H
|
||||
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
|
||||
#define TRIGGER_SKILL_SLUG_MAX 65
|
||||
#define TRIGGER_SKILL_CONTENT_MAX 4096
|
||||
#define TRIGGER_FILTER_JSON_MAX 2048
|
||||
|
||||
typedef enum {
|
||||
TRIGGER_ACTION_LLM = 0,
|
||||
TRIGGER_ACTION_TEMPLATE = 1
|
||||
} trigger_action_type_t;
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[TRIGGER_SKILL_SLUG_MAX];
|
||||
char skill_content[TRIGGER_SKILL_CONTENT_MAX];
|
||||
char filter_json[TRIGGER_FILTER_JSON_MAX];
|
||||
trigger_action_type_t action_type;
|
||||
int enabled;
|
||||
time_t last_fired;
|
||||
time_t last_seen_created_at;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct trigger_manager {
|
||||
active_trigger_t* triggers;
|
||||
int count;
|
||||
int capacity;
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
time_t last_poll_at;
|
||||
} trigger_manager_t;
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
int trigger_manager_poll(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
void trigger_manager_cleanup(trigger_manager_t* mgr);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user