Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb45012495 | ||
|
|
6c78f6e79f | ||
|
|
421e1abb15 | ||
|
|
c93972019c | ||
|
|
f7dd1f98c6 | ||
|
|
93bfd0741d | ||
|
|
ffed72c9fd | ||
|
|
1b9ecb782c | ||
|
|
aec697ad7b |
@@ -53,11 +53,11 @@ Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fa
|
||||
|
||||
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.53
|
||||
## Current Status — v0.0.62
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.53 — Add skill_create trigger schema fields and startup cheerleader triggered skill example
|
||||
> Last release update: v0.0.62 — fix: webhook trigger lookup uses trigger manager and add startup PoC triggers/tests
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
|
||||
+7784
File diff suppressed because it is too large
Load Diff
+41
-2
@@ -190,6 +190,45 @@ Returns the context broken into labeled, individually-sized parts. Useful for un
|
||||
|
||||
---
|
||||
|
||||
### POST /api/trigger/:d_tag
|
||||
|
||||
Fires a webhook-triggered skill by `d_tag`.
|
||||
|
||||
**Path params:**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `d_tag` | yes | Skill `d` tag of a skill configured with `trigger=webhook` |
|
||||
|
||||
**Request body:**
|
||||
|
||||
Optional JSON payload; if present it is passed into the synthetic trigger event as `payload`.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "external-cron",
|
||||
"note": "run maintenance sweep"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"d_tag": "maintenance-sweep",
|
||||
"fired": true
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Returns `404` if no skill exists for `d_tag` or no active trigger is registered.
|
||||
- Returns `400` if the matched skill is not a `webhook` trigger or is disabled.
|
||||
- Trigger execution uses the same trigger cooldown policy as other trigger types.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/prompt/run-simple
|
||||
|
||||
Submit a system prompt and user message for a simple LLM call with no tools. Useful for quick prompt iteration.
|
||||
@@ -477,8 +516,8 @@ The following endpoints are planned but not yet implemented:
|
||||
| GET | `/api/events/soul` | Fetch agent soul event |
|
||||
| PUT | `/api/events/soul` | Update soul content |
|
||||
| GET | `/api/events/skills` | List published skills |
|
||||
| GET | `/api/events/skills/:slug` | Fetch skill by slug |
|
||||
| PUT | `/api/events/skills/:slug` | Update skill |
|
||||
| GET | `/api/events/skills/:d_tag` | Fetch skill by d_tag |
|
||||
| PUT | `/api/events/skills/:d_tag` | Update skill |
|
||||
| GET | `/api/events/adoption` | Fetch adoption list |
|
||||
| GET | `/api/events/profile` | Fetch agent profile |
|
||||
| PUT | `/api/events/profile` | Update agent profile |
|
||||
|
||||
+52
-8
@@ -204,14 +204,14 @@ sequenceDiagram
|
||||
|
||||
## Triggered Skills
|
||||
|
||||
A triggered skill has a Nostr subscription filter attached. When matching events arrive, the skill executes automatically.
|
||||
A triggered skill has a trigger source attached. Didactyl supports `nostr-subscription`, `webhook`, `cron`, and `chain` trigger types.
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type: `nostr-subscription` |
|
||||
| `filter` | Yes | JSON-encoded Nostr subscription filter |
|
||||
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
|
||||
| `filter` | Yes | Type-specific filter (Nostr JSON filter, webhook placeholder payload, cron expression, or source skill `d` tag) |
|
||||
| `action` | No | `template` or `llm` (default: `llm`) |
|
||||
| `enabled` | No | Whether active (default: `true`) |
|
||||
|
||||
@@ -251,6 +251,44 @@ The skill content defines the execution context. The triggering event is availab
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Types
|
||||
|
||||
#### `nostr-subscription`
|
||||
|
||||
`filter` is a JSON-encoded Nostr subscription filter.
|
||||
|
||||
```json
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"]
|
||||
```
|
||||
|
||||
#### `webhook`
|
||||
|
||||
`filter` is required for schema compatibility and can be a simple placeholder like `{}`; webhook firing happens via HTTP.
|
||||
|
||||
```json
|
||||
["trigger", "webhook"],
|
||||
["filter", "{}"]
|
||||
```
|
||||
|
||||
#### `cron`
|
||||
|
||||
`filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`.
|
||||
|
||||
```json
|
||||
["trigger", "cron"],
|
||||
["filter", "0 * * * *"]
|
||||
```
|
||||
|
||||
#### `chain`
|
||||
|
||||
`filter` is the source skill `d` tag to chain from.
|
||||
|
||||
```json
|
||||
["trigger", "chain"],
|
||||
["filter", "source-skill-d-tag"]
|
||||
```
|
||||
|
||||
### Trigger Lifecycle
|
||||
|
||||
```mermaid
|
||||
@@ -264,14 +302,23 @@ flowchart TD
|
||||
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]
|
||||
FIND_TRIGGERS --> REGISTER[Register trigger by type]
|
||||
REGISTER --> NOSTR_SUB[nostr-subscription: create Nostr subscriptions]
|
||||
REGISTER --> CRON_REG[cron: keep expression for poll loop]
|
||||
REGISTER --> WEBHOOK_REG[webhook: route via /api/trigger/:d_tag]
|
||||
REGISTER --> CHAIN_REG[chain: wait for source skill completion]
|
||||
end
|
||||
|
||||
subgraph Execution
|
||||
EVENT_IN[Matching event arrives] --> LOOKUP[Find associated skill]
|
||||
EVENT_IN[Matching Nostr event] --> LOOKUP[Find associated skill]
|
||||
WEBHOOK_IN[POST /api/trigger/:d_tag] --> LOOKUP
|
||||
CRON_TICK[cron poll match] --> LOOKUP
|
||||
LOOKUP --> CHECK_TYPE{Action type?}
|
||||
CHECK_TYPE -->|template| INTERPOLATE[Interpolate + execute prefix]
|
||||
CHECK_TYPE -->|llm| RESOLVE[Resolve LLM + assemble context + run]
|
||||
RESOLVE --> CHAIN_CHECK[Check chain triggers]
|
||||
INTERPOLATE --> CHAIN_CHECK
|
||||
CHAIN_CHECK --> CHAIN_FIRE[Fire matching chain triggers]
|
||||
end
|
||||
|
||||
PUBLISHED --> LOAD_SKILLS
|
||||
@@ -351,9 +398,6 @@ A spelling checker runs with no soul — purely functional, minimal context, che
|
||||
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `cron` triggers | Time-based triggers |
|
||||
| `webhook` triggers | HTTP webhook triggers |
|
||||
| `chain` triggers | Output of one skill triggers another |
|
||||
| Skill composition | Pipeline multiple skills |
|
||||
| Agent-to-agent sharing | Discover and adopt skills across agents |
|
||||
| Trigger marketplace | Popular triggers rise via adoption count |
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Didactyl — Nostr Subscriptions
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl maintains persistent websocket subscriptions to Nostr relays for the lifetime of the process. Subscriptions are opened during startup and are **never closed** — the relay pool keeps them alive, automatically reconnecting and resubscribing when relays drop.
|
||||
|
||||
All subscriptions are created via `nostr_relay_pool_subscribe()` from `nostr_core_lib` and are sent to every relay in the configured relay list.
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
The subscriptions are opened in a specific order during `main()` startup. The diagram below shows the full sequence:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[nostr_handler_init] --> B[Connect to all relays]
|
||||
B --> C[nostr_handler_reconcile_startup_events]
|
||||
C --> D[Publish startup events to relays]
|
||||
D --> E[trigger_manager_init]
|
||||
E --> F[trigger_manager_load_from_startup_events]
|
||||
F --> G["Subscribe: Admin Context"]
|
||||
G --> H["Subscribe: Self Skills"]
|
||||
H --> I[Send startup DM to admin]
|
||||
I --> J["Subscribe: DMs"]
|
||||
J --> K[Enter main poll loop]
|
||||
K --> L["Poll: nostr_handler_poll + trigger_manager_poll + http_api_poll"]
|
||||
|
||||
style F fill:#2a7,stroke:#333,color:#fff
|
||||
style G fill:#27a,stroke:#333,color:#fff
|
||||
style H fill:#27a,stroke:#333,color:#fff
|
||||
style J fill:#27a,stroke:#333,color:#fff
|
||||
```
|
||||
|
||||
## Subscription Categories
|
||||
|
||||
### 1. Admin Context Subscription
|
||||
|
||||
**Function:** `nostr_handler_subscribe_admin_context()` in `src/nostr_handler.c`
|
||||
**When:** During startup, before self-skill subscription
|
||||
**Condition:** Only if `admin_context.enabled` is true in config
|
||||
|
||||
This creates up to two persistent subscriptions for the admin's pubkey:
|
||||
|
||||
#### Profile Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 0 (profile), 3 (contacts), 10002 (relay list) — each configurable |
|
||||
| **Authors** | Admin pubkey |
|
||||
| **Limit** | 32 |
|
||||
| **Callback** | `on_admin_context_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
Tracks the admin's profile metadata, contact list (WoT), and relay preferences. Used to build agent context about who the admin is.
|
||||
|
||||
#### Kind 1 Notes Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 1 |
|
||||
| **Authors** | Admin pubkey |
|
||||
| **Limit** | Configurable via `kind_1_limit`, default 10, max 256 |
|
||||
| **Callback** | `on_admin_context_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | Only if `admin_context.track_kind_1` is true |
|
||||
|
||||
Tracks the admin's recent public notes. Used for agent context and as the event source for triggered skills that watch admin posts.
|
||||
|
||||
### 2. Self-Skill Subscription
|
||||
|
||||
**Function:** `nostr_handler_subscribe_self_skills()` in `src/nostr_handler.c`
|
||||
**When:** During startup, after admin context subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 31123 (public skill), 31124 (private skill), 10123 (adoption list) |
|
||||
| **Authors** | Agent's own pubkey |
|
||||
| **Limit** | 300 |
|
||||
| **Callback** | `on_self_skill_event` |
|
||||
| **EOSE Callback** | `on_self_skill_eose` |
|
||||
| **Dedup** | Disabled (handles dedup internally via cache upsert) |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
This is the core skill awareness subscription. It serves three purposes:
|
||||
|
||||
1. **Cache population** — Every arriving event is stored in the in-memory self-skill cache via `self_skill_cache_upsert_event_locked()`, making skills available for LLM tool calls and context building.
|
||||
|
||||
2. **Live trigger registration** — When a kind 31123 or 31124 event arrives with `trigger=nostr-subscription` and a valid `filter` tag, `register_trigger_from_self_skill_event()` immediately calls `trigger_manager_add()` to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart.
|
||||
|
||||
3. **Deferred bulk load** — After EOSE, the `on_self_skill_eose` callback fires `trigger_manager_load_from_skills()` as a one-time bulk scan of the adoption list. This catches any skills that were already cached before the per-event path was wired up.
|
||||
|
||||
### 3. DM Subscriptions
|
||||
|
||||
**Function:** `nostr_handler_subscribe_dms()` in `src/nostr_handler.c`
|
||||
**When:** During startup, after self-skill subscription and startup DM
|
||||
**Required:** Yes — startup fails if DM subscription cannot be created
|
||||
|
||||
Creates one or two subscriptions depending on the configured DM protocol:
|
||||
|
||||
#### NIP-04 DM Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 4 |
|
||||
| **#p** | Agent's own pubkey |
|
||||
| **Since** | Process start time |
|
||||
| **Limit** | 100 |
|
||||
| **Callback** | `on_event` (routes to `agent_on_message`) |
|
||||
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | `dm_protocol` is `nip04` or `both` |
|
||||
|
||||
#### NIP-17 DM Subscription
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Kinds** | 1059 (gift wrap) |
|
||||
| **#p** | Agent's own pubkey |
|
||||
| **Since** | Process start time |
|
||||
| **Limit** | 400 |
|
||||
| **Callback** | `on_event` (unwraps gift wrap, routes to `agent_on_message`) |
|
||||
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
|
||||
| **Close on EOSE** | No |
|
||||
| **Condition** | `dm_protocol` is `nip17` or `both` |
|
||||
|
||||
### 4. Trigger Subscriptions
|
||||
|
||||
**Function:** `register_trigger_subscription_locked()` in `src/trigger_manager.c`
|
||||
**When:** Dynamically, whenever a trigger is registered via `trigger_manager_add()`
|
||||
**Created by:** `nostr_handler_subscribe_with_filter()` wrapper
|
||||
|
||||
Each active trigger gets its own persistent subscription based on the skill's `filter` tag:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Filter** | Parsed from the skill's `filter` tag JSON |
|
||||
| **Since** | From filter, or defaults to `now - 30s` |
|
||||
| **Limit** | From filter, or defaults to 200 |
|
||||
| **Callback** | `on_trigger_subscription_event` |
|
||||
| **Dedup** | Enabled |
|
||||
| **Close on EOSE** | No |
|
||||
|
||||
When an event matches the filter, `maybe_fire_trigger_locked()` checks cooldown and dedup, then executes the trigger action (LLM or template).
|
||||
|
||||
Trigger subscriptions are created at three points:
|
||||
- **Startup config scan** — `trigger_manager_load_from_startup_events()` parses `startup_events[]` from config for skills with trigger tags
|
||||
- **Live self-skill event** — `register_trigger_from_self_skill_event()` in the self-skill subscription callback
|
||||
- **EOSE bulk load** — `trigger_manager_load_from_skills()` after self-skill EOSE
|
||||
- **Runtime tool call** — `skill_create` tool with trigger parameters
|
||||
|
||||
## Subscription Parameters
|
||||
|
||||
All subscriptions share these common pool parameters:
|
||||
|
||||
| Parameter | Value | Meaning |
|
||||
|-----------|-------|---------|
|
||||
| `close_on_eose` | 0 | Subscription stays open after initial EOSE |
|
||||
| `result_mode` | `NOSTR_POOL_EOSE_FULL_SET` | EOSE fires after all relays respond or timeout |
|
||||
| `relay_timeout_seconds` | 30 | Per-relay timeout for initial response |
|
||||
| `eose_timeout_seconds` | 120 | Overall EOSE timeout across all relays |
|
||||
|
||||
## Subscription Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
INIT["Process Start"] --> CONNECT["Connect Relays"]
|
||||
CONNECT --> SUB["Open Subscriptions"]
|
||||
SUB --> LIVE["Live Event Stream"]
|
||||
LIVE --> |"Relay disconnects"| RECON["Auto-Reconnect"]
|
||||
RECON --> |"Relay reconnects"| RESUB["Auto-Resubscribe"]
|
||||
RESUB --> LIVE
|
||||
LIVE --> |"SIGINT/SIGTERM"| SHUT["Shutdown"]
|
||||
SHUT --> CLOSE["Close All + Cleanup"]
|
||||
```
|
||||
|
||||
Subscriptions are never manually closed during normal operation. The relay pool handles reconnection and resubscription transparently. On shutdown, `trigger_manager_cleanup()` closes trigger subscriptions and `nostr_handler_cleanup()` destroys the pool.
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Subscription | Kinds | Target | Persistent | Created At |
|
||||
|-------------|-------|--------|-----------|------------|
|
||||
| Admin Profile | 0, 3, 10002 | Admin pubkey | Yes | Startup |
|
||||
| Admin Notes | 1 | Admin pubkey | Yes | Startup |
|
||||
| Self Skills | 31123, 31124, 10123 | Own pubkey | Yes | Startup |
|
||||
| DMs NIP-04 | 4 | Own pubkey (#p) | Yes | Startup |
|
||||
| DMs NIP-17 | 1059 | Own pubkey (#p) | Yes | Startup |
|
||||
| Trigger N | Per skill filter | Varies | Yes | Dynamic |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Skills](SKILLS.md) — Skill event format and trigger tags
|
||||
- [Tools](TOOLS.md) — `skill_create` tool with trigger parameters
|
||||
- [API](API.md) — `trigger_list` and `trigger_status` endpoints
|
||||
+3
-3
@@ -75,9 +75,9 @@ All endpoints return JSON. All mutations use POST/PUT/DELETE. All reads use GET.
|
||||
| GET | `/api/events/soul` | Fetch the agent soul event (kind 31120, d=soul) |
|
||||
| PUT | `/api/events/soul` | Update soul content, republish to relays |
|
||||
| GET | `/api/events/skills` | List all published skills (kind 31123/31124 by own pubkey) |
|
||||
| GET | `/api/events/skills/:slug` | Fetch a single skill by slug |
|
||||
| PUT | `/api/events/skills/:slug` | Update skill content/tags, republish |
|
||||
| DELETE | `/api/events/skills/:slug` | Remove skill from adoption list |
|
||||
| GET | `/api/events/skills/:d_tag` | Fetch a single skill by d_tag |
|
||||
| PUT | `/api/events/skills/:d_tag` | Update skill content/tags, republish |
|
||||
| DELETE | `/api/events/skills/:d_tag` | Remove skill from adoption list |
|
||||
| GET | `/api/events/adoption` | Fetch kind 10123 adoption list |
|
||||
| GET | `/api/events/startup` | List startup events from config |
|
||||
| GET | `/api/events/profile` | Fetch agent kind 0 profile |
|
||||
|
||||
@@ -53,7 +53,7 @@ These are not yet implemented but are on the roadmap:
|
||||
| GET | `/api/events/soul` | Agent soul/system prompt event |
|
||||
| PUT | `/api/events/soul` | Update soul content |
|
||||
| GET | `/api/events/skills` | List skills |
|
||||
| GET/PUT | `/api/events/skills/:slug` | Read/update individual skills |
|
||||
| GET/PUT | `/api/events/skills/:d_tag` | Read/update individual skills |
|
||||
| GET | `/api/events/profile` | Agent Nostr profile |
|
||||
| GET | `/api/tools` | List all tool schemas |
|
||||
| POST | `/api/tools/:name/execute` | Execute a tool directly |
|
||||
|
||||
@@ -21,7 +21,7 @@ The entire triggered-skill infrastructure is **already built and functional**:
|
||||
|
||||
### Gap Found
|
||||
|
||||
The `skill_create` tool **schema** (what the LLM sees) only exposes 5 parameters: `slug`, `content`, `scope`, `description`, `auto_adopt`. The execution function already handles `trigger`, `filter`, `action`, and `enabled` — but these are **not declared in the tool schema**, so the LLM will never pass them.
|
||||
The `skill_create` tool **schema** (what the LLM sees) only exposes 5 parameters: `d_tag`, `content`, `scope`, `description`, `auto_adopt`. The execution function already handles `trigger`, `filter`, `action`, and `enabled` — but these are **not declared in the tool schema**, so the LLM will never pass them.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ The agent selects the profile matching the active model, falling back to `defaul
|
||||
│ - skill_remove (remove) │
|
||||
│ │
|
||||
│ Structure per skill: │
|
||||
│ - slug (string) │
|
||||
│ - d_tag (string) │
|
||||
│ - description (string) │
|
||||
│ - content (string, full) │
|
||||
│ - scope (public/private) │
|
||||
|
||||
@@ -34,7 +34,7 @@ NIP: NIP-23
|
||||
Event Kind: 30023
|
||||
Format: The content field must be markdown text...
|
||||
Required Tags:
|
||||
- d: Addressable identifier slug...
|
||||
- d: Addressable identifier d_tag...
|
||||
- title: Human-readable article title
|
||||
- published_at: Unix timestamp as string...
|
||||
Procedure:
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
# Plan: Implement Webhook, Cron, and Chain Trigger Types
|
||||
|
||||
## Overview
|
||||
|
||||
Add three new trigger types to Didactyl's trigger system alongside the existing `nostr-subscription` type. Implementation order: webhook → cron → chain, followed by documentation updates.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Current Flow
|
||||
NS[Nostr Event] --> SUB[Subscription Callback]
|
||||
SUB --> MF[maybe_fire_trigger_locked]
|
||||
MF --> EA{Action Type?}
|
||||
EA -->|template| TA[execute_template_action]
|
||||
EA -->|llm| LA[execute_llm_action]
|
||||
LA --> AOT[agent_on_trigger]
|
||||
end
|
||||
```
|
||||
|
||||
All triggers are implicitly `nostr-subscription` — there is no type discriminator field in `active_trigger_t`.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Trigger Sources
|
||||
NS[Nostr Subscription]
|
||||
WH[Webhook HTTP POST]
|
||||
CR[Cron Timer]
|
||||
CH[Chain - Post-Execution]
|
||||
end
|
||||
|
||||
subgraph Trigger Manager
|
||||
NS --> MF[maybe_fire_trigger]
|
||||
WH --> MF
|
||||
CR --> MF
|
||||
CH --> MF
|
||||
end
|
||||
|
||||
subgraph Dispatch
|
||||
MF --> EA{Action Type?}
|
||||
EA -->|template| TA[execute_template_action]
|
||||
EA -->|llm| LA[execute_llm_action]
|
||||
LA --> AOT[agent_on_trigger]
|
||||
AOT -->|on completion| CHK[Check chain triggers]
|
||||
CHK -->|match| CH
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation — Type System and API Changes
|
||||
|
||||
### 1.1 Add trigger type enum to `trigger_manager.h`
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:15)
|
||||
|
||||
Add a new enum after the existing `trigger_action_type_t` at line 18:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
|
||||
TRIGGER_TYPE_WEBHOOK,
|
||||
TRIGGER_TYPE_CRON,
|
||||
TRIGGER_TYPE_CHAIN
|
||||
} trigger_type_t;
|
||||
```
|
||||
|
||||
### 1.2 Extend `active_trigger_t` struct
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:20)
|
||||
|
||||
Add three new fields to the struct after [`enabled`](src/trigger_manager.h:25):
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type; // discriminator: which trigger source
|
||||
time_t last_cron_fire; // cron: last time this trigger fired
|
||||
char cron_expr[64]; // cron: parsed 5-field cron expression
|
||||
```
|
||||
|
||||
The struct currently has `subscription` and `subscription_ctx` fields which are only relevant for `nostr-subscription` — they'll remain but be NULL for other types.
|
||||
|
||||
### 1.3 Add trigger type string conversion helpers
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:57) — add declarations:
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type_from_string(const char *s);
|
||||
const char* trigger_type_to_string(trigger_type_t t);
|
||||
```
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:20) — add implementations after `clamp_enabled()`:
|
||||
|
||||
```c
|
||||
trigger_type_t trigger_type_from_string(const char *s) {
|
||||
if (!s) return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
if (strcmp(s, "webhook") == 0) return TRIGGER_TYPE_WEBHOOK;
|
||||
if (strcmp(s, "cron") == 0) return TRIGGER_TYPE_CRON;
|
||||
if (strcmp(s, "chain") == 0) return TRIGGER_TYPE_CHAIN;
|
||||
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
const char* trigger_type_to_string(trigger_type_t t) {
|
||||
switch (t) {
|
||||
case TRIGGER_TYPE_WEBHOOK: return "webhook";
|
||||
case TRIGGER_TYPE_CRON: return "cron";
|
||||
case TRIGGER_TYPE_CHAIN: return "chain";
|
||||
default: return "nostr-subscription";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Update `trigger_manager_add()` signature
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:44) and [`src/trigger_manager.c`](src/trigger_manager.c:569)
|
||||
|
||||
Add `const char* trigger_type_str` parameter:
|
||||
|
||||
```c
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str, // NEW
|
||||
int enabled);
|
||||
```
|
||||
|
||||
Inside the function body at [line 604](src/trigger_manager.c:604), after `memset(t, 0, sizeof(*t))` and field assignments:
|
||||
|
||||
```c
|
||||
t->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
|
||||
// For cron triggers, copy the filter as the cron expression
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
t->last_cron_fire = 0;
|
||||
}
|
||||
|
||||
// Only create Nostr subscription for nostr-subscription type
|
||||
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: subscription failed d_tag=%s", skill_d_tag);
|
||||
memset(t, 0, sizeof(*t));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 Update `trigger_manager_update()` signature
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:51) and [`src/trigger_manager.c`](src/trigger_manager.c:661)
|
||||
|
||||
Same pattern — add `const char* trigger_type_str` parameter. Inside the body at [line 683](src/trigger_manager.c:683):
|
||||
|
||||
```c
|
||||
t->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
}
|
||||
|
||||
// Only (re)subscribe for nostr-subscription type
|
||||
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) { ... }
|
||||
} else {
|
||||
// Close any existing subscription if type changed
|
||||
close_trigger_subscription_locked(t);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.6 Update all call sites of `trigger_manager_add()` and `trigger_manager_update()`
|
||||
|
||||
There are 5 call sites that need the new `trigger_type_str` parameter:
|
||||
|
||||
| Call Site | File | Line | Current `trigger_type_str` value |
|
||||
|---|---|---|---|
|
||||
| `trigger_manager_load_from_skills()` | [`src/trigger_manager.c`](src/trigger_manager.c:485) | 485 | `trigger_s` — already extracted from tags |
|
||||
| `trigger_manager_load_from_startup_events()` | [`src/trigger_manager.c`](src/trigger_manager.c:555) | 555 | `trigger_s` — already extracted from tags |
|
||||
| `execute_skill_create()` | [`src/tools.c`](src/tools.c:3678) | 3678 | `trigger_str` — already available |
|
||||
| `execute_skill_edit()` update call | [`src/tools.c`](src/tools.c:4213) | 4213 | `merged_trigger` — already available |
|
||||
| `execute_skill_edit()` add fallback | [`src/tools.c`](src/tools.c:4219) | 4219 | `merged_trigger` — already available |
|
||||
| `trigger_manager_add()` → `trigger_manager_update()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:588) | 588 | Pass through from caller |
|
||||
| `trigger_manager_update()` → `trigger_manager_add()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:680) | 680 | Pass through from caller |
|
||||
|
||||
### 1.7 Update loading functions to accept all trigger types
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:482)
|
||||
|
||||
In `trigger_manager_load_from_skills()`, change the filter at line 482 from:
|
||||
```c
|
||||
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
|
||||
```
|
||||
To:
|
||||
```c
|
||||
if (trigger_s && filter_s && filter_s[0] != '\0' &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0)) {
|
||||
```
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:547)
|
||||
|
||||
Same change in `trigger_manager_load_from_startup_events()` at line 547.
|
||||
|
||||
Note: For `webhook` type, `filter` can be empty/unused since webhooks are triggered by HTTP POST, not by a filter match. Consider allowing empty filter for webhook type. However, keeping the existing requirement that filter must be non-empty is simpler — webhook skills can use `filter: "{}"` as a placeholder.
|
||||
|
||||
### 1.8 Update `trigger_manager_status_json()`
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:761)
|
||||
|
||||
After the existing `cJSON_AddStringToObject(item, "filter_json", ...)` at line 762, add:
|
||||
|
||||
```c
|
||||
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
|
||||
```
|
||||
|
||||
### 1.9 Update `tools.c` validation
|
||||
|
||||
**File:** [`src/tools.c`](src/tools.c:3603)
|
||||
|
||||
In `execute_skill_create()`, change line 3603 from:
|
||||
```c
|
||||
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
|
||||
```
|
||||
To:
|
||||
```c
|
||||
if (trigger_str &&
|
||||
strcmp(trigger_str, "nostr-subscription") != 0 &&
|
||||
strcmp(trigger_str, "webhook") != 0 &&
|
||||
strcmp(trigger_str, "cron") != 0 &&
|
||||
strcmp(trigger_str, "chain") != 0) {
|
||||
```
|
||||
|
||||
Update the error message to list valid types.
|
||||
|
||||
**File:** [`src/tools.c`](src/tools.c:4177)
|
||||
|
||||
In `execute_skill_edit()`, change line 4177 from:
|
||||
```c
|
||||
if (strcmp(merged_trigger, "nostr-subscription") != 0) {
|
||||
```
|
||||
To the same multi-type check.
|
||||
|
||||
Also update the `trigger_manager_add()` and `trigger_manager_update()` calls at [lines 3678](src/tools.c:3678) and [4213-4224](src/tools.c:4213) to pass the trigger type string.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Webhook Trigger
|
||||
|
||||
### 2.1 Add `POST /api/trigger/:d_tag` route handler
|
||||
|
||||
**File:** [`src/http_api.c`](src/http_api.c:1154)
|
||||
|
||||
Add a new handler function before `http_handler()`:
|
||||
|
||||
```c
|
||||
static void handle_trigger_webhook(struct mg_connection* c, struct mg_http_message* hm, const char* d_tag) {
|
||||
if (!g_api_ctx.trigger_manager) {
|
||||
reply_error(c, 503, "trigger manager unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the trigger by d_tag
|
||||
// Need a new function: trigger_manager_find_by_d_tag() that returns a copy
|
||||
active_trigger_t trigger_copy;
|
||||
if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger_copy) != 0) {
|
||||
reply_error(c, 404, "no trigger found for d_tag");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger_copy.trigger_type != TRIGGER_TYPE_WEBHOOK) {
|
||||
reply_error(c, 400, "trigger is not a webhook type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trigger_copy.enabled) {
|
||||
reply_error(c, 400, "trigger is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse optional JSON body as the webhook payload
|
||||
cJSON* payload = parse_body_json(hm);
|
||||
if (!payload) {
|
||||
reply_error(c, 400, "invalid JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build synthetic triggering event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "webhook");
|
||||
cJSON_AddStringToObject(event, "d_tag", d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
|
||||
cJSON_AddItemToObject(event, "payload", payload);
|
||||
|
||||
// Fire the trigger - dispatch based on action type
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
|
||||
agent_on_trigger(trigger_copy.skill_d_tag,
|
||||
trigger_copy.skill_content,
|
||||
event,
|
||||
"webhook");
|
||||
}
|
||||
// Template actions could also be supported here
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
// Return success immediately
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
cJSON_AddStringToObject(root, "d_tag", d_tag);
|
||||
cJSON_AddStringToObject(root, "status", "fired");
|
||||
reply_json(c, 200, root);
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Add route to `http_handler()`
|
||||
|
||||
**File:** [`src/http_api.c`](src/http_api.c:1201)
|
||||
|
||||
Before the 404 fallthrough at line 1206, add:
|
||||
|
||||
```c
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
|
||||
// Extract d_tag from URI: /api/trigger/{d_tag}
|
||||
struct mg_str uri = hm->uri;
|
||||
const char* prefix = "/api/trigger/";
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (uri.len > prefix_len) {
|
||||
char d_tag[TRIGGER_SKILL_D_TAG_MAX];
|
||||
size_t tag_len = uri.len - prefix_len;
|
||||
if (tag_len >= sizeof(d_tag)) tag_len = sizeof(d_tag) - 1;
|
||||
memcpy(d_tag, uri.buf + prefix_len, tag_len);
|
||||
d_tag[tag_len] = '\0';
|
||||
handle_trigger_webhook(c, hm, d_tag);
|
||||
return;
|
||||
}
|
||||
reply_error(c, 400, "missing d_tag in trigger URL");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Add `trigger_manager_find()` function
|
||||
|
||||
**File:** [`src/trigger_manager.h`](src/trigger_manager.h:50) and [`src/trigger_manager.c`](src/trigger_manager.c:631)
|
||||
|
||||
New function to look up a trigger by d_tag and return a copy:
|
||||
|
||||
```c
|
||||
// Declaration
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
|
||||
|
||||
// Implementation
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) {
|
||||
if (!mgr || !skill_d_tag || !out) return -1;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
*out = mgr->triggers[idx];
|
||||
// Clear pointer fields in copy to prevent double-free
|
||||
out->subscription = NULL;
|
||||
out->subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Webhook cooldown
|
||||
|
||||
The webhook handler should respect the same cooldown as other triggers. Either:
|
||||
- Call `maybe_fire_trigger_locked()` from the webhook handler (requires refactoring to expose it), or
|
||||
- Add cooldown checking in the webhook handler using `trigger_copy.last_fired` and updating it via a new `trigger_manager_mark_fired()` function
|
||||
|
||||
Recommended: Add a `trigger_manager_fire()` function that encapsulates the cooldown check and dispatch, usable by both the Nostr subscription callback and the webhook handler. This avoids duplicating cooldown logic.
|
||||
|
||||
```c
|
||||
int trigger_manager_fire(trigger_manager_t* mgr, const char* skill_d_tag, cJSON* event, const char* source);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Cron Trigger
|
||||
|
||||
### 3.1 Cron expression parser
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
|
||||
|
||||
Add a minimal 5-field cron expression matcher. The cron expression format is: `minute hour day-of-month month day-of-week`
|
||||
|
||||
```c
|
||||
// Returns 1 if the cron expression matches the given time, 0 otherwise
|
||||
static int cron_matches(const char* expr, const struct tm* tm);
|
||||
|
||||
// Helper: check if a single field matches a value
|
||||
// Supports: *, specific number, comma-separated list, ranges with -, step with /
|
||||
static int cron_field_matches(const char* field, int value, int min, int max);
|
||||
```
|
||||
|
||||
The parser needs to handle:
|
||||
- `*` — match any
|
||||
- `5` — match exact value
|
||||
- `1,15` — match list
|
||||
- `1-5` — match range
|
||||
- `*/15` — match step
|
||||
- `1-5/2` — match range with step
|
||||
|
||||
This is ~80-100 lines of C. Keep it simple — no named days/months, no special strings like `@hourly`.
|
||||
|
||||
### 3.2 Implement `trigger_manager_poll()` for cron
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:718)
|
||||
|
||||
Replace the no-op `trigger_manager_poll()` with cron checking logic:
|
||||
|
||||
```c
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr) return 0;
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
// Only check once per minute (cron resolution is 1 minute)
|
||||
if (now - mgr->last_poll_at < 60) return 0;
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
struct tm tm_now;
|
||||
localtime_r(&now, &tm_now);
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) continue;
|
||||
if (t->cron_expr[0] == '\0') continue;
|
||||
|
||||
if (!cron_matches(t->cron_expr, &tm_now)) continue;
|
||||
|
||||
// Prevent double-fire within same minute
|
||||
struct tm tm_last;
|
||||
localtime_r(&t->last_cron_fire, &tm_last);
|
||||
if (t->last_cron_fire > 0 &&
|
||||
tm_last.tm_min == tm_now.tm_min &&
|
||||
tm_last.tm_hour == tm_now.tm_hour &&
|
||||
tm_last.tm_mday == tm_now.tm_mday) {
|
||||
continue;
|
||||
}
|
||||
|
||||
t->last_cron_fire = now;
|
||||
t->last_fired = now;
|
||||
|
||||
// Copy trigger data before unlocking
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
// Build synthetic cron event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "cron");
|
||||
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
cJSON_AddStringToObject(event, "cron_expr", trigger_copy.cron_expr);
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, "cron");
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, "cron");
|
||||
}
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
// Re-lock and continue scanning
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Cron expression in skill tags
|
||||
|
||||
For cron triggers, the `filter` tag contains the cron expression instead of a JSON filter:
|
||||
- `["trigger", "cron"]`
|
||||
- `["filter", "0 * * * *"]` — fires every hour at minute 0
|
||||
- `["filter", "*/5 * * * *"]` — fires every 5 minutes
|
||||
|
||||
This reuses the existing `filter` tag semantics — the filter meaning depends on the trigger type.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Chain Trigger
|
||||
|
||||
### 4.1 Post-execution hook in `agent_on_trigger()`
|
||||
|
||||
**File:** [`src/agent.c`](src/agent.c:1913)
|
||||
|
||||
After the tool loop completes at [line 2041](src/agent.c:2041), before cleanup, add a chain trigger check:
|
||||
|
||||
```c
|
||||
// After the tool loop, check for chain triggers
|
||||
// Need access to trigger_manager — use agent_get_trigger_manager() or global
|
||||
trigger_manager_fire_chains(g_trigger_manager, skill_d_tag, messages);
|
||||
```
|
||||
|
||||
This requires:
|
||||
1. The agent needs access to the trigger manager — it already has this via [`agent_set_trigger_manager()`](src/agent.h:12)
|
||||
2. A new function `trigger_manager_fire_chains()` that scans for chain triggers whose filter matches the completed skill's d_tag
|
||||
|
||||
### 4.2 Add `trigger_manager_fire_chains()` function
|
||||
|
||||
**File:** [`src/trigger_manager.c`](src/trigger_manager.c)
|
||||
|
||||
```c
|
||||
void trigger_manager_fire_chains(trigger_manager_t* mgr,
|
||||
const char* source_d_tag,
|
||||
cJSON* source_output) {
|
||||
if (!mgr || !source_d_tag) return;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) continue;
|
||||
|
||||
// For chain triggers, filter_json contains the source skill d_tag
|
||||
if (strcmp(t->filter_json, source_d_tag) != 0) continue;
|
||||
|
||||
// Cooldown check
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg->triggers.cooldown_seconds;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) continue;
|
||||
|
||||
t->last_fired = now;
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
// Build synthetic chain event
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event, "type", "chain");
|
||||
cJSON_AddStringToObject(event, "source_d_tag", source_d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
if (source_output) {
|
||||
cJSON* output_copy = cJSON_Duplicate(source_output, 1);
|
||||
if (output_copy) {
|
||||
cJSON_AddItemToObject(event, "source_output", output_copy);
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
|
||||
execute_llm_action(&trigger_copy, event, "chain");
|
||||
}
|
||||
// Note: template actions could also be supported
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Chain trigger recursion protection
|
||||
|
||||
To prevent infinite chain loops (A → B → A → B → ...), add a chain depth counter:
|
||||
|
||||
- Add a `static __thread int chain_depth = 0;` in `trigger_manager_fire_chains()`
|
||||
- Increment before firing, decrement after
|
||||
- Refuse to fire if depth exceeds a limit (e.g., 5)
|
||||
|
||||
### 4.4 Chain trigger skill tags
|
||||
|
||||
For chain triggers, the `filter` tag contains the source skill's d_tag:
|
||||
- `["trigger", "chain"]`
|
||||
- `["filter", "data-fetcher"]` — fires after `data-fetcher` skill completes
|
||||
|
||||
### 4.5 What to pass as chain context
|
||||
|
||||
The chain trigger's synthetic event should include the source skill's final LLM response. This requires capturing the last assistant message from the tool loop in `agent_on_trigger()`. Currently the function doesn't return any output — it just runs and exits.
|
||||
|
||||
**Approach:** After the tool loop at [line 2041](src/agent.c:2041), extract the last assistant message from `messages` array and pass it to `trigger_manager_fire_chains()`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Documentation
|
||||
|
||||
### 5.1 Update `docs/SKILLS.md`
|
||||
|
||||
**File:** [`docs/SKILLS.md`](docs/SKILLS.md:209)
|
||||
|
||||
Expand the Trigger Tags table to show all four types and their filter semantics:
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
|
||||
| `filter` | Yes | Type-dependent: JSON filter, empty/unused, cron expression, or source d_tag |
|
||||
| `action` | No | `template` or `llm` — default: `llm` |
|
||||
| `enabled` | No | Whether active — default: `true` |
|
||||
|
||||
Add sections for each new trigger type with examples.
|
||||
|
||||
Update the Future Extensions table to mark webhook, cron, and chain as implemented.
|
||||
|
||||
### 5.2 Update `docs/API.md`
|
||||
|
||||
**File:** [`docs/API.md`](docs/API.md:49)
|
||||
|
||||
Add documentation for the new webhook endpoint:
|
||||
|
||||
```
|
||||
### POST /api/trigger/:d_tag
|
||||
|
||||
Fire a webhook trigger by skill d_tag.
|
||||
|
||||
**URL Parameters:**
|
||||
- `d_tag` — The skill's d_tag identifier
|
||||
|
||||
**Request Body:** Optional JSON payload passed as context to the skill
|
||||
|
||||
**Response:**
|
||||
{
|
||||
"success": true,
|
||||
"d_tag": "my-webhook-skill",
|
||||
"status": "fired"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Build and Push
|
||||
|
||||
1. Run `make -j` and fix any compilation warnings/errors
|
||||
2. Test webhook with: `curl -X POST http://localhost:8484/api/trigger/test-skill -d '{"message":"hello"}'`
|
||||
3. Push with `./increment_and_push.sh "feat: add webhook, cron, and chain trigger types"`
|
||||
|
||||
---
|
||||
|
||||
## Files Modified Summary
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| [`src/trigger_manager.h`](src/trigger_manager.h) | Add `trigger_type_t` enum, extend `active_trigger_t`, update function signatures, add new declarations |
|
||||
| [`src/trigger_manager.c`](src/trigger_manager.c) | Type conversion helpers, update load/add/update functions, cron parser, poll implementation, chain fire function, find function |
|
||||
| [`src/tools.c`](src/tools.c) | Update `execute_skill_create()` and `execute_skill_edit()` validation and call sites |
|
||||
| [`src/http_api.c`](src/http_api.c) | Add webhook route handler and route entry |
|
||||
| [`src/agent.c`](src/agent.c) | Add chain trigger post-execution hook in `agent_on_trigger()` |
|
||||
| [`src/agent.h`](src/agent.h) | No changes needed — `agent_on_trigger()` signature unchanged |
|
||||
| [`docs/SKILLS.md`](docs/SKILLS.md) | Document all three new trigger types |
|
||||
| [`docs/API.md`](docs/API.md) | Document webhook endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Add `trigger_type_t` enum to `trigger_manager.h`
|
||||
- [ ] Extend `active_trigger_t` struct with `trigger_type`, `last_cron_fire`, `cron_expr`
|
||||
- [ ] Add `trigger_type_from_string()` and `trigger_type_to_string()` declarations and implementations
|
||||
- [ ] Add `trigger_manager_find()` declaration and implementation
|
||||
- [ ] Add `trigger_manager_fire_chains()` declaration and implementation
|
||||
- [ ] Update `trigger_manager_add()` signature with `trigger_type_str` parameter
|
||||
- [ ] Update `trigger_manager_update()` signature with `trigger_type_str` parameter
|
||||
- [ ] Update `trigger_manager_add()` body: set trigger_type, conditional subscription registration
|
||||
- [ ] Update `trigger_manager_update()` body: set trigger_type, conditional subscription
|
||||
- [ ] Update `trigger_manager_load_from_skills()` to accept all 4 trigger types
|
||||
- [ ] Update `trigger_manager_load_from_startup_events()` to accept all 4 trigger types
|
||||
- [ ] Update `trigger_manager_status_json()` to include type field
|
||||
- [ ] Update `execute_skill_create()` validation at line 3603
|
||||
- [ ] Update `execute_skill_create()` `trigger_manager_add()` call at line 3678
|
||||
- [ ] Update `execute_skill_edit()` validation at line 4177
|
||||
- [ ] Update `execute_skill_edit()` `trigger_manager_update()` and `trigger_manager_add()` calls at lines 4213-4224
|
||||
- [ ] Add `handle_trigger_webhook()` handler to `http_api.c`
|
||||
- [ ] Add webhook route to `http_handler()` in `http_api.c`
|
||||
- [ ] Implement `cron_field_matches()` and `cron_matches()` in `trigger_manager.c`
|
||||
- [ ] Implement cron polling in `trigger_manager_poll()`
|
||||
- [ ] Add chain trigger post-execution hook in `agent_on_trigger()`
|
||||
- [ ] Add chain depth recursion protection
|
||||
- [ ] Update `docs/SKILLS.md` with all three new trigger types
|
||||
- [ ] Update `docs/API.md` with webhook endpoint
|
||||
- [ ] Build with `make -j` and verify clean compilation
|
||||
- [ ] Push with `./increment_and_push.sh`
|
||||
+30
-30
@@ -12,12 +12,12 @@ Skills are Nostr events. The tools are thin orchestration wrappers over the exis
|
||||
|
||||
| Kind | Purpose | Replaceable? | Key tag |
|
||||
|---|---|---|---|
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<slug>` |
|
||||
| `31123` | Public skill definition | Yes (d-tag) | `d=<d_tag>` |
|
||||
| `31124` | Private skill definition | Yes (d-tag) | `d=<d_tag>` |
|
||||
| `10123` | Public skill adoption list | Yes (replaceable) | `a` refs to 31123 events |
|
||||
|
||||
All skill events carry these standard tags:
|
||||
- `["d", "<slug>"]` — unique identifier within the author's pubkey
|
||||
- `["d", "<d_tag>"]` — unique identifier within the author's pubkey
|
||||
- `["app", "didactyl"]` — app namespace
|
||||
- `["scope", "public"]` or `["scope", "private"]`
|
||||
|
||||
@@ -38,37 +38,37 @@ All skill events carry these standard tags:
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"d_tag": { "type": "string", "description": "Unique skill identifier (lowercase, hyphens allowed)" },
|
||||
"content": { "type": "string", "description": "Skill body — markdown instructions or structured JSON" },
|
||||
"scope": { "type": "string", "description": "public (kind 31123) or private (kind 31124). Default: public" },
|
||||
"description": { "type": "string", "description": "Short one-line description for the skill" },
|
||||
"auto_adopt": { "type": "boolean", "description": "Automatically add to adoption list (kind 10123). Default: true" }
|
||||
},
|
||||
"required": ["slug", "content"]
|
||||
"required": ["d_tag", "content"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_create`):**
|
||||
|
||||
1. Validate `slug` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
1. Validate `d_tag` — must be non-empty, lowercase alphanumeric + hyphens, no spaces
|
||||
2. Determine kind: `31123` if scope is `"public"` or absent; `31124` if `"private"`
|
||||
3. Build tags array:
|
||||
- `["d", slug]`
|
||||
- `["d", d_tag]`
|
||||
- `["app", "didactyl"]`
|
||||
- `["scope", scope]`
|
||||
- `["description", description]` if provided
|
||||
4. Call `nostr_handler_publish_kind_event(kind, content, tags, &result)`
|
||||
5. If `auto_adopt` is true (default), update the kind `10123` adoption list:
|
||||
- Query existing `10123` event for own pubkey (same pattern as `execute_nostr_list_manage`)
|
||||
- Add `["a", "31123:<own_pubkey>:<slug>"]` tag if not already present
|
||||
- Add `["a", "31123:<own_pubkey>:<d_tag>"]` tag if not already present
|
||||
- Republish the updated `10123` event
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `slug`, `adopted`
|
||||
6. Return JSON with `success`, `event_id`, `naddr_uri`, `d_tag`, `adopted`
|
||||
|
||||
**Key design decisions:**
|
||||
- Auto-adopt defaults to `true` — creating a skill you don't adopt is unusual
|
||||
- Private skills (31124) are NOT added to the public adoption list (10123)
|
||||
- Republishing with the same slug replaces the previous version (replaceable event)
|
||||
- Republishing with the same d_tag replaces the previous version (replaceable event)
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ All skill events carry these standard tags:
|
||||
- Private only: `{"kinds": [31124], "authors": [own_pubkey]}`
|
||||
2. Call `nostr_handler_query_json(filter, 8000)`
|
||||
3. Parse results, extract for each event:
|
||||
- `slug` (from d-tag)
|
||||
- `d_tag` (from d-tag)
|
||||
- `kind`
|
||||
- `scope` (from scope tag)
|
||||
- `description` (from description tag, if present)
|
||||
@@ -123,21 +123,21 @@ All skill events carry these standard tags:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author" },
|
||||
"slug": { "type": "string", "description": "Skill slug (d-tag value)" },
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag (d-tag value)" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["pubkey", "slug"]
|
||||
"required": ["pubkey", "d_tag"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Execution logic (`execute_skill_adopt`):**
|
||||
|
||||
1. Validate pubkey (64-char hex) and slug (non-empty)
|
||||
1. Validate pubkey (64-char hex) and d_tag (non-empty)
|
||||
2. Default kind to 31123 if not provided
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Check if `["a", "<kind>:<pubkey>:<slug>"]` already exists — if so, return success with `already_adopted: true`
|
||||
5. Check if `["a", "<kind>:<pubkey>:<d_tag>"]` already exists — if so, return success with `already_adopted: true`
|
||||
6. Add the tag, republish `10123`
|
||||
7. Return JSON with `success`, `adopted_address`, `event_id`
|
||||
|
||||
@@ -157,10 +157,10 @@ All skill events carry these standard tags:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey of the skill author. Defaults to own pubkey." },
|
||||
"slug": { "type": "string", "description": "Skill slug to remove" },
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag to remove" },
|
||||
"kind": { "type": "integer", "description": "Skill kind (31123 or 31124). Default: 31123" }
|
||||
},
|
||||
"required": ["slug"]
|
||||
"required": ["d_tag"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -169,7 +169,7 @@ All skill events carry these standard tags:
|
||||
|
||||
1. Default pubkey to own pubkey if not provided
|
||||
2. Default kind to 31123
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<slug>"`
|
||||
3. Build the `a`-tag value: `"<kind>:<pubkey>:<d_tag>"`
|
||||
4. Query existing kind `10123` event for own pubkey
|
||||
5. Find and remove matching `["a", ...]` tag
|
||||
6. Republish `10123`
|
||||
@@ -211,7 +211,7 @@ All skill events carry these standard tags:
|
||||
- Return skill summaries
|
||||
3. If `query` is provided (keyword search):
|
||||
- Query `{"kinds": [31123]}` with limit
|
||||
- Filter results client-side by matching `query` against slug, description tag, or content
|
||||
- Filter results client-side by matching `query` against d_tag, description tag, or content
|
||||
- Return matching skill summaries
|
||||
4. Default (no params): return own adopted skills from `10123`
|
||||
|
||||
@@ -236,7 +236,7 @@ This is essentially the same pattern already used in `execute_nostr_list_manage(
|
||||
### Shared helper: skill summary extraction
|
||||
|
||||
```c
|
||||
// Extract slug, kind, scope, description, created_at from a skill event JSON
|
||||
// Extract d_tag, kind, scope, description, created_at from a skill event JSON
|
||||
static cJSON* extract_skill_summary(cJSON* event);
|
||||
```
|
||||
|
||||
@@ -269,7 +269,7 @@ flowchart TD
|
||||
1. **Schema registration** — Add 5 new tool definitions in `tools_build_openai_schema_json()` (t22–t26)
|
||||
2. **Execution functions** — Add 5 new `execute_skill_*()` static functions
|
||||
3. **Dispatch** — Add 5 new `strcmp` branches in `tools_execute()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_slug()`
|
||||
4. **Shared helpers** — Add `fetch_adoption_list_tags()`, `publish_adoption_list()`, `extract_skill_summary()`, and `validate_skill_d_tag()`
|
||||
|
||||
### `README.md`
|
||||
|
||||
@@ -284,19 +284,19 @@ flowchart TD
|
||||
|
||||
## Slug Validation Rules
|
||||
|
||||
A valid skill slug must:
|
||||
A valid skill d_tag must:
|
||||
- Be 1–64 characters
|
||||
- Contain only lowercase letters, digits, and hyphens
|
||||
- Not start or end with a hyphen
|
||||
- Not contain consecutive hyphens
|
||||
|
||||
```c
|
||||
static int validate_skill_slug(const char* slug) {
|
||||
if (!slug || slug[0] == '\0' || strlen(slug) > 64) return 0;
|
||||
if (slug[0] == '-') return 0;
|
||||
static int validate_skill_d_tag(const char* d_tag) {
|
||||
if (!d_tag || d_tag[0] == '\0' || strlen(d_tag) > 64) return 0;
|
||||
if (d_tag[0] == '-') return 0;
|
||||
int prev_dash = 0;
|
||||
for (size_t i = 0; slug[i]; i++) {
|
||||
char c = slug[i];
|
||||
for (size_t i = 0; d_tag[i]; i++) {
|
||||
char c = d_tag[i];
|
||||
if (c == '-') {
|
||||
if (prev_dash) return 0;
|
||||
prev_dash = 1;
|
||||
@@ -306,7 +306,7 @@ static int validate_skill_slug(const char* slug) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (slug[strlen(slug) - 1] == '-') return 0;
|
||||
if (d_tag[strlen(d_tag) - 1] == '-') return 0;
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
@@ -315,7 +315,7 @@ static int validate_skill_slug(const char* slug) {
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Shared helpers** — `validate_skill_slug`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
1. **Shared helpers** — `validate_skill_d_tag`, `fetch_adoption_list_tags`, `publish_adoption_list`, `extract_skill_summary`
|
||||
2. **`skill_create`** — most important tool, enables the agent to author skills
|
||||
3. **`skill_list`** — lets the agent see what it has published
|
||||
4. **`skill_adopt`** — adopt skills from other authors
|
||||
|
||||
@@ -19,7 +19,7 @@ A skill is a Nostr event (kind `31123` for public, `31124` for private) that def
|
||||
- **Parameters** like temperature and max_tokens
|
||||
- A **description** for human display
|
||||
|
||||
Skills are replaceable events keyed by their `d` tag (the slug). Publishing a new event with the same `d` tag replaces the previous version.
|
||||
Skills are replaceable events keyed by their `d` tag (the d_tag). Publishing a new event with the same `d` tag replaces the previous version.
|
||||
|
||||
### Skill Event Structure
|
||||
|
||||
@@ -206,7 +206,7 @@ To find skills by a specific author:
|
||||
1. Receive the event from the relay
|
||||
2. Parse `event.content` as JSON to get the skill definition object
|
||||
3. Extract `description`, `template`, `temperature`, `max_tokens`
|
||||
4. Extract the `d` tag value from `event.tags` as the skill slug
|
||||
4. Extract the `d` tag value from `event.tags` as the skill d_tag
|
||||
5. Extract the `description` tag from `event.tags` as a display label
|
||||
6. The skill is ready to display and execute
|
||||
|
||||
@@ -261,7 +261,7 @@ To find skills by a specific author:
|
||||
|
||||
- **Skills list**: Radio buttons or selectable cards, one skill selected at a time
|
||||
- Each skill card shows:
|
||||
- Skill slug (the `d` tag)
|
||||
- Skill d_tag (the `d` tag)
|
||||
- Description (from the `description` tag or content field)
|
||||
- Author pubkey (truncated, e.g., `52a3e8...0acb8`)
|
||||
- **Run Selected Skill** button: executes the selected skill against the current text
|
||||
@@ -398,7 +398,7 @@ sequenceDiagram
|
||||
**Building the event:**
|
||||
|
||||
```javascript
|
||||
function buildSkillEvent(slug, description, template, temperature, maxTokens) {
|
||||
function buildSkillEvent(d_tag, description, template, temperature, maxTokens) {
|
||||
const content = JSON.stringify({
|
||||
description: description,
|
||||
context_mode: 'full',
|
||||
@@ -413,7 +413,7 @@ function buildSkillEvent(slug, description, template, temperature, maxTokens) {
|
||||
kind: 31123,
|
||||
content: content,
|
||||
tags: [
|
||||
['d', slug],
|
||||
['d', d_tag],
|
||||
['app', 'didactyl'],
|
||||
['scope', 'public'],
|
||||
['description', description],
|
||||
|
||||
+14
-14
@@ -62,13 +62,13 @@ Today, skills are passive — they're injected into the LLM context via [`append
|
||||
#### A. `/run` slash command (admin direct invocation)
|
||||
|
||||
```
|
||||
/run deploy-website staging # run own adopted skill by slug
|
||||
/run deploy-website staging # run own adopted skill by d_tag
|
||||
/run 31123:<pubkey>:deploy-website staging # run anyone's skill by address
|
||||
/run deploy-website {"target": "production"} # JSON args
|
||||
```
|
||||
|
||||
Parsing:
|
||||
1. First token after `/run` is the skill identifier (slug or `kind:pubkey:slug` address)
|
||||
1. First token after `/run` is the skill identifier (d_tag or `kind:pubkey:d_tag` address)
|
||||
2. Everything after is args (try JSON first, fall back to `{"input": "plain text"}`)
|
||||
|
||||
#### B. `skill_run` tool (LLM-mediated invocation)
|
||||
@@ -78,13 +78,13 @@ The LLM can invoke skills on behalf of the admin during conversation:
|
||||
```json
|
||||
{
|
||||
"name": "skill_run",
|
||||
"description": "Fetch and execute a skill one-shot without adopting it. Works with own adopted skills by slug or any public skill by address.",
|
||||
"description": "Fetch and execute a skill one-shot without adopting it. Works with own adopted skills by d_tag or any public skill by address.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Skill slug for own adopted skills" },
|
||||
"address": { "type": "string", "description": "Full skill address: kind:pubkey:slug" },
|
||||
"pubkey": { "type": "string", "description": "Author pubkey, used with slug to form address" },
|
||||
"d_tag": { "type": "string", "description": "Skill d_tag for own adopted skills" },
|
||||
"address": { "type": "string", "description": "Full skill address: kind:pubkey:d_tag" },
|
||||
"pubkey": { "type": "string", "description": "Author pubkey, used with d_tag to form address" },
|
||||
"args": { "type": "string", "description": "Arguments or context to pass to the skill" },
|
||||
"sandbox": { "type": "boolean", "description": "Override sandbox setting. Default: true for external, false for own skills" }
|
||||
}
|
||||
@@ -104,8 +104,8 @@ Both `/run` and `skill_run` use the same underlying executor:
|
||||
```mermaid
|
||||
graph TD
|
||||
A[/run or skill_run called] --> B{Skill identifier type?}
|
||||
B -->|slug only| C[Look up in adopted skills cache]
|
||||
B -->|address or pubkey+slug| D[Fetch skill event from Nostr]
|
||||
B -->|d_tag only| C[Look up in adopted skills cache]
|
||||
B -->|address or pubkey+d_tag| D[Fetch skill event from Nostr]
|
||||
C --> E{Found?}
|
||||
D --> E
|
||||
E -->|No| F[Return error: skill not found]
|
||||
@@ -133,7 +133,7 @@ Skill execution context:
|
||||
- The user's arguments provide the context for this execution.
|
||||
- Keep output concise and actionable.
|
||||
|
||||
Skill slug: deploy-website
|
||||
Skill d_tag: deploy-website
|
||||
Skill address: 31123:<pubkey>:deploy-website
|
||||
Skill source: [own | external:<author_display_name>]
|
||||
|
||||
@@ -175,7 +175,7 @@ graph TD
|
||||
A[Friend creates skill] -->|kind 31123| B[Published on Nostr relays]
|
||||
B --> C{How do you find it?}
|
||||
C -->|skill_search popular:true| D[Discovery via WoT adoption lists]
|
||||
C -->|Friend tells you the slug| E[Direct reference]
|
||||
C -->|Friend tells you the d_tag| E[Direct reference]
|
||||
C -->|skill_search pubkey:friend| F[Browse friends skills]
|
||||
D --> G[skill_run - try it once, sandboxed]
|
||||
E --> G
|
||||
@@ -185,7 +185,7 @@ graph TD
|
||||
H -->|No| J[Done - nothing persisted]
|
||||
I --> K[Shows in adopted skills context]
|
||||
K --> L[Agent uses it automatically]
|
||||
L -->|Or invoke directly| M[/run skill-slug args]
|
||||
L -->|Or invoke directly| M[/run skill-d_tag args]
|
||||
```
|
||||
|
||||
## 3. Skill-Tool Maturity Levels
|
||||
@@ -255,7 +255,7 @@ steps:
|
||||
args:
|
||||
kind: 30023
|
||||
content: "{{file_content}}"
|
||||
tags: [["d", "{{slug}}"]]
|
||||
tags: [["d", "{{d_tag}}"]]
|
||||
|
||||
# Conditional - simple
|
||||
- if: "{{listing.success}}"
|
||||
@@ -266,7 +266,7 @@ steps:
|
||||
- return: "Failed: {{listing.error}}"
|
||||
|
||||
# Return final result
|
||||
- return: "Published to {{slug}}"
|
||||
- return: "Published to {{d_tag}}"
|
||||
```
|
||||
|
||||
### Variable Substitution
|
||||
@@ -333,7 +333,7 @@ typedef enum {
|
||||
|
||||
| Scenario | Default sandbox | Override |
|
||||
|----------|----------------|---------|
|
||||
| Own adopted skill via `/run slug` | No sandbox | N/A |
|
||||
| Own adopted skill via `/run d_tag` | No sandbox | N/A |
|
||||
| External skill via `/run address` | Sandbox ON | `/run --unsafe address` |
|
||||
| `skill_run` tool, own skill | No sandbox | `sandbox: true` |
|
||||
| `skill_run` tool, external skill | Sandbox ON | `sandbox: false` |
|
||||
|
||||
@@ -21,7 +21,7 @@ Create `src/trigger_manager.c` and `src/trigger_manager.h` — the core componen
|
||||
#define TRIGGER_COOLDOWN_SECONDS 60
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[65];
|
||||
char skill_d_tag[65];
|
||||
char skill_content[4096]; // the action template or LLM prompt
|
||||
int action_type; // 0 = llm, 1 = template
|
||||
char filter_json[2048]; // the Nostr subscription filter
|
||||
@@ -43,11 +43,11 @@ typedef struct {
|
||||
```c
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
int trigger_manager_load_from_skills(trigger_manager_t* mgr);
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_slug,
|
||||
int trigger_manager_add(trigger_manager_t* mgr, const char* skill_d_tag,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_slug,
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
|
||||
int trigger_manager_update(trigger_manager_t* mgr, const char* skill_d_tag,
|
||||
const char* content, const char* filter_json,
|
||||
int action_type, int enabled);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
@@ -115,7 +115,7 @@ When `skill_remove` is called for a triggered skill:
|
||||
The agent currently only handles DM-initiated conversations via `agent_on_message()`. Add a new entry point:
|
||||
|
||||
```c
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
|
||||
+114
-40
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "llm.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "tools.h"
|
||||
#include "prompt_template.h"
|
||||
#include "cjson/cJSON.h"
|
||||
@@ -48,7 +49,7 @@ typedef struct {
|
||||
typedef struct {
|
||||
int kind;
|
||||
char author_pubkey_hex[65];
|
||||
char slug[65];
|
||||
char d_tag[65];
|
||||
char* content;
|
||||
} agent_adopted_skill_t;
|
||||
|
||||
@@ -473,14 +474,14 @@ static char* build_slash_help_all_json(void) {
|
||||
int sn = cJSON_GetArraySize(skills);
|
||||
for (int i = 0; i < sn; i++) {
|
||||
cJSON* row = cJSON_GetArrayItem(skills, i);
|
||||
cJSON* slug = row ? cJSON_GetObjectItemCaseSensitive(row, "slug") : NULL;
|
||||
cJSON* d_tag = row ? cJSON_GetObjectItemCaseSensitive(row, "d_tag") : NULL;
|
||||
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
|
||||
const char* slug_s = (slug && cJSON_IsString(slug) && slug->valuestring) ? slug->valuestring : NULL;
|
||||
const char* d_tag_s = (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring) ? d_tag->valuestring : NULL;
|
||||
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
|
||||
if (!slug_s || slug_s[0] == '\0') {
|
||||
if (!d_tag_s || d_tag_s[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (append_textf_local(&out, &cap, &used, "- %s%s%s\n", slug_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
if (append_textf_local(&out, &cap, &used, "- %s%s%s\n", d_tag_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
cJSON_Delete(skill_root);
|
||||
free(skill_list_json);
|
||||
free(out);
|
||||
@@ -1449,8 +1450,8 @@ static cJSON* find_tag_value_string_local(cJSON* tags, const char* key) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_skill_address_tag_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_slug[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey_hex || !out_slug) {
|
||||
static int parse_skill_address_tag_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_d_tag[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey_hex || !out_d_tag) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -1478,11 +1479,11 @@ static int parse_skill_address_tag_local(const char* addr, int* out_kind, char o
|
||||
memcpy(out_pubkey_hex, p1 + 1, 64U);
|
||||
out_pubkey_hex[64] = '\0';
|
||||
|
||||
size_t slug_len = strlen(p2 + 1);
|
||||
if (slug_len == 0 || slug_len >= 65U) {
|
||||
size_t d_tag_len = strlen(p2 + 1);
|
||||
if (d_tag_len == 0 || d_tag_len >= 65U) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out_slug, p2 + 1, slug_len + 1U);
|
||||
memcpy(out_d_tag, p2 + 1, d_tag_len + 1U);
|
||||
|
||||
*out_kind = kind;
|
||||
return 0;
|
||||
@@ -1494,7 +1495,7 @@ static void clear_adopted_skills_cache_locked(void) {
|
||||
g_adopted_skills[i].content = NULL;
|
||||
g_adopted_skills[i].kind = 0;
|
||||
g_adopted_skills[i].author_pubkey_hex[0] = '\0';
|
||||
g_adopted_skills[i].slug[0] = '\0';
|
||||
g_adopted_skills[i].d_tag[0] = '\0';
|
||||
}
|
||||
g_adopted_skills_count = 0;
|
||||
}
|
||||
@@ -1543,8 +1544,8 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
|
||||
int skill_kind = 0;
|
||||
char skill_author[65] = {0};
|
||||
char skill_slug[65] = {0};
|
||||
if (parse_skill_address_tag_local(val->valuestring, &skill_kind, skill_author, skill_slug) != 0) {
|
||||
char skill_d_tag[65] = {0};
|
||||
if (parse_skill_address_tag_local(val->valuestring, &skill_kind, skill_author, skill_d_tag) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1566,7 +1567,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
|
||||
strcmp(ev_pubkey->valuestring, skill_author) != 0 ||
|
||||
!ev_d || !cJSON_IsString(ev_d) || !ev_d->valuestring ||
|
||||
strcmp(ev_d->valuestring, skill_slug) != 0) {
|
||||
strcmp(ev_d->valuestring, skill_d_tag) != 0) {
|
||||
continue;
|
||||
}
|
||||
cJSON* dup = cJSON_Duplicate(ev, 1);
|
||||
@@ -1595,7 +1596,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
|
||||
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(skill_author));
|
||||
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(skill_slug));
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(skill_d_tag));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
|
||||
@@ -1632,7 +1633,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
|
||||
tmp[tmp_count].kind = skill_kind;
|
||||
snprintf(tmp[tmp_count].author_pubkey_hex, sizeof(tmp[tmp_count].author_pubkey_hex), "%s", skill_author);
|
||||
snprintf(tmp[tmp_count].slug, sizeof(tmp[tmp_count].slug), "%s", skill_slug);
|
||||
snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", skill_d_tag);
|
||||
tmp[tmp_count].content = strdup(content->valuestring);
|
||||
if (tmp[tmp_count].content) {
|
||||
tmp_count++;
|
||||
@@ -1652,17 +1653,17 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
continue;
|
||||
}
|
||||
|
||||
char slug[65] = {0};
|
||||
char d_tag_val[65] = {0};
|
||||
if (se->tags_json) {
|
||||
cJSON* tags = cJSON_Parse(se->tags_json);
|
||||
cJSON* d_tag = tags ? find_tag_value_string_local(tags, "d") : NULL;
|
||||
if (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring && d_tag->valuestring[0] != '\0') {
|
||||
snprintf(slug, sizeof(slug), "%s", d_tag->valuestring);
|
||||
snprintf(d_tag_val, sizeof(d_tag_val), "%s", d_tag->valuestring);
|
||||
}
|
||||
cJSON_Delete(tags);
|
||||
}
|
||||
if (slug[0] == '\0') {
|
||||
snprintf(slug, sizeof(slug), "startup-skill-%d", i + 1);
|
||||
if (d_tag_val[0] == '\0') {
|
||||
snprintf(d_tag_val, sizeof(d_tag_val), "startup-skill-%d", i + 1);
|
||||
}
|
||||
|
||||
tmp[tmp_count].kind = se->kind;
|
||||
@@ -1672,7 +1673,7 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
g_cfg->keys.public_key_hex[0] != '\0'
|
||||
? g_cfg->keys.public_key_hex
|
||||
: "unknown");
|
||||
snprintf(tmp[tmp_count].slug, sizeof(tmp[tmp_count].slug), "%s", slug);
|
||||
snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", d_tag_val);
|
||||
tmp[tmp_count].content = strdup(se->content);
|
||||
if (tmp[tmp_count].content) {
|
||||
tmp_count++;
|
||||
@@ -1803,11 +1804,11 @@ static int append_adopted_skills_context(cJSON* messages) {
|
||||
|
||||
int head_n = snprintf(payload + used,
|
||||
capacity - used,
|
||||
"\n\n---\n\n### Skill\nSkill slug: %s\nSkill address: %d:%s:%s\n\nInstructions:\n",
|
||||
g_adopted_skills[i].slug,
|
||||
"\n\n---\n\n### Skill\nSkill d_tag: %s\nSkill address: %d:%s:%s\n\nInstructions:\n",
|
||||
g_adopted_skills[i].d_tag,
|
||||
g_adopted_skills[i].kind,
|
||||
g_adopted_skills[i].author_pubkey_hex,
|
||||
g_adopted_skills[i].slug);
|
||||
g_adopted_skills[i].d_tag);
|
||||
if (head_n < 0 || (size_t)head_n >= (capacity - used)) {
|
||||
break;
|
||||
}
|
||||
@@ -1910,11 +1911,11 @@ void agent_set_trigger_manager(struct trigger_manager* trigger_manager) {
|
||||
g_tools_ctx.trigger_manager = trigger_manager;
|
||||
}
|
||||
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url) {
|
||||
if (!g_cfg || !g_system_context || !skill_slug || !skill_content || !triggering_event) {
|
||||
if (!g_cfg || !g_system_context || !skill_d_tag || !skill_content || !triggering_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1931,9 +1932,9 @@ void agent_on_trigger(const char* skill_slug,
|
||||
"- 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: ";
|
||||
"Skill d_tag: ";
|
||||
|
||||
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_slug) +
|
||||
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_d_tag) +
|
||||
strlen("\nRelay: ") + strlen(relay) + strlen("\n\nSkill instructions:\n") +
|
||||
strlen(skill_content) + 1U;
|
||||
char* system_prompt = (char*)malloc(system_len);
|
||||
@@ -1947,7 +1948,7 @@ void agent_on_trigger(const char* skill_slug,
|
||||
"%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
|
||||
g_system_context,
|
||||
trigger_prefix,
|
||||
skill_slug,
|
||||
skill_d_tag,
|
||||
relay,
|
||||
skill_content);
|
||||
|
||||
@@ -1962,20 +1963,93 @@ void agent_on_trigger(const char* skill_slug,
|
||||
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_auto(g_cfg->admin.pubkey, fallback);
|
||||
char* tools_json = tools_build_openai_schema_json(&g_tools_ctx);
|
||||
if (!tools_json) {
|
||||
free(system_prompt);
|
||||
free(user_prompt);
|
||||
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: tools unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, response);
|
||||
free(response);
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
if (!messages || !cJSON_IsArray(messages) ||
|
||||
append_simple_message(messages, "system", system_prompt) != 0 ||
|
||||
append_simple_message(messages, "user", user_prompt) != 0) {
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
free(system_prompt);
|
||||
free(user_prompt);
|
||||
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: prompt initialization failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
append_context_log(g_cfg->admin.pubkey, "llm_trigger", user_prompt);
|
||||
free(system_prompt);
|
||||
free(user_prompt);
|
||||
|
||||
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 6;
|
||||
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (!messages_json) {
|
||||
break;
|
||||
}
|
||||
|
||||
append_context_log(g_cfg->admin.pubkey, "llm_trigger_with_tools_messages", messages_json);
|
||||
|
||||
llm_response_t resp;
|
||||
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
|
||||
free(messages_json);
|
||||
if (rc != 0) {
|
||||
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: LLM request failed.");
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.tool_call_count <= 0) {
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
if (append_assistant_tool_calls_message(messages, &resp) != 0) {
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
for (int i = 0; i < resp.tool_call_count; i++) {
|
||||
llm_tool_call_t* tc = &resp.tool_calls[i];
|
||||
char* tool_result = tools_execute(&g_tools_ctx, tc->name, tc->arguments_json);
|
||||
if (!tool_result) {
|
||||
tool_result = strdup("{\"success\":false,\"error\":\"tool execution failed\"}");
|
||||
}
|
||||
|
||||
if (append_tool_result_message(messages,
|
||||
tc->id ? tc->id : "",
|
||||
tool_result ? tool_result : "{\"success\":false,\"error\":\"tool execution failed\"}") != 0) {
|
||||
free(tool_result);
|
||||
llm_response_free(&resp);
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, "Triggered skill execution failed: tool result append failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
free(tool_result);
|
||||
}
|
||||
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
if (g_trigger_manager) {
|
||||
(void)trigger_manager_fire_chains(g_trigger_manager,
|
||||
skill_d_tag,
|
||||
triggering_event,
|
||||
relay_url && relay_url[0] != '\0' ? relay_url : "trigger");
|
||||
}
|
||||
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
}
|
||||
|
||||
int agent_build_admin_messages_json(const char* current_user_message,
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ struct trigger_manager;
|
||||
|
||||
int agent_init(didactyl_config_t* config, const char* system_context);
|
||||
void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
void agent_on_trigger(const char* skill_slug,
|
||||
void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url);
|
||||
|
||||
+5
-9
@@ -548,27 +548,23 @@ static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char* slug = NULL;
|
||||
cJSON* slug_val = find_tag_value_string(tags, "slug");
|
||||
if (slug_val && cJSON_IsString(slug_val) && slug_val->valuestring && slug_val->valuestring[0] != '\0') {
|
||||
slug = slug_val->valuestring;
|
||||
}
|
||||
const char* d_tag = NULL;
|
||||
|
||||
if (!slug) {
|
||||
if (!d_tag) {
|
||||
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(item, "content_fields");
|
||||
if (content_fields && cJSON_IsObject(content_fields)) {
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(content_fields, "name");
|
||||
if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
|
||||
slug = name->valuestring;
|
||||
d_tag = name->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
if (!d_tag) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return set_tag_value_string(tags, "d", slug);
|
||||
return set_tag_value_string(tags, "d", d_tag);
|
||||
}
|
||||
|
||||
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
|
||||
|
||||
+104
-4
@@ -5,6 +5,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "llm.h"
|
||||
@@ -80,6 +81,96 @@ static int estimate_tokens_from_chars(int chars) {
|
||||
return (chars + 3) / 4;
|
||||
}
|
||||
|
||||
static int uri_extract_after_prefix(const struct mg_str* uri,
|
||||
const char* prefix,
|
||||
char* out,
|
||||
size_t out_sz) {
|
||||
if (!uri || !prefix || !out || out_sz == 0) {
|
||||
return -1;
|
||||
}
|
||||
out[0] = '\0';
|
||||
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (uri->len <= prefix_len || strncmp(uri->buf, prefix, prefix_len) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t rem = uri->len - prefix_len;
|
||||
if (rem == 0 || rem >= out_sz) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(out, uri->buf + prefix_len, rem);
|
||||
out[rem] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
static void handle_trigger_webhook(struct mg_connection* c,
|
||||
const struct mg_http_message* hm,
|
||||
const char* d_tag) {
|
||||
if (!g_api_ctx.trigger_manager) {
|
||||
reply_error(c, 503, "trigger manager unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
active_trigger_t trigger;
|
||||
if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger) != 0) {
|
||||
reply_error(c, 404, "active trigger not found for d_tag");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger.trigger_type != TRIGGER_TYPE_WEBHOOK) {
|
||||
reply_error(c, 400, "trigger type is not webhook");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trigger.enabled) {
|
||||
reply_error(c, 400, "webhook trigger is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* payload = parse_body_json(hm);
|
||||
if (!payload || !cJSON_IsObject(payload)) {
|
||||
cJSON_Delete(payload);
|
||||
reply_error(c, 400, "invalid JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
if (!event) {
|
||||
cJSON_Delete(payload);
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(event, "type", "webhook");
|
||||
cJSON_AddStringToObject(event, "skill_d_tag", d_tag);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
|
||||
cJSON_AddItemToObject(event, "payload", payload);
|
||||
|
||||
int fired = trigger_manager_fire(g_api_ctx.trigger_manager, d_tag, event, "webhook");
|
||||
|
||||
cJSON_Delete(event);
|
||||
|
||||
if (fired < 0) {
|
||||
reply_error(c, 404, "active trigger not found for d_tag");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
reply_error(c, 500, "oom");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag);
|
||||
cJSON_AddBoolToObject(out, "fired", fired > 0 ? 1 : 0);
|
||||
reply_json(c, 200, out);
|
||||
cJSON_Delete(out);
|
||||
}
|
||||
|
||||
static char* maybe_model_override_begin(cJSON* body, llm_config_t* out_old_cfg, int* out_overridden) {
|
||||
if (!body || !out_old_cfg || !out_overridden) return NULL;
|
||||
*out_overridden = 0;
|
||||
@@ -524,14 +615,14 @@ static char* build_slash_help_all_json_local(void) {
|
||||
int sn = cJSON_GetArraySize(skills);
|
||||
for (int i = 0; i < sn; i++) {
|
||||
cJSON* row = cJSON_GetArrayItem(skills, i);
|
||||
cJSON* slug = row ? cJSON_GetObjectItemCaseSensitive(row, "slug") : NULL;
|
||||
cJSON* d_tag = row ? cJSON_GetObjectItemCaseSensitive(row, "d_tag") : NULL;
|
||||
cJSON* desc = row ? cJSON_GetObjectItemCaseSensitive(row, "description") : NULL;
|
||||
const char* slug_s = (slug && cJSON_IsString(slug) && slug->valuestring) ? slug->valuestring : NULL;
|
||||
const char* d_tag_s = (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring) ? d_tag->valuestring : NULL;
|
||||
const char* desc_s = (desc && cJSON_IsString(desc) && desc->valuestring) ? desc->valuestring : "";
|
||||
if (!slug_s || slug_s[0] == '\0') {
|
||||
if (!d_tag_s || d_tag_s[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", slug_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
if (append_textf_http(&out, &cap, &used, "- %s%s%s\n", d_tag_s, desc_s[0] ? " — " : "", desc_s) != 0) {
|
||||
cJSON_Delete(skill_root);
|
||||
free(skill_list_json);
|
||||
free(out);
|
||||
@@ -1202,6 +1293,15 @@ static void http_handler(struct mg_connection* c, int ev, void* ev_data) {
|
||||
handle_prompt_compare(c, hm);
|
||||
return;
|
||||
}
|
||||
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
|
||||
char d_tag[TRIGGER_SKILL_D_TAG_MAX] = {0};
|
||||
if (uri_extract_after_prefix(&hm->uri, "/api/trigger/", d_tag, sizeof(d_tag)) != 0) {
|
||||
reply_error(c, 400, "missing or invalid d_tag");
|
||||
return;
|
||||
}
|
||||
handle_trigger_webhook(c, hm, d_tag);
|
||||
return;
|
||||
}
|
||||
|
||||
reply_error(c, 404, "not found");
|
||||
}
|
||||
|
||||
+44
-2
@@ -20,6 +20,37 @@
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
typedef struct {
|
||||
trigger_manager_t* trigger_manager;
|
||||
int loaded_once;
|
||||
} deferred_trigger_load_ctx_t;
|
||||
|
||||
static void on_self_skill_eose_load_triggers(int event_count, void* user_data) {
|
||||
deferred_trigger_load_ctx_t* ctx = (deferred_trigger_load_ctx_t*)user_data;
|
||||
if (!ctx || !ctx->trigger_manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx->loaded_once) {
|
||||
DEBUG_TRACE("[didactyl] deferred trigger load skipped (already loaded once) event_count=%d", event_count);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] deferred trigger load begin after self-skill EOSE (event_count=%d)", event_count);
|
||||
if (trigger_manager_load_from_skills(ctx->trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] deferred trigger load failed after self-skill EOSE");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx->loaded_once = 1;
|
||||
|
||||
char* status = trigger_manager_status_json(ctx->trigger_manager);
|
||||
if (status) {
|
||||
DEBUG_INFO("[didactyl] deferred trigger load status: %s", status);
|
||||
free(status);
|
||||
}
|
||||
}
|
||||
|
||||
static void signal_handler(int signum) {
|
||||
(void)signum;
|
||||
g_running = 0;
|
||||
@@ -231,10 +262,21 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
agent_set_trigger_manager(&trigger_manager);
|
||||
nostr_handler_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)");
|
||||
deferred_trigger_load_ctx_t deferred_trigger_ctx = {
|
||||
.trigger_manager = &trigger_manager,
|
||||
.loaded_once = 0
|
||||
};
|
||||
nostr_handler_set_self_skill_eose_callback(on_self_skill_eose_load_triggers, &deferred_trigger_ctx);
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: deferred trigger load will run after self-skill EOSE");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load begin");
|
||||
if (trigger_manager_load_from_startup_events(&trigger_manager) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: startup-config trigger load failed (continuing)");
|
||||
}
|
||||
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load end");
|
||||
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
|
||||
if (nostr_handler_subscribe_admin_context() != 0) {
|
||||
|
||||
+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 53
|
||||
#define DIDACTYL_VERSION "v0.0.53"
|
||||
#define DIDACTYL_VERSION_PATCH 62
|
||||
#define DIDACTYL_VERSION "v0.0.62"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+137
-1
@@ -11,6 +11,7 @@
|
||||
#include "../../nostr_core_lib/cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "debug.h"
|
||||
#include "trigger_manager.h"
|
||||
|
||||
#define NIP17_MAX_RELAYS 32
|
||||
#define NIP17_MAX_GIFT_WRAPS 8
|
||||
@@ -48,6 +49,11 @@ static pthread_mutex_t g_self_skill_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
#define DM_DEDUP_CACHE_SIZE 256
|
||||
|
||||
static nostr_self_skill_eose_cb_t g_self_skill_eose_cb = NULL;
|
||||
static void* g_self_skill_eose_user_data = NULL;
|
||||
|
||||
static struct trigger_manager* g_trigger_manager = NULL;
|
||||
|
||||
static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65];
|
||||
static int g_seen_dm_count = 0;
|
||||
static int g_seen_dm_next = 0;
|
||||
@@ -266,6 +272,7 @@ static void upsert_kind1_note(time_t created_at, const char* content);
|
||||
static int startup_self_kind1_exists(void);
|
||||
static void load_startup_display_name(void);
|
||||
static void build_startup_kind1_content(char* out, size_t out_size, const char* fallback);
|
||||
static void register_trigger_from_self_skill_event(cJSON* event);
|
||||
static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data);
|
||||
static void dm_history_clear_locked(void);
|
||||
static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at);
|
||||
@@ -442,6 +449,78 @@ static const char* find_tag_value_local(cJSON* tags, const char* key) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_enabled_flag_local(const char* enabled_s) {
|
||||
if (!enabled_s || enabled_s[0] == '\0') {
|
||||
return 1;
|
||||
}
|
||||
return (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
}
|
||||
|
||||
static void register_trigger_from_self_skill_event(cJSON* event) {
|
||||
if (!event || !g_cfg || !g_trigger_manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
|
||||
|
||||
if (!kind || !cJSON_IsNumber(kind) ||
|
||||
!pubkey || !cJSON_IsString(pubkey) || !pubkey->valuestring ||
|
||||
!content || !cJSON_IsString(content) || !content->valuestring ||
|
||||
!tags || !cJSON_IsArray(tags)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int kind_val = (int)kind->valuedouble;
|
||||
if (kind_val != 31123 && kind_val != 31124) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* d_tag = find_tag_value_local(tags, "d");
|
||||
const char* trigger = find_tag_value_local(tags, "trigger");
|
||||
const char* filter = find_tag_value_local(tags, "filter");
|
||||
const char* action = find_tag_value_local(tags, "action");
|
||||
const char* enabled = find_tag_value_local(tags, "enabled");
|
||||
|
||||
int trigger_supported = trigger &&
|
||||
(strcmp(trigger, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger, "webhook") == 0 ||
|
||||
strcmp(trigger, "cron") == 0 ||
|
||||
strcmp(trigger, "chain") == 0);
|
||||
if (!d_tag || d_tag[0] == '\0' ||
|
||||
!trigger_supported ||
|
||||
!filter || filter[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
trigger_action_type_t action_type = (action && strcmp(action, "template") == 0)
|
||||
? TRIGGER_ACTION_TEMPLATE
|
||||
: TRIGGER_ACTION_LLM;
|
||||
int is_enabled = parse_enabled_flag_local(enabled);
|
||||
|
||||
int rc = trigger_manager_add((trigger_manager_t*)g_trigger_manager,
|
||||
d_tag,
|
||||
content->valuestring,
|
||||
filter,
|
||||
action_type,
|
||||
trigger,
|
||||
is_enabled);
|
||||
if (rc == 0) {
|
||||
DEBUG_INFO("[didactyl] live self-skill trigger registered d_tag=%s action=%s enabled=%d",
|
||||
d_tag,
|
||||
action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm",
|
||||
is_enabled);
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] live self-skill trigger register failed d_tag=%s", d_tag);
|
||||
}
|
||||
}
|
||||
|
||||
static void self_skill_cache_upsert_event_locked(cJSON* event) {
|
||||
if (!event || !cJSON_IsObject(event)) {
|
||||
return;
|
||||
@@ -942,6 +1021,16 @@ static void on_eose(cJSON** events, int event_count, void* user_data) {
|
||||
DEBUG_TRACE("[didactyl] DEBUG on_eose called: event_count=%d", event_count);
|
||||
}
|
||||
|
||||
static void on_self_skill_eose(cJSON** events, int event_count, void* user_data) {
|
||||
(void)events;
|
||||
(void)user_data;
|
||||
DEBUG_INFO("[didactyl] self-skill EOSE received: cached skill events=%d", event_count);
|
||||
|
||||
if (g_self_skill_eose_cb) {
|
||||
g_self_skill_eose_cb(event_count, g_self_skill_eose_user_data);
|
||||
}
|
||||
}
|
||||
|
||||
static void free_admin_context_locked(void) {
|
||||
free(g_admin_kind0_json);
|
||||
g_admin_kind0_json = NULL;
|
||||
@@ -1152,6 +1241,8 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
|
||||
return;
|
||||
}
|
||||
|
||||
register_trigger_from_self_skill_event(event);
|
||||
|
||||
pthread_mutex_lock(&g_self_skill_mutex);
|
||||
self_skill_cache_upsert_event_locked(event);
|
||||
pthread_mutex_unlock(&g_self_skill_mutex);
|
||||
@@ -1320,6 +1411,10 @@ int nostr_handler_subscribe_admin_context(void) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager) {
|
||||
g_trigger_manager = trigger_manager;
|
||||
}
|
||||
|
||||
int nostr_handler_subscribe_self_skills(void) {
|
||||
if (!g_cfg || !g_pool) {
|
||||
return -1;
|
||||
@@ -1349,7 +1444,7 @@ int nostr_handler_subscribe_self_skills(void) {
|
||||
g_cfg->relay_count,
|
||||
filter,
|
||||
on_self_skill_event,
|
||||
on_eose,
|
||||
on_self_skill_eose,
|
||||
NULL,
|
||||
0,
|
||||
0,
|
||||
@@ -1493,6 +1588,11 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data) {
|
||||
g_self_skill_eose_cb = callback;
|
||||
g_self_skill_eose_user_data = user_data;
|
||||
}
|
||||
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) {
|
||||
if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message) {
|
||||
return -1;
|
||||
@@ -1755,6 +1855,42 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
|
||||
return out;
|
||||
}
|
||||
|
||||
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
|
||||
cJSON* filter,
|
||||
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
|
||||
void (*on_eose)(cJSON** events, int event_count, void* user_data),
|
||||
void* user_data,
|
||||
int close_on_eose,
|
||||
int enable_deduplication,
|
||||
nostr_pool_eose_result_mode_t result_mode,
|
||||
int relay_timeout_seconds,
|
||||
int eose_timeout_seconds) {
|
||||
if (!g_cfg || !g_pool || !filter || !on_event) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return nostr_relay_pool_subscribe(
|
||||
g_pool,
|
||||
(const char**)g_cfg->relays,
|
||||
g_cfg->relay_count,
|
||||
filter,
|
||||
on_event,
|
||||
on_eose,
|
||||
user_data,
|
||||
close_on_eose,
|
||||
enable_deduplication,
|
||||
result_mode,
|
||||
relay_timeout_seconds,
|
||||
eose_timeout_seconds);
|
||||
}
|
||||
|
||||
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription) {
|
||||
if (!subscription) {
|
||||
return 0;
|
||||
}
|
||||
return nostr_pool_subscription_close(subscription);
|
||||
}
|
||||
|
||||
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason) {
|
||||
if (!g_cfg || !g_pool || !g_startup_publish_tracking_enabled || !g_startup_published) {
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
typedef enum {
|
||||
DIDACTYL_SENDER_ADMIN = 1,
|
||||
@@ -27,16 +30,31 @@ typedef struct {
|
||||
char** relays;
|
||||
} nostr_publish_result_t;
|
||||
|
||||
typedef void (*nostr_self_skill_eose_cb_t)(int event_count, void* user_data);
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config);
|
||||
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager);
|
||||
int nostr_handler_subscribe_admin_context(void);
|
||||
int nostr_handler_subscribe_self_skills(void);
|
||||
char* nostr_handler_get_self_events_by_kind_json(int kind);
|
||||
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data);
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
|
||||
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
|
||||
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
|
||||
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
|
||||
cJSON* filter,
|
||||
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
|
||||
void (*on_eose)(cJSON** events, int event_count, void* user_data),
|
||||
void* user_data,
|
||||
int close_on_eose,
|
||||
int enable_deduplication,
|
||||
nostr_pool_eose_result_mode_t result_mode,
|
||||
int relay_timeout_seconds,
|
||||
int eose_timeout_seconds);
|
||||
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription);
|
||||
int nostr_handler_poll(int timeout_ms);
|
||||
int nostr_handler_reconcile_startup_events(void);
|
||||
void nostr_handler_refresh_relay_statuses(void);
|
||||
|
||||
+405
-48
@@ -21,6 +21,8 @@
|
||||
#include "llm.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
static int validate_skill_d_tag(const char* d_tag);
|
||||
|
||||
static char* json_error(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
@@ -251,6 +253,27 @@ static int add_string_tag(cJSON* tags, const char* key, const char* value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int remove_tag_key_all(cJSON* tags, const char* key) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !key || key[0] == '\0') return 0;
|
||||
int removed = 0;
|
||||
for (int i = cJSON_GetArraySize(tags) - 1; i >= 0; i--) {
|
||||
cJSON* tag = cJSON_GetArrayItem(tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 1) continue;
|
||||
cJSON* k = cJSON_GetArrayItem(tag, 0);
|
||||
if (k && cJSON_IsString(k) && k->valuestring && strcmp(k->valuestring, key) == 0) {
|
||||
cJSON_DeleteItemFromArray(tags, i);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
static int upsert_string_tag(cJSON* tags, const char* key, const char* value) {
|
||||
if (!tags || !cJSON_IsArray(tags) || !key || !value || value[0] == '\0') return -1;
|
||||
remove_tag_key_all(tags, key);
|
||||
return add_string_tag(tags, key, value);
|
||||
}
|
||||
|
||||
static char* trim_copy(const char* start, size_t len) {
|
||||
while (len > 0 && isspace((unsigned char)start[0])) {
|
||||
start++;
|
||||
@@ -431,7 +454,7 @@ static char* first_markdown_image_url(const char* content) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static char* slugify_string(const char* in, const char* fallback) {
|
||||
static char* d_tagify_string(const char* in, const char* fallback) {
|
||||
if (!in || in[0] == '\0') {
|
||||
return fallback ? strdup(fallback) : NULL;
|
||||
}
|
||||
@@ -493,17 +516,17 @@ static void ensure_nip23_metadata_tags(int kind, const char* content, cJSON** ta
|
||||
}
|
||||
|
||||
if (!has_tag_key(tags, "d")) {
|
||||
char* slug = NULL;
|
||||
char* d_tag = NULL;
|
||||
if (title) {
|
||||
slug = slugify_string(title, "long-form-note");
|
||||
d_tag = d_tagify_string(title, "long-form-note");
|
||||
} else {
|
||||
char* derived_title = first_markdown_h1(content);
|
||||
slug = slugify_string(derived_title, "long-form-note");
|
||||
d_tag = d_tagify_string(derived_title, "long-form-note");
|
||||
free(derived_title);
|
||||
}
|
||||
if (slug) {
|
||||
(void)add_string_tag(tags, "d", slug);
|
||||
free(slug);
|
||||
if (d_tag) {
|
||||
(void)add_string_tag(tags, "d", d_tag);
|
||||
free(d_tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,15 +870,15 @@ static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int validate_skill_slug(const char* slug) {
|
||||
if (!slug) return 0;
|
||||
size_t len = strlen(slug);
|
||||
static int validate_skill_d_tag(const char* d_tag) {
|
||||
if (!d_tag) return 0;
|
||||
size_t len = strlen(d_tag);
|
||||
if (len == 0 || len > 64U) return 0;
|
||||
if (slug[0] == '-' || slug[len - 1] == '-') return 0;
|
||||
if (d_tag[0] == '-' || d_tag[len - 1] == '-') return 0;
|
||||
|
||||
int prev_dash = 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
unsigned char c = (unsigned char)slug[i];
|
||||
unsigned char c = (unsigned char)d_tag[i];
|
||||
if (c == '-') {
|
||||
if (prev_dash) return 0;
|
||||
prev_dash = 1;
|
||||
@@ -986,7 +1009,7 @@ static cJSON* extract_skill_summary(cJSON* event) {
|
||||
cJSON* description = find_tag_value_string(tags, "description");
|
||||
|
||||
if (d && cJSON_IsString(d) && d->valuestring) {
|
||||
cJSON_AddStringToObject(summary, "slug", d->valuestring);
|
||||
cJSON_AddStringToObject(summary, "d_tag", d->valuestring);
|
||||
}
|
||||
if (scope && cJSON_IsString(scope) && scope->valuestring) {
|
||||
cJSON_AddStringToObject(summary, "scope", scope->valuestring);
|
||||
@@ -1043,7 +1066,7 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON_AddItemToObject(t1_props, "content", p_content);
|
||||
cJSON* p_tags = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_tags, "type", "array");
|
||||
cJSON_AddStringToObject(p_tags, "description", "Optional Nostr tags array, e.g. [[\"d\",\"slug\"],[\"t\",\"nostr\"]]");
|
||||
cJSON_AddStringToObject(p_tags, "description", "Optional Nostr tags array, e.g. [[\"d\",\"my-skill\"],[\"t\",\"nostr\"]]");
|
||||
cJSON* p_tags_items = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_tags_items, "type", "array");
|
||||
cJSON* p_tag_item = cJSON_CreateObject();
|
||||
@@ -1617,9 +1640,9 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON_AddItemToObject(t22_params, "properties", t22_props);
|
||||
cJSON_AddItemToObject(t22_params, "required", t22_required);
|
||||
|
||||
cJSON* p_skill_create_slug = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_slug, "type", "string");
|
||||
cJSON_AddItemToObject(t22_props, "slug", p_skill_create_slug);
|
||||
cJSON* p_skill_create_d = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_d, "type", "string");
|
||||
cJSON_AddItemToObject(t22_props, "d", p_skill_create_d);
|
||||
cJSON* p_skill_create_content = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_content, "type", "string");
|
||||
cJSON_AddItemToObject(t22_props, "content", p_skill_create_content);
|
||||
@@ -1649,7 +1672,7 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON_AddStringToObject(p_skill_create_enabled, "type", "boolean");
|
||||
cJSON_AddItemToObject(t22_props, "enabled", p_skill_create_enabled);
|
||||
|
||||
cJSON_AddItemToArray(t22_required, cJSON_CreateString("slug"));
|
||||
cJSON_AddItemToArray(t22_required, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(t22_required, cJSON_CreateString("content"));
|
||||
|
||||
cJSON_AddItemToObject(t22_fn, "parameters", t22_params);
|
||||
@@ -1691,15 +1714,15 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON* p_skill_adopt_pubkey = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_adopt_pubkey, "type", "string");
|
||||
cJSON_AddItemToObject(t24_props, "pubkey", p_skill_adopt_pubkey);
|
||||
cJSON* p_skill_adopt_slug = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_adopt_slug, "type", "string");
|
||||
cJSON_AddItemToObject(t24_props, "slug", p_skill_adopt_slug);
|
||||
cJSON* p_skill_adopt_d = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_adopt_d, "type", "string");
|
||||
cJSON_AddItemToObject(t24_props, "d", p_skill_adopt_d);
|
||||
cJSON* p_skill_adopt_kind = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_adopt_kind, "type", "integer");
|
||||
cJSON_AddItemToObject(t24_props, "kind", p_skill_adopt_kind);
|
||||
|
||||
cJSON_AddItemToArray(t24_required, cJSON_CreateString("pubkey"));
|
||||
cJSON_AddItemToArray(t24_required, cJSON_CreateString("slug"));
|
||||
cJSON_AddItemToArray(t24_required, cJSON_CreateString("d"));
|
||||
|
||||
cJSON_AddItemToObject(t24_fn, "parameters", t24_params);
|
||||
cJSON_AddItemToObject(t24, "function", t24_fn);
|
||||
@@ -1721,19 +1744,70 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
|
||||
cJSON* p_skill_remove_pubkey = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_remove_pubkey, "type", "string");
|
||||
cJSON_AddItemToObject(t25_props, "pubkey", p_skill_remove_pubkey);
|
||||
cJSON* p_skill_remove_slug = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_remove_slug, "type", "string");
|
||||
cJSON_AddItemToObject(t25_props, "slug", p_skill_remove_slug);
|
||||
cJSON* p_skill_remove_d = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_remove_d, "type", "string");
|
||||
cJSON_AddItemToObject(t25_props, "d", p_skill_remove_d);
|
||||
cJSON* p_skill_remove_kind = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_remove_kind, "type", "integer");
|
||||
cJSON_AddItemToObject(t25_props, "kind", p_skill_remove_kind);
|
||||
|
||||
cJSON_AddItemToArray(t25_required, cJSON_CreateString("slug"));
|
||||
cJSON_AddItemToArray(t25_required, cJSON_CreateString("d"));
|
||||
|
||||
cJSON_AddItemToObject(t25_fn, "parameters", t25_params);
|
||||
cJSON_AddItemToObject(t25, "function", t25_fn);
|
||||
cJSON_AddItemToArray(tools, t25);
|
||||
|
||||
cJSON* t25b = cJSON_CreateObject();
|
||||
cJSON* t25b_fn = cJSON_CreateObject();
|
||||
cJSON* t25b_params = cJSON_CreateObject();
|
||||
cJSON* t25b_props = cJSON_CreateObject();
|
||||
cJSON* t25b_required = cJSON_CreateArray();
|
||||
|
||||
cJSON_AddStringToObject(t25b, "type", "function");
|
||||
cJSON_AddStringToObject(t25b_fn, "name", "skill_edit");
|
||||
cJSON_AddStringToObject(t25b_fn, "description", "Edit an existing self skill by d tag and republish it as kind 31123/31124");
|
||||
cJSON_AddStringToObject(t25b_params, "type", "object");
|
||||
cJSON_AddItemToObject(t25b_params, "properties", t25b_props);
|
||||
cJSON_AddItemToObject(t25b_params, "required", t25b_required);
|
||||
|
||||
cJSON* p_skill_edit_d = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_d, "type", "string");
|
||||
cJSON_AddItemToObject(t25b_props, "d", p_skill_edit_d);
|
||||
|
||||
cJSON* p_skill_edit_content = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_content, "type", "string");
|
||||
cJSON_AddItemToObject(t25b_props, "content", p_skill_edit_content);
|
||||
|
||||
cJSON* p_skill_edit_scope = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_scope, "type", "string");
|
||||
cJSON_AddItemToObject(t25b_props, "scope", p_skill_edit_scope);
|
||||
|
||||
cJSON* p_skill_edit_desc = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_desc, "type", "string");
|
||||
cJSON_AddItemToObject(t25b_props, "description", p_skill_edit_desc);
|
||||
|
||||
cJSON* p_skill_edit_trigger = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_trigger, "type", "string");
|
||||
cJSON_AddItemToObject(t25b_props, "trigger", p_skill_edit_trigger);
|
||||
|
||||
cJSON* p_skill_edit_filter = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_filter, "type", "string");
|
||||
cJSON_AddItemToObject(t25b_props, "filter", p_skill_edit_filter);
|
||||
|
||||
cJSON* p_skill_edit_action = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_action, "type", "string");
|
||||
cJSON_AddItemToObject(t25b_props, "action", p_skill_edit_action);
|
||||
|
||||
cJSON* p_skill_edit_enabled = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_enabled, "type", "boolean");
|
||||
cJSON_AddItemToObject(t25b_props, "enabled", p_skill_edit_enabled);
|
||||
|
||||
cJSON_AddItemToArray(t25b_required, cJSON_CreateString("d"));
|
||||
|
||||
cJSON_AddItemToObject(t25b_fn, "parameters", t25b_params);
|
||||
cJSON_AddItemToObject(t25b, "function", t25b_fn);
|
||||
cJSON_AddItemToArray(tools, t25b);
|
||||
|
||||
cJSON* t26 = cJSON_CreateObject();
|
||||
cJSON* t26_fn = cJSON_CreateObject();
|
||||
cJSON* t26_params = cJSON_CreateObject();
|
||||
@@ -3435,7 +3509,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
cJSON* args = parse_tool_args_json(args_json);
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* slug = cJSON_GetObjectItemCaseSensitive(args, "slug");
|
||||
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(args, "d");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
|
||||
cJSON* scope = cJSON_GetObjectItemCaseSensitive(args, "scope");
|
||||
cJSON* description = cJSON_GetObjectItemCaseSensitive(args, "description");
|
||||
@@ -3445,14 +3519,14 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
|
||||
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(args, "enabled");
|
||||
|
||||
if (!slug || !cJSON_IsString(slug) || !slug->valuestring ||
|
||||
if (!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring ||
|
||||
!content || !cJSON_IsString(content) || !content->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create requires slug and content strings");
|
||||
return json_error("skill_create requires d and content strings");
|
||||
}
|
||||
if (!validate_skill_slug(slug->valuestring)) {
|
||||
if (!validate_skill_d_tag(d_tag->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create slug must be lowercase letters/digits/hyphens (1-64 chars)");
|
||||
return json_error("skill_create d must be lowercase letters/digits/hyphens (1-64 chars)");
|
||||
}
|
||||
if (scope && (!cJSON_IsString(scope) || !scope->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
@@ -3499,7 +3573,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
return json_error("failed to create skill tags");
|
||||
}
|
||||
|
||||
if (add_string_tag(tags, "d", slug->valuestring) != 0 ||
|
||||
if (add_string_tag(tags, "d", d_tag->valuestring) != 0 ||
|
||||
add_string_tag(tags, "app", "didactyl") != 0 ||
|
||||
add_string_tag(tags, "scope", scope_str) != 0) {
|
||||
cJSON_Delete(tags);
|
||||
@@ -3526,10 +3600,14 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
: "llm";
|
||||
int enabled_int = (!enabled || cJSON_IsTrue(enabled)) ? 1 : 0;
|
||||
|
||||
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
|
||||
if (trigger_str &&
|
||||
strcmp(trigger_str, "nostr-subscription") != 0 &&
|
||||
strcmp(trigger_str, "webhook") != 0 &&
|
||||
strcmp(trigger_str, "cron") != 0 &&
|
||||
strcmp(trigger_str, "chain") != 0) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_create trigger must be nostr-subscription when provided");
|
||||
return json_error("skill_create trigger must be one of: nostr-subscription, webhook, cron, chain");
|
||||
}
|
||||
if ((trigger_str && !filter_str) || (!trigger_str && filter_str)) {
|
||||
cJSON_Delete(tags);
|
||||
@@ -3568,7 +3646,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
char* adoption_content = NULL;
|
||||
if (fetch_adoption_list_tags(ctx, &adoption_tags, &adoption_content) == 0) {
|
||||
char addr[256];
|
||||
snprintf(addr, sizeof(addr), "31123:%s:%s", ctx->cfg->keys.public_key_hex, slug->valuestring);
|
||||
snprintf(addr, sizeof(addr), "31123:%s:%s", ctx->cfg->keys.public_key_hex, d_tag->valuestring);
|
||||
|
||||
cJSON* tuple = cJSON_CreateArray();
|
||||
if (tuple) {
|
||||
@@ -3602,10 +3680,11 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
? TRIGGER_ACTION_TEMPLATE
|
||||
: TRIGGER_ACTION_LLM;
|
||||
if (trigger_manager_add(ctx->trigger_manager,
|
||||
slug->valuestring,
|
||||
d_tag->valuestring,
|
||||
content->valuestring,
|
||||
filter_str,
|
||||
at,
|
||||
trigger_str,
|
||||
enabled_int) == 0) {
|
||||
trigger_registered = 1;
|
||||
}
|
||||
@@ -3620,7 +3699,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", skill_publish.success ? 1 : 0);
|
||||
cJSON_AddStringToObject(out, "slug", slug->valuestring);
|
||||
cJSON_AddStringToObject(out, "d_tag", d_tag->valuestring);
|
||||
cJSON_AddNumberToObject(out, "kind", kind);
|
||||
cJSON_AddStringToObject(out, "scope", scope_str);
|
||||
cJSON_AddStringToObject(out, "skill_event_id", skill_publish.event_id);
|
||||
@@ -3726,13 +3805,13 @@ static char* execute_skill_adopt(tools_context_t* ctx, const char* args_json) {
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(args, "pubkey");
|
||||
cJSON* slug = cJSON_GetObjectItemCaseSensitive(args, "slug");
|
||||
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(args, "d");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind");
|
||||
|
||||
if (!pubkey || !cJSON_IsString(pubkey) || !pubkey->valuestring || !is_hex_string_len(pubkey->valuestring, 64U) ||
|
||||
!slug || !cJSON_IsString(slug) || !slug->valuestring || !validate_skill_slug(slug->valuestring)) {
|
||||
!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || !validate_skill_d_tag(d_tag->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_adopt requires pubkey (hex) and valid slug");
|
||||
return json_error("skill_adopt requires pubkey (hex) and valid d tag");
|
||||
}
|
||||
|
||||
int kind_val = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : 31123;
|
||||
@@ -3742,7 +3821,7 @@ static char* execute_skill_adopt(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
char addr[256];
|
||||
snprintf(addr, sizeof(addr), "%d:%s:%s", kind_val, pubkey->valuestring, slug->valuestring);
|
||||
snprintf(addr, sizeof(addr), "%d:%s:%s", kind_val, pubkey->valuestring, d_tag->valuestring);
|
||||
|
||||
cJSON* tags = NULL;
|
||||
char* content = NULL;
|
||||
@@ -3819,7 +3898,7 @@ static char* execute_skill_remove(tools_context_t* ctx, const char* args_json) {
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(args, "pubkey");
|
||||
cJSON* slug = cJSON_GetObjectItemCaseSensitive(args, "slug");
|
||||
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(args, "d");
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(args, "kind");
|
||||
|
||||
const char* pubkey_str = ctx->cfg->keys.public_key_hex;
|
||||
@@ -3831,9 +3910,9 @@ static char* execute_skill_remove(tools_context_t* ctx, const char* args_json) {
|
||||
pubkey_str = pubkey->valuestring;
|
||||
}
|
||||
|
||||
if (!slug || !cJSON_IsString(slug) || !slug->valuestring || !validate_skill_slug(slug->valuestring)) {
|
||||
if (!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || !validate_skill_d_tag(d_tag->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_remove requires valid slug");
|
||||
return json_error("skill_remove requires valid d tag");
|
||||
}
|
||||
|
||||
int kind_val = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : 31123;
|
||||
@@ -3843,7 +3922,7 @@ static char* execute_skill_remove(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
char addr[256];
|
||||
snprintf(addr, sizeof(addr), "%d:%s:%s", kind_val, pubkey_str, slug->valuestring);
|
||||
snprintf(addr, sizeof(addr), "%d:%s:%s", kind_val, pubkey_str, d_tag->valuestring);
|
||||
|
||||
cJSON* tags = NULL;
|
||||
char* content = NULL;
|
||||
@@ -3878,7 +3957,7 @@ 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);
|
||||
(void)trigger_manager_remove(ctx->trigger_manager, d_tag->valuestring);
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
@@ -3909,6 +3988,281 @@ static char* execute_skill_remove(tools_context_t* ctx, const char* args_json) {
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_skill_edit(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* d = cJSON_GetObjectItemCaseSensitive(args, "d");
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(args, "content");
|
||||
cJSON* scope = cJSON_GetObjectItemCaseSensitive(args, "scope");
|
||||
cJSON* description = cJSON_GetObjectItemCaseSensitive(args, "description");
|
||||
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 (!d || !cJSON_IsString(d) || !d->valuestring || !validate_skill_d_tag(d->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit requires valid d tag");
|
||||
}
|
||||
if (content && (!cJSON_IsString(content) || !content->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit content must be string when provided");
|
||||
}
|
||||
if (scope && (!cJSON_IsString(scope) || !scope->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit scope must be string when provided");
|
||||
}
|
||||
if (description && (!cJSON_IsString(description) || !description->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit description must be string when provided");
|
||||
}
|
||||
if (trigger && (!cJSON_IsString(trigger) || !trigger->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit trigger must be string when provided");
|
||||
}
|
||||
if (filter && (!cJSON_IsString(filter) || !filter->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit filter must be string when provided");
|
||||
}
|
||||
if (action && (!cJSON_IsString(action) || !action->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit action must be string when provided");
|
||||
}
|
||||
if (enabled && !cJSON_IsBool(enabled)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit enabled must be boolean when provided");
|
||||
}
|
||||
|
||||
char* events_json = nostr_handler_get_self_skill_events_json();
|
||||
if (!events_json) {
|
||||
cJSON_Delete(args);
|
||||
return json_error("skill_edit cache unavailable");
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
return json_error("skill_edit cache returned invalid JSON");
|
||||
}
|
||||
|
||||
cJSON* target = NULL;
|
||||
long long best_created_at = -1;
|
||||
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;
|
||||
|
||||
cJSON* kind = cJSON_GetObjectItemCaseSensitive(ev, "kind");
|
||||
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev, "tags");
|
||||
if (!kind || !cJSON_IsNumber(kind) || !tags || !cJSON_IsArray(tags)) continue;
|
||||
|
||||
int kind_val = (int)kind->valuedouble;
|
||||
if (kind_val != 31123 && kind_val != 31124) continue;
|
||||
|
||||
cJSON* d_val = find_tag_value_string(tags, "d");
|
||||
if (!d_val || !cJSON_IsString(d_val) || !d_val->valuestring) continue;
|
||||
if (strcmp(d_val->valuestring, d->valuestring) != 0) continue;
|
||||
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
long long created = (created_at && cJSON_IsNumber(created_at)) ? (long long)created_at->valuedouble : 0;
|
||||
if (!target || created > best_created_at) {
|
||||
target = ev;
|
||||
best_created_at = created;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
return json_error("skill_edit could not find self skill with that d");
|
||||
}
|
||||
|
||||
cJSON* target_kind = cJSON_GetObjectItemCaseSensitive(target, "kind");
|
||||
cJSON* target_content = cJSON_GetObjectItemCaseSensitive(target, "content");
|
||||
cJSON* target_tags = cJSON_GetObjectItemCaseSensitive(target, "tags");
|
||||
if (!target_kind || !cJSON_IsNumber(target_kind) || !target_content || !cJSON_IsString(target_content) ||
|
||||
!target_content->valuestring || !target_tags || !cJSON_IsArray(target_tags)) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
return json_error("skill_edit found malformed skill event");
|
||||
}
|
||||
|
||||
int out_kind = (int)target_kind->valuedouble;
|
||||
if (scope && scope->valuestring) {
|
||||
if (strcmp(scope->valuestring, "public") == 0) {
|
||||
out_kind = 31123;
|
||||
} else if (strcmp(scope->valuestring, "private") == 0) {
|
||||
out_kind = 31124;
|
||||
} else {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
return json_error("skill_edit scope must be public or private");
|
||||
}
|
||||
}
|
||||
|
||||
const char* out_content = (content && content->valuestring) ? content->valuestring : target_content->valuestring;
|
||||
|
||||
cJSON* tags_out = cJSON_Duplicate(target_tags, 1);
|
||||
if (!tags_out || !cJSON_IsArray(tags_out)) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit failed to duplicate existing tags");
|
||||
}
|
||||
|
||||
if (upsert_string_tag(tags_out, "d", d->valuestring) != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit failed to set d tag");
|
||||
}
|
||||
if (upsert_string_tag(tags_out, "app", "didactyl") != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit failed to set app tag");
|
||||
}
|
||||
if (upsert_string_tag(tags_out, "scope", out_kind == 31123 ? "public" : "private") != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit failed to set scope tag");
|
||||
}
|
||||
|
||||
if (description) {
|
||||
if (description->valuestring[0] == '\0') {
|
||||
remove_tag_key_all(tags_out, "description");
|
||||
} else if (upsert_string_tag(tags_out, "description", description->valuestring) != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit failed to set description tag");
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* existing_trigger = find_tag_value_string(tags_out, "trigger");
|
||||
cJSON* existing_filter = find_tag_value_string(tags_out, "filter");
|
||||
cJSON* existing_action = find_tag_value_string(tags_out, "action");
|
||||
cJSON* existing_enabled = find_tag_value_string(tags_out, "enabled");
|
||||
|
||||
const char* merged_trigger = (trigger && trigger->valuestring) ? trigger->valuestring
|
||||
: (existing_trigger && cJSON_IsString(existing_trigger) ? existing_trigger->valuestring : NULL);
|
||||
const char* merged_filter = (filter && filter->valuestring) ? filter->valuestring
|
||||
: (existing_filter && cJSON_IsString(existing_filter) ? existing_filter->valuestring : NULL);
|
||||
const char* merged_action = (action && action->valuestring) ? action->valuestring
|
||||
: (existing_action && cJSON_IsString(existing_action) && existing_action->valuestring[0] != '\0'
|
||||
? existing_action->valuestring
|
||||
: "llm");
|
||||
|
||||
int merged_enabled = (enabled && cJSON_IsBool(enabled)) ? (cJSON_IsTrue(enabled) ? 1 : 0)
|
||||
: (existing_enabled && cJSON_IsString(existing_enabled) && existing_enabled->valuestring &&
|
||||
strcmp(existing_enabled->valuestring, "false") == 0)
|
||||
? 0
|
||||
: 1;
|
||||
|
||||
int trigger_patch_requested = (trigger || filter || action || enabled) ? 1 : 0;
|
||||
if (trigger_patch_requested || (merged_trigger || merged_filter)) {
|
||||
if ((merged_trigger && !merged_filter) || (!merged_trigger && merged_filter)) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit trigger and filter must both be set together");
|
||||
}
|
||||
remove_tag_key_all(tags_out, "trigger");
|
||||
remove_tag_key_all(tags_out, "filter");
|
||||
remove_tag_key_all(tags_out, "action");
|
||||
remove_tag_key_all(tags_out, "enabled");
|
||||
|
||||
if (merged_trigger && merged_filter) {
|
||||
if (strcmp(merged_trigger, "nostr-subscription") != 0 &&
|
||||
strcmp(merged_trigger, "webhook") != 0 &&
|
||||
strcmp(merged_trigger, "cron") != 0 &&
|
||||
strcmp(merged_trigger, "chain") != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain");
|
||||
}
|
||||
if (add_string_tag(tags_out, "trigger", merged_trigger) != 0 ||
|
||||
add_string_tag(tags_out, "filter", merged_filter) != 0 ||
|
||||
add_string_tag(tags_out, "action", merged_action) != 0 ||
|
||||
add_string_tag(tags_out, "enabled", merged_enabled ? "true" : "false") != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(events);
|
||||
cJSON_Delete(tags_out);
|
||||
return json_error("skill_edit failed to set trigger tags");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nostr_publish_result_t publish_result;
|
||||
memset(&publish_result, 0, sizeof(publish_result));
|
||||
int rc = nostr_handler_publish_kind_event(out_kind, out_content, tags_out, &publish_result);
|
||||
cJSON_Delete(tags_out);
|
||||
cJSON_Delete(events);
|
||||
|
||||
if (rc != 0) {
|
||||
cJSON_Delete(args);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json_error("skill_edit failed to publish updated skill");
|
||||
}
|
||||
|
||||
int trigger_registered = 0;
|
||||
if (ctx->trigger_manager) {
|
||||
if (merged_trigger && merged_filter && merged_enabled) {
|
||||
trigger_action_type_t at = (strcmp(merged_action, "template") == 0)
|
||||
? TRIGGER_ACTION_TEMPLATE
|
||||
: TRIGGER_ACTION_LLM;
|
||||
if (trigger_manager_update(ctx->trigger_manager,
|
||||
d->valuestring,
|
||||
out_content,
|
||||
merged_filter,
|
||||
at,
|
||||
merged_trigger,
|
||||
merged_enabled) == 0 ||
|
||||
trigger_manager_add(ctx->trigger_manager,
|
||||
d->valuestring,
|
||||
out_content,
|
||||
merged_filter,
|
||||
at,
|
||||
merged_trigger,
|
||||
merged_enabled) == 0) {
|
||||
trigger_registered = 1;
|
||||
}
|
||||
} else {
|
||||
(void)trigger_manager_remove(ctx->trigger_manager, d->valuestring);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
cJSON_Delete(args);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
|
||||
cJSON_AddStringToObject(out, "d_tag", d->valuestring);
|
||||
cJSON_AddNumberToObject(out, "kind", out_kind);
|
||||
cJSON_AddStringToObject(out, "scope", out_kind == 31123 ? "public" : "private");
|
||||
cJSON_AddStringToObject(out, "skill_event_id", publish_result.event_id);
|
||||
cJSON_AddBoolToObject(out, "trigger_registered", trigger_registered ? 1 : 0);
|
||||
if (publish_result.naddr_uri[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
|
||||
}
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(args);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return json;
|
||||
}
|
||||
|
||||
static char* execute_skill_search(const char* args_json) {
|
||||
cJSON* args = parse_tool_args_json(args_json);
|
||||
if (!args) return json_error("invalid arguments JSON");
|
||||
@@ -4106,11 +4460,11 @@ static char* execute_skill_search(const char* args_json) {
|
||||
if (!summary) continue;
|
||||
|
||||
if (query_str && query_str[0] != '\0') {
|
||||
cJSON* slug = cJSON_GetObjectItemCaseSensitive(summary, "slug");
|
||||
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(summary, "d_tag");
|
||||
cJSON* desc = cJSON_GetObjectItemCaseSensitive(summary, "description");
|
||||
cJSON* preview = cJSON_GetObjectItemCaseSensitive(summary, "content_preview");
|
||||
int match = 0;
|
||||
if (slug && cJSON_IsString(slug) && slug->valuestring && ci_contains(slug->valuestring, query_str)) match = 1;
|
||||
if (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring && ci_contains(d_tag->valuestring, query_str)) match = 1;
|
||||
if (!match && desc && cJSON_IsString(desc) && desc->valuestring && ci_contains(desc->valuestring, query_str)) match = 1;
|
||||
if (!match && preview && cJSON_IsString(preview) && preview->valuestring && ci_contains(preview->valuestring, query_str)) match = 1;
|
||||
if (!match) {
|
||||
@@ -5649,6 +6003,9 @@ 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, "skill_edit") == 0) {
|
||||
return execute_skill_edit(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "trigger_list") == 0) {
|
||||
return execute_trigger_list(ctx, args_json);
|
||||
}
|
||||
|
||||
+610
-69
@@ -6,16 +6,232 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <ctype.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
#include "nostr_handler.h"
|
||||
|
||||
typedef struct {
|
||||
trigger_manager_t* mgr;
|
||||
char skill_d_tag[TRIGGER_SKILL_D_TAG_MAX];
|
||||
} trigger_subscription_ctx_t;
|
||||
|
||||
static int clamp_enabled(int enabled) {
|
||||
return enabled ? 1 : 0;
|
||||
}
|
||||
|
||||
trigger_type_t trigger_type_from_string(const char* s) {
|
||||
if (!s) {
|
||||
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
}
|
||||
if (strcmp(s, "webhook") == 0) {
|
||||
return TRIGGER_TYPE_WEBHOOK;
|
||||
}
|
||||
if (strcmp(s, "cron") == 0) {
|
||||
return TRIGGER_TYPE_CRON;
|
||||
}
|
||||
if (strcmp(s, "chain") == 0) {
|
||||
return TRIGGER_TYPE_CHAIN;
|
||||
}
|
||||
return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
const char* trigger_type_to_string(trigger_type_t t) {
|
||||
switch (t) {
|
||||
case TRIGGER_TYPE_WEBHOOK:
|
||||
return "webhook";
|
||||
case TRIGGER_TYPE_CRON:
|
||||
return "cron";
|
||||
case TRIGGER_TYPE_CHAIN:
|
||||
return "chain";
|
||||
case TRIGGER_TYPE_NOSTR_SUBSCRIPTION:
|
||||
default:
|
||||
return "nostr-subscription";
|
||||
}
|
||||
}
|
||||
|
||||
static int is_int_in_csv_list(const char* text, int value) {
|
||||
if (!text || text[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char* p = text;
|
||||
while (*p) {
|
||||
while (*p == ' ') p++;
|
||||
char* endptr = NULL;
|
||||
long n = strtol(p, &endptr, 10);
|
||||
if (endptr == p) {
|
||||
return 0;
|
||||
}
|
||||
if ((int)n == value) {
|
||||
return 1;
|
||||
}
|
||||
p = endptr;
|
||||
while (*p == ' ') p++;
|
||||
if (*p == ',') {
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
if (*p == '\0') {
|
||||
break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cron_field_token_matches(const char* token, int value, int min_v, int max_v) {
|
||||
if (!token || token[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (strcmp(token, "*") == 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char* slash = strchr(token, '/');
|
||||
int step = 0;
|
||||
char base[64];
|
||||
if (slash) {
|
||||
size_t base_len = (size_t)(slash - token);
|
||||
if (base_len == 0 || base_len >= sizeof(base)) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(base, token, base_len);
|
||||
base[base_len] = '\0';
|
||||
|
||||
char* endptr = NULL;
|
||||
long step_l = strtol(slash + 1, &endptr, 10);
|
||||
if (endptr == slash + 1 || *endptr != '\0' || step_l <= 0 || step_l > 1024) {
|
||||
return 0;
|
||||
}
|
||||
step = (int)step_l;
|
||||
} else {
|
||||
snprintf(base, sizeof(base), "%s", token);
|
||||
}
|
||||
|
||||
int range_start = min_v;
|
||||
int range_end = max_v;
|
||||
|
||||
if (strcmp(base, "*") == 0) {
|
||||
range_start = min_v;
|
||||
range_end = max_v;
|
||||
} else {
|
||||
const char* dash = strchr(base, '-');
|
||||
if (dash) {
|
||||
char left[32];
|
||||
char right[32];
|
||||
size_t left_len = (size_t)(dash - base);
|
||||
size_t right_len = strlen(dash + 1);
|
||||
if (left_len == 0 || right_len == 0 || left_len >= sizeof(left) || right_len >= sizeof(right)) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(left, base, left_len);
|
||||
left[left_len] = '\0';
|
||||
memcpy(right, dash + 1, right_len + 1U);
|
||||
|
||||
char* e1 = NULL;
|
||||
char* e2 = NULL;
|
||||
long a = strtol(left, &e1, 10);
|
||||
long b = strtol(right, &e2, 10);
|
||||
if (!e1 || *e1 != '\0' || !e2 || *e2 != '\0') {
|
||||
return 0;
|
||||
}
|
||||
range_start = (int)a;
|
||||
range_end = (int)b;
|
||||
} else if (strchr(base, ',')) {
|
||||
if (!is_int_in_csv_list(base, value)) {
|
||||
return 0;
|
||||
}
|
||||
if (step <= 1) {
|
||||
return 1;
|
||||
}
|
||||
return ((value - min_v) % step) == 0;
|
||||
} else {
|
||||
char* e = NULL;
|
||||
long n = strtol(base, &e, 10);
|
||||
if (!e || *e != '\0') {
|
||||
return 0;
|
||||
}
|
||||
range_start = (int)n;
|
||||
range_end = (int)n;
|
||||
}
|
||||
}
|
||||
|
||||
if (range_start < min_v || range_end > max_v || range_start > range_end) {
|
||||
return 0;
|
||||
}
|
||||
if (value < range_start || value > range_end) {
|
||||
return 0;
|
||||
}
|
||||
if (step > 1 && ((value - range_start) % step) != 0) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int cron_field_matches(const char* field, int value, int min_v, int max_v) {
|
||||
if (!field || field[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char tmp[128];
|
||||
snprintf(tmp, sizeof(tmp), "%s", field);
|
||||
|
||||
char* saveptr = NULL;
|
||||
char* token = strtok_r(tmp, ",", &saveptr);
|
||||
while (token) {
|
||||
while (*token == ' ') token++;
|
||||
if (cron_field_token_matches(token, value, min_v, max_v)) {
|
||||
return 1;
|
||||
}
|
||||
token = strtok_r(NULL, ",", &saveptr);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cron_matches_now(const char* expr, time_t now_ts) {
|
||||
if (!expr || expr[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char buf[TRIGGER_FILTER_JSON_MAX];
|
||||
snprintf(buf, sizeof(buf), "%s", expr);
|
||||
|
||||
char* fields[5] = {0};
|
||||
int nf = 0;
|
||||
char* saveptr = NULL;
|
||||
char* tok = strtok_r(buf, " \t", &saveptr);
|
||||
while (tok && nf < 5) {
|
||||
fields[nf++] = tok;
|
||||
tok = strtok_r(NULL, " \t", &saveptr);
|
||||
}
|
||||
if (nf != 5 || tok != NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
struct tm tm_now;
|
||||
localtime_r(&now_ts, &tm_now);
|
||||
|
||||
int minute = tm_now.tm_min;
|
||||
int hour = tm_now.tm_hour;
|
||||
int mday = tm_now.tm_mday;
|
||||
int month = tm_now.tm_mon + 1;
|
||||
int wday = tm_now.tm_wday;
|
||||
|
||||
if (!cron_field_matches(fields[0], minute, 0, 59)) return 0;
|
||||
if (!cron_field_matches(fields[1], hour, 0, 23)) return 0;
|
||||
if (!cron_field_matches(fields[2], mday, 1, 31)) return 0;
|
||||
if (!cron_field_matches(fields[3], month, 1, 12)) return 0;
|
||||
if (!cron_field_matches(fields[4], wday, 0, 6)) return 0;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int ensure_capacity(trigger_manager_t* mgr, int needed) {
|
||||
if (!mgr || needed <= 0) {
|
||||
return -1;
|
||||
@@ -47,13 +263,13 @@ static int ensure_capacity(trigger_manager_t* mgr, int needed) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0') {
|
||||
static int find_trigger_index_locked(trigger_manager_t* mgr, const char* skill_d_tag) {
|
||||
if (!mgr || !skill_d_tag || skill_d_tag[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) {
|
||||
if (strncmp(mgr->triggers[i].skill_d_tag, skill_d_tag, TRIGGER_SKILL_D_TAG_MAX) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -80,8 +296,8 @@ static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
|
||||
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) {
|
||||
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_d_tag[65]) {
|
||||
if (!addr || !out_kind || !out_pubkey || !out_d_tag) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -103,9 +319,9 @@ static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65
|
||||
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);
|
||||
size_t d_tag_len = strlen(p2 + 1);
|
||||
if (d_tag_len == 0 || d_tag_len >= 65U) return -1;
|
||||
memcpy(out_d_tag, p2 + 1, d_tag_len + 1U);
|
||||
|
||||
*out_kind = kind;
|
||||
return 0;
|
||||
@@ -168,7 +384,7 @@ static void execute_template_action(trigger_manager_t* mgr,
|
||||
} 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);
|
||||
DEBUG_INFO("[didactyl] trigger template log (%s): %s", t->skill_d_tag, body);
|
||||
} else {
|
||||
(void)nostr_handler_send_dm_auto(mgr->cfg->admin.pubkey, rendered);
|
||||
}
|
||||
@@ -180,7 +396,7 @@ static void execute_llm_action(const active_trigger_t* t, cJSON* event, const ch
|
||||
if (!t || !event) {
|
||||
return;
|
||||
}
|
||||
agent_on_trigger(t->skill_slug, t->skill_content, event, relay_url);
|
||||
agent_on_trigger(t->skill_d_tag, t->skill_content, event, relay_url);
|
||||
}
|
||||
|
||||
static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* event, const char* relay_url) {
|
||||
@@ -220,6 +436,103 @@ static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* e
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void on_trigger_subscription_eose(cJSON** events, int event_count, void* user_data) {
|
||||
(void)events;
|
||||
(void)event_count;
|
||||
(void)user_data;
|
||||
}
|
||||
|
||||
static void close_trigger_subscription_locked(active_trigger_t* t) {
|
||||
if (!t) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (t->subscription) {
|
||||
(void)nostr_handler_close_subscription(t->subscription);
|
||||
t->subscription = NULL;
|
||||
}
|
||||
|
||||
if (t->subscription_ctx) {
|
||||
free(t->subscription_ctx);
|
||||
t->subscription_ctx = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_trigger_subscription_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
trigger_subscription_ctx_t* ctx = (trigger_subscription_ctx_t*)user_data;
|
||||
if (!ctx || !ctx->mgr || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
(void)trigger_manager_fire(ctx->mgr, ctx->skill_d_tag, event, relay_url);
|
||||
}
|
||||
|
||||
static int register_trigger_subscription_locked(trigger_manager_t* mgr, active_trigger_t* t) {
|
||||
if (!mgr || !t) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
close_trigger_subscription_locked(t);
|
||||
|
||||
if (t->trigger_type != TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!t->enabled || t->filter_json[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_Parse(t->filter_json);
|
||||
if (!filter || !cJSON_IsObject(filter)) {
|
||||
cJSON_Delete(filter);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
|
||||
if (!since || !cJSON_IsNumber(since)) {
|
||||
time_t now = time(NULL);
|
||||
time_t default_since = now > 30 ? now - 30 : 0;
|
||||
cJSON_AddNumberToObject(filter, "since", (double)default_since);
|
||||
}
|
||||
|
||||
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
|
||||
if (!limit || !cJSON_IsNumber(limit)) {
|
||||
cJSON_AddNumberToObject(filter, "limit", 200);
|
||||
}
|
||||
|
||||
trigger_subscription_ctx_t* ctx = (trigger_subscription_ctx_t*)calloc(1, sizeof(*ctx));
|
||||
if (!ctx) {
|
||||
cJSON_Delete(filter);
|
||||
return -1;
|
||||
}
|
||||
|
||||
ctx->mgr = mgr;
|
||||
snprintf(ctx->skill_d_tag, sizeof(ctx->skill_d_tag), "%s", t->skill_d_tag);
|
||||
|
||||
nostr_pool_subscription_t* sub = nostr_handler_subscribe_with_filter(
|
||||
filter,
|
||||
on_trigger_subscription_event,
|
||||
on_trigger_subscription_eose,
|
||||
ctx,
|
||||
0,
|
||||
0,
|
||||
NOSTR_POOL_EOSE_FULL_SET,
|
||||
30,
|
||||
120);
|
||||
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (!sub) {
|
||||
free(ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
t->subscription = sub;
|
||||
t->subscription_ctx = ctx;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
|
||||
if (!mgr || !cfg) {
|
||||
return -1;
|
||||
@@ -286,8 +599,8 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
|
||||
int kind = 0;
|
||||
char pubkey[65] = {0};
|
||||
char slug[65] = {0};
|
||||
if (parse_address_tag(val->valuestring, &kind, pubkey, slug) != 0) {
|
||||
char d_tag[65] = {0};
|
||||
if (parse_address_tag(val->valuestring, &kind, pubkey, d_tag) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -309,7 +622,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
|
||||
strcmp(ev_pubkey->valuestring, pubkey) != 0 ||
|
||||
!ev_d || !cJSON_IsString(ev_d) || !ev_d->valuestring ||
|
||||
strcmp(ev_d->valuestring, slug) != 0) {
|
||||
strcmp(ev_d->valuestring, d_tag) != 0) {
|
||||
continue;
|
||||
}
|
||||
cJSON* dup = cJSON_Duplicate(ev, 1);
|
||||
@@ -338,7 +651,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
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_AddItemToArray(d_values, cJSON_CreateString(d_tag));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
|
||||
@@ -373,10 +686,15 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
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') {
|
||||
int trigger_supported = trigger_s &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0);
|
||||
if (trigger_supported && 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) {
|
||||
if (trigger_manager_add(mgr, d_tag, content->valuestring, filter_s, at, trigger_s, is_enabled) == 0) {
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
@@ -389,13 +707,90 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int considered = 0;
|
||||
|
||||
for (int i = 0; i < mgr->cfg->startup_event_count; i++) {
|
||||
startup_event_t* ev = &mgr->cfg->startup_events[i];
|
||||
if (!ev) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ev->kind != 31123 && ev->kind != 31124) {
|
||||
continue;
|
||||
}
|
||||
|
||||
considered++;
|
||||
|
||||
if (!ev->content || ev->content[0] == '\0' || !ev->tags_json || ev->tags_json[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_Parse(ev->tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
DEBUG_WARN("[didactyl] startup trigger skip: invalid tags_json for startup event index=%d", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* d = find_tag_value_string(tags, "d");
|
||||
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* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
|
||||
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 (!d_tag || d_tag[0] == '\0') {
|
||||
cJSON_Delete(tags);
|
||||
DEBUG_WARN("[didactyl] startup trigger skip: missing d tag for startup event index=%d", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
int trigger_supported = trigger_s &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0);
|
||||
if (!trigger_supported || !filter_s || filter_s[0] == '\0') {
|
||||
cJSON_Delete(tags);
|
||||
continue;
|
||||
}
|
||||
|
||||
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, d_tag, ev->content, filter_s, at, trigger_s, is_enabled) == 0) {
|
||||
loaded++;
|
||||
DEBUG_INFO("[didactyl] startup trigger registered d_tag=%s action=%s enabled=%d", d_tag, at == TRIGGER_ACTION_TEMPLATE ? "template" : "llm", is_enabled);
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup trigger failed d_tag=%s", d_tag);
|
||||
}
|
||||
|
||||
cJSON_Delete(tags);
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from startup config (considered=%d)", loaded, considered);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -405,10 +800,10 @@ int trigger_manager_add(trigger_manager_t* mgr,
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int existing = find_trigger_index_locked(mgr, skill_slug);
|
||||
int existing = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (existing >= 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_update(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
return trigger_manager_update(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled);
|
||||
}
|
||||
|
||||
int max_active = mgr->cfg ? mgr->cfg->triggers.max_active : TRIGGER_DEFAULT_MAX_ACTIVE;
|
||||
@@ -426,35 +821,51 @@ int trigger_manager_add(trigger_manager_t* mgr,
|
||||
|
||||
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_d_tag, sizeof(t->skill_d_tag), "%s", skill_d_tag);
|
||||
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->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
t->last_fired = 0;
|
||||
t->last_seen_created_at = 0;
|
||||
t->last_cron_fire = 0;
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
}
|
||||
t->subscription = NULL;
|
||||
t->subscription_ctx = NULL;
|
||||
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger add rejected: failed to create subscription d_tag=%s", skill_d_tag);
|
||||
memset(t, 0, sizeof(*t));
|
||||
return -1;
|
||||
}
|
||||
|
||||
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));
|
||||
DEBUG_INFO("[didactyl] trigger added d_tag=%s action=%d enabled=%d", skill_d_tag, (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') {
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag) {
|
||||
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
close_trigger_subscription_locked(&mgr->triggers[idx]);
|
||||
|
||||
if (idx < mgr->count - 1) {
|
||||
memmove(&mgr->triggers[idx],
|
||||
&mgr->triggers[idx + 1],
|
||||
@@ -466,17 +877,130 @@ int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger removed slug=%s", skill_slug);
|
||||
DEBUG_INFO("[didactyl] trigger removed d_tag=%s", skill_d_tag);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) {
|
||||
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !out) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out = mgr->triggers[idx];
|
||||
out->subscription = NULL;
|
||||
out->subscription_ctx = NULL;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int trigger_manager_fire(trigger_manager_t* mgr,
|
||||
const char* skill_d_tag,
|
||||
cJSON* event,
|
||||
const char* source_label) {
|
||||
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !event) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int fired = maybe_fire_trigger_locked(mgr, idx, event, source_label);
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return fired;
|
||||
}
|
||||
|
||||
int trigger_manager_fire_chains(trigger_manager_t* mgr,
|
||||
const char* source_skill_d_tag,
|
||||
cJSON* source_event,
|
||||
const char* source_label) {
|
||||
if (!mgr || !source_skill_d_tag || source_skill_d_tag[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
static __thread int s_chain_depth = 0;
|
||||
if (s_chain_depth >= 5) {
|
||||
DEBUG_WARN("[didactyl] chain trigger depth limit reached for source=%s", source_skill_d_tag);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int fired_count = 0;
|
||||
s_chain_depth++;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int count_snapshot = mgr->count;
|
||||
for (int i = 0; i < count_snapshot; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) {
|
||||
continue;
|
||||
}
|
||||
if (t->filter_json[0] == '\0' || strcmp(t->filter_json, source_skill_d_tag) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
int cooldown = mgr->cfg ? mgr->cfg->triggers.cooldown_seconds : 0;
|
||||
if (cooldown < 0) cooldown = 0;
|
||||
if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) {
|
||||
continue;
|
||||
}
|
||||
|
||||
t->last_fired = now;
|
||||
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
if (event) {
|
||||
cJSON_AddStringToObject(event, "type", "chain");
|
||||
cJSON_AddStringToObject(event, "source_d_tag", source_skill_d_tag);
|
||||
cJSON_AddStringToObject(event, "source_label", source_label ? source_label : "chain");
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
if (source_event) {
|
||||
cJSON* dup = cJSON_Duplicate(source_event, 1);
|
||||
if (dup) {
|
||||
cJSON_AddItemToObject(event, "source_event", dup);
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, "chain");
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, "chain");
|
||||
}
|
||||
cJSON_Delete(event);
|
||||
fired_count++;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
s_chain_depth--;
|
||||
return fired_count;
|
||||
}
|
||||
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_slug || skill_slug[0] == '\0' || !content || !filter_json) {
|
||||
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -486,10 +1010,10 @@ int trigger_manager_update(trigger_manager_t* mgr,
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, skill_slug);
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_add(mgr, skill_slug, content, filter_json, action_type, enabled);
|
||||
return trigger_manager_add(mgr, skill_d_tag, content, filter_json, action_type, trigger_type_str, enabled);
|
||||
}
|
||||
|
||||
active_trigger_t* t = &mgr->triggers[idx];
|
||||
@@ -497,10 +1021,22 @@ int trigger_manager_update(trigger_manager_t* mgr,
|
||||
snprintf(t->filter_json, sizeof(t->filter_json), "%s", filter_json);
|
||||
t->action_type = action_type;
|
||||
t->enabled = clamp_enabled(enabled);
|
||||
t->trigger_type = trigger_type_from_string(trigger_type_str);
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
|
||||
} else {
|
||||
t->cron_expr[0] = '\0';
|
||||
}
|
||||
|
||||
if (register_trigger_subscription_locked(mgr, t) != 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
DEBUG_WARN("[didactyl] trigger update failed to (re)subscribe d_tag=%s", skill_d_tag);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger updated slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
|
||||
DEBUG_INFO("[didactyl] trigger updated d_tag=%s action=%d enabled=%d", skill_d_tag, (int)action_type, clamp_enabled(enabled));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -522,67 +1058,67 @@ int trigger_manager_active_count(trigger_manager_t* mgr) {
|
||||
}
|
||||
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg || !mgr->cfg->triggers.enabled) {
|
||||
return 0;
|
||||
if (!mgr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 10) {
|
||||
if (now <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
|
||||
return 0;
|
||||
}
|
||||
mgr->last_poll_at = now;
|
||||
|
||||
int fired = 0;
|
||||
|
||||
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') {
|
||||
int count_snapshot = mgr->count;
|
||||
for (int i = 0; i < count_snapshot; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_Parse(snapshot.filter_json);
|
||||
if (!filter || !cJSON_IsObject(filter)) {
|
||||
cJSON_Delete(filter);
|
||||
const char* expr = (t->cron_expr[0] != '\0') ? t->cron_expr : t->filter_json;
|
||||
if (!cron_matches_now(expr, now)) {
|
||||
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, 1200);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
continue;
|
||||
}
|
||||
t->last_cron_fire = now;
|
||||
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int e = 0; e < n; e++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, e);
|
||||
if (!ev || !cJSON_IsObject(ev)) continue;
|
||||
active_trigger_t trigger_copy = *t;
|
||||
trigger_copy.subscription = NULL;
|
||||
trigger_copy.subscription_ctx = NULL;
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, snapshot.skill_slug);
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
if (event) {
|
||||
cJSON_AddStringToObject(event, "type", "cron");
|
||||
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
|
||||
cJSON_AddStringToObject(event, "cron_expr", expr);
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)now);
|
||||
if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
|
||||
execute_template_action(mgr, &trigger_copy, event, "cron");
|
||||
} else {
|
||||
execute_llm_action(&trigger_copy, event, "cron");
|
||||
}
|
||||
|
||||
(void)maybe_fire_trigger_locked(mgr, idx, ev, NULL);
|
||||
cJSON_Delete(event);
|
||||
fired++;
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return 0;
|
||||
|
||||
return fired;
|
||||
}
|
||||
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
@@ -623,10 +1159,12 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(item, "skill_slug", t->skill_slug);
|
||||
cJSON_AddStringToObject(item, "skill_d_tag", t->skill_d_tag);
|
||||
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
|
||||
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
|
||||
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
|
||||
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
|
||||
cJSON_AddBoolToObject(item, "subscribed", t->subscription ? 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);
|
||||
@@ -648,6 +1186,9 @@ void trigger_manager_cleanup(trigger_manager_t* mgr) {
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
for (int i = 0; i < mgr->count; i++) {
|
||||
close_trigger_subscription_locked(&mgr->triggers[i]);
|
||||
}
|
||||
free(mgr->triggers);
|
||||
mgr->triggers = NULL;
|
||||
mgr->count = 0;
|
||||
|
||||
+34
-5
@@ -5,9 +5,11 @@
|
||||
#include <time.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
|
||||
#define TRIGGER_SKILL_SLUG_MAX 65
|
||||
#define TRIGGER_SKILL_D_TAG_MAX 65
|
||||
#define TRIGGER_SKILL_CONTENT_MAX 4096
|
||||
#define TRIGGER_FILTER_JSON_MAX 2048
|
||||
|
||||
@@ -16,14 +18,26 @@ typedef enum {
|
||||
TRIGGER_ACTION_TEMPLATE = 1
|
||||
} trigger_action_type_t;
|
||||
|
||||
typedef enum {
|
||||
TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
|
||||
TRIGGER_TYPE_WEBHOOK = 1,
|
||||
TRIGGER_TYPE_CRON = 2,
|
||||
TRIGGER_TYPE_CHAIN = 3
|
||||
} trigger_type_t;
|
||||
|
||||
typedef struct {
|
||||
char skill_slug[TRIGGER_SKILL_SLUG_MAX];
|
||||
char skill_d_tag[TRIGGER_SKILL_D_TAG_MAX];
|
||||
char skill_content[TRIGGER_SKILL_CONTENT_MAX];
|
||||
char filter_json[TRIGGER_FILTER_JSON_MAX];
|
||||
trigger_action_type_t action_type;
|
||||
int enabled;
|
||||
trigger_type_t trigger_type;
|
||||
time_t last_fired;
|
||||
time_t last_seen_created_at;
|
||||
time_t last_cron_fire;
|
||||
char cron_expr[64];
|
||||
nostr_pool_subscription_t* subscription;
|
||||
void* subscription_ctx;
|
||||
} active_trigger_t;
|
||||
|
||||
typedef struct trigger_manager {
|
||||
@@ -37,19 +51,34 @@ typedef struct trigger_manager {
|
||||
|
||||
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_load_from_startup_events(trigger_manager_t* mgr);
|
||||
trigger_type_t trigger_type_from_string(const char* s);
|
||||
const char* trigger_type_to_string(trigger_type_t t);
|
||||
|
||||
int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str,
|
||||
int enabled);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug);
|
||||
int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_d_tag);
|
||||
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);
|
||||
int trigger_manager_update(trigger_manager_t* mgr,
|
||||
const char* skill_slug,
|
||||
const char* skill_d_tag,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str,
|
||||
int enabled);
|
||||
int trigger_manager_fire(trigger_manager_t* mgr,
|
||||
const char* skill_d_tag,
|
||||
cJSON* event,
|
||||
const char* source_label);
|
||||
int trigger_manager_fire_chains(trigger_manager_t* mgr,
|
||||
const char* source_skill_d_tag,
|
||||
cJSON* source_event,
|
||||
const char* source_label);
|
||||
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);
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
pass() {
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
printf "[PASS] %s\n" "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
printf "[FAIL] %s\n" "$1"
|
||||
}
|
||||
|
||||
assert_grep() {
|
||||
local pattern="$1"
|
||||
local file="$2"
|
||||
local desc="$3"
|
||||
if grep -E -q "$pattern" "$file"; then
|
||||
pass "$desc"
|
||||
else
|
||||
fail "$desc (pattern not found in $file)"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_cmd() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
pass "$desc"
|
||||
else
|
||||
fail "$desc"
|
||||
fi
|
||||
}
|
||||
|
||||
printf "== Didactyl Trigger Feature Test ==\n"
|
||||
printf "Root: %s\n" "$ROOT_DIR"
|
||||
|
||||
printf "\n-- Build check --\n"
|
||||
if make -j >/tmp/didactyl-test-build.log 2>&1; then
|
||||
pass "Project builds successfully"
|
||||
else
|
||||
fail "Project build failed (see /tmp/didactyl-test-build.log)"
|
||||
fi
|
||||
|
||||
assert_cmd "Built binary exists" test -x ./didactyl
|
||||
|
||||
printf "\n-- Source wiring checks --\n"
|
||||
assert_grep "TRIGGER_TYPE_WEBHOOK" src/trigger_manager.h "Webhook trigger type defined"
|
||||
assert_grep "TRIGGER_TYPE_CRON" src/trigger_manager.h "Cron trigger type defined"
|
||||
assert_grep "TRIGGER_TYPE_CHAIN" src/trigger_manager.h "Chain trigger type defined"
|
||||
|
||||
assert_grep "trigger_manager_fire\(" src/trigger_manager.c "Generic trigger fire API implemented"
|
||||
assert_grep "trigger_manager_fire_chains\(" src/trigger_manager.c "Chain fire API implemented"
|
||||
assert_grep "cron_matches_now\(" src/trigger_manager.c "Cron matcher implemented"
|
||||
assert_grep "trigger_type_to_string" src/trigger_manager.c "Trigger type serialization implemented"
|
||||
|
||||
assert_grep "\"/api/trigger/\*\"" src/http_api.c "Webhook API route registered"
|
||||
assert_grep "handle_trigger_webhook\(" src/http_api.c "Webhook handler implemented"
|
||||
|
||||
assert_grep "nostr-subscription, webhook, cron, chain" src/tools.c "skill_create validation accepts 4 trigger types"
|
||||
assert_grep "skill_edit trigger must be one of: nostr-subscription, webhook, cron, chain" src/tools.c "skill_edit validation accepts 4 trigger types"
|
||||
|
||||
assert_grep "trigger_manager_fire_chains\(" src/agent.c "Post-execution chain hook wired in agent"
|
||||
|
||||
printf "\n-- Docs checks --\n"
|
||||
assert_grep "\`webhook\`" docs/SKILLS.md "SKILLS docs include webhook trigger"
|
||||
assert_grep "\`cron\`" docs/SKILLS.md "SKILLS docs include cron trigger"
|
||||
assert_grep "\`chain\`" docs/SKILLS.md "SKILLS docs include chain trigger"
|
||||
assert_grep "POST /api/trigger/:d_tag" docs/API.md "API docs include webhook endpoint"
|
||||
|
||||
printf "\n-- Optional local API probe --\n"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
HTTP_CODE=""
|
||||
HTTP_CODE="$(curl -k -s --connect-timeout 2 --max-time 5 -o /tmp/didactyl-trigger-probe.out -w "%{http_code}" https://127.0.0.1:8484/api/trigger/nonexistent -X POST -H 'Content-Type: application/json' -d '{}' || true)"
|
||||
if [[ "$HTTP_CODE" == "000" ]]; then
|
||||
HTTP_CODE="$(curl -s --connect-timeout 2 --max-time 5 -o /tmp/didactyl-trigger-probe.out -w "%{http_code}" http://127.0.0.1:8484/api/trigger/nonexistent -X POST -H 'Content-Type: application/json' -d '{}' || true)"
|
||||
fi
|
||||
|
||||
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "400" || "$HTTP_CODE" == "404" || "$HTTP_CODE" == "503" ]]; then
|
||||
pass "Webhook endpoint responds on local API (http code: $HTTP_CODE)"
|
||||
else
|
||||
fail "Webhook endpoint probe did not get expected response (http code: ${HTTP_CODE:-none})"
|
||||
fi
|
||||
else
|
||||
fail "curl not available for API probe"
|
||||
fi
|
||||
|
||||
printf "\n== Result ==\n"
|
||||
printf "Pass: %d\n" "$PASS_COUNT"
|
||||
printf "Fail: %d\n" "$FAIL_COUNT"
|
||||
|
||||
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user