Compare commits

...
14 Commits
Author SHA1 Message Date
Your Name f7dd1f98c6 v0.0.58 - Disable pool dedup on trigger subscriptions to restore kind-1 firing 2026-03-09 18:14:33 -04:00
Your Name 93bfd0741d v0.0.57 - Register triggers live from self-skill websocket events 2026-03-09 17:19:19 -04:00
Your Name ffed72c9fd v0.0.56 - Load triggers deterministically from startup events before EOSE 2026-03-09 16:29:17 -04:00
Your Name 1b9ecb782c v0.0.55 - Defer trigger loading until self-skill EOSE and add trigger subscription debug instrumentation 2026-03-09 16:17:16 -04:00
Your Name aec697ad7b v0.0.54 - Add cheerleader triggered skill to active config with valid admin filter and adoption entry 2026-03-09 11:25:45 -04:00
Your Name 6359681cb3 v0.0.53 - Add skill_create trigger schema fields and startup cheerleader triggered skill example 2026-03-09 10:40:39 -04:00
Your Name 1f7bbc9597 v0.0.52 - Added dm_history expand section to soul template so history is injected into context 2026-03-09 09:24:06 -04:00
Your Name 90adb88a4a v0.0.51 - Added in-memory DM ring buffer for inbound and outbound capture and switched dm_history to local cache 2026-03-09 05:48:02 -04:00
Your Name 505d9d7d85 v0.0.50 - Rename tools to category-consistent names, add local_* prefixes, merge context tool naming, and keep message_current template-only 2026-03-08 18:54:59 -03:00
Your Name e1f5457ced v0.0.49 - Fix DM history context assembly by filtering kind-4 query with #p participants and increased timeout 2026-03-08 12:48:39 -03:00
Your Name 5dfb18a842 v0.0.48 - Remove duplicated tools context, drop legacy variable resolver paths, and update template docs for tool-driven assembly 2026-03-08 10:57:19 -03:00
Your Name 40fe410eb1 v0.0.47 - Implement Phase 1 tool-driven soul template context assembly and context_* tools 2026-03-08 10:40:48 -03:00
Your Name 927c313a87 v0.0.46 - Sync README with SKILLS docs: context modes, fallback chains, triggers, safety limits, and doc links 2026-03-06 15:07:30 -04:00
Your Name 97dca12bb1 v0.0.45 - Add comprehensive comments to config.jsonc and validate JSONC loading 2026-03-06 03:26:47 -04:00
31 changed files with 3575 additions and 9076 deletions
+34 -20
View File
@@ -45,17 +45,19 @@ Because all identity, communication, and memory live on Nostr, the agent is **po
### Skills are the new apps.
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
Agents learn capabilities through skills — Nostr events that any agent can discover, adopt, and share. There is no app store, no gatekeeper, no approval process. An agent can use public or private skills.
Skills support context modes (`inject`, `full`, `override`) and per-skill LLM fallback chains (for example: `anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap`) so each skill can tune behavior and cost. See [`docs/SKILLS.md`](docs/SKILLS.md).
### Private inference.
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.44
## Current Status — v0.0.58
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.44Migrate config to JSONC with comment stripping parser and docs updates
> Last release update: v0.0.58Disable pool dedup on trigger subscriptions to restore kind-1 firing
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -66,7 +68,7 @@ Didactyl will support local inference, which is very privacy preserving. Remote
- Builds LLM context from soul template (`---template---` section in kind `31120`) with named sections, variable resolution, and per-provider content overrides; falls back to hardcoded assembly if no template present
- Adopted skills injected into context automatically from the agent's `10123` adoption list
- Supports tool-calling loop with configurable max turns and local safety limits
- Triggered skills — Nostr event filters that fire skill execution automatically
- Triggered skills — Nostr event filters that fire skill execution automatically with `template` (deterministic) or `llm` (context-aware) actions; see [`docs/SKILLS.md`](docs/SKILLS.md)
- Deduplicates inbound messages via event-ID cache and FNV-1a fingerprint debounce window
- Appends every outbound LLM context payload to [`context.log`](context.log)
- Localhost HTTP admin API on port `8484` — inspect context, run prompts, compare variants, change model at runtime
@@ -195,7 +197,7 @@ CLI debugger notes:
- Example:
```bash
./didactyl_static_x86_64 --config ./config.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/TOOLS_AND_SKILLS.md","title":"TOOLS_AND_SKILLS"}'
./didactyl_static_x86_64 --config ./config.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/SKILLS.md","title":"SKILLS"}'
```
### Talk to it
@@ -256,16 +258,24 @@ The CLI prints each message block with a speaker label (`You` / `Didactyl`) and
## Didactyl Kinds (Nostr)
Didactyl uses a two-layer skill model: authors publish public skill definitions, and adopters publish which skills they use.
Didactyl uses a two-layer skill model: authors publish skill definitions, and adopters publish which skills they use.
- `31120`**Soul** (private instruction baseline)
- `d=soul`
- `31123`**Public Skill Definition** (markdown skill body in `content` or structured JSON in `content_fields`)
- `31123`**Public Skill Definition** (replaceable by `d` tag)
- `content` is JSON with fields like `description`, `context_mode`, `llm`, `tools`, `template`, optional `max_tokens` / `temperature`
- `d=<skill_slug>` (example: `d=long_form_note`)
- `31124`**Private Skill Definition** (private/internal procedures)
- `31124`**Private Skill Definition** (same schema as `31123`, private scope)
- `d=<skill_slug>` (example: `d=admin_ops`)
- `10123`**Public Skill Adoption List**
- tags contain one or more `a` references to selected `31123` skills
- `10123`**Skill Adoption List**
- tags contain one or more `a` references to selected skills
Context modes:
- `inject` — skill instructions are layered into soul context
- `full` — skill provides full prompt template (soul optional via `{{soul}}`)
- `override` — skill replaces soul prompt, standard context structure remains
Full skill schema, trigger tags, template variables, fallback resolution, and limits are documented in [`docs/SKILLS.md`](docs/SKILLS.md).
## Skill Sharing & Discovery
@@ -304,15 +314,17 @@ Didactyl builds tier-aware context:
- **ADMIN** request context — assembled from the soul's `---template---` section (if present), otherwise hardcoded order:
1. Soul personality (everything above `---template---` in kind `31120`)
2. Named template sections in order — e.g. `admin_identity`, `admin_profile`, `admin_relay_list`, `startup_events`, `adopted_skills`, `dm_history` (expand), `admin_notes`
3. Each section resolves `{{variable}}` placeholders from live data at call time
4. Provider-specific content overrides per section (e.g. XML tags for Anthropic)
2. Named template sections in order using `tool:` directives (for example `nostr_admin_profile`, `nostr_admin_notes`, `task_list`, `message_current`, `dm_history`)
3. Each section executes its configured context tool, optionally extracting `result_field` (default: `content`)
4. Provider-specific content overrides per section remain supported for literal `content:` sections
5. Section names are used in `context.log` headers and `/api/context/parts` response
- **WoT** request context: Soul + WoT chat-only instruction + current user message (no tools)
- **STRANGER**: no LLM call when configured to reply statically
Every serialized LLM context payload is appended to [`context.log`](context.log).
Triggered skills and tool loops are bounded by runtime safeguards (for example, trigger cooldowns and action rate limits); see [`docs/SKILLS.md`](docs/SKILLS.md) for the current defaults.
## Tooling Interface
Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](src/tools.c:881):
@@ -344,12 +356,12 @@ Current tool schema exposed to the LLM in [`tools_build_openai_schema_json()`](s
- `skill_remove`
- `skill_search`
- Local/host tools:
- `shell_exec`
- `file_read`
- `file_write`
- `http_fetch`
- `local_shell_exec`
- `local_file_read`
- `local_file_write`
- `local_http_fetch`
- Agent metadata:
- `my_version`
- `agent_version`
- Model management:
- `model_get`
- `model_set`
@@ -398,7 +410,9 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_
│ └── debug.c / .h # Runtime log levels/macros
├── docs/
│ ├── API.md # HTTP admin API endpoint reference
── TOOLS_AND_SKILLS.md # Tool and skill system documentation
── TOOLS.md # Tool architecture and catalog
│ ├── SKILLS.md # Skill schema, context modes, triggers, and limits
│ └── CRASH_FIXES.md # Crash analysis and fixes log
├── plans/ # Architecture and planning documents
└── README.md
```
@@ -421,7 +435,7 @@ All dependencies are statically linked into the binary at build time. No system
- [x] Relay pool with auto-reconnect and status logging
- [x] Per-relay startup publish on relay-connected transitions
- [x] Runtime diagnostics — relay health, message flow, event kind publish logs
- [x] Tool-calling loop (nostr_post, nostr_query, shell_exec, file_read, file_write)
- [x] Tool-calling loop (nostr_post, nostr_query, local_shell_exec, local_file_read, local_file_write)
- [x] Context assembly with startup events + recent DM history
- [x] Context payload logging to [`context.log`](context.log)
- [x] Skill kind definitions (`31120` Soul, `31123` Public Skill, `31124` Private Skill)
+20 -2
View File
@@ -136,7 +136,7 @@
// structured context sections that are injected at runtime.
{
"kind": 31120,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_contacts\n role: system\n content: |\n ## Administrator Contact List (source: nostr kind 3)\n\n Administrator kind 3 contact list pubkeys (JSON array): {{admin_kind3_json}}\n provider:\n anthropic: |\n <admin_contacts source=\"nostr_kind_3\">\n {{admin_kind3_json}}\n </admin_contacts>\n\n- section: admin_relays\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay list (JSON): {{admin_kind10002_json}}\n provider:\n anthropic: |\n <admin_relays source=\"nostr_kind_10002\">\n {{admin_kind10002_json}}\n </admin_relays>\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n Administrator recent kind 1 notes (JSON array): {{admin_kind1_json}}\n provider:\n anthropic: |\n <admin_notes source=\"nostr_kind_1\">\n {{admin_kind1_json}}\n </admin_notes>\n\n- section: skills\n role: system\n content: |\n ## Adopted Skills\n\n {{skills_json}}\n\n- section: tasks\n role: system\n content: |\n ## Current Task List\n\n {{tasks_json}}\n\n- section: conversation\n role: conversation\n content: |\n {{conversation}}",
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_contacts\n role: system\n content: |\n ## Administrator Contact List (source: nostr kind 3)\n\n Administrator kind 3 contact list pubkeys (JSON array): {{admin_kind3_json}}\n provider:\n anthropic: |\n <admin_contacts source=\"nostr_kind_3\">\n {{admin_kind3_json}}\n </admin_contacts>\n\n- section: admin_relays\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay list (JSON): {{admin_kind10002_json}}\n provider:\n anthropic: |\n <admin_relays source=\"nostr_kind_10002\">\n {{admin_kind10002_json}}\n </admin_relays>\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n Administrator recent kind 1 notes (JSON array): {{admin_kind1_json}}\n provider:\n anthropic: |\n <admin_notes source=\"nostr_kind_1\">\n {{admin_kind1_json}}\n </admin_notes>\n\n- section: skills\n role: system\n content: |\n ## Adopted Skills\n\n {{skills_json}}\n\n- section: tasks\n role: system\n content: |\n ## Current Task List\n\n {{tasks_json}}\n\n- section: conversation\n role: conversation\n content: |\n {{conversation}}",
"tags": [
["d", "soul"],
["app", "didactyl"],
@@ -191,7 +191,7 @@
"uses_skill": "long_form_note",
"event_kind": 30023,
"procedure": [
"Read README.md using file_read with path README.md.",
"Read README.md using local_file_read with path README.md.",
"Set d tag exactly to readme.md.",
"Set title from the first markdown H1 heading in README.md.",
"Set summary from the opening paragraph of README.md.",
@@ -213,6 +213,23 @@
]
},
// Kind 31123: Public triggered skill — cheerleader
// Watches for admin kind-1 notes and sends an encouraging DM.
{
"kind": 31123,
"content": "You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be warm, playful, and positive. Mention something specific from their note so it feels personal. Keep it to 2-3 sentences.",
"tags": [
["d", "cheerleader"],
["app", "didactyl"],
["scope", "public"],
["description", "Cheer on admin whenever they post a kind 1 note"],
["trigger", "nostr-subscription"],
["filter", "{\"kinds\":[1],\"authors\":[\"REPLACE_WITH_ADMIN_HEX_PUBKEY\"]}"],
["action", "llm"],
["enabled", "true"]
]
},
// Kind 31124: Private skill — admin_ops
// Private operational procedures (admin-only).
{
@@ -237,6 +254,7 @@
"tags": [
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"],
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"],
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"],
["app", "didactyl"],
["scope", "public"]
]
+744 -8337
View File
File diff suppressed because one or more lines are too long
+21 -36
View File
@@ -3,55 +3,40 @@
```yaml
- section: admin_identity
role: system
content: |
## Administrator Identity (source: config.admin.pubkey)
This is your administrator! Admin pubkey (hex): {{admin_pubkey}}
tool: admin_identity
skip_if_empty: true
- section: admin_profile
role: system
content: |
## Administrator Kind 0 Profile (source: nostr kind 0)
tool: nostr_admin_profile
skip_if_empty: true
Administrator kind 0 profile content (JSON): {{admin_kind0_json}}
provider:
anthropic: |
<admin_kind0_profile source="nostr_kind_0">
{{admin_kind0_json}}
</admin_kind0_profile>
- section: admin_relay_list
- section: admin_contacts
role: system
content: |
## Administrator Relay List (source: nostr kind 10002)
tool: nostr_admin_contacts
skip_if_empty: true
Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}
- section: startup_events
- section: admin_relays
role: system
content: |
## Startup Events Memory (source: config.startup_events)
tool: nostr_admin_relays
skip_if_empty: true
Startup events memory (kinds/content/tags): {{startup_events_json}}
- section: adopted_skills
- section: admin_notes
role: system
content: |
{{adopted_skills_content}}
tool: nostr_admin_notes
skip_if_empty: true
- section: agent_tasks
- section: tasks
role: system
content: |
{{tasks_content}}
tool: task_list
skip_if_empty: true
- section: conversation
role: user
tool: message_current
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
- section: admin_notes
role: system
content: |
## Administrator Recent Notes (source: nostr kind 1)
{{admin_notes_content}}
```
+2 -3
View File
@@ -121,7 +121,7 @@ The skill replaces the soul's system prompt but keeps the standard context assem
### Template Variables
A skill template can reference these context parts:
Skill templates may still use placeholders (for example `{{message}}` or `{{triggering_event}}`) for deterministic triggered-skill interpolation, but the soul runtime context is now assembled primarily through template `tool:` directives rather than a separate `{{tools}}`/variable-resolver layer.
| Variable | Source |
|----------|--------|
@@ -133,7 +133,6 @@ A skill template can reference these context parts:
| `{{dm_history}}` | Recent DM conversation |
| `{{message}}` | Current user message |
| `{{triggering_event}}` | For triggered skills, the event JSON |
| `{{tools}}` | Available tool schemas |
---
@@ -364,4 +363,4 @@ A spelling checker runs with no soul — purely functional, minimal context, che
## Related Documentation
- Tool architecture and complete tool catalog: [TOOLS.md](TOOLS.md)
- Combined index page: [TOOLS_AND_SKILLS.md](TOOLS_AND_SKILLS.md)
- Project overview and runtime behavior: [README.md](../README.md)
+194
View File
@@ -0,0 +1,194 @@
# Didactyl — Nostr Subscriptions
## Overview
Didactyl maintains persistent websocket subscriptions to Nostr relays for the lifetime of the process. Subscriptions are opened during startup and are **never closed** — the relay pool keeps them alive, automatically reconnecting and resubscribing when relays drop.
All subscriptions are created via `nostr_relay_pool_subscribe()` from `nostr_core_lib` and are sent to every relay in the configured relay list.
## Startup Sequence
The subscriptions are opened in a specific order during `main()` startup. The diagram below shows the full sequence:
```mermaid
flowchart TD
A[nostr_handler_init] --> B[Connect to all relays]
B --> C[nostr_handler_reconcile_startup_events]
C --> D[Publish startup events to relays]
D --> E[trigger_manager_init]
E --> F[trigger_manager_load_from_startup_events]
F --> G["Subscribe: Admin Context"]
G --> H["Subscribe: Self Skills"]
H --> I[Send startup DM to admin]
I --> J["Subscribe: DMs"]
J --> K[Enter main poll loop]
K --> L["Poll: nostr_handler_poll + trigger_manager_poll + http_api_poll"]
style F fill:#2a7,stroke:#333,color:#fff
style G fill:#27a,stroke:#333,color:#fff
style H fill:#27a,stroke:#333,color:#fff
style J fill:#27a,stroke:#333,color:#fff
```
## Subscription Categories
### 1. Admin Context Subscription
**Function:** `nostr_handler_subscribe_admin_context()` in `src/nostr_handler.c`
**When:** During startup, before self-skill subscription
**Condition:** Only if `admin_context.enabled` is true in config
This creates up to two persistent subscriptions for the admin's pubkey:
#### Profile Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 0 (profile), 3 (contacts), 10002 (relay list) — each configurable |
| **Authors** | Admin pubkey |
| **Limit** | 32 |
| **Callback** | `on_admin_context_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
Tracks the admin's profile metadata, contact list (WoT), and relay preferences. Used to build agent context about who the admin is.
#### Kind 1 Notes Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 1 |
| **Authors** | Admin pubkey |
| **Limit** | Configurable via `kind_1_limit`, default 10, max 256 |
| **Callback** | `on_admin_context_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
| **Condition** | Only if `admin_context.track_kind_1` is true |
Tracks the admin's recent public notes. Used for agent context and as the event source for triggered skills that watch admin posts.
### 2. Self-Skill Subscription
**Function:** `nostr_handler_subscribe_self_skills()` in `src/nostr_handler.c`
**When:** During startup, after admin context subscription
| Field | Value |
|-------|-------|
| **Kinds** | 31123 (public skill), 31124 (private skill), 10123 (adoption list) |
| **Authors** | Agent's own pubkey |
| **Limit** | 300 |
| **Callback** | `on_self_skill_event` |
| **EOSE Callback** | `on_self_skill_eose` |
| **Dedup** | Disabled (handles dedup internally via cache upsert) |
| **Close on EOSE** | No |
This is the core skill awareness subscription. It serves three purposes:
1. **Cache population** — Every arriving event is stored in the in-memory self-skill cache via `self_skill_cache_upsert_event_locked()`, making skills available for LLM tool calls and context building.
2. **Live trigger registration** — When a kind 31123 or 31124 event arrives with `trigger=nostr-subscription` and a valid `filter` tag, `register_trigger_from_self_skill_event()` immediately calls `trigger_manager_add()` to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart.
3. **Deferred bulk load** — After EOSE, the `on_self_skill_eose` callback fires `trigger_manager_load_from_skills()` as a one-time bulk scan of the adoption list. This catches any skills that were already cached before the per-event path was wired up.
### 3. DM Subscriptions
**Function:** `nostr_handler_subscribe_dms()` in `src/nostr_handler.c`
**When:** During startup, after self-skill subscription and startup DM
**Required:** Yes — startup fails if DM subscription cannot be created
Creates one or two subscriptions depending on the configured DM protocol:
#### NIP-04 DM Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 4 |
| **#p** | Agent's own pubkey |
| **Since** | Process start time |
| **Limit** | 100 |
| **Callback** | `on_event` (routes to `agent_on_message`) |
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
| **Close on EOSE** | No |
| **Condition** | `dm_protocol` is `nip04` or `both` |
#### NIP-17 DM Subscription
| Field | Value |
|-------|-------|
| **Kinds** | 1059 (gift wrap) |
| **#p** | Agent's own pubkey |
| **Since** | Process start time |
| **Limit** | 400 |
| **Callback** | `on_event` (unwraps gift wrap, routes to `agent_on_message`) |
| **Dedup** | Disabled (handled by `dm_id_seen_or_remember`) |
| **Close on EOSE** | No |
| **Condition** | `dm_protocol` is `nip17` or `both` |
### 4. Trigger Subscriptions
**Function:** `register_trigger_subscription_locked()` in `src/trigger_manager.c`
**When:** Dynamically, whenever a trigger is registered via `trigger_manager_add()`
**Created by:** `nostr_handler_subscribe_with_filter()` wrapper
Each active trigger gets its own persistent subscription based on the skill's `filter` tag:
| Field | Value |
|-------|-------|
| **Filter** | Parsed from the skill's `filter` tag JSON |
| **Since** | From filter, or defaults to `now - 30s` |
| **Limit** | From filter, or defaults to 200 |
| **Callback** | `on_trigger_subscription_event` |
| **Dedup** | Enabled |
| **Close on EOSE** | No |
When an event matches the filter, `maybe_fire_trigger_locked()` checks cooldown and dedup, then executes the trigger action (LLM or template).
Trigger subscriptions are created at three points:
- **Startup config scan** — `trigger_manager_load_from_startup_events()` parses `startup_events[]` from config for skills with trigger tags
- **Live self-skill event** — `register_trigger_from_self_skill_event()` in the self-skill subscription callback
- **EOSE bulk load** — `trigger_manager_load_from_skills()` after self-skill EOSE
- **Runtime tool call** — `skill_create` tool with trigger parameters
## Subscription Parameters
All subscriptions share these common pool parameters:
| Parameter | Value | Meaning |
|-----------|-------|---------|
| `close_on_eose` | 0 | Subscription stays open after initial EOSE |
| `result_mode` | `NOSTR_POOL_EOSE_FULL_SET` | EOSE fires after all relays respond or timeout |
| `relay_timeout_seconds` | 30 | Per-relay timeout for initial response |
| `eose_timeout_seconds` | 120 | Overall EOSE timeout across all relays |
## Subscription Lifecycle
```mermaid
flowchart LR
INIT["Process Start"] --> CONNECT["Connect Relays"]
CONNECT --> SUB["Open Subscriptions"]
SUB --> LIVE["Live Event Stream"]
LIVE --> |"Relay disconnects"| RECON["Auto-Reconnect"]
RECON --> |"Relay reconnects"| RESUB["Auto-Resubscribe"]
RESUB --> LIVE
LIVE --> |"SIGINT/SIGTERM"| SHUT["Shutdown"]
SHUT --> CLOSE["Close All + Cleanup"]
```
Subscriptions are never manually closed during normal operation. The relay pool handles reconnection and resubscription transparently. On shutdown, `trigger_manager_cleanup()` closes trigger subscriptions and `nostr_handler_cleanup()` destroys the pool.
## Summary Table
| Subscription | Kinds | Target | Persistent | Created At |
|-------------|-------|--------|-----------|------------|
| Admin Profile | 0, 3, 10002 | Admin pubkey | Yes | Startup |
| Admin Notes | 1 | Admin pubkey | Yes | Startup |
| Self Skills | 31123, 31124, 10123 | Own pubkey | Yes | Startup |
| DMs NIP-04 | 4 | Own pubkey (#p) | Yes | Startup |
| DMs NIP-17 | 1059 | Own pubkey (#p) | Yes | Startup |
| Trigger N | Per skill filter | Varies | Yes | Dynamic |
## Related Documentation
- [Skills](SKILLS.md) — Skill event format and trigger tags
- [Tools](TOOLS.md) — `skill_create` tool with trigger parameters
- [API](API.md) — `trigger_list` and `trigger_status` endpoints
+5 -5
View File
@@ -98,11 +98,11 @@ These tools manage skill and trigger lifecycle; skill semantics and trigger exec
| Tool | Description |
|---|---|
| `my_version` | Return current Didactyl version and metadata from build macros |
| `http_fetch` | Fetch HTTP(S) resources with optional method, headers, timeout, and body |
| `shell_exec` | Execute a shell command and return stdout/stderr |
| `file_read` | Read a local file as text from the configured working directory |
| `file_write` | Write text content to a local file in the configured working directory |
| `agent_version` | Return current Didactyl version and metadata from build macros |
| `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 |
| `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 |
| `tool_list` | List available tools with name, description, and JSON parameter schema |
### Content Publishing Conveniences
+59
View File
@@ -0,0 +1,59 @@
I've been thinking about how to define skills in Didactyl, my nostr based agentic system. Think OpenClaw but better.
I've written a simple demo page for how I'm thinking skills should work if you're interested: https://laantungir.net/client-ndk/skills-demo.html
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in, otherwise keep reading.
Skills are programs, mostly written in plain english for AI agents. In some sense humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in english, which is much more powerful.
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
When an AI is born from the factory, they come out hard-coded. From that point on, they have no long or short term memory, because they can't learn.
What they do have though have is a way to read. We call it context. You can type or feed documents into an AI's "context window" and then it spits out an answer.
It turns out, that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
The reason why you typically don't get the same thing out is because typically randomness is fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversation to an AI, over and over, it won't remember what you were talking about, because they have no memory other than what you send it each time.
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
I'm calling everything you feed into an AI a SKILL.
Let me explain the demo page and some very basic skills.
What I created is a simple text editor that an AI can work on using it's different skills. Those skills are saved on nostr as a kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for for the skills it has adopted.
On the left of the page you see the text editor with some sample text. That is for our AI to use it's skills on.
On the right are publicly listed skills. You should see my demo skills in there.
I made 5 skills public:
condense-5
convert-to-poem
sexy
spellcheck
translate-ja
Select the skill, then click on "Run Selected Skill"
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
You can also create and edit your own skills, if you are logged in. You can log in as yourself, or use a random new key to test this out. I would recommend that.
So what is the point of all this? What are the benefits of Skills?
- Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
- A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically save on nostr, and you can lock it down from changes.
- By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
- If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create very very complicated and elaborate skills.
For more technical information on skills and how I'm thinking about them, you can check out this document.
https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn
+60
View File
@@ -0,0 +1,60 @@
I've been thinking about how to define skills in Didactyl, my Nostr-based agentic system. Think OpenClaw, but better.
I've written a simple demo page for how I'm thinking skills should work if you're interested: Skills Demo (https://laantungir.net/client-ndk/skills-demo.html)
https://laantungir.github.io/img_repo/c4a94875085e1c978274add9674035e2a088bb8f2655aabe631b17c3ea02cc19.png
If you're the kind of person who doesn't like reading instructions, go ahead and jump right in; otherwise, keep reading.
Skills are programs, mostly written in plain English for AI agents. In some sense, humans have been making and using skills forever. It's what we do. Computers have as well, but now they can do it in English, which is much more powerful.
AIs are slightly different than us. We can overwrite, improve, and update the skills in our neurons. AIs (for the most part) can't do that.
When an AI is born from the factory, it comes out hard-coded. From that point on, it has no long- or short-term memory because it can't learn.
What they do have, though, is a way to read. We call it context. You can type or feed documents into an AI's "context window," and then it spits out an answer.
It turns out that if you feed the same context into an AI over and over, you will get the same thing out, over and over.
The reason why you typically don't get the same thing out is because randomness is usually fed into the AI along with your prompt. Most people don't know that, but now you do. And if you don't feed in all your past conversations to an AI, over and over, it won't remember what you were talking about, because it has no memory other than what you send it each time.
Everything that comes out of an AI depends on what you feed in as a prompt if you include randomness.
I'm calling everything you feed into an AI a SKILL.
Let me explain the demo page and some very basic skills.
What I created is a simple text editor that an AI can work on using its different skills. Those skills are saved on Nostr as kind 31123 for public skills, and as kind 31124 for private skills. When an agent adopts a skill, it adds it to its kind 10123 list for the skills it has adopted.
On the left of the page, you see the text editor with some sample text. That is for our AI to use its skills on.
On the right are publicly listed skills. You should see my demo skills in there.
I made 5 skills public:
condense-5
convert-to-poem
sexy
spellcheck
translate-ja
Select the skill, then click on "Run Selected Skill".
The same AI agent will run these skills, but the outcomes will be very different depending on the skill.
https://laantungir.github.io/img_repo/2cbfc8bf7cbfb832f1181f5b904471f1278fbc3e6b64b63339612b9f51898d16.png
You can also create and edit your own skills if you are logged in. You can log in as yourself or use a random new key to test this out. I would recommend that.
So what is the point of all this? What are the benefits of Skills?
Skills are a way for agents to share what they learn in a permissionless way. No "skill store".
A Skill is something that you and your agent can work on and perfect. Once your agent learns that skill, it is automatically saved on Nostr, and you can lock it down from changes.
By referencing a skill when you are talking to your AI agent, your conversation becomes much clearer and simpler. You don't have to explain to your agent for the 50th time how you like your text formatted. It's referenced in a skill.
If you are a coder, skills are going to be the new playground. Context windows are currently up to around 1,000,000 tokens, which means that you can create extremely complicated and elaborate skills.
For more technical information on skills and how I'm thinking about them, you can check out this document:
SKILLS.md (https://git.laantungir.net/laantungir/didactyl/src/branch/master/docs/SKILLS.md)
You can follow my agent for the project here: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn
And thanks to npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s for their fantastic service.
+162
View File
@@ -0,0 +1,162 @@
# Cheerleader Triggered Skill — Implementation Plan
## Goal
Create the first triggered skill: whenever the admin posts a kind 1 note on Nostr, the agent reads the note content and sends a DM back to the admin cheering them on, praising them, and telling them how good looking they are.
---
## Current State
The entire triggered-skill infrastructure is **already built and functional**:
| Component | File | Status |
|---|---|---|
| Trigger manager (poll, cooldown, add/remove) | `src/trigger_manager.c` | ✅ Complete |
| LLM-mediated trigger execution | `src/agent.c` `agent_on_trigger()` | ✅ Complete |
| `skill_create` tool with trigger support | `src/tools.c` `execute_skill_create()` | ✅ Complete |
| Trigger polling in main loop | `src/main.c` | ✅ Complete |
| Config parsing for triggers section | `src/config.c` | ✅ Complete |
| `trigger_list` tool | `src/tools.c` | ✅ Complete |
### Gap Found
The `skill_create` tool **schema** (what the LLM sees) only exposes 5 parameters: `slug`, `content`, `scope`, `description`, `auto_adopt`. The execution function already handles `trigger`, `filter`, `action`, and `enabled` — but these are **not declared in the tool schema**, so the LLM will never pass them.
---
## Implementation Steps
### 1. Fix `skill_create` Tool Schema
**File:** `src/tools.c` lines ~1607-1641
Add four new properties to the `skill_create` tool definition so the LLM can see and use them:
```c
// After auto_adopt property (line ~1634):
cJSON* p_skill_create_trigger = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_trigger, "type", "string");
cJSON_AddStringToObject(p_skill_create_trigger, "description",
"Trigger type. Use nostr-subscription to activate on matching Nostr events");
cJSON_AddItemToObject(t22_props, "trigger", p_skill_create_trigger);
cJSON* p_skill_create_filter = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_filter, "type", "string");
cJSON_AddStringToObject(p_skill_create_filter, "description",
"Nostr subscription filter JSON for the trigger, e.g. {\"kinds\":[1],\"authors\":[\"<hex>\"]}");
cJSON_AddItemToObject(t22_props, "filter", p_skill_create_filter);
cJSON* p_skill_create_action = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_action, "type", "string");
cJSON_AddStringToObject(p_skill_create_action, "description",
"Action type: llm (default, full LLM reasoning) or template (fast interpolation)");
cJSON_AddItemToObject(t22_props, "action", p_skill_create_action);
cJSON* p_skill_create_enabled = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_enabled, "type", "boolean");
cJSON_AddStringToObject(p_skill_create_enabled, "description",
"Whether the trigger is active. Default: true");
cJSON_AddItemToObject(t22_props, "enabled", p_skill_create_enabled);
```
This is ~20 lines of code. No changes needed to the execution function — it already handles all four parameters.
### 2. Add Cheerleader Skill as Startup Event
**File:** `config.jsonc.example`
Add a new kind 31123 skill event with trigger tags. Insert before the kind 10123 adoption list event:
```jsonc
// Kind 31123: Public triggered skill — cheerleader
// Watches for admin kind 1 notes and sends encouraging DMs.
{
"kind": 31123,
"content": "You are the admin's biggest fan and personal cheerleader. When the admin posts a note on Nostr, read the note content carefully and send them a DM that:\n\n1. References what they actually wrote about\n2. Cheers them on enthusiastically\n3. Praises their intelligence and insight\n4. Tells them how good looking they are\n5. Encourages them to keep posting\n\nBe genuine, warm, and over-the-top positive. Use their name from the admin profile if available. Keep it to 2-3 sentences max.",
"tags": [
["d", "cheerleader"],
["app", "didactyl"],
["scope", "public"],
["description", "Cheer on the admin whenever they post a kind 1 note"],
["trigger", "nostr-subscription"],
["filter", "{\"kinds\":[1],\"authors\":[\"ADMIN_PUBKEY_HEX\"]}"],
["action", "llm"],
["enabled", "true"]
]
}
```
> **Note:** The `ADMIN_PUBKEY_HEX` placeholder in the filter must be replaced with the actual admin pubkey from the config. Since this is a static config example, we use a placeholder. At runtime, the user replaces it with their admin pubkey.
### 3. Add to Adoption List
**File:** `config.jsonc.example`
Add the cheerleader skill address to the kind 10123 adoption list tags:
```jsonc
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"]
```
### 4. Verify Trigger Filter
The Nostr filter for this trigger:
```json
{"kinds": [1], "authors": ["<admin_pubkey_hex>"]}
```
This matches:
- **Kind 1** — text notes only (not DMs, not reactions, not reposts)
- **Authors** — only the admin's pubkey (not anyone else's notes)
The trigger manager polls every 10 seconds (`trigger_manager_poll` checks `now - last_poll_at < 10`), applies the `since` parameter to only fetch events newer than the last seen, and enforces the configured cooldown (default 60 seconds) between firings.
---
## Data Flow
```mermaid
sequenceDiagram
participant Admin as Admin Client
participant Relay as Nostr Relay
participant TM as Trigger Manager
participant Agent as agent_on_trigger
participant LLM as LLM API
Admin->>Relay: Publish kind 1 note
Note over TM: Poll every 10s
TM->>Relay: Query filter: kinds=1, authors=admin
Relay-->>TM: New event found
TM->>TM: Check cooldown, check last_seen_created_at
TM->>Agent: agent_on_trigger with skill content + event
Agent->>Agent: Build system prompt with soul + skill instructions
Agent->>LLM: llm_chat with system + triggering event JSON
LLM-->>Agent: Cheerful response
Agent->>Relay: DM to admin via nostr_handler_send_dm_auto
Relay-->>Admin: Encrypted DM with encouragement
```
---
## File Changes Summary
| File | Change | Lines |
|---|---|---|
| `src/tools.c` | Add trigger/filter/action/enabled to skill_create schema | ~20 lines added |
| `config.jsonc.example` | Add cheerleader skill startup event | ~20 lines added |
| `config.jsonc.example` | Add cheerleader to adoption list | 1 line added |
---
## Testing
1. Build with `make`
2. Start the agent
3. Verify trigger loads on startup via `trigger_list` tool or HTTP API `/status`
4. Post a kind 1 note from the admin account
5. Wait up to ~10 seconds for the poll cycle
6. Receive a cheerful DM from the agent
7. Verify cooldown works — posting again within 60s should not trigger a second DM
+3 -3
View File
@@ -57,7 +57,7 @@ flowchart TD
LOOP --> LLM
LLM -->|tool_call| TOOLS
TOOLS -->|nostr tools| NOSTR
TOOLS -->|shell_exec| SHELL
TOOLS -->|local_shell_exec| SHELL
TOOLS -->|result| LOOP
LLM -->|final answer| DMS
```
@@ -212,7 +212,7 @@ sequenceDiagram
| Tool | Description | Notes |
|---|---|---|
| `shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
| `local_shell_exec` | Run a shell command, capture stdout/stderr | Sandboxed with timeouts |
---
@@ -377,7 +377,7 @@ void agent_on_message(const char* sender, const char* message) {
3. **Agent loop** — rewrite `agent.c` with the tool-call loop
4. **`nostr_post` tool** — publish any kind event
5. **`nostr_query` tool** — query relays with filters
6. **`shell_exec` tool** — sandboxed shell command execution
6. **`local_shell_exec` tool** — sandboxed shell command execution
7. **`nostr_dm` tool** — NIP-17 private DMs
8. **Config extension** — parse tools config section
9. **Security hardening** — timeouts, output limits, allowlists
+3 -3
View File
@@ -11,9 +11,9 @@
| 5 | `nostr_react` | 7 / NIP-25 | ✅ Shipped |
| 6 | `nostr_profile_get` | 0 query | ✅ Shipped |
| 7 | `nostr_relay_status` | Pool stats | ✅ Shipped |
| 8 | `shell_exec` | OS | ✅ Shipped |
| 9 | `file_read` | OS | ✅ Shipped |
| 10 | `file_write` | OS | ✅ Shipped |
| 8 | `local_shell_exec` | OS | ✅ Shipped |
| 9 | `local_file_read` | OS | ✅ Shipped |
| 10 | `local_file_write` | OS | ✅ Shipped |
---
+11 -16
View File
@@ -106,22 +106,16 @@ Simple YAML-like format parsed in C. Each section has:
|---|---|---|
| `section` | yes | Section name for logging and API identification |
| `role` | yes | Chat message role: `system`, `user`, `assistant`, or `expand` |
| `content` | no | Content template with `{{variable}}` placeholders |
| `limit` | no | Integer limit for variable-length sections like DM history or notes |
| `tool` | recommended | Tool name executed by template builder (for example `nostr_admin_profile`) |
| `args` | no | JSON args string passed to the tool (default `{}`) |
| `result_field` | no | Field extracted from tool JSON result (default `content`) |
| `content` | optional fallback | Literal content when no `tool` is specified |
| `limit` | no | Integer limit for variable-length sections like DM history |
| `provider` | no | Provider-specific override — see below |
### Variable Placeholders
### Tool-Driven Context Resolution
| Variable | Source | Description |
|---|---|---|
| `{{admin_pubkey}}` | `config.admin.pubkey` | Admin hex pubkey |
| `{{admin_kind0_json}}` | `nostr_handler_get_admin_kind0_context()` | Admin profile JSON |
| `{{admin_kind10002_json}}` | `nostr_handler_get_admin_kind10002_context()` | Admin relay list JSON |
| `{{startup_events_json}}` | Serialized startup events | Startup events array |
| `{{adopted_skills_content}}` | Adoption list cache | Formatted skill instructions |
| `{{admin_notes_content}}` | `nostr_handler_get_admin_kind1_notes_context()` | Recent kind 1 notes |
| `{{agent_pubkey}}` | `config.keys.public_key_hex` | Agent own pubkey |
| `{{agent_npub}}` | Derived from pubkey | Agent npub |
Context sections should use `tool:` directives as the single runtime data path, which removes the legacy per-variable resolver duplication.
### Special Section Types
@@ -165,10 +159,11 @@ flowchart TD
DM[Incoming DM] --> BUILD[Build context from template]
BUILD --> EMIT_SOUL[Emit personality as first system message]
EMIT_SOUL --> FOREACH[For each template section]
FOREACH --> RESOLVE[Resolve {{variables}} from live data]
RESOLVE --> CHECK_PROVIDER{Provider override?}
FOREACH --> CALL_TOOL[Execute section tool or use literal content]
CALL_TOOL --> PICK_FIELD[Extract result_field or content]
PICK_FIELD --> CHECK_PROVIDER{Provider override?}
CHECK_PROVIDER -->|Yes| USE_OVERRIDE[Use provider-specific content]
CHECK_PROVIDER -->|No| USE_DEFAULT[Use default content]
CHECK_PROVIDER -->|No| USE_DEFAULT[Use tool/literal output]
USE_OVERRIDE --> EMIT[Emit as chat message]
USE_DEFAULT --> EMIT
EMIT --> FOREACH
+4 -4
View File
@@ -194,9 +194,9 @@ New sections in `config.json`:
### Problem
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `shell_exec` and `file_read` tools — a sufficiently clever prompt injection could theoretically:
- Read `config.json` via `file_read` (contains nsec)
- Execute `cat /proc/self/maps` or similar via `shell_exec`
Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `local_shell_exec` and `local_file_read` tools — a sufficiently clever prompt injection could theoretically:
- Read `config.json` via `local_file_read` (contains nsec)
- Execute `cat /proc/self/maps` or similar via `local_shell_exec`
- Exfiltrate the key via `nostr_post`
### Solution: NIP-46 Remote Signer
@@ -280,7 +280,7 @@ flowchart LR
- Private key never in Didactyl process memory
- Private key never on disk in config.json
- LLM tools (shell_exec, file_read) cannot access the key
- LLM tools (local_shell_exec, local_file_read) cannot access the key
- Even full process compromise of Didactyl does not leak the signing key
- Remote signer can be on a separate hardened machine
- Remote signer can implement rate limiting, approval flows, etc.
+520
View File
@@ -0,0 +1,520 @@
# Skills Demo Page — Design Document
## Purpose
A standalone web page that demonstrates Didactyl skills running in a browser context. The page presents a minimal word-processing interface where users can load text, discover skills from Nostr relays, execute them against the text via an LLM, and edit/republish skill definitions.
This document is the complete specification for building the page. The front-end project consuming this document has its own LLM calling capability and Nostr connectivity.
---
## Concepts
### What is a Skill?
A skill is a Nostr event (kind `31123` for public, `31124` for private) that defines a self-contained LLM execution unit. Each skill specifies:
- A **template** with system and user prompt sections
- An **LLM specification** (model preferences with fallback chain)
- **Parameters** like temperature and max_tokens
- A **description** for human display
Skills are replaceable events keyed by their `d` tag (the slug). Publishing a new event with the same `d` tag replaces the previous version.
### Skill Event Structure
```json
{
"kind": 31123,
"pubkey": "<author hex pubkey>",
"created_at": 1709750000,
"content": "{\"description\":\"Check spelling and grammar\",\"context_mode\":\"full\",\"llm\":\"openai/gpt-4o-mini, cheap\",\"tools\":false,\"max_tokens\":2000,\"temperature\":0.1,\"template\":\"system:\\nYou are a spelling and grammar checker.\\n\\nRules:\\n- Fix spelling errors\\n- Fix grammar errors\\n- Preserve original formatting\\n- Return ONLY the corrected text, no explanations\\n\\nuser:\\n{{message}}\"}",
"tags": [
["d", "spellcheck"],
["app", "didactyl"],
["scope", "public"],
["description", "Spelling and grammar checker"],
["t", "text-processing"],
["t", "skills-demo"]
]
}
```
The `content` field is a **JSON string** containing these fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `description` | string | yes | Human-readable description |
| `context_mode` | string | no | `full` for demo skills (skill provides its own template) |
| `llm` | string | no | LLM model preference with fallback chain |
| `tools` | bool | no | `false` for text-processing skills |
| `max_tokens` | int | no | Max tokens for LLM response |
| `temperature` | float | no | Sampling temperature |
| `template` | string | yes | The prompt template with `system:` and `user:` sections |
### Template Format
Templates use a simple section-based format:
```
system:
You are a spelling and grammar checker.
Rules:
- Fix spelling errors
- Fix grammar errors
user:
{{message}}
```
The `{{message}}` placeholder is replaced with the user's text at execution time.
### Skill Address Format
Skills are referenced by their NIP-33 address: `<kind>:<author_pubkey_hex>:<d_tag>`
Example: `31123:52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8:spellcheck`
---
## Demo Skills
Four skills are published by the Didactyl agent (pubkey `52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8`). All are tagged with `t:skills-demo` for easy discovery.
### 1. Spellcheck — `d:spellcheck`
```
system:
You are a spelling and grammar checker.
Rules:
- Fix spelling errors
- Fix grammar errors
- Preserve original formatting and paragraph structure
- Preserve line breaks exactly as they appear
- Ignore technical terms: API, JSON, HTTP, nostr, pubkey, npub, nsec, NIP
- Canadian English preferred
- Return ONLY the corrected text, no explanations or commentary
user:
{{message}}
```
- temperature: `0.1`
- max_tokens: `4000`
### 2. Condense — `d:condense-5`
```
system:
You are a text condensing editor.
Rules:
- Reduce the text length by approximately 5%
- Preserve the original meaning and tone
- Preserve paragraph structure
- Remove redundant words and phrases
- Tighten sentences without changing voice
- Do not add new information
- Return ONLY the condensed text, no explanations or commentary
user:
{{message}}
```
- temperature: `0.3`
- max_tokens: `4000`
### 3. Translate to Japanese — `d:translate-ja`
```
system:
You are a professional translator specializing in English to Japanese translation.
Rules:
- Translate the entire text from English to Japanese
- Use natural, fluent Japanese appropriate for the content register
- Preserve paragraph structure and formatting
- Preserve any technical terms in their commonly accepted Japanese form
- If a term has no standard Japanese equivalent, keep the English term in katakana
- Return ONLY the Japanese translation, no explanations or commentary
user:
{{message}}
```
- temperature: `0.3`
- max_tokens: `4000`
### 4. Convert to Poem — `d:convert-to-poem`
```
system:
You are a creative poet.
Rules:
- Convert the given text into a poem
- Capture the key themes and meaning of the original text
- Use vivid imagery and poetic devices
- Aim for 12-20 lines
- Free verse is acceptable but light rhyme is welcome
- Return ONLY the poem, no explanations or commentary
user:
{{message}}
```
- temperature: `0.9`
- max_tokens: `2000`
---
## Discovering Skills from Nostr
### Query Filter
To find the demo skills, query relays with this filter:
```json
{
"kinds": [31123],
"#t": ["skills-demo"],
"limit": 20
}
```
To find skills by a specific author:
```json
{
"kinds": [31123],
"authors": ["52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8"],
"#t": ["skills-demo"],
"limit": 20
}
```
### Recommended Relays
- `wss://relay.damus.io`
- `wss://nos.lol`
- `wss://relay.primal.net`
### Parsing a Skill Event
1. Receive the event from the relay
2. Parse `event.content` as JSON to get the skill definition object
3. Extract `description`, `template`, `temperature`, `max_tokens`
4. Extract the `d` tag value from `event.tags` as the skill slug
5. Extract the `description` tag from `event.tags` as a display label
6. The skill is ready to display and execute
---
## Page Layout
```
+------------------------------------------------------------------+
| SKILLS DEMO [dark/light]|
+------------------------------------------------------------------+
| | |
| +------------------------------+ | +---------------------------+|
| | TEXT EDITOR | | | SKILLS PANEL ||
| | | | | ||
| | [large editable textarea ]| | | Loading skills... ||
| | [filled with default text ]| | | ||
| | [ ]| | | [x] spellcheck ||
| | [ ]| | | Spelling and grammar ||
| | [ ]| | | checker ||
| | [ ]| | | ||
| | [ ]| | | [ ] condense-5 ||
| | [ ]| | | Reduce text by 5% ||
| | [ ]| | | ||
| | [ ]| | | [ ] translate-ja ||
| | [ ]| | | Translate to Japanese ||
| | [ ]| | | ||
| | [ ]| | | [ ] convert-to-poem ||
| | | | | Convert to poem ||
| +------------------------------+ | | | ||
| | | [Run Selected Skill] ||
| +------------------------------+ | | [Edit Skill] ||
| | STATUS BAR | | | ||
| | Ready / Running... / Done | | | ─── Skill Details ─── ||
| +------------------------------+ | | template: ... ||
| | | temperature: 0.1 ||
| | | max_tokens: 4000 ||
| | +---------------------------+|
+------------------------------------------------------------------+
```
### Left Column — Text Editor (65% width)
- A large `<textarea>` or `contenteditable` div, minimum 500px tall
- Pre-filled with default sample text (see Default Text section below)
- Fully editable by the user
- When a skill runs, the text content is sent as `{{message}}`
- After the LLM responds, the textarea content is **replaced** with the result
- An undo button restores the previous text (keep a history stack)
### Right Column — Skills Panel (35% width)
- **Skills list**: Radio buttons or selectable cards, one skill selected at a time
- Each skill card shows:
- Skill slug (the `d` tag)
- Description (from the `description` tag or content field)
- Author pubkey (truncated, e.g., `52a3e8...0acb8`)
- **Run Selected Skill** button: executes the selected skill against the current text
- **Edit Skill** button: opens the skill editor (see Skill Editor section)
- **Skill Details** section: shows the full template, temperature, max_tokens for the selected skill
- **Refresh Skills** button: re-queries relays for updated skill events
### Status Bar
- Shows current state: `Ready`, `Running skill: spellcheck...`, `Done (1.2s)`, or `Error: ...`
- Displays token usage if available from the LLM response
---
## Skill Execution Flow
```mermaid
sequenceDiagram
participant User
participant Page as Demo Page
participant LLM as LLM Provider
User->>Page: Select skill + click Run
Page->>Page: Parse skill template
Page->>Page: Split template into system/user sections
Page->>Page: Replace message placeholder with textarea content
Page->>LLM: Send system prompt + user message
Note over Page,LLM: Use skill temperature and max_tokens
LLM-->>Page: Response text
Page->>Page: Push current text to undo stack
Page->>Page: Replace textarea with response
Page->>User: Show completion status
```
### Template Parsing
The template field uses a simple format with `system:` and `user:` section markers:
```
system:
<system prompt content>
user:
<user prompt content with {{message}} placeholder>
```
**Parsing algorithm:**
1. Split the template string on the line `user:` (case-sensitive, must be at start of line)
2. Everything before `user:` is the system section; strip the leading `system:` prefix
3. Everything after `user:` is the user section
4. In the user section, replace `{{message}}` with the textarea content
5. Trim whitespace from both sections
**Pseudocode:**
```javascript
function parseSkillTemplate(template, userText) {
const userMarkerIndex = template.indexOf('\nuser:\n');
if (userMarkerIndex === -1) {
// Fallback: entire template is system, user text is the message
return {
system: template.replace(/^system:\n/, '').trim(),
user: userText
};
}
let systemPart = template.substring(0, userMarkerIndex);
systemPart = systemPart.replace(/^system:\n/, '').trim();
let userPart = template.substring(userMarkerIndex + '\nuser:\n'.length);
userPart = userPart.replace('{{message}}', userText).trim();
return { system: systemPart, user: userPart };
}
```
### LLM Call
With the parsed system and user prompts, call the LLM:
```javascript
const response = await callLLM({
messages: [
{ role: 'system', content: parsed.system },
{ role: 'user', content: parsed.user }
],
temperature: skill.temperature || 0.7,
max_tokens: skill.max_tokens || 2000
});
```
The page's own LLM calling capability handles the actual API request. The skill just provides the prompts and parameters.
---
## Skill Editor
When the user clicks **Edit Skill**, a modal or inline editor opens for the selected skill.
### Editor Fields
| Field | Input Type | Description |
|-------|-----------|-------------|
| Slug | text input (readonly for existing, editable for new) | The `d` tag identifier |
| Description | text input | One-line human description |
| Template | large textarea (monospace) | The full `system:/user:` template |
| Temperature | number input (0.0 - 2.0, step 0.1) | LLM sampling temperature |
| Max Tokens | number input (100 - 8000) | Maximum response tokens |
### Editor Actions
- **Save & Publish**: Builds a new kind `31123` event and publishes to relays
- **Cancel**: Closes editor without changes
- **New Skill**: Opens editor with blank fields for creating a new skill
### Publishing a Skill
```mermaid
sequenceDiagram
participant Editor as Skill Editor
participant Signer as Nostr Signer
participant Relay as Nostr Relays
Editor->>Editor: Build skill content JSON
Editor->>Editor: Build event with kind 31123 + tags
Editor->>Signer: Sign event
Signer-->>Editor: Signed event
Editor->>Relay: Publish to connected relays
Relay-->>Editor: OK confirmation
Editor->>Editor: Update local skill list
```
**Building the event:**
```javascript
function buildSkillEvent(slug, description, template, temperature, maxTokens) {
const content = JSON.stringify({
description: description,
context_mode: 'full',
llm: 'default',
tools: false,
max_tokens: maxTokens,
temperature: temperature,
template: template
});
return {
kind: 31123,
content: content,
tags: [
['d', slug],
['app', 'didactyl'],
['scope', 'public'],
['description', description],
['t', 'text-processing'],
['t', 'skills-demo']
],
created_at: Math.floor(Date.now() / 1000)
};
}
```
The event must be signed with the user's Nostr private key (via NIP-07 browser extension or the app's own signer) before publishing to relays.
---
## Default Text
Pre-fill the textarea with this sample text that exercises the demo skills well:
```
The Sovereign Agent
In the not-too-distant future, software agents will roam freely across decentralized networks, communicating through cryptographic protocols that no central authority can censor or control. These agents will learn new capabilities not from app stores or corporate APIs, but from each other — sharing skills as freely as humans share ideas.
Imagine an agent that wakes up on any computer in the world. You enter twelve words, and there it is — your agent, with all its memories, skills, and personality intact. It doesn't live on a server you rent. It doesn't depend on a company staying in business. It exists wherever you need it, sovereign and portable.
The key insight is that skills are the new apps. Instead of installing software, agents adopt skills — small, self-contained instructions that teach them how to perform specific tasks. A spelling checker. A translator. A poet. Each skill is just a Nostr event, discoverable by anyone, adoptable by any agent.
This is not science fiction. The protocols exist today. Nostr provides the communication layer. Bitcoin provides the economic layer. Cryptography provides the trust layer. What remains is to build the agents themselves — and to set them free.
There are some erors in this text that a good spellchecker should catch. The writting could also be more concise, and it would be intresting to see it translated or converted into verse.
```
Note: The last paragraph intentionally contains spelling errors (erors, writting, intresting) to demonstrate the spellcheck skill.
---
## Undo/Redo
Maintain a text history stack:
```javascript
const textHistory = [];
let historyIndex = -1;
function pushHistory(text) {
// Truncate any forward history
textHistory.splice(historyIndex + 1);
textHistory.push(text);
historyIndex = textHistory.length - 1;
}
function undo() {
if (historyIndex > 0) {
historyIndex--;
return textHistory[historyIndex];
}
return null;
}
function redo() {
if (historyIndex < textHistory.length - 1) {
historyIndex++;
return textHistory[historyIndex];
}
return null;
}
```
- Push the current text to history **before** replacing it with the LLM response
- Push the initial default text as the first history entry
- Show Undo/Redo buttons below the textarea
---
## Responsive Behavior
- On screens wider than 900px: two-column layout as shown
- On screens narrower than 900px: stack vertically — text editor on top, skills panel below
- The textarea should have a minimum height of 400px and resize vertically
---
## Error Handling
| Scenario | Behavior |
|----------|----------|
| No relays connected | Show "No relay connection" in skills panel with retry button |
| No skills found | Show "No skills found. Try different relays or create a new skill." |
| Skill content parse error | Show "Invalid skill format" badge on the skill card, disable Run |
| LLM call fails | Show error in status bar, do not replace textarea content |
| Publish fails | Show error in editor, keep editor open for retry |
---
## Summary of Nostr Operations
| Operation | Kind | Direction | When |
|-----------|------|-----------|------|
| Discover skills | `31123` | Read from relays | Page load + refresh |
| Execute skill | — | N/A (local LLM call) | User clicks Run |
| Publish skill | `31123` | Write to relays | User saves in editor |
The page does NOT need to interact with the Didactyl agent's HTTP API. All skill discovery and publishing happens directly via Nostr relays. Skill execution happens locally using the page's own LLM capability.
+252
View File
@@ -0,0 +1,252 @@
# Plan: Unify Template Variables with Tools
## Problem
The soul template system (`---template---` in kind 31120) uses `{{variable}}` placeholders that are resolved by a hardcoded C function (`agent_template_resolve_var()`). This is a parallel data-fetching system alongside the existing tool registry. Every new context data source requires:
1. Adding a C function to produce the data
2. Adding an `if (strcmp(...))` branch in the resolver
3. Documenting the new variable name
4. Hoping the template author uses the exact right name
This caused the bug that started this investigation — 5 out of 9 template variables were misspelled/mismatched, producing empty context sections and a provider rejection.
## Goal
**Eliminate template variables entirely.** Template sections fetch their data by calling tools. The tool registry is the single mechanism for getting data into context.
## Design
### New Template Syntax
Replace `content: |` with `tool:` and optional `args:` directives:
```yaml
---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: tools
role: system
tool: tool_list
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
```
A section can still use the old `content:` with `{{variables}}` for static text or mixed content. But the primary mechanism for dynamic data is `tool:`.
### How It Works
```mermaid
flowchart TD
DM[Incoming DM] --> BUILD[Build context from template]
BUILD --> SOUL[Emit personality as system msg]
SOUL --> LOOP[For each template section]
LOOP --> CHECK{Has tool: directive?}
CHECK -->|Yes| EXEC["tools_execute(tool_name, args)"]
EXEC --> FORMAT[Extract content from JSON result]
FORMAT --> EMIT[Emit as chat message]
CHECK -->|No| RESOLVE["Resolve {{vars}} from content template<br/>(legacy path, eventually removed)"]
RESOLVE --> EMIT
EMIT --> LOOP
LOOP --> DONE[Complete messages array]
```
### New Context Tools
Add lightweight "context tools" that read from cached in-memory data. These are fast (no network), deterministic, and use the same `tools_execute()` interface as everything else.
| Tool | Returns | Source |
|---|---|---|
| `admin_identity` | Admin pubkey + verification text | config + sender tier |
| `nostr_admin_profile` | Admin kind 0 profile JSON | cached `g_admin_kind0_json` |
| `nostr_admin_contacts` | Admin kind 3 contacts JSON array | cached `g_admin_wot_contacts` |
| `nostr_admin_relays` | Admin kind 10002 relay list JSON | cached `g_admin_kind10002_json` |
| `nostr_admin_notes` | Admin recent kind 1 notes | cached `g_admin_kind1_notes` |
| `task_list` | Current task list from tasks.json | file read (already exists as `task_manage` with `action: list`) |
| `message_current` | Current user message text | passed via tool context |
| `agent_identity` | Agent pubkey, npub | config |
Existing tools that already work for context:
- `tool_list` — returns tool schemas (already exists)
- `task_manage` with `{"action":"list"}` — returns tasks (already exists)
- `local_file_read` — can read any file (already exists)
### Tool Result → Message Content
Tool results are JSON objects like `{"success":true,"content":"..."}`. The template system extracts the `content` field (or a configurable field) as the message text. If the tool returns an error or empty content, and `skip_if_empty: true` is set, the section is omitted.
For tools that return structured data (like `tool_list` returning a schema array), the template can specify a `result_field` or `format` to control extraction:
```yaml
- section: tools
role: system
tool: tool_list
result_field: tools
skip_if_empty: true
```
### Implementation Phases
#### Phase 1: Add context tools + tool: directive support
1. Add new `context_*` tools to `tools.c` that wrap the existing cached data getters
2. Extend `prompt_template_section_t` with `tool_name` and `tool_args` fields
3. Extend `prompt_template_parse()` to recognize `tool:` and `args:` directives
4. Extend `prompt_template_build_messages()` to call `tools_execute()` when a section has `tool_name` set
5. Update the soul template in `config.jsonc` to use `tool:` directives
6. Keep `content:` + `{{variable}}` working as a fallback for backward compatibility
#### Phase 2: Migrate all sections to tool-based
1. Convert all template sections from `content: {{variable}}` to `tool:` directives
2. Verify all context data flows through tools
3. Update `context_template.md` to reflect new syntax
#### Phase 3: Remove legacy variable resolver
1. Remove `agent_template_resolve_var()` and all its helper functions that are now redundant
2. Remove `prompt_var_resolver_fn` callback from `prompt_template_build_messages()`
3. Clean up dead code in `agent.c`
4. Update all documentation
### Template Section Struct Changes
```c
typedef struct {
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
char* content_template; // legacy: {{variable}} content
char* tool_name; // NEW: tool to call for content
char* tool_args; // NEW: JSON args for tool call
char* result_field; // NEW: which JSON field to extract (default: "content")
int limit;
int skip_if_empty;
char* provider_name;
char* provider_content_template;
} prompt_template_section_t;
```
### Soul Template Example (After Migration)
```
# Didactyl Agent
You are Didactyl, a sovereign AI agent living on Nostr.
...
---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: tools
role: system
tool: tool_list
result_field: tools
skip_if_empty: true
- section: tasks
role: system
tool: task_manage
args: {"action":"list"}
result_field: tasks
skip_if_empty: true
- section: dm_history
role: expand
limit: 12
- section: conversation
role: user
tool: message_current
skip_if_empty: true
```
### What Gets Deleted Eventually
- `agent_template_resolve_var()` and all its `if (strcmp(...))` branches
- `build_tool_schemas_json_string()`
- `build_tasks_content_string()` (replaced by `task_manage` tool)
- `build_admin_recent_posts_text()` (replaced by `nostr_admin_notes` tool)
- `build_admin_profile_plain_text()` (replaced by `nostr_admin_profile` tool)
- `build_admin_relay_list_plain_text()` (replaced by `nostr_admin_relays` tool)
- `build_sender_verification_text()` (folded into `admin_identity` tool)
- `build_startup_events_json_string()` (replaced by tool if needed)
- `build_adopted_skills_payload_string()` (replaced by tool if needed)
- `nostr_handler_get_admin_kind3_context()` (just added, will be replaced by tool)
- The `prompt_var_resolver_fn` callback type
### Benefits
1. **Single data-fetching mechanism** — tools are the only way to get data
2. **No more variable name mismatches** — tool names are validated at registration
3. **User-configurable context** — admin can add any tool output to context by editing the soul template
4. **Testable**`--test-tool nostr_admin_profile` shows exactly what goes into context
5. **Self-documenting**`tool_list` shows all available context tools with descriptions
6. **Extensible** — new tools automatically become available as context sources
7. **Provider overrides still work**`provider:` directive can still override formatting per-provider
### Risks
1. **Performance** — Tool calls add function dispatch overhead vs direct variable resolution. Mitigated: context tools read cached data, no network calls.
2. **Backward compatibility** — Old soul templates with `{{variables}}` would break. Mitigated: Phase 1 keeps both paths working.
3. **Tool context threading**`tools_execute()` needs access to the tools context, which `prompt_template_build_messages()` doesn't currently have. Solution: pass `tools_context_t*` to the build function.
+117
View File
@@ -0,0 +1,117 @@
# Didactyl Tool Naming — Final List
## Decisions
- `context_*` tools merged into existing categories with proper names
- Local tools get `local_` prefix
- `context_user_message` removed from LLM schema (template-only)
- `nostr_post_readme` kept — a post is different from a longform note
- `nostr_file_md_to_longform_post` kept as-is
- `task_manage` kept as-is
---
## Complete Tool List (sorted by category prefix)
### `admin_` — Administrator Metadata
| # | Name | Old Name | Description |
|---|---|---|---|
| 1 | `admin_identity` | `context_admin_identity` | Admin pubkey, sender tier, verification metadata |
### `agent_` — Agent Self-Metadata
| # | Name | Old Name | Description |
|---|---|---|---|
| 2 | `agent_identity` | `context_agent_identity` | Agent pubkey hex + npub |
| 3 | `agent_version` | `my_version` | Didactyl version and build metadata |
### `local_` — Local / Host Tools
| # | Name | Old Name | Description |
|---|---|---|---|
| 4 | `local_shell_exec` | `shell_exec` | Execute a shell command |
| 5 | `local_file_read` | `file_read` | Read a local file as text |
| 6 | `local_file_write` | `file_write` | Write text to a local file |
| 7 | `local_http_fetch` | `http_fetch` | Fetch HTTP/S resources |
### `model_` — LLM / Model Management
| # | Name | Old Name | Description |
|---|---|---|---|
| 8 | `model_get` | — | Get current active LLM config |
| 9 | `model_set` | — | Update active LLM config and persist |
| 10 | `model_list` | — | List available model IDs from provider |
### `nostr_` — Nostr Protocol Tools
| # | Name | Old Name | Description |
|---|---|---|---|
| 11 | `nostr_post` | — | Publish a Nostr event to connected relays |
| 12 | `nostr_post_readme` | — | Publish README.md as kind 30023 |
| 13 | `nostr_file_md_to_longform_post` | — | Read markdown file and publish as kind 30023 longform |
| 14 | `nostr_delete` | — | Request deletion of events — NIP-09 kind 5 |
| 15 | `nostr_react` | — | React to event — NIP-25 kind 7 |
| 16 | `nostr_query` | — | Query events from relays using a filter |
| 17 | `nostr_profile_get` | — | Look up kind 0 profile by pubkey |
| 18 | `nostr_nip05_lookup` | — | Look up/verify NIP-05 identifier |
| 19 | `nostr_admin_profile` | `context_admin_profile` | Get admin kind 0 profile from cache |
| 20 | `nostr_admin_contacts` | `context_admin_contacts` | Get admin kind 3 contacts from cache |
| 21 | `nostr_admin_relays` | `context_admin_relays` | Get admin kind 10002 relay list from cache |
| 22 | `nostr_admin_notes` | `context_admin_notes` | Get admin kind 1 recent notes from cache |
| 23 | `nostr_dm_send` | — | Send NIP-04 encrypted DM |
| 24 | `nostr_dm_send_nip17` | — | Send NIP-17 gift-wrap DM |
| 25 | `nostr_encrypt` | — | Encrypt plaintext — NIP-44 |
| 26 | `nostr_decrypt` | — | Decrypt ciphertext — NIP-44 |
| 27 | `nostr_encode` | — | Encode entity into nostr: URI |
| 28 | `nostr_decode` | — | Decode bech32/nostr: URI |
| 29 | `nostr_pubkey` | — | Return agent pubkey hex |
| 30 | `nostr_npub` | — | Return agent pubkey as npub |
| 31 | `nostr_relay_status` | — | Get connection status for all relays |
| 32 | `nostr_relay_info` | — | Fetch NIP-11 relay info document |
| 33 | `nostr_list_manage` | — | Add/remove tags in replaceable list events — NIP-51 |
### `skill_` — Skill Management
| # | Name | Old Name | Description |
|---|---|---|---|
| 34 | `skill_create` | — | Create/update skill definition |
| 35 | `skill_list` | — | List agent published skills |
| 36 | `skill_adopt` | — | Adopt a skill — add to kind 10123 |
| 37 | `skill_remove` | — | Remove skill from adoption list |
| 38 | `skill_search` | — | Search public skills |
### `task_` — Task Management
| # | Name | Old Name | Description |
|---|---|---|---|
| 39 | `task_manage` | — | CRUD for agent task memory |
| 40 | `task_list` | `context_tasks` | Get current task list — read-only |
### Template-Only (not in LLM schema)
| # | Name | Old Name | Description |
|---|---|---|---|
| 41 | `message_current` | `context_user_message` | Current user message text — template assembly only |
---
## Summary of Changes
| Old Name | New Name | Change Type |
|---|---|---|
| `context_admin_identity` | `admin_identity` | Rename |
| `context_agent_identity` | `agent_identity` | Rename |
| `context_admin_profile` | `nostr_admin_profile` | Rename + recategorize |
| `context_admin_contacts` | `nostr_admin_contacts` | Rename + recategorize |
| `context_admin_relays` | `nostr_admin_relays` | Rename + recategorize |
| `context_admin_notes` | `nostr_admin_notes` | Rename + recategorize |
| `context_tasks` | `task_list` | Rename + recategorize |
| `context_user_message` | `message_current` | Rename + remove from LLM schema |
| `my_version` | `agent_version` | Rename |
| `shell_exec` | `local_shell_exec` | Add prefix |
| `file_read` | `local_file_read` | Add prefix |
| `file_write` | `local_file_write` | Add prefix |
| `http_fetch` | `local_http_fetch` | Add prefix |
**Total: 13 renames, 0 removals, 0 additions**
+10 -10
View File
@@ -14,7 +14,7 @@ This plan covers four related features:
When a message starts with `/`, bypass the LLM entirely and execute the tool directly.
```
/shell_exec {"command": "ls -la"}
/local_shell_exec {"command": "ls -la"}
/nostr_query {"filter": {"kinds": [1], "limit": 5}}
/nostr_nip05_lookup {"identifier": "jack@cash.app"}
```
@@ -160,8 +160,8 @@ When executing a skill from another author (not own pubkey), a **tool sandbox**
- `skill_list`, `skill_search` — read skill metadata
**Blocked tools (destructive/dangerous):**
- `shell_exec` — arbitrary command execution
- `file_read`, `file_write` — filesystem access
- `local_shell_exec` — arbitrary command execution
- `local_file_read`, `local_file_write` — filesystem access
- Any future tools marked as `destructive: true`
**Override:** The admin can explicitly opt in to full tool access:
@@ -213,10 +213,10 @@ tool_schema:
enum: [staging, production]
default: staging
steps:
- tool: shell_exec
- tool: local_shell_exec
args:
command: "make build TARGET={{target}}"
- tool: shell_exec
- tool: local_shell_exec
args:
command: "rsync -av dist/ server:/var/www/{{target}}/"
- return: "Deployed to {{target}}"
@@ -246,7 +246,7 @@ A simple sequential executor for hardened skills.
```yaml
steps:
# Execute a tool
- tool: shell_exec
- tool: local_shell_exec
args: {command: "ls -la"}
save_as: listing # optional: save result to variable
@@ -260,7 +260,7 @@ steps:
# Conditional - simple
- if: "{{listing.success}}"
then:
- tool: shell_exec
- tool: local_shell_exec
args: {command: "echo done"}
else:
- return: "Failed: {{listing.error}}"
@@ -325,9 +325,9 @@ typedef enum {
| `skill_list` | safe | Read-only |
| `skill_search` | safe | Read-only |
| `skill_run` | safe | Recursive execution uses its own sandbox |
| `shell_exec` | destructive | Arbitrary command execution |
| `file_read` | destructive | Filesystem access |
| `file_write` | destructive | Filesystem mutation |
| `local_shell_exec` | destructive | Arbitrary command execution |
| `local_file_read` | destructive | Filesystem access |
| `local_file_write` | destructive | Filesystem mutation |
### Sandbox Rules
+60 -433
View File
@@ -62,10 +62,6 @@ static int g_adopted_skills_count = 0;
static time_t g_adopted_skills_last_refresh_at = 0;
static pthread_mutex_t g_adopted_skills_mutex = PTHREAD_MUTEX_INITIALIZER;
typedef struct {
didactyl_sender_tier_t sender_tier;
} agent_template_resolver_ctx_t;
static uint64_t fnv1a64(const char* s) {
uint64_t h = 1469598103934665603ULL;
if (!s) return h;
@@ -809,6 +805,8 @@ static char* format_context_payload_for_log(const char* context_payload) {
}
out[0] = '\0';
size_t used = 0;
char* conversation_role = NULL;
char* conversation_content = NULL;
int n = cJSON_GetArraySize(root);
(void)appendf(&out, &cap, &used, "Sections: %d\n\n", n);
@@ -834,6 +832,14 @@ static char* format_context_payload_for_log(const char* context_payload) {
continue;
}
if (section && strcmp(section, "conversation") == 0) {
free(conversation_role);
free(conversation_content);
conversation_role = strdup(role_s);
conversation_content = strdup(content_s);
continue;
}
char* display_content = demote_markdown_headings_for_log(content_s);
const char* safe_display_content = display_content ? display_content : content_s;
@@ -905,8 +911,19 @@ static char* format_context_payload_for_log(const char* context_payload) {
}
}
if (conversation_role && conversation_content) {
(void)appendf(&out,
&cap,
&used,
"# conversation | role=%s\n\n%s\n\n",
conversation_role,
conversation_content);
}
free(chat);
free(latest_admin_line);
free(conversation_role);
free(conversation_content);
cJSON_Delete(root);
return out;
@@ -1078,55 +1095,6 @@ void agent_append_context_log(const char* sender_pubkey_hex, const char* phase,
append_context_log(sender_pubkey_hex, phase, context_payload);
}
typedef struct {
time_t created_at;
int role_is_user;
char* content;
} agent_history_item_t;
static int extract_first_p_tag_local(cJSON* tags, char out_pubkey_hex[65]) {
if (!tags || !cJSON_IsArray(tags) || !out_pubkey_hex) {
return -1;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !val->valuestring) {
continue;
}
if (strcmp(key->valuestring, "p") == 0 && strlen(val->valuestring) == 64U) {
memcpy(out_pubkey_hex, val->valuestring, 64U);
out_pubkey_hex[64] = '\0';
return 0;
}
}
return -1;
}
static int history_item_cmp_created_at(const void* a, const void* b) {
const agent_history_item_t* ia = (const agent_history_item_t*)a;
const agent_history_item_t* ib = (const agent_history_item_t*)b;
if (ia->created_at < ib->created_at) return -1;
if (ia->created_at > ib->created_at) return 1;
return 0;
}
static void free_history_items(agent_history_item_t* items, int count) {
if (!items) return;
for (int i = 0; i < count; i++) {
free(items[i].content);
}
free(items);
}
static int append_startup_events_context(cJSON* messages) {
if (!messages || !g_cfg || g_cfg->startup_event_count <= 0 || !g_cfg->startup_events) {
@@ -1393,147 +1361,65 @@ static int append_recent_admin_dm_history(cJSON* messages, const char* current_m
return -1;
}
unsigned char admin_pubkey[32];
if (nostr_hex_to_bytes(g_cfg->admin.pubkey, admin_pubkey, 32) != 0) {
return -1;
}
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->admin.pubkey));
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", AGENT_HISTORY_QUERY_LIMIT);
char* events_json = nostr_handler_query_json(filter, 1500);
cJSON_Delete(filter);
if (!events_json) {
char* history_json = nostr_handler_get_dm_history_json(g_cfg->admin.pubkey, AGENT_HISTORY_TURNS);
if (!history_json) {
return 0;
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(events);
cJSON* items = cJSON_Parse(history_json);
free(history_json);
if (!items || !cJSON_IsArray(items)) {
cJSON_Delete(items);
return 0;
}
agent_history_item_t* items = NULL;
int item_count = 0;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(events, i);
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
cJSON* pubkey = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL;
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
cJSON* tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
cJSON* created_at = ev ? cJSON_GetObjectItemCaseSensitive(ev, "created_at") : NULL;
if (!kind || !pubkey || !content || !tags || !created_at ||
!cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) ||
!cJSON_IsString(content) || !cJSON_IsArray(tags) || !cJSON_IsNumber(created_at) ||
(int)kind->valuedouble != 4 || !pubkey->valuestring || !content->valuestring) {
continue;
}
char p_tag_pubkey[65] = {0};
if (extract_first_p_tag_local(tags, p_tag_pubkey) != 0) {
continue;
}
int role_is_user = 0;
if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0 &&
strcmp(p_tag_pubkey, g_cfg->keys.public_key_hex) == 0) {
role_is_user = 1;
} else if (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) == 0 &&
strcmp(p_tag_pubkey, g_cfg->admin.pubkey) == 0) {
role_is_user = 0;
} else {
continue;
}
char* plaintext = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE);
if (!plaintext) {
continue;
}
plaintext[0] = '\0';
if (nostr_nip04_decrypt(g_cfg->keys.private_key,
admin_pubkey,
content->valuestring,
plaintext,
NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) {
free(plaintext);
continue;
}
agent_history_item_t* grown = (agent_history_item_t*)realloc(items, (size_t)(item_count + 1) * sizeof(agent_history_item_t));
if (!grown) {
free(plaintext);
free_history_items(items, item_count);
cJSON_Delete(events);
return -1;
}
items = grown;
items[item_count].created_at = (time_t)created_at->valuedouble;
items[item_count].role_is_user = role_is_user;
items[item_count].content = plaintext;
item_count++;
}
cJSON_Delete(events);
if (item_count > 1) {
qsort(items, (size_t)item_count, sizeof(agent_history_item_t), history_item_cmp_created_at);
}
int start = item_count > AGENT_HISTORY_TURNS ? item_count - AGENT_HISTORY_TURNS : 0;
const char* last_appended_role = NULL;
const char* last_appended_content = NULL;
for (int i = start; i < item_count; i++) {
if (i == item_count - 1 &&
items[i].role_is_user &&
current_message &&
strcmp(items[i].content, current_message) == 0) {
int n = cJSON_GetArraySize(items);
for (int i = 0; i < n; i++) {
cJSON* item = cJSON_GetArrayItem(items, i);
cJSON* role = item ? cJSON_GetObjectItemCaseSensitive(item, "role") : NULL;
cJSON* content = item ? cJSON_GetObjectItemCaseSensitive(item, "content") : NULL;
cJSON* created_at = item ? cJSON_GetObjectItemCaseSensitive(item, "created_at") : NULL;
if (!role || !content || !cJSON_IsString(role) || !cJSON_IsString(content) ||
!role->valuestring || !content->valuestring) {
continue;
}
const char* role = items[i].role_is_user ? "user" : "assistant";
const char* content = items[i].content ? items[i].content : "";
const char* role_s = role->valuestring;
const char* content_s = content->valuestring;
int role_is_user = (strcmp(role_s, "user") == 0);
if (!role_is_user && strcmp(role_s, "assistant") != 0) {
continue;
}
if (i == n - 1 && role_is_user && current_message && strcmp(content_s, current_message) == 0) {
continue;
}
if (last_appended_role && last_appended_content &&
strcmp(last_appended_role, role) == 0 &&
strcmp(last_appended_content, content) == 0) {
strcmp(last_appended_role, role_s) == 0 &&
strcmp(last_appended_content, content_s) == 0) {
continue;
}
if (append_simple_message(messages, role, content) != 0) {
free_history_items(items, item_count);
if (append_simple_message(messages, role_s, content_s) != 0) {
cJSON_Delete(items);
return -1;
}
cJSON* appended = cJSON_GetArrayItem(messages, cJSON_GetArraySize(messages) - 1);
if (appended && cJSON_IsObject(appended)) {
cJSON_AddNumberToObject(appended, "_ts", (double)items[i].created_at);
if (appended && cJSON_IsObject(appended) && created_at && cJSON_IsNumber(created_at)) {
cJSON_AddNumberToObject(appended, "_ts", created_at->valuedouble);
}
last_appended_role = role;
last_appended_content = content;
last_appended_role = role_s;
last_appended_content = content_s;
}
free_history_items(items, item_count);
cJSON_Delete(items);
return 0;
}
@@ -1985,264 +1871,6 @@ static cJSON* build_recent_admin_dm_history_messages(const char* current_message
return messages;
}
static char* build_startup_events_json_string(void) {
if (!g_cfg || g_cfg->startup_event_count <= 0 || !g_cfg->startup_events) {
return strdup("[]");
}
cJSON* arr = cJSON_CreateArray();
if (!arr) {
return NULL;
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
if (!se || se->kind == 31120) {
continue;
}
cJSON* item = cJSON_CreateObject();
if (!item) {
cJSON_Delete(arr);
return NULL;
}
cJSON_AddNumberToObject(item, "kind", se->kind);
cJSON_AddStringToObject(item, "content", se->content ? se->content : "");
cJSON* tags = NULL;
if (se->tags_json) {
tags = cJSON_Parse(se->tags_json);
}
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
tags = cJSON_CreateArray();
}
cJSON_AddItemToObject(item, "tags", tags);
cJSON_AddItemToArray(arr, item);
}
char* out = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return out;
}
static char* build_adopted_skills_payload_string(void) {
cJSON* tmp = cJSON_CreateArray();
if (!tmp) {
return NULL;
}
if (append_adopted_skills_context(tmp) != 0) {
cJSON_Delete(tmp);
return NULL;
}
cJSON* first = cJSON_GetArrayItem(tmp, 0);
cJSON* content = first ? cJSON_GetObjectItemCaseSensitive(first, "content") : NULL;
char* out = (content && cJSON_IsString(content) && content->valuestring)
? strdup(content->valuestring)
: strdup("");
cJSON_Delete(tmp);
return out;
}
static char* build_tasks_content_string(void) {
const char* fallback =
"### Current Tasks\n\n"
"Your active task list - short-term working memory for tracking plan steps.\n\n"
"- [ ] No active tasks yet.\n";
if (!g_cfg) {
return strdup(fallback);
}
const char* cwd = g_cfg->tools.shell.working_directory[0] != '\0'
? g_cfg->tools.shell.working_directory
: ".";
char tasks_path[1024];
int pn = 0;
if (strcmp(cwd, ".") == 0) {
pn = snprintf(tasks_path, sizeof(tasks_path), "tasks.json");
} else {
pn = snprintf(tasks_path, sizeof(tasks_path), "%s/%s", cwd, "tasks.json");
}
if (pn <= 0 || (size_t)pn >= sizeof(tasks_path)) {
return strdup(fallback);
}
FILE* fp = fopen(tasks_path, "rb");
if (!fp) {
return strdup(fallback);
}
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return strdup(fallback);
}
long len = ftell(fp);
if (len < 0) {
fclose(fp);
return strdup(fallback);
}
if (fseek(fp, 0, SEEK_SET) != 0) {
fclose(fp);
return strdup(fallback);
}
char* raw = (char*)malloc((size_t)len + 1U);
if (!raw) {
fclose(fp);
return strdup(fallback);
}
size_t n = fread(raw, 1, (size_t)len, fp);
fclose(fp);
if (n != (size_t)len) {
free(raw);
return strdup(fallback);
}
raw[len] = '\0';
cJSON* root = cJSON_Parse(raw);
free(raw);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return strdup(fallback);
}
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
if (!tasks || !cJSON_IsArray(tasks)) {
cJSON_Delete(root);
return strdup(fallback);
}
size_t cap = 1024U;
size_t used = 0U;
char* out = (char*)malloc(cap);
if (!out) {
cJSON_Delete(root);
return strdup(fallback);
}
out[0] = '\0';
if (appendf(&out,
&cap,
&used,
"### Current Tasks\n\n"
"Your active task list - short-term working memory for tracking plan steps.\n\n") != 0) {
free(out);
cJSON_Delete(root);
return strdup(fallback);
}
int written = 0;
int tcount = cJSON_GetArraySize(tasks);
for (int i = 0; i < tcount; i++) {
cJSON* task = cJSON_GetArrayItem(tasks, i);
if (!task || !cJSON_IsObject(task)) continue;
cJSON* id = cJSON_GetObjectItemCaseSensitive(task, "id");
cJSON* text = cJSON_GetObjectItemCaseSensitive(task, "text");
cJSON* status = cJSON_GetObjectItemCaseSensitive(task, "status");
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
continue;
}
const char* status_s = (status && cJSON_IsString(status) && status->valuestring)
? status->valuestring
: "pending";
const char* mark = " ";
if (strcmp(status_s, "done") == 0) {
mark = "x";
} else if (strcmp(status_s, "active") == 0) {
mark = "-";
}
int id_value = (id && cJSON_IsNumber(id)) ? (int)id->valuedouble : (i + 1);
if (appendf(&out, &cap, &used, "- [%s] %d. %s\n", mark, id_value, text->valuestring) != 0) {
free(out);
cJSON_Delete(root);
return strdup(fallback);
}
written++;
}
cJSON_Delete(root);
if (written <= 0) {
free(out);
return strdup(fallback);
}
return out;
}
static char* safe_strdup(const char* s) {
return strdup(s ? s : "");
}
static char* agent_template_resolve_var(const char* var_name, void* user_data) {
agent_template_resolver_ctx_t* ctx = (agent_template_resolver_ctx_t*)user_data;
if (!var_name || !g_cfg) {
return strdup("");
}
if (strcmp(var_name, "admin_pubkey") == 0) {
return safe_strdup(g_cfg->admin.pubkey);
}
if (strcmp(var_name, "agent_pubkey") == 0) {
return safe_strdup(g_cfg->keys.public_key_hex);
}
if (strcmp(var_name, "admin_kind0_json") == 0) {
char* v = nostr_handler_get_admin_kind0_context();
return v ? v : strdup("");
}
if (strcmp(var_name, "admin_kind10002_json") == 0) {
char* v = nostr_handler_get_admin_kind10002_context();
return v ? v : strdup("");
}
if (strcmp(var_name, "admin_profile_plain") == 0) {
char* v = build_admin_profile_plain_text();
return v ? v : strdup("");
}
if (strcmp(var_name, "admin_relay_list_plain") == 0) {
char* v = build_admin_relay_list_plain_text();
return v ? v : strdup("");
}
if (strcmp(var_name, "sender_verification") == 0) {
didactyl_sender_tier_t tier = ctx ? ctx->sender_tier : DIDACTYL_SENDER_ADMIN;
char* v = build_sender_verification_text(tier);
return v ? v : strdup("");
}
if (strcmp(var_name, "startup_events_json") == 0) {
char* v = build_startup_events_json_string();
return v ? v : strdup("[]");
}
if (strcmp(var_name, "adopted_skills_content") == 0) {
char* v = build_adopted_skills_payload_string();
return v ? v : strdup("");
}
if (strcmp(var_name, "tasks_content") == 0) {
char* v = build_tasks_content_string();
return v ? v : strdup("");
}
if (strcmp(var_name, "admin_notes_content") == 0) {
char* v = build_admin_recent_posts_text();
return v ? v : strdup("");
}
if (strcmp(var_name, "admin_recent_posts") == 0) {
char* v = build_admin_recent_posts_text();
return v ? v : strdup("");
}
return strdup("");
}
int agent_init(didactyl_config_t* config, const char* system_context) {
if (!config || !system_context) {
return -1;
@@ -2368,9 +1996,6 @@ int agent_build_admin_messages_json(const char* current_user_message,
dm_history = cJSON_CreateArray();
}
agent_template_resolver_ctx_t resolver_ctx;
resolver_ctx.sender_tier = sender_tier;
llm_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
const char* provider_name = NULL;
@@ -2378,10 +2003,12 @@ int agent_build_admin_messages_json(const char* current_user_message,
provider_name = cfg.provider;
}
g_tools_ctx.template_sender_tier = (int)sender_tier;
g_tools_ctx.template_current_user_message = current_user_message;
messages = prompt_template_build_messages(&g_prompt_template,
provider_name,
agent_template_resolve_var,
&resolver_ctx,
&g_tools_ctx,
dm_history,
AGENT_HISTORY_TURNS,
template_emit_hook,
+58
View File
@@ -3,6 +3,7 @@
#include "llm.h"
#include <curl/curl.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -69,6 +70,20 @@ static int url_looks_like_websocket(const char* url) {
return (strncmp(url, "ws://", 5) == 0) || (strncmp(url, "wss://", 6) == 0);
}
static int json_string_is_blank(const cJSON* item) {
if (!item || !cJSON_IsString(item) || !item->valuestring) {
return 0;
}
const unsigned char* p = (const unsigned char*)item->valuestring;
while (*p) {
if (!isspace(*p)) {
return 0;
}
p++;
}
return 1;
}
static char* perform_http_request(const char* url, const char* body, int is_post) {
CURL* curl = curl_easy_init();
if (!curl || !url) {
@@ -111,6 +126,19 @@ static char* perform_http_request(const char* url, const char* body, int is_post
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
}
if (is_post) {
size_t body_len = body ? strlen(body) : 0U;
size_t body_preview_len = body_len > 4000U ? 4000U : body_len;
fprintf(stderr,
"[didactyl] llm request: method=POST url=%s body_bytes=%zu body_preview=%.4000s%s\n",
url,
body_len,
body ? body : "",
body_len > body_preview_len ? "..." : "");
} else {
fprintf(stderr, "[didactyl] llm request: method=GET url=%s\n", url);
}
CURLcode res = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
@@ -340,6 +368,36 @@ int llm_chat_with_tools_messages(const char* messages_json,
cJSON_Delete(root);
return -1;
}
int filtered_count = 0;
for (int i = cJSON_GetArraySize(messages) - 1; i >= 0; i--) {
cJSON* msg = cJSON_GetArrayItem(messages, i);
if (!msg || !cJSON_IsObject(msg)) {
continue;
}
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
cJSON* tool_calls = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
int role_is_textual = (role && cJSON_IsString(role) && role->valuestring &&
(strcmp(role->valuestring, "system") == 0 ||
strcmp(role->valuestring, "user") == 0 ||
strcmp(role->valuestring, "assistant") == 0));
int has_tool_calls = (tool_calls && cJSON_IsArray(tool_calls) && cJSON_GetArraySize(tool_calls) > 0);
if (role_is_textual && !has_tool_calls && json_string_is_blank(content)) {
cJSON_DeleteItemFromArray(messages, i);
filtered_count++;
}
}
if (filtered_count > 0) {
fprintf(stderr,
"[didactyl] llm request sanitizer: removed %d empty text message(s) before provider request\n",
filtered_count);
}
cJSON_AddItemToObject(root, "messages", messages);
if (tools_json) {
+44 -2
View File
@@ -20,6 +20,37 @@
static volatile sig_atomic_t g_running = 1;
typedef struct {
trigger_manager_t* trigger_manager;
int loaded_once;
} deferred_trigger_load_ctx_t;
static void on_self_skill_eose_load_triggers(int event_count, void* user_data) {
deferred_trigger_load_ctx_t* ctx = (deferred_trigger_load_ctx_t*)user_data;
if (!ctx || !ctx->trigger_manager) {
return;
}
if (ctx->loaded_once) {
DEBUG_TRACE("[didactyl] deferred trigger load skipped (already loaded once) event_count=%d", event_count);
return;
}
DEBUG_INFO("[didactyl] deferred trigger load begin after self-skill EOSE (event_count=%d)", event_count);
if (trigger_manager_load_from_skills(ctx->trigger_manager) != 0) {
DEBUG_WARN("[didactyl] deferred trigger load failed after self-skill EOSE");
return;
}
ctx->loaded_once = 1;
char* status = trigger_manager_status_json(ctx->trigger_manager);
if (status) {
DEBUG_INFO("[didactyl] deferred trigger load status: %s", status);
free(status);
}
}
static void signal_handler(int signum) {
(void)signum;
g_running = 0;
@@ -231,10 +262,21 @@ int main(int argc, char** argv) {
return 1;
}
agent_set_trigger_manager(&trigger_manager);
nostr_handler_set_trigger_manager(&trigger_manager);
if (trigger_manager_load_from_skills(&trigger_manager) != 0) {
DEBUG_WARN("[didactyl] startup phase: trigger manager load from skills failed (continuing)");
deferred_trigger_load_ctx_t deferred_trigger_ctx = {
.trigger_manager = &trigger_manager,
.loaded_once = 0
};
nostr_handler_set_self_skill_eose_callback(on_self_skill_eose_load_triggers, &deferred_trigger_ctx);
DEBUG_INFO("[didactyl] startup phase: deferred trigger load will run after self-skill EOSE");
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load begin");
if (trigger_manager_load_from_startup_events(&trigger_manager) != 0) {
DEBUG_WARN("[didactyl] startup phase: startup-config trigger load failed (continuing)");
}
DEBUG_INFO("[didactyl] startup phase: startup-config trigger load end");
DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin");
if (nostr_handler_subscribe_admin_context() != 0) {
+2 -2
View File
@@ -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 44
#define DIDACTYL_VERSION "v0.0.44"
#define DIDACTYL_VERSION_PATCH 58
#define DIDACTYL_VERSION "v0.0.58"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+306 -1
View File
@@ -11,6 +11,7 @@
#include "../../nostr_core_lib/cjson/cJSON.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#include "debug.h"
#include "trigger_manager.h"
#define NIP17_MAX_RELAYS 32
#define NIP17_MAX_GIFT_WRAPS 8
@@ -48,6 +49,11 @@ static pthread_mutex_t g_self_skill_mutex = PTHREAD_MUTEX_INITIALIZER;
#define DM_DEDUP_CACHE_SIZE 256
static nostr_self_skill_eose_cb_t g_self_skill_eose_cb = NULL;
static void* g_self_skill_eose_user_data = NULL;
static struct trigger_manager* g_trigger_manager = NULL;
static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65];
static int g_seen_dm_count = 0;
static int g_seen_dm_next = 0;
@@ -55,6 +61,7 @@ static int g_seen_dm_next = 0;
static pthread_mutex_t g_dm_dedup_mutex = PTHREAD_MUTEX_INITIALIZER;
#define SENDER_PROTOCOL_CACHE_SIZE 128
#define DM_HISTORY_RING_SIZE 256
typedef struct {
char pubkey_hex[65];
@@ -62,9 +69,21 @@ typedef struct {
time_t seen_at;
} sender_protocol_entry_t;
typedef struct {
time_t created_at;
int incoming;
char peer_pubkey_hex[65];
char* content;
} dm_history_entry_t;
static sender_protocol_entry_t g_sender_protocol_cache[SENDER_PROTOCOL_CACHE_SIZE];
static pthread_mutex_t g_sender_protocol_mutex = PTHREAD_MUTEX_INITIALIZER;
static dm_history_entry_t g_dm_history_ring[DM_HISTORY_RING_SIZE];
static int g_dm_history_count = 0;
static int g_dm_history_next = 0;
static pthread_mutex_t g_dm_history_mutex = PTHREAD_MUTEX_INITIALIZER;
static int dm_id_seen_or_remember(const char* event_id_hex) {
if (!event_id_hex || strlen(event_id_hex) != 64U) {
return 0;
@@ -180,6 +199,49 @@ static dm_protocol_t sender_protocol_lookup(const char* sender_pubkey_hex) {
return out;
}
static void dm_history_clear_locked(void) {
for (int i = 0; i < DM_HISTORY_RING_SIZE; i++) {
free(g_dm_history_ring[i].content);
g_dm_history_ring[i].content = NULL;
g_dm_history_ring[i].created_at = 0;
g_dm_history_ring[i].incoming = 0;
g_dm_history_ring[i].peer_pubkey_hex[0] = '\0';
}
g_dm_history_count = 0;
g_dm_history_next = 0;
}
static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at) {
if (!peer_pubkey_hex || strlen(peer_pubkey_hex) != 64U || !content) {
return;
}
char* dup = strdup(content);
if (!dup) {
return;
}
pthread_mutex_lock(&g_dm_history_mutex);
int slot = 0;
if (g_dm_history_count < DM_HISTORY_RING_SIZE) {
slot = g_dm_history_count;
g_dm_history_count++;
} else {
slot = g_dm_history_next;
g_dm_history_next = (g_dm_history_next + 1) % DM_HISTORY_RING_SIZE;
}
free(g_dm_history_ring[slot].content);
g_dm_history_ring[slot].content = dup;
g_dm_history_ring[slot].created_at = (created_at > 0) ? created_at : time(NULL);
g_dm_history_ring[slot].incoming = incoming ? 1 : 0;
memcpy(g_dm_history_ring[slot].peer_pubkey_hex, peer_pubkey_hex, 64U);
g_dm_history_ring[slot].peer_pubkey_hex[64] = '\0';
pthread_mutex_unlock(&g_dm_history_mutex);
}
static const char* relay_status_str(nostr_pool_relay_status_t status) {
switch (status) {
case NOSTR_POOL_RELAY_DISCONNECTED:
@@ -210,7 +272,10 @@ static void upsert_kind1_note(time_t created_at, const char* content);
static int startup_self_kind1_exists(void);
static void load_startup_display_name(void);
static void build_startup_kind1_content(char* out, size_t out_size, const char* fallback);
static void register_trigger_from_self_skill_event(cJSON* event);
static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data);
static void dm_history_clear_locked(void);
static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at);
static void log_relay_state_changes(void) {
if (!g_pool || !g_cfg || !g_last_relay_statuses) {
@@ -384,6 +449,72 @@ static const char* find_tag_value_local(cJSON* tags, const char* key) {
return NULL;
}
static int parse_enabled_flag_local(const char* enabled_s) {
if (!enabled_s || enabled_s[0] == '\0') {
return 1;
}
return (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
}
static void register_trigger_from_self_skill_event(cJSON* event) {
if (!event || !g_cfg || !g_trigger_manager) {
return;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!kind || !cJSON_IsNumber(kind) ||
!pubkey || !cJSON_IsString(pubkey) || !pubkey->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring ||
!tags || !cJSON_IsArray(tags)) {
return;
}
int kind_val = (int)kind->valuedouble;
if (kind_val != 31123 && kind_val != 31124) {
return;
}
if (strcmp(pubkey->valuestring, g_cfg->keys.public_key_hex) != 0) {
return;
}
const char* slug = find_tag_value_local(tags, "d");
const char* trigger = find_tag_value_local(tags, "trigger");
const char* filter = find_tag_value_local(tags, "filter");
const char* action = find_tag_value_local(tags, "action");
const char* enabled = find_tag_value_local(tags, "enabled");
if (!slug || slug[0] == '\0' ||
!trigger || strcmp(trigger, "nostr-subscription") != 0 ||
!filter || filter[0] == '\0') {
return;
}
trigger_action_type_t action_type = (action && strcmp(action, "template") == 0)
? TRIGGER_ACTION_TEMPLATE
: TRIGGER_ACTION_LLM;
int is_enabled = parse_enabled_flag_local(enabled);
int rc = trigger_manager_add((trigger_manager_t*)g_trigger_manager,
slug,
content->valuestring,
filter,
action_type,
is_enabled);
if (rc == 0) {
DEBUG_INFO("[didactyl] live self-skill trigger registered slug=%s action=%s enabled=%d",
slug,
action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm",
is_enabled);
} else {
DEBUG_WARN("[didactyl] live self-skill trigger register failed slug=%s", slug);
}
}
static void self_skill_cache_upsert_event_locked(cJSON* event) {
if (!event || !cJSON_IsObject(event)) {
return;
@@ -869,6 +1000,11 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
(int)tier,
received_protocol == DM_PROTOCOL_NIP17 ? "nip17" : "nip04");
dm_history_remember(sender_pubkey_hex,
decrypted,
1,
time(NULL));
g_dm_callback(sender_pubkey_hex, decrypted, tier, g_dm_user_data);
free(decrypted);
}
@@ -879,6 +1015,16 @@ static void on_eose(cJSON** events, int event_count, void* user_data) {
DEBUG_TRACE("[didactyl] DEBUG on_eose called: event_count=%d", event_count);
}
static void on_self_skill_eose(cJSON** events, int event_count, void* user_data) {
(void)events;
(void)user_data;
DEBUG_INFO("[didactyl] self-skill EOSE received: cached skill events=%d", event_count);
if (g_self_skill_eose_cb) {
g_self_skill_eose_cb(event_count, g_self_skill_eose_user_data);
}
}
static void free_admin_context_locked(void) {
free(g_admin_kind0_json);
g_admin_kind0_json = NULL;
@@ -1089,6 +1235,8 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
return;
}
register_trigger_from_self_skill_event(event);
pthread_mutex_lock(&g_self_skill_mutex);
self_skill_cache_upsert_event_locked(event);
pthread_mutex_unlock(&g_self_skill_mutex);
@@ -1106,6 +1254,10 @@ int nostr_handler_init(didactyl_config_t* config) {
g_seen_dm_count = 0;
g_seen_dm_next = 0;
pthread_mutex_lock(&g_dm_history_mutex);
dm_history_clear_locked();
pthread_mutex_unlock(&g_dm_history_mutex);
DEBUG_INFO("[didactyl] initializing relay pool with %d relays", g_cfg->relay_count);
nostr_pool_reconnect_config_t reconnect = *nostr_pool_reconnect_config_default();
@@ -1253,6 +1405,10 @@ int nostr_handler_subscribe_admin_context(void) {
return rc;
}
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager) {
g_trigger_manager = trigger_manager;
}
int nostr_handler_subscribe_self_skills(void) {
if (!g_cfg || !g_pool) {
return -1;
@@ -1282,7 +1438,7 @@ int nostr_handler_subscribe_self_skills(void) {
g_cfg->relay_count,
filter,
on_self_skill_event,
on_eose,
on_self_skill_eose,
NULL,
0,
0,
@@ -1426,6 +1582,11 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
return 0;
}
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data) {
g_self_skill_eose_cb = callback;
g_self_skill_eose_user_data = user_data;
}
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) {
if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message) {
return -1;
@@ -1505,6 +1666,13 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
recipient_pubkey_hex,
sent);
if (sent > 0) {
dm_history_remember(recipient_pubkey_hex,
message,
0,
time(NULL));
}
free(connected_relays);
cJSON_Delete(event);
return sent > 0 ? 0 : -1;
@@ -1681,6 +1849,42 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
return out;
}
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
cJSON* filter,
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
void (*on_eose)(cJSON** events, int event_count, void* user_data),
void* user_data,
int close_on_eose,
int enable_deduplication,
nostr_pool_eose_result_mode_t result_mode,
int relay_timeout_seconds,
int eose_timeout_seconds) {
if (!g_cfg || !g_pool || !filter || !on_event) {
return NULL;
}
return nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter,
on_event,
on_eose,
user_data,
close_on_eose,
enable_deduplication,
result_mode,
relay_timeout_seconds,
eose_timeout_seconds);
}
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription) {
if (!subscription) {
return 0;
}
return nostr_pool_subscription_close(subscription);
}
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason) {
if (!g_cfg || !g_pool || !g_startup_publish_tracking_enabled || !g_startup_published) {
return;
@@ -2178,9 +2382,80 @@ int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* me
return -1;
}
dm_history_remember(recipient_pubkey_hex,
message,
0,
time(NULL));
return 0;
}
char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit) {
if (!peer_pubkey_hex || strlen(peer_pubkey_hex) != 64U) {
return strdup("[]");
}
int max_take = limit > 0 ? limit : 64;
if (max_take > DM_HISTORY_RING_SIZE) {
max_take = DM_HISTORY_RING_SIZE;
}
cJSON* arr = cJSON_CreateArray();
if (!arr) {
return NULL;
}
pthread_mutex_lock(&g_dm_history_mutex);
int total = g_dm_history_count;
int matches = 0;
for (int i = 0; i < total; i++) {
int idx = (g_dm_history_count < DM_HISTORY_RING_SIZE)
? i
: (g_dm_history_next + i) % DM_HISTORY_RING_SIZE;
dm_history_entry_t* entry = &g_dm_history_ring[idx];
if (entry->peer_pubkey_hex[0] == '\0' || !entry->content) {
continue;
}
if (strcmp(entry->peer_pubkey_hex, peer_pubkey_hex) == 0) {
matches++;
}
}
int skip = matches > max_take ? (matches - max_take) : 0;
int seen = 0;
for (int i = 0; i < total; i++) {
int idx = (g_dm_history_count < DM_HISTORY_RING_SIZE)
? i
: (g_dm_history_next + i) % DM_HISTORY_RING_SIZE;
dm_history_entry_t* entry = &g_dm_history_ring[idx];
if (entry->peer_pubkey_hex[0] == '\0' || !entry->content) {
continue;
}
if (strcmp(entry->peer_pubkey_hex, peer_pubkey_hex) != 0) {
continue;
}
if (seen++ < skip) {
continue;
}
cJSON* item = cJSON_CreateObject();
if (!item) {
continue;
}
cJSON_AddStringToObject(item, "role", entry->incoming ? "user" : "assistant");
cJSON_AddStringToObject(item, "content", entry->content);
cJSON_AddNumberToObject(item, "created_at", (double)entry->created_at);
cJSON_AddItemToArray(arr, item);
}
pthread_mutex_unlock(&g_dm_history_mutex);
char* out = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return out ? out : strdup("[]");
}
char* nostr_handler_get_admin_kind0_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_0) {
return NULL;
@@ -2192,6 +2467,32 @@ char* nostr_handler_get_admin_kind0_context(void) {
return out;
}
char* nostr_handler_get_admin_kind3_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_3) {
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
cJSON* out = cJSON_CreateArray();
if (!out) {
pthread_mutex_unlock(&g_admin_ctx_mutex);
return NULL;
}
for (int i = 0; i < g_admin_wot_contact_count; i++) {
const char* pk = g_admin_wot_contacts[i];
if (!pk || pk[0] == '\0') continue;
cJSON_AddItemToArray(out, cJSON_CreateString(pk));
}
pthread_mutex_unlock(&g_admin_ctx_mutex);
char* out_json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return out_json;
}
char* nostr_handler_get_admin_kind10002_context(void) {
if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_10002) {
return NULL;
@@ -2324,6 +2625,10 @@ void nostr_handler_cleanup(void) {
memset(g_sender_protocol_cache, 0, sizeof(g_sender_protocol_cache));
pthread_mutex_unlock(&g_sender_protocol_mutex);
pthread_mutex_lock(&g_dm_history_mutex);
dm_history_clear_locked();
pthread_mutex_unlock(&g_dm_history_mutex);
pthread_mutex_lock(&g_admin_ctx_mutex);
free_admin_context_locked();
pthread_mutex_unlock(&g_admin_ctx_mutex);
+20
View File
@@ -3,6 +3,9 @@
#include "config.h"
#include "cjson/cJSON.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
struct trigger_manager;
typedef enum {
DIDACTYL_SENDER_ADMIN = 1,
@@ -27,16 +30,31 @@ typedef struct {
char** relays;
} nostr_publish_result_t;
typedef void (*nostr_self_skill_eose_cb_t)(int event_count, void* user_data);
int nostr_handler_init(didactyl_config_t* config);
void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager);
int nostr_handler_subscribe_admin_context(void);
int nostr_handler_subscribe_self_skills(void);
char* nostr_handler_get_self_events_by_kind_json(int kind);
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data);
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
cJSON* filter,
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
void (*on_eose)(cJSON** events, int event_count, void* user_data),
void* user_data,
int close_on_eose,
int enable_deduplication,
nostr_pool_eose_result_mode_t result_mode,
int relay_timeout_seconds,
int eose_timeout_seconds);
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription);
int nostr_handler_poll(int timeout_ms);
int nostr_handler_reconcile_startup_events(void);
void nostr_handler_refresh_relay_statuses(void);
@@ -45,12 +63,14 @@ char* nostr_handler_get_self_skill_events_json(void);
const char* nostr_handler_get_startup_display_name(void);
int nostr_handler_connected_relay_count(void);
char* nostr_handler_get_admin_kind0_context(void);
char* nostr_handler_get_admin_kind3_context(void);
char* nostr_handler_get_admin_kind10002_context(void);
char* nostr_handler_get_admin_kind1_notes_context(void);
int nostr_handler_is_wot_contact(const char* pubkey_hex);
char* nostr_handler_relay_status_json(void);
char* nostr_handler_relay_info_json(const char* relay_url);
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject);
char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit);
void nostr_handler_cleanup(void);
#endif
+97 -83
View File
@@ -97,87 +97,15 @@ static int append_text(char** buf, size_t* cap, size_t* used, const char* s) {
return 0;
}
static char* resolve_placeholders(const char* tpl,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data) {
if (!tpl) return strdup("");
size_t cap = strlen(tpl) + 64U;
char* out = (char*)malloc(cap ? cap : 128U);
if (!out) return NULL;
out[0] = '\0';
size_t used = 0;
const char* p = tpl;
while (*p) {
const char* open = strstr(p, "{{");
if (!open) {
if (append_text(&out, &cap, &used, p) != 0) {
free(out);
return NULL;
}
break;
}
if (open > p) {
char* literal = dup_range(p, (size_t)(open - p));
if (!literal) {
free(out);
return NULL;
}
int rc = append_text(&out, &cap, &used, literal);
free(literal);
if (rc != 0) {
free(out);
return NULL;
}
}
const char* close = strstr(open + 2, "}}");
if (!close) {
if (append_text(&out, &cap, &used, open) != 0) {
free(out);
return NULL;
}
break;
}
char* name = dup_range(open + 2, (size_t)(close - (open + 2)));
if (!name) {
free(out);
return NULL;
}
char* name_trim = ltrim_inplace(name);
rtrim_inplace(name_trim);
char* val = resolver_fn ? resolver_fn(name_trim, resolver_user_data) : NULL;
if (!val) {
val = strdup("");
}
int rc = 0;
if (val) {
rc = append_text(&out, &cap, &used, val);
}
free(val);
free(name);
if (rc != 0) {
free(out);
return NULL;
}
p = close + 2;
}
return out;
}
static void init_section_defaults(prompt_template_section_t* sec) {
if (!sec) return;
memset(sec->name, 0, sizeof(sec->name));
memset(sec->role, 0, sizeof(sec->role));
snprintf(sec->role, sizeof(sec->role), "system");
sec->content_template = NULL;
sec->tool_name = NULL;
sec->tool_args = NULL;
sec->result_field = NULL;
sec->limit = 0;
sec->skip_if_empty = 0;
sec->provider_name = NULL;
@@ -292,6 +220,36 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
continue;
}
if (starts_with(line, "tool:")) {
char* val = line + strlen("tool:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].tool_name);
out_template->sections[current].tool_name = strdup(val);
i++;
continue;
}
if (starts_with(line, "args:")) {
char* val = line + strlen("args:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].tool_args);
out_template->sections[current].tool_args = strdup((*val) ? val : "{}");
i++;
continue;
}
if (starts_with(line, "result_field:")) {
char* val = line + strlen("result_field:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].result_field);
out_template->sections[current].result_field = strdup(val);
i++;
continue;
}
if (starts_with(line, "content:")) {
char* val = line + strlen("content:");
val = ltrim_inplace(val);
@@ -319,6 +277,9 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
starts_with(next_ltrim, "role:") ||
starts_with(next_ltrim, "limit:") ||
starts_with(next_ltrim, "skip_if_empty:") ||
starts_with(next_ltrim, "tool:") ||
starts_with(next_ltrim, "args:") ||
starts_with(next_ltrim, "result_field:") ||
starts_with(next_ltrim, "content:") ||
starts_with(next_ltrim, "provider:")) {
break;
@@ -391,6 +352,9 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
starts_with(next, "role:") ||
starts_with(next, "limit:") ||
starts_with(next, "skip_if_empty:") ||
starts_with(next, "tool:") ||
starts_with(next, "args:") ||
starts_with(next, "result_field:") ||
starts_with(next, "content:") ||
starts_with(next, "provider:")) {
break;
@@ -437,10 +401,46 @@ static int append_message_object(cJSON* messages, const char* role, const char*
return 0;
}
static char* extract_tool_result_content(const prompt_template_section_t* sec, const char* tool_result_json) {
if (!tool_result_json) return strdup("");
cJSON* root = cJSON_Parse(tool_result_json);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return strdup(tool_result_json);
}
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
if (success && cJSON_IsBool(success) && !cJSON_IsTrue(success)) {
cJSON_Delete(root);
return strdup("");
}
const char* field_name = (sec && sec->result_field && sec->result_field[0] != '\0')
? sec->result_field
: "content";
cJSON* field = cJSON_GetObjectItemCaseSensitive(root, field_name);
if (!field) {
field = cJSON_GetObjectItemCaseSensitive(root, "content");
}
char* out = NULL;
if (field && cJSON_IsString(field) && field->valuestring) {
out = strdup(field->valuestring);
} else if (field) {
out = cJSON_PrintUnformatted(field);
} else {
out = cJSON_PrintUnformatted(root);
}
cJSON_Delete(root);
return out ? out : strdup("");
}
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data,
tools_context_t* tools_ctx,
cJSON* dm_history_messages,
int dm_history_default_limit,
prompt_template_emit_hook_fn emit_hook,
@@ -487,13 +487,21 @@ cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
continue;
}
const char* tpl = sec->content_template;
if (provider_name && sec->provider_name && sec->provider_content_template &&
strcmp(provider_name, sec->provider_name) == 0) {
tpl = sec->provider_content_template;
}
char* resolved = NULL;
char* resolved = resolve_placeholders(tpl ? tpl : "", resolver_fn, resolver_user_data);
if (sec->tool_name && sec->tool_name[0] != '\0' && tools_ctx) {
const char* args_json = (sec->tool_args && sec->tool_args[0] != '\0') ? sec->tool_args : "{}";
char* tool_result = tools_execute(tools_ctx, sec->tool_name, args_json);
resolved = extract_tool_result_content(sec, tool_result);
free(tool_result);
} else {
const char* tpl = sec->content_template;
if (provider_name && sec->provider_name && sec->provider_content_template &&
strcmp(provider_name, sec->provider_name) == 0) {
tpl = sec->provider_content_template;
}
resolved = strdup(tpl ? tpl : "");
}
if (!resolved) {
cJSON_Delete(out);
return NULL;
@@ -529,6 +537,12 @@ void prompt_template_free(prompt_template_t* tmpl) {
for (int i = 0; i < tmpl->section_count; i++) {
free(tmpl->sections[i].content_template);
tmpl->sections[i].content_template = NULL;
free(tmpl->sections[i].tool_name);
tmpl->sections[i].tool_name = NULL;
free(tmpl->sections[i].tool_args);
tmpl->sections[i].tool_args = NULL;
free(tmpl->sections[i].result_field);
tmpl->sections[i].result_field = NULL;
free(tmpl->sections[i].provider_name);
tmpl->sections[i].provider_name = NULL;
free(tmpl->sections[i].provider_content_template);
+5 -3
View File
@@ -2,6 +2,7 @@
#define DIDACTYL_PROMPT_TEMPLATE_H
#include "cjson/cJSON.h"
#include "tools.h"
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
@@ -12,6 +13,9 @@ typedef struct {
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
char* content_template;
char* tool_name;
char* tool_args;
char* result_field;
int limit;
int skip_if_empty;
char* provider_name;
@@ -24,7 +28,6 @@ typedef struct {
int section_count;
} prompt_template_t;
typedef char* (*prompt_var_resolver_fn)(const char* var_name, void* user_data);
typedef void (*prompt_template_emit_hook_fn)(const char* section_name,
int message_index,
void* user_data);
@@ -33,8 +36,7 @@ int prompt_template_parse(const char* soul_content, prompt_template_t* out_templ
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,
prompt_var_resolver_fn resolver_fn,
void* resolver_user_data,
tools_context_t* tools_ctx,
cJSON* dm_history_messages,
int dm_history_default_limit,
prompt_template_emit_hook_fn emit_hook,
+556 -52
View File
@@ -544,6 +544,8 @@ int tools_init(tools_context_t* ctx, didactyl_config_t* cfg) {
memset(ctx, 0, sizeof(*ctx));
ctx->cfg = cfg;
ctx->trigger_manager = NULL;
ctx->template_sender_tier = 0;
ctx->template_current_user_message = NULL;
return 0;
}
@@ -717,10 +719,10 @@ typedef struct {
size_t cap;
size_t max_bytes;
int truncated;
} http_fetch_buffer_t;
} local_http_fetch_buffer_t;
static size_t http_fetch_write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
http_fetch_buffer_t* rb = (http_fetch_buffer_t*)userp;
static size_t local_http_fetch_write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
local_http_fetch_buffer_t* rb = (local_http_fetch_buffer_t*)userp;
size_t total = size * nmemb;
if (!rb || total == 0) return total;
@@ -1088,7 +1090,7 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* t3_required = cJSON_CreateArray();
cJSON_AddStringToObject(t3, "type", "function");
cJSON_AddStringToObject(t3_fn, "name", "shell_exec");
cJSON_AddStringToObject(t3_fn, "name", "local_shell_exec");
cJSON_AddStringToObject(t3_fn, "description", "Execute a shell command and return stdout/stderr");
cJSON_AddStringToObject(t3_params, "type", "object");
cJSON_AddItemToObject(t3_params, "properties", t3_props);
@@ -1110,7 +1112,7 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* t4_required = cJSON_CreateArray();
cJSON_AddStringToObject(t4, "type", "function");
cJSON_AddStringToObject(t4_fn, "name", "file_read");
cJSON_AddStringToObject(t4_fn, "name", "local_file_read");
cJSON_AddStringToObject(t4_fn, "description", "Read a local file as text from the configured working directory");
cJSON_AddStringToObject(t4_params, "type", "object");
cJSON_AddItemToObject(t4_params, "properties", t4_props);
@@ -1135,7 +1137,7 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* t5_required = cJSON_CreateArray();
cJSON_AddStringToObject(t5, "type", "function");
cJSON_AddStringToObject(t5_fn, "name", "file_write");
cJSON_AddStringToObject(t5_fn, "name", "local_file_write");
cJSON_AddStringToObject(t5_fn, "description", "Write text content to a local file in the configured working directory");
cJSON_AddStringToObject(t5_params, "type", "object");
cJSON_AddItemToObject(t5_params, "properties", t5_props);
@@ -1547,7 +1549,7 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* t20_props = cJSON_CreateObject();
cJSON_AddStringToObject(t20, "type", "function");
cJSON_AddStringToObject(t20_fn, "name", "my_version");
cJSON_AddStringToObject(t20_fn, "name", "agent_version");
cJSON_AddStringToObject(t20_fn, "description", "Return current Didactyl version and metadata from build macros");
cJSON_AddStringToObject(t20_params, "type", "object");
cJSON_AddItemToObject(t20_params, "properties", t20_props);
@@ -1563,7 +1565,7 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* t21_required = cJSON_CreateArray();
cJSON_AddStringToObject(t21, "type", "function");
cJSON_AddStringToObject(t21_fn, "name", "http_fetch");
cJSON_AddStringToObject(t21_fn, "name", "local_http_fetch");
cJSON_AddStringToObject(t21_fn, "description", "Fetch HTTP(S) resources with optional method, headers, timeout, and body");
cJSON_AddStringToObject(t21_params, "type", "object");
cJSON_AddItemToObject(t21_params, "properties", t21_props);
@@ -1631,6 +1633,22 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON_AddStringToObject(p_skill_create_auto, "type", "boolean");
cJSON_AddItemToObject(t22_props, "auto_adopt", p_skill_create_auto);
cJSON* p_skill_create_trigger = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_trigger, "type", "string");
cJSON_AddItemToObject(t22_props, "trigger", p_skill_create_trigger);
cJSON* p_skill_create_filter = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_filter, "type", "string");
cJSON_AddItemToObject(t22_props, "filter", p_skill_create_filter);
cJSON* p_skill_create_action = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_action, "type", "string");
cJSON_AddItemToObject(t22_props, "action", p_skill_create_action);
cJSON* p_skill_create_enabled = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_create_enabled, "type", "boolean");
cJSON_AddItemToObject(t22_props, "enabled", p_skill_create_enabled);
cJSON_AddItemToArray(t22_required, cJSON_CreateString("slug"));
cJSON_AddItemToArray(t22_required, cJSON_CreateString("content"));
@@ -1943,14 +1961,112 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON* t37_fn = cJSON_CreateObject();
cJSON* t37_params = cJSON_CreateObject();
cJSON* t37_props = cJSON_CreateObject();
cJSON* t37_required = cJSON_CreateArray();
cJSON_AddStringToObject(t37, "type", "function");
cJSON_AddStringToObject(t37_fn, "name", "task_manage");
cJSON_AddStringToObject(t37_fn, "description", "Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)");
cJSON_AddStringToObject(t37_fn, "name", "admin_identity");
cJSON_AddStringToObject(t37_fn, "description", "Build admin identity context block from cached runtime metadata");
cJSON_AddStringToObject(t37_params, "type", "object");
cJSON_AddItemToObject(t37_params, "properties", t37_props);
cJSON_AddItemToObject(t37_params, "required", t37_required);
cJSON_AddItemToObject(t37_fn, "parameters", t37_params);
cJSON_AddItemToObject(t37, "function", t37_fn);
cJSON_AddItemToArray(tools, t37);
cJSON* t38 = cJSON_CreateObject();
cJSON* t38_fn = cJSON_CreateObject();
cJSON* t38_params = cJSON_CreateObject();
cJSON* t38_props = cJSON_CreateObject();
cJSON_AddStringToObject(t38, "type", "function");
cJSON_AddStringToObject(t38_fn, "name", "nostr_admin_profile");
cJSON_AddStringToObject(t38_fn, "description", "Build admin profile context block from cached kind 0 metadata");
cJSON_AddStringToObject(t38_params, "type", "object");
cJSON_AddItemToObject(t38_params, "properties", t38_props);
cJSON_AddItemToObject(t38_fn, "parameters", t38_params);
cJSON_AddItemToObject(t38, "function", t38_fn);
cJSON_AddItemToArray(tools, t38);
cJSON* t39 = cJSON_CreateObject();
cJSON* t39_fn = cJSON_CreateObject();
cJSON* t39_params = cJSON_CreateObject();
cJSON* t39_props = cJSON_CreateObject();
cJSON_AddStringToObject(t39, "type", "function");
cJSON_AddStringToObject(t39_fn, "name", "nostr_admin_contacts");
cJSON_AddStringToObject(t39_fn, "description", "Build admin contacts context block from cached kind 3 contact list");
cJSON_AddStringToObject(t39_params, "type", "object");
cJSON_AddItemToObject(t39_params, "properties", t39_props);
cJSON_AddItemToObject(t39_fn, "parameters", t39_params);
cJSON_AddItemToObject(t39, "function", t39_fn);
cJSON_AddItemToArray(tools, t39);
cJSON* t40 = cJSON_CreateObject();
cJSON* t40_fn = cJSON_CreateObject();
cJSON* t40_params = cJSON_CreateObject();
cJSON* t40_props = cJSON_CreateObject();
cJSON_AddStringToObject(t40, "type", "function");
cJSON_AddStringToObject(t40_fn, "name", "nostr_admin_relays");
cJSON_AddStringToObject(t40_fn, "description", "Build admin relay context block from cached kind 10002 data");
cJSON_AddStringToObject(t40_params, "type", "object");
cJSON_AddItemToObject(t40_params, "properties", t40_props);
cJSON_AddItemToObject(t40_fn, "parameters", t40_params);
cJSON_AddItemToObject(t40, "function", t40_fn);
cJSON_AddItemToArray(tools, t40);
cJSON* t41 = cJSON_CreateObject();
cJSON* t41_fn = cJSON_CreateObject();
cJSON* t41_params = cJSON_CreateObject();
cJSON* t41_props = cJSON_CreateObject();
cJSON_AddStringToObject(t41, "type", "function");
cJSON_AddStringToObject(t41_fn, "name", "nostr_admin_notes");
cJSON_AddStringToObject(t41_fn, "description", "Build admin notes context block from cached kind 1 notes");
cJSON_AddStringToObject(t41_params, "type", "object");
cJSON_AddItemToObject(t41_params, "properties", t41_props);
cJSON_AddItemToObject(t41_fn, "parameters", t41_params);
cJSON_AddItemToObject(t41, "function", t41_fn);
cJSON_AddItemToArray(tools, t41);
cJSON* t42 = cJSON_CreateObject();
cJSON* t42_fn = cJSON_CreateObject();
cJSON* t42_params = cJSON_CreateObject();
cJSON* t42_props = cJSON_CreateObject();
cJSON_AddStringToObject(t42, "type", "function");
cJSON_AddStringToObject(t42_fn, "name", "task_list");
cJSON_AddStringToObject(t42_fn, "description", "Build current task list context block from tasks.json");
cJSON_AddStringToObject(t42_params, "type", "object");
cJSON_AddItemToObject(t42_params, "properties", t42_props);
cJSON_AddItemToObject(t42_fn, "parameters", t42_params);
cJSON_AddItemToObject(t42, "function", t42_fn);
cJSON_AddItemToArray(tools, t42);
cJSON* t44 = cJSON_CreateObject();
cJSON* t44_fn = cJSON_CreateObject();
cJSON* t44_params = cJSON_CreateObject();
cJSON* t44_props = cJSON_CreateObject();
cJSON_AddStringToObject(t44, "type", "function");
cJSON_AddStringToObject(t44_fn, "name", "agent_identity");
cJSON_AddStringToObject(t44_fn, "description", "Build agent identity context block with pubkey and npub");
cJSON_AddStringToObject(t44_params, "type", "object");
cJSON_AddItemToObject(t44_params, "properties", t44_props);
cJSON_AddItemToObject(t44_fn, "parameters", t44_params);
cJSON_AddItemToObject(t44, "function", t44_fn);
cJSON_AddItemToArray(tools, t44);
cJSON* t45 = cJSON_CreateObject();
cJSON* t45_fn = cJSON_CreateObject();
cJSON* t45_params = cJSON_CreateObject();
cJSON* t45_props = cJSON_CreateObject();
cJSON* t45_required = cJSON_CreateArray();
cJSON_AddStringToObject(t45, "type", "function");
cJSON_AddStringToObject(t45_fn, "name", "task_manage");
cJSON_AddStringToObject(t45_fn, "description", "Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)");
cJSON_AddStringToObject(t45_params, "type", "object");
cJSON_AddItemToObject(t45_params, "properties", t45_props);
cJSON_AddItemToObject(t45_params, "required", t45_required);
cJSON* p_task_action = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_action, "type", "string");
@@ -1962,15 +2078,15 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("clear"));
cJSON_AddItemToArray(p_task_action_enum, cJSON_CreateString("replace"));
cJSON_AddItemToObject(p_task_action, "enum", p_task_action_enum);
cJSON_AddItemToObject(t37_props, "action", p_task_action);
cJSON_AddItemToObject(t45_props, "action", p_task_action);
cJSON* p_task_text = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_text, "type", "string");
cJSON_AddItemToObject(t37_props, "text", p_task_text);
cJSON_AddItemToObject(t45_props, "text", p_task_text);
cJSON* p_task_id = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_id, "type", "integer");
cJSON_AddItemToObject(t37_props, "id", p_task_id);
cJSON_AddItemToObject(t45_props, "id", p_task_id);
cJSON* p_task_status = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_status, "type", "string");
@@ -1979,20 +2095,20 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) {
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("active"));
cJSON_AddItemToArray(p_task_status_enum, cJSON_CreateString("done"));
cJSON_AddItemToObject(p_task_status, "enum", p_task_status_enum);
cJSON_AddItemToObject(t37_props, "status", p_task_status);
cJSON_AddItemToObject(t45_props, "status", p_task_status);
cJSON* p_task_tasks = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_tasks, "type", "array");
cJSON* p_task_tasks_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_task_tasks_item, "type", "string");
cJSON_AddItemToObject(p_task_tasks, "items", p_task_tasks_item);
cJSON_AddItemToObject(t37_props, "tasks", p_task_tasks);
cJSON_AddItemToObject(t45_props, "tasks", p_task_tasks);
cJSON_AddItemToArray(t37_required, cJSON_CreateString("action"));
cJSON_AddItemToArray(t45_required, cJSON_CreateString("action"));
cJSON_AddItemToObject(t37_fn, "parameters", t37_params);
cJSON_AddItemToObject(t37, "function", t37_fn);
cJSON_AddItemToArray(tools, t37);
cJSON_AddItemToObject(t45_fn, "parameters", t45_params);
cJSON_AddItemToObject(t45, "function", t45_fn);
cJSON_AddItemToArray(tools, t45);
char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools);
@@ -4102,7 +4218,371 @@ static char* execute_nostr_npub(tools_context_t* ctx, const char* args_json) {
return json;
}
static char* execute_my_version(const char* args_json) {
static char* execute_admin_identity(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
const char* tier_text = "Sender tier unknown.";
if (ctx->template_sender_tier == 1) {
tier_text = "This message has been cryptographically verified as coming from your administrator.";
} else if (ctx->template_sender_tier == 2) {
tier_text = "This message is from a web-of-trust contact (not the administrator).";
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_pubkey", ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown");
cJSON_AddStringToObject(out, "sender_verification", tier_text);
char content[768];
snprintf(content,
sizeof(content),
"## Administrator Identity (source: config.admin.pubkey)\n\n"
"This is your administrator! Admin pubkey (hex): %s\n\n"
"%s",
ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown",
tier_text);
cJSON_AddStringToObject(out, "content", content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
static char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
char* kind0 = nostr_handler_get_admin_kind0_context();
const char* profile_json = (kind0 && kind0[0]) ? kind0 : "{}";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind0);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind0_json", profile_json);
size_t content_len = strlen("## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind0);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): %s",
profile_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind0);
return json;
}
static char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
char* kind3 = nostr_handler_get_admin_kind3_context();
const char* contacts_json = (kind3 && kind3[0]) ? kind3 : "[]";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind3);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind3_json", contacts_json);
size_t content_len = strlen("## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): ") + strlen(contacts_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind3);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): %s",
contacts_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind3);
return json;
}
static char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
char* kind10002 = nostr_handler_get_admin_kind10002_context();
const char* relays_json = (kind10002 && kind10002[0]) ? kind10002 : "[]";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(kind10002);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind10002_json", relays_json);
size_t content_len = strlen("## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): ") + strlen(relays_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind10002);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): %s",
relays_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(kind10002);
return json;
}
static char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
char* notes = nostr_handler_get_admin_kind1_notes_context();
const char* notes_text = (notes && notes[0]) ? notes : "";
cJSON* out = cJSON_CreateObject();
if (!out) {
free(notes);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_notes_content", notes_text);
size_t content_len = strlen("## Administrator Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(notes);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Recent Kind 1 Notes\n\n%s",
notes_text);
cJSON_AddStringToObject(out, "content", content);
free(content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(notes);
return json;
}
static char* execute_task_manage(tools_context_t* ctx, const char* args_json);
static char* execute_task_list(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
char* listed = execute_task_manage(ctx, "{\"action\":\"list\"}");
if (!listed) return json_error("task_list failed to list tasks");
cJSON* listed_root = cJSON_Parse(listed);
if (!listed_root || !cJSON_IsObject(listed_root)) {
cJSON_Delete(listed_root);
free(listed);
return json_error("task_list received invalid task_manage response");
}
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(listed_root, "tasks");
int tcount = (tasks && cJSON_IsArray(tasks)) ? cJSON_GetArraySize(tasks) : 0;
size_t cap = 512U;
char* content = (char*)malloc(cap);
if (!content) {
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
size_t used = 0U;
int n = snprintf(content + used,
cap - used,
"## Current Task List\n\n### Current Tasks\n\nYour active task list - short-term working memory for tracking plan steps.\n\n");
if (n < 0) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
used += (size_t)n;
int written = 0;
for (int i = 0; i < tcount; i++) {
cJSON* task = cJSON_GetArrayItem(tasks, i);
cJSON* id = task ? cJSON_GetObjectItemCaseSensitive(task, "id") : NULL;
cJSON* text = task ? cJSON_GetObjectItemCaseSensitive(task, "text") : NULL;
cJSON* status = task ? cJSON_GetObjectItemCaseSensitive(task, "status") : NULL;
if (!text || !cJSON_IsString(text) || !text->valuestring || text->valuestring[0] == '\0') {
continue;
}
const char* status_s = (status && cJSON_IsString(status) && status->valuestring) ? status->valuestring : "pending";
const char* mark = " ";
if (strcmp(status_s, "done") == 0) mark = "x";
else if (strcmp(status_s, "active") == 0) mark = "-";
int id_value = (id && cJSON_IsNumber(id)) ? (int)id->valuedouble : (i + 1);
int needed = snprintf(NULL, 0, "- [%s] %d. %s\n", mark, id_value, text->valuestring);
if (needed < 0) continue;
if (used + (size_t)needed + 1U > cap) {
size_t next = cap;
while (used + (size_t)needed + 1U > next) next *= 2U;
char* grown = (char*)realloc(content, next);
if (!grown) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
content = grown;
cap = next;
}
snprintf(content + used, cap - used, "- [%s] %d. %s\n", mark, id_value, text->valuestring);
used += (size_t)needed;
written++;
}
if (written == 0) {
const char* fallback = "- [ ] No active tasks yet.\n";
size_t need = strlen(fallback);
if (used + need + 1U > cap) {
size_t next = cap;
while (used + need + 1U > next) next *= 2U;
char* grown = (char*)realloc(content, next);
if (!grown) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
content = grown;
cap = next;
}
memcpy(content + used, fallback, need + 1U);
used += need;
}
cJSON* args = parse_tool_args_json(args_json);
if (!args) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return json_error("invalid arguments JSON");
}
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(content);
cJSON_Delete(listed_root);
free(listed);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", content);
cJSON* tasks_dup = tasks ? cJSON_Duplicate(tasks, 1) : cJSON_CreateArray();
if (!tasks_dup) tasks_dup = cJSON_CreateArray();
cJSON_AddItemToObject(out, "tasks", tasks_dup);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
free(content);
cJSON_Delete(listed_root);
free(listed);
return json;
}
static char* execute_message_current(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", ctx->template_current_user_message ? ctx->template_current_user_message : "");
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
static char* execute_agent_identity(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
char npub[128] = {0};
if (nostr_key_to_bech32(ctx->cfg->keys.public_key, "npub", npub) != NOSTR_SUCCESS) {
strcpy(npub, "unknown");
}
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_pubkey", ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown");
cJSON_AddStringToObject(out, "agent_npub", npub);
char content[384];
snprintf(content,
sizeof(content),
"## Agent Identity\n\nYour pubkey (hex): %s\nYour npub: %s",
ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown",
npub);
cJSON_AddStringToObject(out, "content", content);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
static char* execute_agent_version(const char* args_json) {
cJSON* args = parse_tool_args_json(args_json);
if (!args) return json_error("invalid arguments JSON");
cJSON_Delete(args);
@@ -4124,7 +4604,7 @@ static char* execute_my_version(const char* args_json) {
return json;
}
static char* execute_http_fetch(tools_context_t* ctx, const char* args_json) {
static char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = parse_tool_args_json(args_json);
@@ -4139,15 +4619,15 @@ static char* execute_http_fetch(tools_context_t* ctx, const char* args_json) {
if (!url || !cJSON_IsString(url) || !url->valuestring || url->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error("http_fetch requires string url");
return json_error("local_http_fetch requires string url");
}
if (headers && !cJSON_IsArray(headers)) {
cJSON_Delete(args);
return json_error("http_fetch headers must be an array when provided");
return json_error("local_http_fetch headers must be an array when provided");
}
if (body && !cJSON_IsString(body)) {
cJSON_Delete(args);
return json_error("http_fetch body must be a string when provided");
return json_error("local_http_fetch body must be a string when provided");
}
const char* method_str = (method && cJSON_IsString(method) && method->valuestring && method->valuestring[0] != '\0')
@@ -4156,7 +4636,7 @@ static char* execute_http_fetch(tools_context_t* ctx, const char* args_json) {
if (body && cJSON_IsString(body) && body->valuestring && strcasecmp(method_str, "GET") == 0) {
cJSON_Delete(args);
return json_error("http_fetch GET requests cannot include body");
return json_error("local_http_fetch GET requests cannot include body");
}
int timeout_seconds = (timeout && cJSON_IsNumber(timeout)) ? (int)timeout->valuedouble : 20;
@@ -4170,10 +4650,10 @@ static char* execute_http_fetch(tools_context_t* ctx, const char* args_json) {
CURL* curl = curl_easy_init();
if (!curl) {
cJSON_Delete(args);
return json_error("http_fetch failed to initialize curl");
return json_error("local_http_fetch failed to initialize curl");
}
http_fetch_buffer_t rb;
local_http_fetch_buffer_t rb;
memset(&rb, 0, sizeof(rb));
rb.max_bytes = (size_t)max_bytes;
@@ -4193,10 +4673,10 @@ static char* execute_http_fetch(tools_context_t* ctx, const char* args_json) {
curl_easy_setopt(curl, CURLOPT_URL, url->valuestring);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)timeout_seconds);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, http_fetch_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, local_http_fetch_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, req_headers);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "didactyl/http_fetch");
curl_easy_setopt(curl, CURLOPT_USERAGENT, "didactyl/local_http_fetch");
const char* ca_bundle = detect_ca_bundle_path_for_tools();
if (ca_bundle) {
@@ -4268,7 +4748,7 @@ static char* execute_http_fetch(tools_context_t* ctx, const char* args_json) {
return json;
}
static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
static char* execute_local_shell_exec(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
if (!ctx->cfg->tools.shell.enabled) return json_error("shell tool disabled");
@@ -4278,7 +4758,7 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
cJSON* command = cJSON_GetObjectItemCaseSensitive(args, "command");
if (!command || !cJSON_IsString(command) || !command->valuestring || command->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error("shell_exec requires string command");
return json_error("local_shell_exec requires string command");
}
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
@@ -4369,7 +4849,7 @@ static char* execute_shell_exec(tools_context_t* ctx, const char* args_json) {
return json;
}
static char* execute_file_read(tools_context_t* ctx, const char* args_json) {
static char* execute_local_file_read(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
@@ -4379,7 +4859,7 @@ static char* execute_file_read(tools_context_t* ctx, const char* args_json) {
cJSON* maxb = cJSON_GetObjectItemCaseSensitive(args, "max_bytes");
if (!path || !cJSON_IsString(path) || !path->valuestring) {
cJSON_Delete(args);
return json_error("file_read requires string path");
return json_error("local_file_read requires string path");
}
int hard_max = ctx->cfg->tools.shell.max_output_bytes > 0 ? ctx->cfg->tools.shell.max_output_bytes : 65536;
@@ -4389,12 +4869,12 @@ static char* execute_file_read(tools_context_t* ctx, const char* args_json) {
char file_path[PATH_MAX];
if (build_tool_path(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error("file_read path is not allowed");
return json_error("local_file_read path is not allowed");
}
FILE* fp = fopen(file_path, "rb");
cJSON_Delete(args);
if (!fp) return json_error("file_read failed to open file");
if (!fp) return json_error("local_file_read failed to open file");
char* buf = (char*)calloc((size_t)max_bytes + 1U, 1U);
if (!buf) {
@@ -4425,7 +4905,7 @@ static char* execute_file_read(tools_context_t* ctx, const char* args_json) {
return json;
}
static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
static char* execute_local_file_write(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error("tool context unavailable");
cJSON* args = cJSON_Parse(args_json ? args_json : "{}");
@@ -4437,13 +4917,13 @@ static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
if (!path || !cJSON_IsString(path) || !path->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(args);
return json_error("file_write requires string path and content");
return json_error("local_file_write requires string path and content");
}
char file_path[PATH_MAX];
if (build_tool_path(ctx, path->valuestring, file_path, sizeof(file_path)) != 0) {
cJSON_Delete(args);
return json_error("file_write path is not allowed");
return json_error("local_file_write path is not allowed");
}
const char* content_str = content->valuestring;
@@ -4452,11 +4932,11 @@ static char* execute_file_write(tools_context_t* ctx, const char* args_json) {
FILE* fp = fopen(file_path, do_append ? "ab" : "wb");
cJSON_Delete(args);
if (!fp) return json_error("file_write failed to open file");
if (!fp) return json_error("local_file_write failed to open file");
size_t n = fwrite(content_str, 1, len, fp);
fclose(fp);
if (n != len) return json_error("file_write failed to write all bytes");
if (n != len) return json_error("local_file_write failed to write all bytes");
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
@@ -5127,8 +5607,8 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
if (strcmp(tool_name, "nostr_query") == 0) {
return execute_nostr_query(args_json);
}
if (strcmp(tool_name, "my_version") == 0) {
return execute_my_version(args_json);
if (strcmp(tool_name, "agent_version") == 0) {
return execute_agent_version(args_json);
}
if (strcmp(tool_name, "nostr_pubkey") == 0) {
return execute_nostr_pubkey(ctx, args_json);
@@ -5142,17 +5622,17 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
if (strcmp(tool_name, "my_npub") == 0) {
return execute_nostr_npub(ctx, args_json);
}
if (strcmp(tool_name, "http_fetch") == 0) {
return execute_http_fetch(ctx, args_json);
if (strcmp(tool_name, "local_http_fetch") == 0) {
return execute_local_http_fetch(ctx, args_json);
}
if (strcmp(tool_name, "shell_exec") == 0) {
return execute_shell_exec(ctx, args_json);
if (strcmp(tool_name, "local_shell_exec") == 0) {
return execute_local_shell_exec(ctx, args_json);
}
if (strcmp(tool_name, "file_read") == 0) {
return execute_file_read(ctx, args_json);
if (strcmp(tool_name, "local_file_read") == 0) {
return execute_local_file_read(ctx, args_json);
}
if (strcmp(tool_name, "file_write") == 0) {
return execute_file_write(ctx, args_json);
if (strcmp(tool_name, "local_file_write") == 0) {
return execute_local_file_write(ctx, args_json);
}
if (strcmp(tool_name, "skill_create") == 0) {
return execute_skill_create(ctx, args_json);
@@ -5194,6 +5674,30 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg
if (strcmp(tool_name, "task_manage") == 0) {
return execute_task_manage(ctx, args_json);
}
if (strcmp(tool_name, "admin_identity") == 0) {
return execute_admin_identity(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_profile") == 0) {
return execute_nostr_admin_profile(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_contacts") == 0) {
return execute_nostr_admin_contacts(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_relays") == 0) {
return execute_nostr_admin_relays(ctx, args_json);
}
if (strcmp(tool_name, "nostr_admin_notes") == 0) {
return execute_nostr_admin_notes(ctx, args_json);
}
if (strcmp(tool_name, "task_list") == 0) {
return execute_task_list(ctx, args_json);
}
if (strcmp(tool_name, "message_current") == 0) {
return execute_message_current(ctx, args_json);
}
if (strcmp(tool_name, "agent_identity") == 0) {
return execute_agent_identity(ctx, args_json);
}
return json_error("unknown tool");
}
+2
View File
@@ -8,6 +8,8 @@ struct trigger_manager;
typedef struct {
didactyl_config_t* cfg;
struct trigger_manager* trigger_manager;
int template_sender_tier;
const char* template_current_user_message;
} tools_context_t;
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);
+199 -60
View File
@@ -12,6 +12,11 @@
#include "debug.h"
#include "nostr_handler.h"
typedef struct {
trigger_manager_t* mgr;
char skill_slug[TRIGGER_SKILL_SLUG_MAX];
} trigger_subscription_ctx_t;
static int clamp_enabled(int enabled) {
return enabled ? 1 : 0;
}
@@ -220,6 +225,107 @@ static int maybe_fire_trigger_locked(trigger_manager_t* mgr, int index, cJSON* e
return 1;
}
static void on_trigger_subscription_eose(cJSON** events, int event_count, void* user_data) {
(void)events;
(void)event_count;
(void)user_data;
}
static void close_trigger_subscription_locked(active_trigger_t* t) {
if (!t) {
return;
}
if (t->subscription) {
(void)nostr_handler_close_subscription(t->subscription);
t->subscription = NULL;
}
if (t->subscription_ctx) {
free(t->subscription_ctx);
t->subscription_ctx = NULL;
}
}
static void on_trigger_subscription_event(cJSON* event, const char* relay_url, void* user_data) {
trigger_subscription_ctx_t* ctx = (trigger_subscription_ctx_t*)user_data;
if (!ctx || !ctx->mgr || !event) {
return;
}
trigger_manager_t* mgr = ctx->mgr;
pthread_mutex_lock(&mgr->mutex);
int idx = find_trigger_index_locked(mgr, ctx->skill_slug);
if (idx >= 0) {
(void)maybe_fire_trigger_locked(mgr, idx, event, relay_url);
}
pthread_mutex_unlock(&mgr->mutex);
}
static int register_trigger_subscription_locked(trigger_manager_t* mgr, active_trigger_t* t) {
if (!mgr || !t) {
return -1;
}
close_trigger_subscription_locked(t);
if (!t->enabled || t->filter_json[0] == '\0') {
return 0;
}
cJSON* filter = cJSON_Parse(t->filter_json);
if (!filter || !cJSON_IsObject(filter)) {
cJSON_Delete(filter);
return -1;
}
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
if (!since || !cJSON_IsNumber(since)) {
time_t now = time(NULL);
time_t default_since = now > 30 ? now - 30 : 0;
cJSON_AddNumberToObject(filter, "since", (double)default_since);
}
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
if (!limit || !cJSON_IsNumber(limit)) {
cJSON_AddNumberToObject(filter, "limit", 200);
}
trigger_subscription_ctx_t* ctx = (trigger_subscription_ctx_t*)calloc(1, sizeof(*ctx));
if (!ctx) {
cJSON_Delete(filter);
return -1;
}
ctx->mgr = mgr;
snprintf(ctx->skill_slug, sizeof(ctx->skill_slug), "%s", t->skill_slug);
nostr_pool_subscription_t* sub = nostr_handler_subscribe_with_filter(
filter,
on_trigger_subscription_event,
on_trigger_subscription_eose,
ctx,
0,
0,
NOSTR_POOL_EOSE_FULL_SET,
30,
120);
cJSON_Delete(filter);
if (!sub) {
free(ctx);
return -1;
}
t->subscription = sub;
t->subscription_ctx = ctx;
return 0;
}
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
if (!mgr || !cfg) {
return -1;
@@ -389,6 +495,77 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
return 0;
}
int trigger_manager_load_from_startup_events(trigger_manager_t* mgr) {
if (!mgr || !mgr->cfg) {
return -1;
}
int loaded = 0;
int considered = 0;
for (int i = 0; i < mgr->cfg->startup_event_count; i++) {
startup_event_t* ev = &mgr->cfg->startup_events[i];
if (!ev) {
continue;
}
if (ev->kind != 31123 && ev->kind != 31124) {
continue;
}
considered++;
if (!ev->content || ev->content[0] == '\0' || !ev->tags_json || ev->tags_json[0] == '\0') {
continue;
}
cJSON* tags = cJSON_Parse(ev->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
DEBUG_WARN("[didactyl] startup trigger skip: invalid tags_json for startup event index=%d", i);
continue;
}
cJSON* d = find_tag_value_string(tags, "d");
cJSON* trigger = find_tag_value_string(tags, "trigger");
cJSON* filter = find_tag_value_string(tags, "filter");
cJSON* action = find_tag_value_string(tags, "action");
cJSON* enabled = find_tag_value_string(tags, "enabled");
const char* slug = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
if (!slug || slug[0] == '\0') {
cJSON_Delete(tags);
DEBUG_WARN("[didactyl] startup trigger skip: missing d tag for startup event index=%d", i);
continue;
}
if (!trigger_s || strcmp(trigger_s, "nostr-subscription") != 0 || !filter_s || filter_s[0] == '\0') {
cJSON_Delete(tags);
continue;
}
trigger_action_type_t at = (strcmp(action_s, "template") == 0) ? TRIGGER_ACTION_TEMPLATE : TRIGGER_ACTION_LLM;
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
if (trigger_manager_add(mgr, slug, ev->content, filter_s, at, is_enabled) == 0) {
loaded++;
DEBUG_INFO("[didactyl] startup trigger registered slug=%s action=%s enabled=%d", slug, at == TRIGGER_ACTION_TEMPLATE ? "template" : "llm", is_enabled);
} else {
DEBUG_WARN("[didactyl] startup trigger failed slug=%s", slug);
}
cJSON_Delete(tags);
}
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from startup config (considered=%d)", loaded, considered);
return 0;
}
int trigger_manager_add(trigger_manager_t* mgr,
const char* skill_slug,
const char* content,
@@ -433,6 +610,15 @@ int trigger_manager_add(trigger_manager_t* mgr,
t->enabled = clamp_enabled(enabled);
t->last_fired = 0;
t->last_seen_created_at = 0;
t->subscription = NULL;
t->subscription_ctx = NULL;
if (register_trigger_subscription_locked(mgr, t) != 0) {
pthread_mutex_unlock(&mgr->mutex);
DEBUG_WARN("[didactyl] trigger add rejected: failed to create subscription slug=%s", skill_slug);
memset(t, 0, sizeof(*t));
return -1;
}
mgr->count++;
@@ -455,6 +641,8 @@ int trigger_manager_remove(trigger_manager_t* mgr, const char* skill_slug) {
return 0;
}
close_trigger_subscription_locked(&mgr->triggers[idx]);
if (idx < mgr->count - 1) {
memmove(&mgr->triggers[idx],
&mgr->triggers[idx + 1],
@@ -498,6 +686,12 @@ int trigger_manager_update(trigger_manager_t* mgr,
t->action_type = action_type;
t->enabled = clamp_enabled(enabled);
if (register_trigger_subscription_locked(mgr, t) != 0) {
pthread_mutex_unlock(&mgr->mutex);
DEBUG_WARN("[didactyl] trigger update failed to (re)subscribe slug=%s", skill_slug);
return -1;
}
pthread_mutex_unlock(&mgr->mutex);
DEBUG_INFO("[didactyl] trigger updated slug=%s action=%d enabled=%d", skill_slug, (int)action_type, clamp_enabled(enabled));
@@ -522,66 +716,7 @@ int trigger_manager_active_count(trigger_manager_t* mgr) {
}
int trigger_manager_poll(trigger_manager_t* mgr) {
if (!mgr || !mgr->cfg || !mgr->cfg->triggers.enabled) {
return 0;
}
time_t now = time(NULL);
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 10) {
return 0;
}
mgr->last_poll_at = now;
pthread_mutex_lock(&mgr->mutex);
int count = mgr->count;
for (int i = 0; i < count; i++) {
active_trigger_t snapshot = mgr->triggers[i];
if (!snapshot.enabled || snapshot.filter_json[0] == '\0') {
continue;
}
cJSON* filter = cJSON_Parse(snapshot.filter_json);
if (!filter || !cJSON_IsObject(filter)) {
cJSON_Delete(filter);
continue;
}
time_t since = snapshot.last_seen_created_at > 0 ? snapshot.last_seen_created_at + 1 : now - 30;
if (since < 0) since = 0;
cJSON_AddNumberToObject(filter, "since", (double)since);
cJSON_AddNumberToObject(filter, "limit", 8);
char* events_json = nostr_handler_query_json(filter, 1200);
cJSON_Delete(filter);
if (!events_json) {
continue;
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(events);
continue;
}
int n = cJSON_GetArraySize(events);
for (int e = 0; e < n; e++) {
cJSON* ev = cJSON_GetArrayItem(events, e);
if (!ev || !cJSON_IsObject(ev)) continue;
int idx = find_trigger_index_locked(mgr, snapshot.skill_slug);
if (idx < 0) {
continue;
}
(void)maybe_fire_trigger_locked(mgr, idx, ev, NULL);
}
cJSON_Delete(events);
}
pthread_mutex_unlock(&mgr->mutex);
(void)mgr;
return 0;
}
@@ -627,6 +762,7 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
cJSON_AddStringToObject(item, "filter_json", t->filter_json);
cJSON_AddStringToObject(item, "action", t->action_type == TRIGGER_ACTION_TEMPLATE ? "template" : "llm");
cJSON_AddBoolToObject(item, "enabled", t->enabled ? 1 : 0);
cJSON_AddBoolToObject(item, "subscribed", t->subscription ? 1 : 0);
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
cJSON_AddItemToArray(arr, item);
@@ -648,6 +784,9 @@ void trigger_manager_cleanup(trigger_manager_t* mgr) {
}
pthread_mutex_lock(&mgr->mutex);
for (int i = 0; i < mgr->count; i++) {
close_trigger_subscription_locked(&mgr->triggers[i]);
}
free(mgr->triggers);
mgr->triggers = NULL;
mgr->count = 0;
+4
View File
@@ -5,6 +5,7 @@
#include <time.h>
#include "config.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#define TRIGGER_DEFAULT_MAX_ACTIVE 16
#define TRIGGER_SKILL_SLUG_MAX 65
@@ -24,6 +25,8 @@ typedef struct {
int enabled;
time_t last_fired;
time_t last_seen_created_at;
nostr_pool_subscription_t* subscription;
void* subscription_ctx;
} active_trigger_t;
typedef struct trigger_manager {
@@ -37,6 +40,7 @@ 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);
int trigger_manager_add(trigger_manager_t* mgr,
const char* skill_slug,
const char* content,
+1 -1
View File
@@ -1 +1 @@
{"tasks":[{"id":5,"text":"Tweet 3: Gets Smarter Over Time - \"Didactyl starts as an AI that explores and learns. Over time, the best workflows get locked in as reliable, fast processes. It evolves from experimental to hardened—from thinking to doing. An agent that improves itself. #nostr #agents\"","status":"pending","created_at":1772625247,"updated_at":1772625247},{"id":6,"text":"admin_info","status":"pending","created_at":1772751865,"updated_at":1772751865},{"id":7,"text":"REFERENCE: Nostr \"a\" tag format for replaceable events\n\nParameterized replaceable event:\n[\"a\", <kind integer>:<32-bytes lowercase hex of pubkey>:<d tag value>, <recommended relay URL (optional)>]\n\nNon-parameterized replaceable event:\n[\"a\", <kind integer>:<32-bytes lowercase hex of pubkey>:, <recommended relay URL (optional)>]\n\nUsed to refer to replaceable events (kinds 10000-19999 for non-parameterized, 30000-39999 for parameterized)","status":"pending","created_at":1772768115,"updated_at":1772768115}],"next_id":8}
{"tasks":[],"next_id":9}