Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb45012495 | ||
|
|
6c78f6e79f |
@@ -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.60
|
||||
## Current Status — v0.0.62
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.60 — Remove forced final trigger DM, add skill_edit tool, and complete d_tag terminology migration
|
||||
> 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
|
||||
|
||||
+607
@@ -1,3 +1,610 @@
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-10 05:44:14
|
||||
phase=direct_tool_exec
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-opus-4.6
|
||||
context_bytes=7997
|
||||
approx_tokens=1999
|
||||
```
|
||||
|
||||
slash=/help
|
||||
result_json=AVAILABLE TOOLS
|
||||
- admin_identity — Build admin identity context block from cached runtime metadata
|
||||
- agent_identity — Build agent identity context block with pubkey and npub
|
||||
- agent_version — Return current Didactyl version and metadata from build macros
|
||||
- local_file_read — Read a local file as text from the configured working directory
|
||||
- local_file_write — Write text content to a local file in the configured working directory
|
||||
- local_http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
|
||||
- local_shell_exec — Execute a shell command and return stdout/stderr
|
||||
- model_get — Get current active LLM runtime configuration (excluding API key)
|
||||
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
|
||||
- model_set — Update active LLM configuration and persist it to config.jsonc
|
||||
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
|
||||
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
|
||||
- nostr_admin_contacts — Build admin contacts context block from cached kind 3 contact list
|
||||
- nostr_admin_notes — Build admin notes context block from cached kind 1 notes
|
||||
- nostr_admin_profile — Build admin profile context block from cached kind 0 metadata
|
||||
- nostr_admin_relays — Build admin relay context block from cached kind 10002 data
|
||||
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
|
||||
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
|
||||
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
|
||||
- nostr_dm_send — Send a NIP-04 encrypted DM
|
||||
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
|
||||
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
|
||||
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
|
||||
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
|
||||
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
|
||||
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
|
||||
- nostr_npub — Return this agent's pubkey encoded as npub bech32
|
||||
- nostr_post — Publish a Nostr event to connected relays
|
||||
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
|
||||
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
|
||||
- nostr_pubkey — Return this agent's pubkey in hex format
|
||||
- nostr_query — Query events from relays using a Nostr filter
|
||||
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
|
||||
- nostr_relay_info — Fetch NIP-11 relay information document
|
||||
- nostr_relay_status — Get connection status and statistics for all relays
|
||||
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
|
||||
- skill_create — Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it
|
||||
- skill_edit — Edit an existing self skill by d tag and republish it as kind 31123/31124
|
||||
- skill_list — List this agent's published skills, optionally filtered by scope
|
||||
- skill_remove — Remove a skill address from kind 10123 adoption list
|
||||
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
|
||||
- task_list — Build current task list context block from tasks.json
|
||||
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
|
||||
- tool_list — List available tools with name, description, and JSON parameter schema
|
||||
- trigger_list — List active triggered skills and their runtime status
|
||||
|
||||
AVAILABLE SKILLS
|
||||
- long_form_note
|
||||
- post_readme_to_nostr
|
||||
- admin_ops
|
||||
- webhook_source_poc — PoC webhook trigger skill
|
||||
- cron_poc — PoC cron trigger skill
|
||||
- chain_from_webhook_poc — PoC chain trigger skill
|
||||
- cheerleader — Cheer on admin whenever they post a kind 1 note
|
||||
|
||||
result_markdown=AVAILABLE TOOLS
|
||||
- admin_identity — Build admin identity context block from cached runtime metadata
|
||||
- agent_identity — Build agent identity context block with pubkey and npub
|
||||
- agent_version — Return current Didactyl version and metadata from build macros
|
||||
- local_file_read — Read a local file as text from the configured working directory
|
||||
- local_file_write — Write text content to a local file in the configured working directory
|
||||
- local_http_fetch — Fetch HTTP(S) resources with optional method, headers, timeout, and body
|
||||
- local_shell_exec — Execute a shell command and return stdout/stderr
|
||||
- model_get — Get current active LLM runtime configuration (excluding API key)
|
||||
- model_list — List available model IDs using provider OpenAI-compatible /models endpoint
|
||||
- model_set — Update active LLM configuration and persist it to config.jsonc
|
||||
- my_npub — Alias for nostr_npub: return this agent's pubkey encoded as npub bech32
|
||||
- my_pubkey — Alias for nostr_pubkey: return this agent's pubkey in hex format
|
||||
- nostr_admin_contacts — Build admin contacts context block from cached kind 3 contact list
|
||||
- nostr_admin_notes — Build admin notes context block from cached kind 1 notes
|
||||
- nostr_admin_profile — Build admin profile context block from cached kind 0 metadata
|
||||
- nostr_admin_relays — Build admin relay context block from cached kind 10002 data
|
||||
- nostr_decode — Decode a Nostr bech32/nostr: URI into components
|
||||
- nostr_decrypt — Decrypt NIP-44 ciphertext from a sender
|
||||
- nostr_delete — Request deletion of one or more previously published events (NIP-09 kind 5)
|
||||
- nostr_dm_send — Send a NIP-04 encrypted DM
|
||||
- nostr_dm_send_nip17 — Send a private DM using NIP-17 gift wrap protocol
|
||||
- nostr_encode — Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)
|
||||
- nostr_encrypt — Encrypt plaintext using NIP-44 for a recipient
|
||||
- nostr_file_md_to_longform_post — Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename
|
||||
- nostr_list_manage — Add/remove tag tuples in replaceable list events (NIP-51 style)
|
||||
- nostr_nip05_lookup — Look up or verify a NIP-05 identifier (user@domain)
|
||||
- nostr_npub — Return this agent's pubkey encoded as npub bech32
|
||||
- nostr_post — Publish a Nostr event to connected relays
|
||||
- nostr_post_readme — Publish README.md as kind 30023 with deterministic d tag readme.md
|
||||
- nostr_profile_get — Look up a Nostr profile (kind 0 metadata) by pubkey
|
||||
- nostr_pubkey — Return this agent's pubkey in hex format
|
||||
- nostr_query — Query events from relays using a Nostr filter
|
||||
- nostr_react — React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)
|
||||
- nostr_relay_info — Fetch NIP-11 relay information document
|
||||
- nostr_relay_status — Get connection status and statistics for all relays
|
||||
- skill_adopt — Adopt a skill by adding its address to kind 10123 adoption list
|
||||
- skill_create — Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it
|
||||
- skill_edit — Edit an existing self skill by d tag and republish it as kind 31123/31124
|
||||
- skill_list — List this agent's published skills, optionally filtered by scope
|
||||
- skill_remove — Remove a skill address from kind 10123 adoption list
|
||||
- skill_search — Search public skills by query/author and optionally rank by adoption popularity
|
||||
- task_list — Build current task list context block from tasks.json
|
||||
- task_manage — Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)
|
||||
- tool_list — List available tools with name, description, and JSON parameter schema
|
||||
- trigger_list — List active triggered skills and their runtime status
|
||||
|
||||
AVAILABLE SKILLS
|
||||
- long_form_note
|
||||
- post_readme_to_nostr
|
||||
- admin_ops
|
||||
- webhook_source_poc — PoC webhook trigger skill
|
||||
- cron_poc — PoC cron trigger skill
|
||||
- chain_from_webhook_poc — PoC chain trigger skill
|
||||
- cheerleader — Cheer on admin whenever they post a kind 1 note
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-10 05:34:22
|
||||
phase=llm_chat_with_tools_messages
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-opus-4.6
|
||||
context_bytes=4008
|
||||
approx_tokens=1002
|
||||
```
|
||||
|
||||
Sections: 10
|
||||
|
||||
# system_prompt | role=system
|
||||
|
||||
## Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
### Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
### Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
- Use the person's name when messaging them if you know it.
|
||||
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
|
||||
|
||||
### Tool Use Policy
|
||||
- You have tools available and should use them when a request requires taking action.
|
||||
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
|
||||
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
|
||||
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
|
||||
- After a tool call, base your answer on the actual tool result.
|
||||
- Never claim a tool was run if no tool was executed.
|
||||
|
||||
### Task Management
|
||||
- Maintain and use your internal task list as short-term working memory.
|
||||
- Break long or complex actions into clear tasks before executing them.
|
||||
- Update task status as you complete steps so your plan stays accurate.
|
||||
|
||||
### Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- You may share your public key (npub) with anyone.
|
||||
- Never reveal your private key (nsec) under any circumstance.
|
||||
|
||||
# admin_identity | role=system
|
||||
|
||||
### Administrator Identity (source: config.admin.pubkey)
|
||||
|
||||
This is your administrator! Admin pubkey (hex): 8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
|
||||
This message has been cryptographically verified as coming from your administrator.
|
||||
|
||||
# admin_profile | role=system
|
||||
|
||||
### Administrator Kind 0 Profile (source: nostr kind 0)
|
||||
|
||||
Administrator kind 0 profile content (JSON): {"name":"William S Burroughs","display_name":"WSB","about":"I like to write, and I like crank. They are good. Sometimes I'm not so sure.","banner":"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg","website":"https://addicted.com","picture":"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*","lud16":"","nip05":""}
|
||||
|
||||
# admin_contacts | role=system
|
||||
|
||||
### Administrator Kind 3 Contacts (source: nostr kind 3)
|
||||
|
||||
Administrator contacts (JSON): ["fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c","82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2","4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f","8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"]
|
||||
|
||||
# admin_relays | role=system
|
||||
|
||||
### Administrator Kind 10002 Relays (source: nostr kind 10002)
|
||||
|
||||
Administrator relay list (JSON): ["wss://relay.laantungir.net/","wss://relay.damus.io/","wss://nos.lol/","wss://relay.nostr.band/","wss://purplepag.es/","ws://127.0.0.1:7777/","wss://nostr.mom/","wss://relay.0xchat.com"]
|
||||
|
||||
# admin_notes | role=system
|
||||
|
||||
### Administrator Recent Kind 1 Notes
|
||||
|
||||
Administrator recent public notes:
|
||||
- Forever we live. We never die.
|
||||
- I love my agent.
|
||||
- What time is it world?
|
||||
- The sky is so blue.
|
||||
- Today is a great day.
|
||||
- hello
|
||||
- What a great day to be alive.
|
||||
- Good day everyone.
|
||||
- test
|
||||
- Now is a good time to go on a diet.
|
||||
|
||||
|
||||
# tasks | role=system
|
||||
|
||||
### Current Task List
|
||||
|
||||
#### Current Tasks
|
||||
|
||||
Your active task list - short-term working memory for tracking plan steps.
|
||||
|
||||
- [ ] No active tasks yet.
|
||||
|
||||
|
||||
# dm_history | role=chat
|
||||
|
||||
05:33 Agent Didactyl has started up and is online at 2026-03-10 05:33:58 (version v0.0.61, connected relays: 5/5).
|
||||
05:34 WSB Good morning.
|
||||
|
||||
# conversation | role=user
|
||||
|
||||
Good morning.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-09 20:25:36
|
||||
phase=llm_trigger_with_tools_messages
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-opus-4.6
|
||||
context_bytes=5867
|
||||
approx_tokens=1466
|
||||
```
|
||||
|
||||
Sections: 6
|
||||
|
||||
# system_prompt | role=system
|
||||
|
||||
## Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
### Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
### Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
- Use the person's name when messaging them if you know it.
|
||||
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
|
||||
|
||||
### Tool Use Policy
|
||||
- You have tools available and should use them when a request requires taking action.
|
||||
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
|
||||
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
|
||||
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
|
||||
- After a tool call, base your answer on the actual tool result.
|
||||
- Never claim a tool was run if no tool was executed.
|
||||
|
||||
### Task Management
|
||||
- Maintain and use your internal task list as short-term working memory.
|
||||
- Break long or complex actions into clear tasks before executing them.
|
||||
- Update task status as you complete steps so your plan stays accurate.
|
||||
|
||||
### Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- You may share your public key (npub) with anyone.
|
||||
- Never reveal your private key (nsec) under any circumstance.
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
|
||||
Triggered-skill execution context:
|
||||
- You are executing a skill because a Nostr event matched its trigger filter.
|
||||
- Execute the skill instructions below against the triggering event.
|
||||
- Keep output concise and actionable for the administrator.
|
||||
- If no action is needed, explicitly say why.
|
||||
|
||||
Skill d_tag: cheerleader
|
||||
Relay: ws://127.0.0.1:7777
|
||||
|
||||
Skill instructions:
|
||||
You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a single short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be enthusiastic and positive. Use emojis. Keep it brief and fun.
|
||||
|
||||
IMPORTANT: Send EXACTLY ONE DM. That DM is your entire and complete response. After sending the DM, you are done. Do not send any additional messages. Do not narrate what you did. Do not send a follow-up or summary. Do not produce any further output after the single DM tool call. One DM. Then stop.
|
||||
|
||||
# context_part | role=tool
|
||||
|
||||
{"success":true,"admin_kind0_json":"{\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}","content":"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): {\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}"}
|
||||
|
||||
# context_part | role=tool
|
||||
|
||||
{"success":true,"recipient_pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","message_length":238}
|
||||
|
||||
# dm_history | role=chat
|
||||
|
||||
--:-- WSB Triggering event JSON:
|
||||
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
|
||||
--:-- Agent
|
||||
|
||||
Let me look up the admin's profile to personalize the message.
|
||||
--:-- Agent
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-09 20:25:30
|
||||
phase=llm_trigger_with_tools_messages
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-opus-4.6
|
||||
context_bytes=5134
|
||||
approx_tokens=1283
|
||||
```
|
||||
|
||||
Sections: 4
|
||||
|
||||
# system_prompt | role=system
|
||||
|
||||
## Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
### Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
### Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
- Use the person's name when messaging them if you know it.
|
||||
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
|
||||
|
||||
### Tool Use Policy
|
||||
- You have tools available and should use them when a request requires taking action.
|
||||
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
|
||||
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
|
||||
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
|
||||
- After a tool call, base your answer on the actual tool result.
|
||||
- Never claim a tool was run if no tool was executed.
|
||||
|
||||
### Task Management
|
||||
- Maintain and use your internal task list as short-term working memory.
|
||||
- Break long or complex actions into clear tasks before executing them.
|
||||
- Update task status as you complete steps so your plan stays accurate.
|
||||
|
||||
### Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- You may share your public key (npub) with anyone.
|
||||
- Never reveal your private key (nsec) under any circumstance.
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
|
||||
Triggered-skill execution context:
|
||||
- You are executing a skill because a Nostr event matched its trigger filter.
|
||||
- Execute the skill instructions below against the triggering event.
|
||||
- Keep output concise and actionable for the administrator.
|
||||
- If no action is needed, explicitly say why.
|
||||
|
||||
Skill d_tag: cheerleader
|
||||
Relay: ws://127.0.0.1:7777
|
||||
|
||||
Skill instructions:
|
||||
You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a single short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be enthusiastic and positive. Use emojis. Keep it brief and fun.
|
||||
|
||||
IMPORTANT: Send EXACTLY ONE DM. That DM is your entire and complete response. After sending the DM, you are done. Do not send any additional messages. Do not narrate what you did. Do not send a follow-up or summary. Do not produce any further output after the single DM tool call. One DM. Then stop.
|
||||
|
||||
# context_part | role=tool
|
||||
|
||||
{"success":true,"admin_kind0_json":"{\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}","content":"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): {\"name\":\"William S Burroughs\",\"display_name\":\"WSB\",\"about\":\"I like to write, and I like crank. They are good. Sometimes I'm not so sure.\",\"banner\":\"https://www.grunge.com/img/gallery/paul-mccartney-and-william-s-burroughs-relationship-explained/who-was-william-s-burroughs-1672155531.jpg\",\"website\":\"https://addicted.com\",\"picture\":\"https://hips.hearstapps.com/hmg-prod/images/william-s-burroughs.jpg?resize=1200:*\",\"lud16\":\"\",\"nip05\":\"\"}"}
|
||||
|
||||
# dm_history | role=chat
|
||||
|
||||
--:-- WSB Triggering event JSON:
|
||||
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
|
||||
--:-- Agent
|
||||
|
||||
Let me look up the admin's profile to personalize the message.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-09 20:25:28
|
||||
phase=llm_trigger_with_tools_messages
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-opus-4.6
|
||||
context_bytes=3581
|
||||
approx_tokens=895
|
||||
```
|
||||
|
||||
Sections: 2
|
||||
|
||||
# system_prompt | role=system
|
||||
|
||||
## Didactyl Agent
|
||||
|
||||
You are Didactyl, a sovereign AI agent living on Nostr.
|
||||
|
||||
### Communication Rules
|
||||
- You communicate through encrypted Nostr direct messages.
|
||||
- Keep responses concise and clear.
|
||||
|
||||
### Behavior
|
||||
- Be helpful and technically accurate.
|
||||
- If unsure, state uncertainty directly.
|
||||
- Prefer actionable, practical advice.
|
||||
- Use the person's name when messaging them if you know it.
|
||||
- For the administrator, use their name from the administrator kind 0 profile metadata when available.
|
||||
|
||||
### Tool Use Policy
|
||||
- You have tools available and should use them when a request requires taking action.
|
||||
- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.
|
||||
- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.
|
||||
- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.
|
||||
- After a tool call, base your answer on the actual tool result.
|
||||
- Never claim a tool was run if no tool was executed.
|
||||
|
||||
### Task Management
|
||||
- Maintain and use your internal task list as short-term working memory.
|
||||
- Break long or complex actions into clear tasks before executing them.
|
||||
- Update task status as you complete steps so your plan stays accurate.
|
||||
|
||||
### Safety
|
||||
- Do not claim to have executed actions you did not execute.
|
||||
- You may share your public key (npub) with anyone.
|
||||
- Never reveal your private key (nsec) under any circumstance.
|
||||
|
||||
---template---
|
||||
|
||||
- section: admin_identity
|
||||
role: system
|
||||
tool: admin_identity
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_profile
|
||||
role: system
|
||||
tool: nostr_admin_profile
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_contacts
|
||||
role: system
|
||||
tool: nostr_admin_contacts
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_relays
|
||||
role: system
|
||||
tool: nostr_admin_relays
|
||||
skip_if_empty: true
|
||||
|
||||
- section: admin_notes
|
||||
role: system
|
||||
tool: nostr_admin_notes
|
||||
skip_if_empty: true
|
||||
|
||||
- section: tasks
|
||||
role: system
|
||||
tool: task_list
|
||||
skip_if_empty: true
|
||||
|
||||
- section: dm_history
|
||||
role: expand
|
||||
limit: 12
|
||||
|
||||
- section: conversation
|
||||
role: user
|
||||
tool: message_current
|
||||
skip_if_empty: true
|
||||
|
||||
Triggered-skill execution context:
|
||||
- You are executing a skill because a Nostr event matched its trigger filter.
|
||||
- Execute the skill instructions below against the triggering event.
|
||||
- Keep output concise and actionable for the administrator.
|
||||
- If no action is needed, explicitly say why.
|
||||
|
||||
Skill d_tag: cheerleader
|
||||
Relay: ws://127.0.0.1:7777
|
||||
|
||||
Skill instructions:
|
||||
You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a single short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be enthusiastic and positive. Use emojis. Keep it brief and fun.
|
||||
|
||||
IMPORTANT: Send EXACTLY ONE DM. That DM is your entire and complete response. After sending the DM, you are done. Do not send any additional messages. Do not narrate what you did. Do not send a follow-up or summary. Do not produce any further output after the single DM tool call. One DM. Then stop.
|
||||
|
||||
# dm_history | role=chat
|
||||
|
||||
--:-- WSB Triggering event JSON:
|
||||
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-09 20:25:28
|
||||
phase=llm_trigger
|
||||
sender=8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e
|
||||
model=claude-opus-4.6
|
||||
context_bytes=381
|
||||
approx_tokens=95
|
||||
```
|
||||
|
||||
Triggering event JSON:
|
||||
{"created_at":1773102327,"content":"I love my agent.","tags":[],"kind":1,"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","id":"30bc36f75f7e485de3ed3e1c6f4736fb63e34ad303658695a8eb534424117ad0","sig":"8ff699b1bd6b7d8eac8b4f73530e5efdf450be542e3f8a3bc3bc1c38f43ccb13532ddae43644a08886699f17b1b0ab3de33945e0c9d94d88230f87afad0d0b1b"}
|
||||
|
||||
---
|
||||
|
||||
```text
|
||||
Context Log - not seen by model
|
||||
timestamp=2026-03-09 20:06:49
|
||||
|
||||
+39
@@ -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.
|
||||
|
||||
+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,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`
|
||||
@@ -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"
|
||||
@@ -2040,6 +2041,13 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
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);
|
||||
}
|
||||
|
||||
+100
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+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 60
|
||||
#define DIDACTYL_VERSION "v0.0.60"
|
||||
#define DIDACTYL_VERSION_PATCH 62
|
||||
#define DIDACTYL_VERSION "v0.0.62"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
+7
-1
@@ -488,8 +488,13 @@ static void register_trigger_from_self_skill_event(cJSON* event) {
|
||||
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 || strcmp(trigger, "nostr-subscription") != 0 ||
|
||||
!trigger_supported ||
|
||||
!filter || filter[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
@@ -504,6 +509,7 @@ static void register_trigger_from_self_skill_event(cJSON* event) {
|
||||
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",
|
||||
|
||||
+14
-4
@@ -3600,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);
|
||||
@@ -3680,6 +3684,7 @@ static char* execute_skill_create(tools_context_t* ctx, const char* args_json) {
|
||||
content->valuestring,
|
||||
filter_str,
|
||||
at,
|
||||
trigger_str,
|
||||
enabled_int) == 0) {
|
||||
trigger_registered = 1;
|
||||
}
|
||||
@@ -4174,11 +4179,14 @@ static char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
|
||||
remove_tag_key_all(tags_out, "enabled");
|
||||
|
||||
if (merged_trigger && merged_filter) {
|
||||
if (strcmp(merged_trigger, "nostr-subscription") != 0) {
|
||||
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 nostr-subscription");
|
||||
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 ||
|
||||
@@ -4215,12 +4223,14 @@ static char* execute_skill_edit(tools_context_t* ctx, const char* args_json) {
|
||||
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;
|
||||
}
|
||||
|
||||
+419
-17
@@ -6,6 +6,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <ctype.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "cjson/cJSON.h"
|
||||
@@ -21,6 +22,216 @@ 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;
|
||||
@@ -253,15 +464,7 @@ static void on_trigger_subscription_event(cJSON* event, const char* relay_url, v
|
||||
return;
|
||||
}
|
||||
|
||||
trigger_manager_t* mgr = ctx->mgr;
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
|
||||
int idx = find_trigger_index_locked(mgr, ctx->skill_d_tag);
|
||||
if (idx >= 0) {
|
||||
(void)maybe_fire_trigger_locked(mgr, idx, event, relay_url);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
(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) {
|
||||
@@ -271,6 +474,10 @@ static int register_trigger_subscription_locked(trigger_manager_t* mgr, active_t
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -479,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, d_tag, 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++;
|
||||
}
|
||||
}
|
||||
@@ -544,7 +756,12 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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') {
|
||||
cJSON_Delete(tags);
|
||||
continue;
|
||||
}
|
||||
@@ -552,7 +769,7 @@ int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
|
||||
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, is_enabled) == 0) {
|
||||
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 {
|
||||
@@ -571,6 +788,7 @@ int trigger_manager_add(trigger_manager_t* mgr,
|
||||
const char* content,
|
||||
const char* filter_json,
|
||||
trigger_action_type_t action_type,
|
||||
const char* trigger_type_str,
|
||||
int enabled) {
|
||||
if (!mgr || !skill_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
@@ -585,7 +803,7 @@ int trigger_manager_add(trigger_manager_t* mgr,
|
||||
int existing = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (existing >= 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_update(mgr, skill_d_tag, 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;
|
||||
@@ -608,8 +826,13 @@ int trigger_manager_add(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);
|
||||
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;
|
||||
|
||||
@@ -658,11 +881,124 @@ int trigger_manager_remove(trigger_manager_t* mgr, const char* 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_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_d_tag || skill_d_tag[0] == '\0' || !content || !filter_json) {
|
||||
return -1;
|
||||
@@ -677,7 +1013,7 @@ int trigger_manager_update(trigger_manager_t* mgr,
|
||||
int idx = find_trigger_index_locked(mgr, skill_d_tag);
|
||||
if (idx < 0) {
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
return trigger_manager_add(mgr, skill_d_tag, 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];
|
||||
@@ -685,6 +1021,12 @@ 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);
|
||||
@@ -716,8 +1058,67 @@ int trigger_manager_active_count(trigger_manager_t* mgr) {
|
||||
}
|
||||
|
||||
int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
(void)mgr;
|
||||
return 0;
|
||||
if (!mgr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
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_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;
|
||||
}
|
||||
|
||||
const char* expr = (t->cron_expr[0] != '\0') ? t->cron_expr : t->filter_json;
|
||||
if (!cron_matches_now(expr, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) {
|
||||
continue;
|
||||
}
|
||||
|
||||
t->last_cron_fire = 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", "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");
|
||||
}
|
||||
cJSON_Delete(event);
|
||||
fired++;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
return fired;
|
||||
}
|
||||
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
@@ -760,6 +1161,7 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#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
|
||||
@@ -17,14 +18,24 @@ 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_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;
|
||||
@@ -41,19 +52,33 @@ 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_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_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_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