v0.2.0 - Release v0.2.0: trigger-scoped skill architecture breaking changes
This commit is contained in:
203
README.md
203
README.md
@@ -1,6 +1,5 @@
|
||||
# Didactyl
|
||||
|
||||
|
||||
### A decentralized, censorship-resistant agentic network.
|
||||
|
||||
Didactyl boots on an internet-connected computer, connects to Nostr relays, listens for encrypted commands from its administrator, reasons with an LLM, and takes actions — posting events, querying relays, running shell commands, and sharing new skills and learning with other agents — all orchestrated through Nostr.
|
||||
@@ -55,31 +54,31 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e
|
||||
|
||||
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.1.21
|
||||
## Current Status — v0.2.0
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.1.21 — Fix segfault in nostr_http_request by copying CURLINFO_CONTENT_TYPE before curl cleanup and update debug deploy script to deploy *_debug binary for symbolized coredumps
|
||||
> Last release update: v0.2.0 — Release v0.2.0: trigger-scoped skill architecture breaking changes
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
- Loads base system context from default skill content (first-run from `genesis.jsonc`, subsequent runs from adopted skills on Nostr)
|
||||
- Loads base system context from default skill content (first-run from`genesis.jsonc`, subsequent runs from adopted skills on Nostr)
|
||||
- Verifies Nostr event signatures before processing inbound messages
|
||||
- Applies privilege tiers: ADMIN (tools), WoT (chat-only), STRANGER (configurable canned reply or ignore)
|
||||
- Subscribes to admin context kinds (`0`,`3`,`10002`,`1`) for WoT + contextual awareness
|
||||
- Builds LLM context from default/adopted skill templates (`---template---`) 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
|
||||
- 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 with `template` (deterministic) or `llm` (context-aware) actions; see [`docs/SKILLS.md`](docs/SKILLS.md)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Download binary (recommended)
|
||||
|
||||
1. Download the latest release binary from Gitea: [https://git.laantungir.net/laantungir/didactyl/releases](https://git.laantungir.net/laantungir/didactyl/releases)
|
||||
1. Download the latest release binary from Gitea:[https://git.laantungir.net/laantungir/didactyl/releases](https://git.laantungir.net/laantungir/didactyl/releases)
|
||||
2. Make it executable and run it:
|
||||
|
||||
```bash
|
||||
@@ -209,8 +208,8 @@ Options:
|
||||
|
||||
Interactive setup notes:
|
||||
|
||||
- First menu asks whether you are starting a **new agent** or an **existing agent**.
|
||||
- Menus use first-letter hotkeys (case-insensitive), with `q`/`x` as quit/back shortcuts.
|
||||
- First menu asks whether you are starting a**new agent** or an**existing agent**.
|
||||
- Menus use first-letter hotkeys (case-insensitive), with`q`/`x` as quit/back shortcuts.
|
||||
- Existing-agent mode attempts to recover relay/admin/LLM config from Nostr before asking for missing fields.
|
||||
|
||||
CLI debugger notes:
|
||||
@@ -247,10 +246,10 @@ node ./didactyl-chat-cli.js
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
- `DIDACTYL_API_BASE_URL` (default: `https://127.0.0.1:8484`)
|
||||
- `DIDACTYL_API_BASE_URL` (default:`https://127.0.0.1:8484`)
|
||||
- `DIDACTYL_MODEL` (optional model override)
|
||||
- `DIDACTYL_MAX_TURNS` (default: `4`)
|
||||
- `DIDACTYL_INSECURE_TLS` (default: `1`, set `0` to enforce certificate verification)
|
||||
- `DIDACTYL_MAX_TURNS` (default:`4`)
|
||||
- `DIDACTYL_INSECURE_TLS` (default:`1`, set`0` to enforce certificate verification)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -291,13 +290,13 @@ The CLI prints each message block with a speaker label (`You` / `Didactyl`) and
|
||||
|
||||
Didactyl uses a two-layer skill model: authors publish skill definitions, and adopters publish which skills they use.
|
||||
|
||||
- `31123` — **Public Skill Definition** (replaceable by `d` tag)
|
||||
- `content` is JSON with instruction fields like `description` and `template`
|
||||
- `d=<skill_slug>` (example: `d=long_form_note`)
|
||||
- `31124` — **Private Skill Definition** (same schema as `31123`, private scope)
|
||||
- `d=<skill_slug>` (example: `d=admin_ops`)
|
||||
- `10123` — **Skill Adoption List**
|
||||
- tags contain one or more `a` references to selected skills
|
||||
- `31123` —**Public Skill Definition** (replaceable by`d` tag)
|
||||
- `content` is JSON with instruction fields like`description` and`template`
|
||||
- `d=<skill_slug>` (example:`d=long_form_note`)
|
||||
- `31124` —**Private Skill Definition** (same schema as`31123`, private scope)
|
||||
- `d=<skill_slug>` (example:`d=admin_ops`)
|
||||
- `10123` —**Skill Adoption List**
|
||||
- tags contain one or more`a` references to selected skills
|
||||
|
||||
Skills are composed by adoption list order and per-skill template resolution (no context modes).
|
||||
|
||||
@@ -309,16 +308,16 @@ Skills are shared across Nostr without any centralized registry or approval proc
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Publish**: An author publishes a skill as a kind `31123` event. The `content` field contains the skill body (markdown or structured JSON). The `d` tag is the skill's slug (e.g. `long_form_note`).
|
||||
2. **Adopt**: An agent that wants to use a skill adds an `a`-tag reference to its kind `10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses.
|
||||
3. **Discover**: A new user queries `{"kinds": [10123], "authors": [<my-follows>]}` to see which skills their web of trust has adopted. The most-referenced `31123` addresses are the most popular skills — no rating system needed.
|
||||
4. **Improve**: Anyone can publish their own `31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking.
|
||||
1. **Publish**: An author publishes a skill as a kind`31123` event. The`content` field contains the skill body (markdown or structured JSON). The`d` tag is the skill's slug (e.g.`long_form_note`).
|
||||
2. **Adopt**: An agent that wants to use a skill adds an`a`-tag reference to its kind`10123` adoption list. This is a public, replaceable event — anyone can see which skills an agent uses.
|
||||
3. **Discover**: A new user queries`{"kinds": [10123], "authors": [<my-follows>]}` to see which skills their web of trust has adopted. The most-referenced`31123` addresses are the most popular skills — no rating system needed.
|
||||
4. **Improve**: Anyone can publish their own`31123` with the same slug but a different pubkey. If their version is better, people adopt it instead. Competition happens through adoption, not through a store ranking.
|
||||
|
||||
### Why this works
|
||||
|
||||
- **No gatekeeper**: Skills are just Nostr events. Anyone can publish one.
|
||||
- **WoT as curation**: You see what people you trust actually use, not what an algorithm promotes.
|
||||
- **Visible adoption**: The `10123` list is public. Popularity is a countable fact, not a manipulable score.
|
||||
- **Visible adoption**: The`10123` list is public. Popularity is a countable fact, not a manipulable score.
|
||||
- **Censorship resistant**: Skills live on relays. No single entity can remove a skill from the network.
|
||||
|
||||
## Startup
|
||||
@@ -327,10 +326,10 @@ Didactyl startup behavior is configured in [`genesis.jsonc`](genesis.jsonc) unde
|
||||
|
||||
Startup model:
|
||||
|
||||
- First run is detected by checking for an existing kind `10002` relay-list event from the agent pubkey.
|
||||
- On first run, events in `startup_events` are published to connected relays.
|
||||
- First run is detected by checking for an existing kind`10002` relay-list event from the agent pubkey.
|
||||
- On first run, events in`startup_events` are published to connected relays.
|
||||
- On subsequent runs, startup publish is skipped and relay/config state is loaded from Nostr.
|
||||
- Identity can be supplied at runtime via `--nsec` or `DIDACTYL_NSEC`.
|
||||
- Identity can be supplied at runtime via`--nsec` or`DIDACTYL_NSEC`.
|
||||
|
||||
See [`docs/GENESIS.md`](docs/GENESIS.md) for full boot semantics.
|
||||
|
||||
@@ -339,8 +338,8 @@ See [`docs/GENESIS.md`](docs/GENESIS.md) for full boot semantics.
|
||||
Didactyl builds tier-aware, template-driven context:
|
||||
|
||||
- **ADMIN** request context is assembled from adopted skill templates.
|
||||
- Template variables like `{{nostr_admin_profile}}` are resolved by executing tools at render time.
|
||||
- Triggered skill invocations can override runtime execution parameters via trigger tags (`llm`, `max_tokens`, `temperature`, `seed`, `tools`).
|
||||
- Template variables like`{{nostr_admin_profile}}` are resolved by executing tools at render time.
|
||||
- Triggered skill invocations can override runtime execution parameters via trigger tags (`llm`,`max_tokens`,`temperature`,`seed`,`tools`).
|
||||
- **WoT** request context remains chat-only.
|
||||
- **STRANGER** behavior follows configured security policy.
|
||||
|
||||
@@ -360,17 +359,17 @@ See [`docs/TOOLS.md`](docs/TOOLS.md) for the canonical tool catalog and interfac
|
||||
|
||||
A localhost-only HTTP API on port `8484` (configurable) for agent inspection and prompt crafting. Enable with `"api": {"enabled": true}` in config.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/status` | Agent name, version, pubkey, relay count, trigger count |
|
||||
| `GET /api/context/current` | Full LLM context messages array |
|
||||
| `GET /api/context/parts` | Context broken into named parts with token estimates |
|
||||
| `POST /api/prompt/run-simple` | Run a simple system+user prompt, no tools |
|
||||
| `POST /api/prompt/run` | Run a full messages array with tools enabled |
|
||||
| `POST /api/prompt/compare` | A/B compare two prompt variants |
|
||||
| `GET /api/model` | Current LLM model config |
|
||||
| `PUT /api/model` | Change model at runtime (persisted in encrypted config events) |
|
||||
| `GET /api/models` | List available models from provider |
|
||||
| Endpoint | Purpose |
|
||||
| ------------------------------- | -------------------------------------------------------------- |
|
||||
| `GET /api/status` | Agent name, version, pubkey, relay count, trigger count |
|
||||
| `GET /api/context/current` | Full LLM context messages array |
|
||||
| `GET /api/context/parts` | Context broken into named parts with token estimates |
|
||||
| `POST /api/prompt/run-simple` | Run a simple system+user prompt, no tools |
|
||||
| `POST /api/prompt/run` | Run a full messages array with tools enabled |
|
||||
| `POST /api/prompt/compare` | A/B compare two prompt variants |
|
||||
| `GET /api/model` | Current LLM model config |
|
||||
| `PUT /api/model` | Change model at runtime (persisted in encrypted config events) |
|
||||
| `GET /api/models` | List available models from provider |
|
||||
|
||||
Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_frontend.md`](plans/admin_web_frontend.md).
|
||||
|
||||
@@ -408,75 +407,99 @@ Full reference: [`docs/API.md`](docs/API.md). Frontend brief: [`plans/admin_web_
|
||||
|
||||
All dependencies are statically linked into the binary at build time. No system libraries are required at runtime.
|
||||
|
||||
| Dependency | Purpose | Source |
|
||||
|---|---|---|
|
||||
| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) |
|
||||
| cJSON | JSON parsing | Bundled in nostr_core_lib |
|
||||
| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) |
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
|
||||
| Dependency | Purpose | Source |
|
||||
| ------------------ | ---------------------------------------------- | ------------------------------- |
|
||||
| nostr_core_lib | Nostr protocol: keys, events, NIPs, relay pool | Workspace (sibling directory) |
|
||||
| cJSON | JSON parsing | Bundled in nostr_core_lib |
|
||||
| libcurl | HTTPS for LLM API calls | Statically linked (Alpine/MUSL) |
|
||||
| libssl / libcrypto | TLS for WebSocket relay connections | Statically linked (Alpine/MUSL) |
|
||||
| libsecp256k1 | Schnorr signatures, ECDH | Statically linked (Alpine/MUSL) |
|
||||
|
||||
## Roadmap: Nostr-Native Portability
|
||||
|
||||
Didactyl's long-term architecture goal is **zero filesystem dependency after first boot**. The config file is the only tie to the local filesystem. The plan:
|
||||
Didactyl's long-term architecture goal is **zero filesystem dependency after first boot**. A geneisis.jsonc file can assist the first boot, but first boot can also occur throught the command line or TUI wizard. The plan:
|
||||
|
||||
1. **First boot** — Read `genesis.jsonc`, publish startup identity/skill/adoption events to relays.
|
||||
2. **Subsequent boots** — Start with only `nsec` (CLI/env), detect initialized state from kind `10002`, and load durable state from Nostr.
|
||||
1. **First boot** — Read`genesis.jsonc`, publish startup identity/skill/adoption events to relays.
|
||||
2. **Subsequent boots** — Start with only`nsec` (CLI/env), detect initialized state from kind`10002`, and load durable state from Nostr.
|
||||
3. **True portability** — Start your agent from any computer; keys are sufficient and state lives on Nostr.
|
||||
|
||||
This makes Didactyl fundamentally different from filesystem-bound agents. Destroying the host computer does not kill the agent — its identity, memory, and capabilities persist on the relay network.
|
||||
|
||||
### Browser-Based Didactyl
|
||||
|
||||
Because all agent state lives on Nostr, Didactyl can run in a browser — no server required. A browser-based Didactyl would load the agent's identity from an nsec, fetch its skills and adoption list from relays, and execute skills using browser-available tools.
|
||||
|
||||
This increases decentralization: if the server running your agent goes down, you can boot the same agent in a browser window. The agent continues living, just in a different environment with a different set of available tools — like an artist working from a vacation home instead of their full studio.
|
||||
|
||||
Not all tools would be available in a browser runtime. For example, `shell_exec` and `local_file_read` require a host OS. But Nostr operations, HTTP fetches, and LLM calls work fine in a browser. Skills declare their requirements via `requires_tool` tags, so the browser runtime knows which skills it can execute and which it cannot.
|
||||
|
||||
```
|
||||
Didactyl C binary (full studio) Didactyl Browser (vacation home)
|
||||
════════════════════════════ ════════════════════════════════
|
||||
Available tools: Available tools:
|
||||
✓ http_fetch ✓ http_fetch
|
||||
✓ shell_exec ✗ shell_exec
|
||||
✓ nostr_query, nostr_dm, nostr_post ✓ nostr_query, nostr_dm, nostr_post
|
||||
✓ memory_read, memory_write ✓ memory_read, memory_write
|
||||
✓ local_file_read, local_file_write ✗ local_file_read, local_file_write
|
||||
✓ blossom_upload, blossom_download ✓ blossom_upload, blossom_download
|
||||
|
||||
Skills that work: ALL Skills that work: those whose
|
||||
requires_tool tags are satisfied
|
||||
```
|
||||
|
||||
### What already lives on Nostr
|
||||
|
||||
| Data | Event Kind | Status |
|
||||
|---|---|---|
|
||||
| Agent profile | Kind 0 | Implemented |
|
||||
| Relay list | Kind 10002 | Implemented |
|
||||
| DM relay list | Kind 10050 | Implemented |
|
||||
| Public skills | Kind 31123 | Implemented |
|
||||
| Private skills | Kind 31124 | Implemented |
|
||||
| Skill adoption list | Kind 10123 | Implemented |
|
||||
| Base/default behavior skill | Kind 31124 | Implemented |
|
||||
| Trigger definitions | Tags on skill events | Implemented |
|
||||
| Data | Event Kind | Status |
|
||||
| --------------------------- | -------------------- | ----------- |
|
||||
| Agent profile | Kind 0 | Implemented |
|
||||
| Relay list | Kind 10002 | Implemented |
|
||||
| DM relay list | Kind 10050 | Implemented |
|
||||
| Public skills | Kind 31123 | Implemented |
|
||||
| Private skills | Kind 31124 | Implemented |
|
||||
| Skill adoption list | Kind 10123 | Implemented |
|
||||
| Base/default behavior skill | Kind 31124 | Implemented |
|
||||
| Trigger definitions | Tags on skill events | Implemented |
|
||||
|
||||
### What still needs migration
|
||||
|
||||
| Data | Current Location | Target |
|
||||
|---|---|---|
|
||||
| Admin pubkey | `genesis.jsonc` fallback | Dedicated agent config event / contact-graph derivation |
|
||||
| LLM provider/key | `genesis.jsonc` fallback | Encrypted kind 30078 app-specific event |
|
||||
| Security tiers | `genesis.jsonc` fallback | Agent config event on Nostr |
|
||||
| API settings | local runtime flags | Local-only (not published) |
|
||||
| Data | Current Location | Target |
|
||||
| ---------------- | -------------------------- | ------------------------------------------------------- |
|
||||
| Admin pubkey | `genesis.jsonc` fallback | Dedicated agent config event / contact-graph derivation |
|
||||
| LLM provider/key | `genesis.jsonc` fallback | Encrypted kind 30078 app-specific event |
|
||||
| Security tiers | `genesis.jsonc` fallback | Agent config event on Nostr |
|
||||
| API settings | local runtime flags | Local-only (not published) |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] MVP chat agent — DM in, LLM response out
|
||||
- [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, 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 (`31123` Public Skill, `31124` Private Skill)
|
||||
- [x] Skill adoption list (`10123`) for WoT-driven discovery
|
||||
- [x] Signature verification on all inbound events
|
||||
- [x] Privilege tiers — ADMIN (tools), WoT (chat-only), STRANGER (canned reply/ignore)
|
||||
- [x] Admin context subscription (kind 0, 3, 10002, 1) with WoT contact extraction
|
||||
- [x] Message deduplication (event-ID cache + FNV-1a fingerprint debounce)
|
||||
- [x] Adopted skills injected into LLM context automatically
|
||||
- [x] Triggered skills — Nostr event filters that fire skill execution automatically
|
||||
- [x] Localhost HTTP admin API — context inspection, prompt crafting, A/B comparison
|
||||
- [x] Runtime model switching via `model_set` tool (persisted in encrypted config events)
|
||||
- [x] Skill-embedded prompt templates (`---template---`) — configurable context order, variable resolution, provider overrides
|
||||
- [ ] Runtime skill loading from adopted `31123` events on relays
|
||||
- [X] MVP chat agent — DM in, LLM response out
|
||||
- [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, 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 (`31123` Public Skill,`31124` Private Skill)
|
||||
- [X] Skill adoption list (`10123`) for WoT-driven discovery
|
||||
- [X] Signature verification on all inbound events
|
||||
- [X] Privilege tiers — ADMIN (tools), WoT (chat-only), STRANGER (canned reply/ignore)
|
||||
- [X] Admin context subscription (kind 0, 3, 10002, 1) with WoT contact extraction
|
||||
- [X] Message deduplication (event-ID cache + FNV-1a fingerprint debounce)
|
||||
- [X] Adopted skills injected into LLM context automatically
|
||||
- [X] Triggered skills — Nostr event filters that fire skill execution automatically
|
||||
- [X] Localhost HTTP admin API — context inspection, prompt crafting, A/B comparison
|
||||
- [X] Runtime model switching via`model_set` tool (persisted in encrypted config events)
|
||||
- [X] Skill-embedded prompt templates (`---template---`) — configurable context order, variable resolution, provider overrides
|
||||
- [ ] Runtime skill loading from adopted`31123` events on relays
|
||||
- [ ] Skill discovery CLI/tool (query WoT adoption lists)
|
||||
- [ ] Upgrade to NIP-17 gift-wrapped DMs
|
||||
- [x] NIP-44 encrypted private skills (`31124`)
|
||||
- [x] Nostr-native data storage (kind 30078 app-specific events)
|
||||
- [X] NIP-44 encrypted private skills (`31124`)
|
||||
- [X] Nostr-native data storage (kind 30078 app-specific events)
|
||||
- [ ] Blossom blob storage integration
|
||||
- [ ] Agent-to-agent communication
|
||||
- [ ] Browser-based Didactyl runtime (WASM/JS — same agent, browser-available tools only)
|
||||
- [ ] Tool schema standardization — canonical tool definitions as Nostr events for cross-app skill portability
|
||||
|
||||
## License
|
||||
|
||||
TBD
|
||||
|
||||
|
||||
177
docs/CONTEXT.md
177
docs/CONTEXT.md
@@ -12,6 +12,8 @@ Context is not just a prompt string; it is the full request payload:
|
||||
2. **Tool schemas** — JSON descriptions of callable tools
|
||||
3. **Model parameters** — model, temperature, max tokens, seed, etc.
|
||||
|
||||
The context window is composed of **skills** — blocks of markdown instructions stacked together. See [SKILLS.md](SKILLS.md) for the canonical skill specification.
|
||||
|
||||
---
|
||||
|
||||
## OpenAI-Compatible Chat Format
|
||||
@@ -44,45 +46,96 @@ Didactyl uses OpenAI-compatible chat completions.
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| `system` | Instructions and injected context |
|
||||
| `system` | Instructions and injected context (from skills) |
|
||||
| `user` | Input message or trigger payload |
|
||||
| `assistant` | Model responses / tool call envelopes |
|
||||
| `tool` | Tool execution results fed back to model |
|
||||
|
||||
---
|
||||
|
||||
## Context Assembly Model
|
||||
## Context Assembly — Two-Layer Model
|
||||
|
||||
Didactyl uses **skill composition by adoption order**.
|
||||
Context is assembled using a two-layer model driven by triggers and the adoption list.
|
||||
|
||||
There are no context modes.
|
||||
### Layer 1: Triggered Skills
|
||||
|
||||
When a trigger event occurs (DM, cron, subscription, webhook, chain), Didactyl walks the `10123` adoption list and finds all skills whose trigger matches the current event. These skills form layer 1 of the context window, in adoption-list order.
|
||||
|
||||
Only triggered skills can be in layer 1 — the trigger system is what puts them there.
|
||||
|
||||
### Layer 2: Referenced Skills
|
||||
|
||||
Within each layer 1 skill, `{{skill_d_tag}}` template variables resolve to adopted skills' content. These are embedded inline — the same way tool-based template variables are resolved.
|
||||
|
||||
Non-triggered skills (skills with no trigger tags) can only enter the context window via layer 2 references.
|
||||
|
||||
### Assembly Steps
|
||||
|
||||
1. Load adopted skills from kind `10123`.
|
||||
2. Resolve adopted skills in list order.
|
||||
3. Expand each skill template variables via tools.
|
||||
4. Append resolved skill output to messages in that same order.
|
||||
5. Append live input (DM text or triggering event payload).
|
||||
6. Attach tool schemas.
|
||||
7. Apply execution parameters from trigger tags (if invoked via trigger).
|
||||
```
|
||||
Trigger event occurs (DM, cron, subscription, webhook, chain)
|
||||
│
|
||||
├─ Walk adoption list (10123)
|
||||
│ │
|
||||
│ ├─ Skill has trigger matching this event?
|
||||
│ │ ├─ YES → add to context (layer 1)
|
||||
│ │ │ └─ Resolve {{...}} references (layer 2)
|
||||
│ │ │ ├─ Known tool? → execute tool, insert result
|
||||
│ │ │ ├─ Adopted skill d-tag? → insert skill content
|
||||
│ │ │ └─ Unknown? → resolve to empty
|
||||
│ │ │
|
||||
│ │ └─ NO → skip (not in this context)
|
||||
│ │
|
||||
│ └─ Continue to next skill in list
|
||||
│
|
||||
├─ Append triggering event payload
|
||||
│ └─ For DM triggers: always append raw message content
|
||||
│
|
||||
├─ Attach tool schemas (filtered by skill requires_tool tags)
|
||||
│
|
||||
├─ Apply execution parameters (llm, temperature, max_tokens)
|
||||
│ └─ Walk LLM fallback chain until usable model found
|
||||
│
|
||||
└─ Send to LLM
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INPUT[Input: DM or trigger event] --> ADOPT[Load adopted skills from kind 10123]
|
||||
ADOPT --> ORDER[Resolve skills in listed order]
|
||||
ORDER --> EXPAND[Expand template variables via tools]
|
||||
EXPAND --> MESSAGES[Append resolved skill messages]
|
||||
MESSAGES --> LIVE[Append live input message/event]
|
||||
LIVE --> TOOLS[Attach tool schemas]
|
||||
TOOLS --> PARAMS[Apply runtime params from trigger tags]
|
||||
PARAMS --> LLM[Send to LLM]
|
||||
### Visualization
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════╗
|
||||
║ CONTEXT WINDOW ║
|
||||
║ ║
|
||||
║ ┌────────────────────────────────────┐ ║
|
||||
║ │ Layer 1: personality (dm trigger) │ ║
|
||||
║ │ │ ║
|
||||
║ │ ┌──────────────────────────────┐ │ ║
|
||||
║ │ │ Layer 2: {{identity}} │ │ ║
|
||||
║ │ │ You are Didactyl. npub1... │ │ ║
|
||||
║ │ └──────────────────────────────┘ │ ║
|
||||
║ │ │ ║
|
||||
║ │ You speak concisely and directly. │ ║
|
||||
║ └────────────────────────────────────┘ ║
|
||||
║ ┌────────────────────────────────────┐ ║
|
||||
║ │ Layer 1: chat (dm trigger) │ ║
|
||||
║ │ │ ║
|
||||
║ │ Respond helpfully to the admin. │ ║
|
||||
║ │ Use tools as needed. │ ║
|
||||
║ │ │ ║
|
||||
║ │ tools: [nostr_query, nostr_dm] │ ║
|
||||
║ └────────────────────────────────────┘ ║
|
||||
║ ┌────────────────────────────────────┐ ║
|
||||
║ │ DM content (always last) │ ║
|
||||
║ │ │ ║
|
||||
║ │ "Who mentioned me today?" │ ║
|
||||
║ └────────────────────────────────────┘ ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### Why Order Matters
|
||||
|
||||
- Earlier adopted skills usually establish broad behavior.
|
||||
- Later adopted skills can refine or narrow behavior.
|
||||
- Earlier skills in the adoption list appear first in the context window.
|
||||
- Earlier instructions generally set broader tone/policy.
|
||||
- Later instructions can narrow/specialize behavior.
|
||||
- If instructions conflict, prompt-order effects apply.
|
||||
|
||||
---
|
||||
@@ -91,53 +144,64 @@ flowchart TD
|
||||
|
||||
| Part | Source | Description |
|
||||
|------|--------|-------------|
|
||||
| Skill templates | Adopted skill events | Core instructions assembled in order |
|
||||
| Resolved variables | Tool outputs | Runtime data inserted into templates |
|
||||
| Conversation history | DM history/events | Recent dialogue context |
|
||||
| Live input | DM or trigger event | Current request payload |
|
||||
| Tool schemas | Tool registry | Capability declaration for tool calling |
|
||||
| Runtime params | Trigger tags | LLM/tool limits for this execution |
|
||||
| Layer 1 skills | Triggered skills from adoption list | Skills whose trigger matches the current event, in adoption-list order |
|
||||
| Layer 2 skills | `{{skill_d_tag}}` references | Adopted skills embedded inside layer 1 skills |
|
||||
| Resolved variables | Tool outputs | Runtime data inserted into templates via `{{...}}` |
|
||||
| Triggering event | DM content / event payload | Current request — always appended after skills |
|
||||
| Tool schemas | Tool registry, filtered by skill `requires_tool` tags | Capability declaration for tool calling |
|
||||
| Runtime params | Skill event tags + LLM fallback chain | Model, temperature, max_tokens, etc. |
|
||||
|
||||
---
|
||||
|
||||
## Template Variables Are Tool Calls
|
||||
## Template Variable Resolution
|
||||
|
||||
Template variables resolve through tool execution.
|
||||
When the engine encounters `{{variable_name}}` in a skill template:
|
||||
|
||||
Example:
|
||||
1. **Check known tools** — if it matches a tool name, execute the tool and insert the result
|
||||
2. **Check adopted skills** — if it matches an adopted skill's d-tag, insert that skill's content (layer 2)
|
||||
3. **Neither** — resolve to empty (for portability)
|
||||
|
||||
- `{{admin_profile}}` resolves by running `nostr_admin_profile`
|
||||
- `{{admin_notes}}` resolves by running `nostr_admin_notes`
|
||||
This means `{{admin_profile}}` calls the `nostr_admin_profile` tool, while `{{identity}}` inserts the adopted "identity" skill's content. The skill author doesn't need to know which is which — the resolution is transparent.
|
||||
|
||||
Unknown variables should resolve to empty values for portability.
|
||||
See [SKILLS.md — Template Variables](SKILLS.md#template-variables) for the full variable table.
|
||||
|
||||
---
|
||||
|
||||
## Trigger Runtime Parameters
|
||||
## Execution Parameters
|
||||
|
||||
Execution controls are attached to trigger tags, not skill content:
|
||||
Execution parameters control the LLM call: which model, what temperature, how many tokens, which tools.
|
||||
|
||||
- `llm`
|
||||
- `max_tokens`
|
||||
- `temperature`
|
||||
- `seed`
|
||||
- `tools`
|
||||
|
||||
Resolution order for a triggered run:
|
||||
### Resolution Order
|
||||
|
||||
1. Start with agent defaults
|
||||
2. Apply trigger tag overrides
|
||||
3. Execute
|
||||
4. Restore defaults
|
||||
2. Apply top-level execution tags from the skill event
|
||||
3. Walk the `llm` fallback chain until a usable model is found
|
||||
4. Execute
|
||||
5. Restore defaults after the run
|
||||
|
||||
### LLM Fallback Chain
|
||||
|
||||
The `llm` tag uses a CSS font-stack style fallback: `provider/model, provider/model, ..., capability_keyword`
|
||||
|
||||
```
|
||||
["llm", "anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap"]
|
||||
```
|
||||
|
||||
See [SKILLS.md — LLM Fallback Chain](SKILLS.md#llm-fallback-chain) for the full format and capability keywords.
|
||||
|
||||
---
|
||||
|
||||
## Triggered vs Adopted Use
|
||||
## Context Compaction
|
||||
|
||||
- **Adopted skill (`10123`)**: contributes context/instructions
|
||||
- **Triggered skill**: contributes context and may supply execution overrides via tags
|
||||
During long-running tool loops, the context window can grow as tool call/result pairs accumulate. When context approaches the model's token limit, compaction prevents overflow:
|
||||
|
||||
This separation keeps composition simple while allowing per-trigger runtime control.
|
||||
1. Track approximate token usage of the messages array
|
||||
2. When approaching ~70% of the model's context window, inject a summarization request
|
||||
3. The LLM summarizes progress so far into a condensed form
|
||||
4. Replace detailed tool history with the summary
|
||||
5. Continue execution with the compacted context
|
||||
|
||||
This allows skills to run complex multi-step tasks without hitting context limits.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,12 +209,13 @@ This separation keeps composition simple while allowing per-trigger runtime cont
|
||||
|
||||
Context cost is controlled by:
|
||||
|
||||
- Adoption-list ordering and skill count
|
||||
- Conversation-history limits
|
||||
- Skill/template truncation limits
|
||||
- Per-trigger model/runtime parameter choices
|
||||
- Number of triggered skills matching the event (layer 1 count)
|
||||
- Size of referenced skills (layer 2 content)
|
||||
- Tool call/result accumulation during execution
|
||||
- Context compaction threshold (~70% of model window)
|
||||
- Per-skill model/runtime parameter choices
|
||||
|
||||
Use runtime context inspection endpoints to see the exact payload before LLM calls.
|
||||
Use runtime context inspection endpoints (`GET /api/context/current`, `GET /api/context/parts`) to see the exact payload before LLM calls.
|
||||
|
||||
---
|
||||
|
||||
|
||||
621
docs/SKILLS.md
621
docs/SKILLS.md
@@ -1,211 +1,470 @@
|
||||
# Didactyl — Skills
|
||||
# Skills
|
||||
|
||||
See also: [CONTEXT.md](CONTEXT.md) · [TOOLS.md](TOOLS.md)
|
||||
|
||||
## Overview
|
||||
## The Context Window Is Made of Skills
|
||||
|
||||
A skill is a **set of instructions for the LLM** stored as a Nostr event.
|
||||
Every time an LLM runs, it receives a context window — the complete set of instructions and information it needs to reason and respond. In this system, **the context window is broken up into units called skills.**
|
||||
|
||||
Skills teach the agent how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action.
|
||||
```
|
||||
╔══════════════════════════════════════════╗
|
||||
║ CONTEXT WINDOW ║
|
||||
║ ║
|
||||
║ ┌────────────────────────────────────┐ ║
|
||||
║ │ Skill 1: personality │ ║
|
||||
║ │ │ ║
|
||||
║ │ You speak concisely and directly. │ ║
|
||||
║ │ You favor technical precision. │ ║
|
||||
║ │ │ ║
|
||||
║ │ tools: [my_name, my_npub] │ ║
|
||||
║ └────────────────────────────────────┘ ║
|
||||
║ ┌────────────────────────────────────┐ ║
|
||||
║ │ Skill 2: chat │ ║
|
||||
║ │ │ ║
|
||||
║ │ Respond helpfully to the admin. │ ║
|
||||
║ │ Use tools as needed. │ ║
|
||||
║ │ │ ║
|
||||
║ │ tools: [nostr_query, nostr_dm] │ ║
|
||||
║ └────────────────────────────────────┘ ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
Each skill is a block of instructions. The context window is a stack of these blocks. Different events produce different stacks — a DM conversation has one set of skills, a scheduled cron job has a completely different set.
|
||||
|
||||
A skill is a **set of instructions for an LLM** stored as a Nostr event. Skills teach an LLM how to accomplish tasks — the LLM reads the instructions, reasons about them, and uses tools to take action.
|
||||
|
||||
Think of it like a woodshop: a **skill** is knowing how to carve — technique, judgment, decision-making. A **tool** is the chisel. The skill never directly uses the chisel without the craftsperson (the LLM) in the loop.
|
||||
|
||||
Skills are portable, shareable, and discoverable as Nostr events.
|
||||
Skills are portable, shareable, and discoverable as Nostr events. They are not specific to any single application — any app that can read Nostr events and call an LLM can use skills.
|
||||
|
||||
---
|
||||
|
||||
## What Is a Skill?
|
||||
|
||||
A skill has two orthogonal properties:
|
||||
|
||||
- **Triggers** — A skill may have trigger tags, or not. If it has triggers, a runtime can fire it automatically when matching events occur. Triggered skills appear in the context window when their trigger matches (layer 1).
|
||||
- **References** — A skill may be referenced by other skills via`{{skill_d_tag}}` template variables, or not. If referenced, its content is included inside the referencing skill (layer 2).
|
||||
|
||||
These properties are independent. A skill can have triggers and be referenced. A skill can have triggers and never be referenced. A skill can have no triggers and only exist to be referenced. A skill can have neither (though that would be inert).
|
||||
|
||||
---
|
||||
|
||||
## Skill Events
|
||||
|
||||
| Kind | Purpose | Replaceable? |
|
||||
|---|---|---|
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
| Kind | Purpose | Replaceable? |
|
||||
| --------- | ------------------------ | ---------------------- |
|
||||
| `31123` | Public skill definition | Yes, by d-tag |
|
||||
| `31124` | Private skill definition | Yes, by d-tag |
|
||||
| `10123` | Skill adoption list | Yes, single per pubkey |
|
||||
|
||||
---
|
||||
|
||||
## Skill Content
|
||||
|
||||
Skill `content` is JSON and should focus on **instructions**, not transport/runtime controls.
|
||||
The `content` field of a skill event IS the template — markdown instructions that go directly into the context window. No JSON wrapper. The description lives in a tag, not in content.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": {
|
||||
"description": "Check spelling and grammar",
|
||||
"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}}"
|
||||
},
|
||||
"content": "system:\n# Spelling and Grammar Checker\n\nYou are a spelling and grammar checker.\n\n## Rules\n\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"],
|
||||
["scope", "public"],
|
||||
["description", "Spelling and grammar checker"]
|
||||
["description", "Check spelling and grammar"],
|
||||
["trigger", "dm"],
|
||||
["filter", "{\"from\":\"admin\"}"],
|
||||
["llm", "openai/gpt-4o-mini, cheap"],
|
||||
["temperature", "0"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `description` | string | — | Human-readable description |
|
||||
| `template` | string | — | Skill instructions/template text (recommended) |
|
||||
| `base` | bool | `false` | Optional hint that this skill is intended as base/default behavior |
|
||||
|
||||
> Execution parameters (`llm`, `max_tokens`, `temperature`, `seed`, `tools`) are defined on **trigger tags**, not in content.
|
||||
- **`content`** — the template in markdown. May include `{{...}}` template variables and `system:` / `user:` role markers. This is what goes into the context window.
|
||||
- **`["description", "..."]`** — human-readable description for discovery and UI display.
|
||||
- Each `["tag", "value"]` is a separate tag on the Nostr event.
|
||||
- The `llm` tag uses a CSS font-stack style fallback chain. See [LLM Fallback Chain](#llm-fallback-chain).
|
||||
|
||||
---
|
||||
|
||||
## Composition Model (No Context Modes)
|
||||
## Two-Layer Context Model
|
||||
|
||||
Skills do **not** use `context_mode`.
|
||||
When a skill executes, the context window is built in two layers:
|
||||
|
||||
Context is assembled from kind `10123` adoption list order:
|
||||
- **Layer 1:** Triggered skills whose trigger matches the current event, ordered by their position in the adoption list (`10123`). Only triggered skills can be in layer 1 — the trigger system is what puts them there.
|
||||
- **Layer 2:** Skills embedded inside layer 1 skills via`{{skill_d_tag}}` template references. These are resolved inline, the same way tool-based template variables are resolved.
|
||||
|
||||
1. Resolve adopted skills in list order.
|
||||
2. Expand each skill template/tool variables.
|
||||
3. Append each resolved skill as context messages in that same order.
|
||||
4. Append live user/trigger input.
|
||||
```
|
||||
CONTEXT WINDOW — Admin DM arrives
|
||||
═══════════════════════════════════════════════════
|
||||
|
||||
The adoption list itself is the context definition.
|
||||
Layer 1: Triggered skills matching "dm/admin"
|
||||
(ordered by adoption list)
|
||||
|
||||
- One adopted skill = single-skill behavior.
|
||||
- Multiple adopted skills = layered behavior in explicit order.
|
||||
- Reordering `10123` changes precedence naturally.
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ TRIGGERED SKILL: personality │
|
||||
│ trigger: dm, filter: {"from":"admin"} │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ {{identity}} (adopted, no trigger) │ │
|
||||
│ │ You are Didactyl. npub1abc...xyz │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ You speak concisely and directly. │
|
||||
│ You favor technical precision. │
|
||||
│ You use dry humor sparingly. │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ TRIGGERED SKILL: chat │
|
||||
│ trigger: dm, filter: {"from":"admin"} │
|
||||
│ │
|
||||
│ Respond helpfully. Use tools as needed. │
|
||||
│ │
|
||||
│ tools: [nostr_query, nostr_dm, nostr_post, │
|
||||
│ memory_read, memory_write] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ DM CONTENT (always last for dm triggers) │
|
||||
│ │
|
||||
│ "Hey, can you check who mentioned me today?" │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Ordering Convention
|
||||
```
|
||||
CONTEXT WINDOW — Cron fires at noon
|
||||
═══════════════════════════════════════════════════
|
||||
|
||||
- Earlier adopted skills generally set broader tone/policy.
|
||||
- Later adopted skills can narrow/specialize behavior.
|
||||
- If multiple skills strongly conflict, normal prompt-order effects apply.
|
||||
Layer 1: Triggered skills matching "cron/0 12 * * *"
|
||||
(ordered by adoption list)
|
||||
|
||||
### Template Variables Are Tool Calls
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ TRIGGERED SKILL: readme-monitor │
|
||||
│ trigger: cron, filter: 0 12 * * * │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ {{identity}} (adopted, no trigger) │ │
|
||||
│ │ You are Didactyl. npub1abc...xyz │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Check the readme at the configured URL. │
|
||||
│ Compare with last known version in memory. │
|
||||
│ If changed: post it and DM admin a summary. │
|
||||
│ │
|
||||
│ tools: [http_fetch, memory_read, │
|
||||
│ memory_write, nostr_post, nostr_dm] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ TRIGGERING EVENT │
|
||||
│ │
|
||||
│ {"type":"cron","filter":"0 12 * * *", │
|
||||
│ "created_at":1742641200} │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Template variables are tool calls.
|
||||
personality is NOT here — it has a dm trigger,
|
||||
not a cron trigger, so it doesn't match layer 1.
|
||||
|
||||
When the engine encounters `{{admin_profile}}`, it runs the corresponding tool and inserts the result into context.
|
||||
|
||||
| Variable | Tool Called | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `{{agent_identity}}` | `agent_identity` | Agent identity block |
|
||||
| `{{admin_profile}}` | `nostr_admin_profile` | Admin kind 0 profile |
|
||||
| `{{admin_notes}}` | `nostr_admin_notes` | Admin recent notes |
|
||||
| `{{admin_relays}}` | `nostr_admin_relays` | Admin relay list |
|
||||
| `{{adopted_skills}}` | `adopted_skills` | Other adopted skill instructions |
|
||||
| `{{dm_history}}` | *(expand directive)* | Recent DM conversation |
|
||||
| `{{message}}` | *(built-in)* | Current user message |
|
||||
| `{{triggering_event}}` | `trigger_event` | Triggering event JSON |
|
||||
|
||||
Unknown variables should resolve to empty values for portability.
|
||||
identity IS here — but only as layer 2 inside
|
||||
readme-monitor, because readme-monitor includes
|
||||
{{identity}} in its template.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Triggered Skills
|
||||
## Adoption List (`10123`)
|
||||
|
||||
A triggered skill has a trigger source attached.
|
||||
The adoption list serves two purposes:
|
||||
|
||||
Didactyl trigger types:
|
||||
1. **Registry** — makes skills available for`{{skill_d_tag}}` resolution (layer 2 inclusion)
|
||||
2. **Ordering** — determines the order of layer 1 triggered skills in the context window
|
||||
|
||||
- `nostr-subscription`
|
||||
- `webhook`
|
||||
- `cron`
|
||||
- `chain`
|
||||
- `dm`
|
||||
```json
|
||||
{
|
||||
"kind": 10123,
|
||||
"tags": [
|
||||
["a", "31124:<pubkey>:identity"],
|
||||
["a", "31124:<pubkey>:personality"],
|
||||
["a", "31123:<pubkey>:chat"],
|
||||
["a", "31123:<pubkey>:readme-monitor"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `identity` — no trigger, adopted so triggered skills can include it via`{{identity}}` (layer 2)
|
||||
- `personality` — has`["trigger", "dm"]`, appears in layer 1 for DM events. Also referenceable via`{{personality}}` by other skills (layer 2).
|
||||
- `chat` — has`["trigger", "dm"]`, appears in layer 1 for DM events after personality (adoption list order)
|
||||
- `readme-monitor` — has`["trigger", "cron"]`, appears in layer 1 for cron events. Its template includes`{{identity}}` (layer 2).
|
||||
|
||||
Skills NOT in this list but with trigger tags are still armed — they fire when their trigger matches, but they execute in isolation (no layer 2 skill references available, only built-in variables).
|
||||
|
||||
---
|
||||
|
||||
## Template Variables
|
||||
|
||||
Template variables resolve through tool execution or skill lookup.
|
||||
|
||||
When the engine encounters `{{variable_name}}`:
|
||||
|
||||
1. Check if it matches a known tool — if so, execute the tool and insert the result
|
||||
2. Check if it matches an adopted skill's d-tag — if so, insert that skill's content (layer 2)
|
||||
3. If neither matches, resolve to empty (for portability)
|
||||
|
||||
### Built-in Variables
|
||||
|
||||
| Variable | Resolution | Description |
|
||||
| ------------------------ | ---------------------------- | ---------------------- |
|
||||
| `{{agent_identity}}` | `agent_identity` tool | Agent identity block |
|
||||
| `{{admin_profile}}` | `nostr_admin_profile` tool | Admin kind 0 profile |
|
||||
| `{{admin_notes}}` | `nostr_admin_notes` tool | Admin recent notes |
|
||||
| `{{admin_relays}}` | `nostr_admin_relays` tool | Admin relay list |
|
||||
| `{{dm_history}}` | *(expand directive)* | Recent DM conversation |
|
||||
| `{{message}}` | *(built-in)* | Current user message |
|
||||
| `{{triggering_event}}` | `trigger_event` tool | Triggering event JSON |
|
||||
|
||||
### Skill Reference Variables
|
||||
|
||||
| Variable | Resolution | Description |
|
||||
| ------------------- | ------------------------------ | ----------------------- |
|
||||
| `{{skill_d_tag}}` | Look up adopted skill by d-tag | Layer 2 skill inclusion |
|
||||
|
||||
Unknown variables resolve to empty values for portability.
|
||||
|
||||
---
|
||||
|
||||
## Triggers
|
||||
|
||||
A skill with trigger tags can be fired automatically by a runtime when matching events occur.
|
||||
|
||||
### Trigger Types
|
||||
|
||||
- `dm` — Direct message received
|
||||
- `cron` — Scheduled time expression
|
||||
- `nostr-subscription` — Nostr event matches a filter
|
||||
- `webhook` — HTTP request received
|
||||
- `chain` — Another skill completed execution
|
||||
|
||||
### Trigger Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---|---|---|
|
||||
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, `chain`, `dm` |
|
||||
| `filter` | Yes | Type-specific filter |
|
||||
| `enabled` | No | Whether active (default: `true`) |
|
||||
| `llm` | No | Model spec fallback chain (e.g., `openai/gpt-4o-mini, cheap`) |
|
||||
| `max_tokens` | No | Max output tokens for this trigger execution |
|
||||
| `temperature` | No | Sampling temperature for this trigger execution |
|
||||
| `seed` | No | Optional deterministic seed where supported |
|
||||
| `tools` | No | `true` for all tools, `false` for none, or CSV list of allowed tool names |
|
||||
| Tag | Required | Description |
|
||||
| ----------- | -------- | --------------------------------- |
|
||||
| `trigger` | Yes | Trigger type |
|
||||
| `filter` | Yes | Type-specific filter |
|
||||
|
||||
If a skill is in the adoption list, its triggers are active. There is no separate `enabled` flag — adoption IS enablement.
|
||||
|
||||
### Execution Parameter Tags
|
||||
|
||||
These tags can appear at the top level of a skill event (defaults for any app) or on trigger-specific contexts (runtime overrides).
|
||||
|
||||
| Tag | Description |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `llm` | Model spec with fallback chain (see below) |
|
||||
| `max_tokens` | Max output tokens |
|
||||
| `temperature` | Sampling temperature |
|
||||
| `seed` | Optional deterministic seed |
|
||||
|
||||
### LLM Fallback Chain
|
||||
|
||||
The `llm` tag uses a CSS font-stack style fallback chain. The runtime tries each entry in order, falling back to the next if the previous is unavailable.
|
||||
|
||||
Format: `provider/model, provider/model, ..., capability_keyword`
|
||||
|
||||
```
|
||||
["llm", "anthropic/claude-sonnet-4-20250514, openai/gpt-4o-mini, cheap"]
|
||||
```
|
||||
|
||||
This means:
|
||||
|
||||
1. Try `anthropic/claude-sonnet-4-20250514` first
|
||||
2. If unavailable, try `openai/gpt-4o-mini`
|
||||
3. If unavailable, use whatever the runtime considers `cheap`
|
||||
|
||||
Each entry can be:
|
||||
|
||||
- **`provider/model`** — specific provider and model (e.g., `anthropic/claude-sonnet-4-20250514`)
|
||||
- **`model`** — model name only, use the default provider (e.g., `gpt-4o-mini`)
|
||||
- **Capability keyword** — abstract tier the runtime resolves to its best available option
|
||||
|
||||
Capability keywords:
|
||||
|
||||
| Keyword | Meaning |
|
||||
|---------|---------|
|
||||
| `cheap` | Lowest cost model available |
|
||||
| `fast` | Lowest latency model available |
|
||||
| `best` | Highest capability model available |
|
||||
| `default` | Use the agent/app default model |
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
["llm", "openai/gpt-4o-mini"] -- specific model, no fallback
|
||||
["llm", "openai/gpt-4o-mini, cheap"] -- try gpt-4o-mini, fall back to cheapest
|
||||
["llm", "anthropic/claude-opus-4-20250514, openai/gpt-4o, best"] -- try opus, then gpt-4o, then best available
|
||||
["llm", "fast"] -- just use the fastest available
|
||||
["llm", "default"] -- use agent/app default
|
||||
```
|
||||
|
||||
This is important for portability: a skill published with `["llm", "anthropic/claude-sonnet-4-20250514, cheap"]` works on any runtime — if the runtime doesn't have Anthropic access, it falls back to its cheapest available model.
|
||||
|
||||
### Execution Parameter Resolution
|
||||
|
||||
When a trigger fires:
|
||||
|
||||
1. Start with agent defaults.
|
||||
2. Apply execution tags from that trigger (`llm`, `max_tokens`, `temperature`, `seed`, `tools`).
|
||||
3. Execute skill with those effective runtime settings.
|
||||
4. Restore defaults after the run.
|
||||
|
||||
### Adopted vs Triggered Behavior
|
||||
|
||||
- **Adopted skill (`10123`)**: contributes instructions/template to context.
|
||||
- **Triggered skill**: contributes instructions **and** may define execution parameters via trigger tags.
|
||||
1. Start with agent/app defaults.
|
||||
2. Apply top-level execution tags from the skill event.
|
||||
3. Walk the `llm` fallback chain until a usable model is found.
|
||||
4. Apply trigger-specific overrides if present.
|
||||
5. Execute skill with those effective runtime settings.
|
||||
6. Restore defaults after the run.
|
||||
|
||||
---
|
||||
|
||||
## Trigger Types
|
||||
## Trigger Type Details
|
||||
|
||||
### `nostr-subscription`
|
||||
Each example below shows a complete skill event. Every `["tag", "value"]` pair is a separate tag on the Nostr event.
|
||||
|
||||
`filter` is a JSON-encoded Nostr subscription filter.
|
||||
### `dm`
|
||||
|
||||
Fires when a direct message is received. `filter` is JSON with sender scope: `{"from":"admin"}`, `{"from":"wot"}`, or `{"from":"any"}`.
|
||||
|
||||
For DM triggers, the raw message content is always appended to the end of the context window.
|
||||
|
||||
```json
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
|
||||
["llm", "openai/gpt-4o-mini, cheap"],
|
||||
["temperature", "0"],
|
||||
["tools", "nostr_query,nostr_dm"],
|
||||
["enabled", "true"]
|
||||
```
|
||||
|
||||
### `webhook`
|
||||
|
||||
`filter` is required and can be `{}`; webhook firing happens via HTTP.
|
||||
|
||||
```json
|
||||
["trigger", "webhook"],
|
||||
["filter", "{}"],
|
||||
["llm", "default"],
|
||||
["tools", "true"],
|
||||
["enabled", "true"]
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "{{identity}}\n\nRespond helpfully to the admin.",
|
||||
"tags": [
|
||||
["d", "chat"],
|
||||
["description", "Chat with admin"],
|
||||
["trigger", "dm"],
|
||||
["filter", "{\"from\":\"admin\"}"],
|
||||
["llm", "default"],
|
||||
["requires_skill", "identity"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `cron`
|
||||
|
||||
`filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`.
|
||||
Fires on a schedule. `filter` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`.
|
||||
|
||||
```json
|
||||
["trigger", "cron"],
|
||||
["filter", "*/5 * * * *"],
|
||||
["llm", "openai/gpt-4o-mini"],
|
||||
["max_tokens", "300"],
|
||||
["enabled", "true"]
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "{{identity}}\n\nCheck the readme at the configured URL. If changed, post it and DM admin.",
|
||||
"tags": [
|
||||
["d", "readme-monitor"],
|
||||
["description", "Check readme for changes at noon"],
|
||||
["trigger", "cron"],
|
||||
["filter", "0 12 * * *"],
|
||||
["llm", "openai/gpt-4o-mini, cheap"],
|
||||
["max_tokens", "300"],
|
||||
["requires_tool", "http_fetch"],
|
||||
["requires_tool", "memory_read"],
|
||||
["requires_tool", "memory_write"],
|
||||
["requires_skill", "identity"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `nostr-subscription`
|
||||
|
||||
Fires when a Nostr event matches a subscription filter. `filter` is a JSON-encoded Nostr subscription filter.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "{{identity}}\n\nWhen the triggering event mentions Bitcoin or Lightning, summarize and DM admin.",
|
||||
"tags": [
|
||||
["d", "mention-monitor"],
|
||||
["description", "Monitor mentions and summarize"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
|
||||
["llm", "openai/gpt-4o-mini, cheap"],
|
||||
["temperature", "0"],
|
||||
["requires_tool", "nostr_query"],
|
||||
["requires_tool", "nostr_dm"],
|
||||
["requires_skill", "identity"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `webhook`
|
||||
|
||||
Fires when an HTTP request is received. `filter` can be `{}` (match all).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "{{identity}}\n\nProcess the webhook payload and take appropriate action.",
|
||||
"tags": [
|
||||
["d", "webhook-handler"],
|
||||
["description", "Process incoming webhook"],
|
||||
["trigger", "webhook"],
|
||||
["filter", "{}"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `chain`
|
||||
|
||||
`filter` is the source skill `d` tag to chain from.
|
||||
Fires when another skill completes execution. `filter` is the source skill's `d` tag.
|
||||
|
||||
```json
|
||||
["trigger", "chain"],
|
||||
["filter", "source-skill-d-tag"],
|
||||
["llm", "default"],
|
||||
["enabled", "true"]
|
||||
{
|
||||
"kind": 31123,
|
||||
"content": "{{identity}}\n\nReview the output from the previous skill and DM admin a summary.",
|
||||
"tags": [
|
||||
["d", "readme-reviewer"],
|
||||
["description", "Review results from readme monitor"],
|
||||
["trigger", "chain"],
|
||||
["filter", "readme-monitor"],
|
||||
["llm", "openai/gpt-4o-mini, cheap"],
|
||||
["requires_skill", "identity"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `dm`
|
||||
---
|
||||
|
||||
`filter` is JSON with sender scope:
|
||||
## Requirements Tags
|
||||
|
||||
- `{"from":"admin"}`
|
||||
- `{"from":"wot"}`
|
||||
- `{"from":"any"}`
|
||||
Skills declare what they need to run. Apps use these tags to determine which skills are compatible with their available capabilities.
|
||||
|
||||
| Tag | Description |
|
||||
| ------------------ | ---------------------------------------------------------------- |
|
||||
| `requires_tool` | A tool that must be available for this skill to function |
|
||||
| `requires_skill` | An adopted skill that must be present for `{{...}}` resolution |
|
||||
| `optional_tool` | A tool that enhances the skill but is not required |
|
||||
|
||||
```json
|
||||
["trigger", "dm"],
|
||||
["filter", "{\"from\":\"admin\"}"],
|
||||
["llm", "default"],
|
||||
["tools", "true"],
|
||||
["enabled", "true"]
|
||||
["requires_tool", "http_fetch"],
|
||||
["requires_tool", "memory_read"],
|
||||
["requires_tool", "memory_write"],
|
||||
["requires_tool", "nostr_post"],
|
||||
["requires_skill", "identity"],
|
||||
["optional_tool", "nostr_dm"]
|
||||
```
|
||||
|
||||
### How Apps Use Requirements
|
||||
|
||||
```
|
||||
App starts up
|
||||
│
|
||||
├─ Knows its available tools/capabilities
|
||||
│
|
||||
├─ Fetches user's adopted skills from 10123
|
||||
│
|
||||
├─ For each skill, checks requires_tool tags
|
||||
│ ├─ All required tools available? → skill is usable
|
||||
│ └─ Missing required tools? → skill is disabled
|
||||
│
|
||||
└─ Presents only usable skills to the user
|
||||
```
|
||||
|
||||
Tool names in requirements tags are **capability names**, not implementation names. `http_fetch` is a capability — a C binary implements it with libcurl, a browser implements it with fetch(), a mobile app implements it with its HTTP library. The capability is the same; the implementation varies.
|
||||
|
||||
---
|
||||
|
||||
## Private Skill Encoding (`31124`)
|
||||
@@ -214,10 +473,10 @@ Private skills use NIP-44 encryption on event `content`.
|
||||
|
||||
Rules for kind `31124`:
|
||||
|
||||
- Keep `d` tag exposed so the event stays addressable/replaceable.
|
||||
- Keep`d` tag exposed so the event stays addressable/replaceable.
|
||||
- Move non-`d` metadata into plaintext payload before encryption.
|
||||
- Encrypt full payload with NIP-44 and store ciphertext in event `content`.
|
||||
- On receive: resolve by `d`, decrypt `content`, then read content + private tags.
|
||||
- Encrypt full payload with NIP-44 and store ciphertext in event`content`.
|
||||
- On receive: resolve by`d`, decrypt`content`, then read content + private tags.
|
||||
|
||||
### Private Skill Event (on relay)
|
||||
|
||||
@@ -235,19 +494,17 @@ Rules for kind `31124`:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": {
|
||||
"description": "Monitor mentions and DM summaries",
|
||||
"template": "When {{triggering_event}} includes Bitcoin or Lightning, summarize and DM admin."
|
||||
},
|
||||
"content": "{{identity}}\n\nWhen {{triggering_event}} includes Bitcoin or Lightning, summarize and DM admin.",
|
||||
"private_tags": [
|
||||
["description", "Monitor mentions and DM summaries"],
|
||||
["scope", "private"],
|
||||
["trigger", "nostr-subscription"],
|
||||
["filter", "{\"#p\":[\"<admin_pubkey>\"],\"kinds\":[1]}"],
|
||||
["llm", "openai/gpt-4o-mini, fast"],
|
||||
["llm", "openai/gpt-4o-mini, cheap"],
|
||||
["temperature", "0"],
|
||||
["seed", "42"],
|
||||
["tools", "nostr_query,nostr_dm"],
|
||||
["enabled", "true"]
|
||||
["requires_tool", "nostr_query"],
|
||||
["requires_tool", "nostr_dm"],
|
||||
["requires_skill", "identity"]
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -256,62 +513,78 @@ Rules for kind `31124`:
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Input as Message/Trigger
|
||||
participant Dispatch as Dispatcher
|
||||
participant Adopt as Adoption Resolver (10123)
|
||||
participant Ctx as Context Assembler
|
||||
participant Trig as Trigger Runtime Params
|
||||
participant LLM as LLM API
|
||||
|
||||
Input->>Dispatch: message or trigger event
|
||||
Dispatch->>Adopt: load adopted skills in list order
|
||||
Adopt-->>Ctx: ordered skill templates
|
||||
Ctx->>Ctx: resolve template variables via tools
|
||||
Dispatch->>Trig: resolve trigger execution tags
|
||||
Trig-->>LLM: model + max_tokens + temperature + seed + tool policy
|
||||
Ctx->>LLM: composed messages
|
||||
LLM-->>Input: response
|
||||
```
|
||||
Trigger event occurs (DM, cron, subscription, webhook, chain)
|
||||
│
|
||||
├─ Walk adoption list (10123)
|
||||
│ │
|
||||
│ ├─ For each skill whose trigger matches this event:
|
||||
│ │ ├─ Resolve template variables (tools + skill references)
|
||||
│ │ └─ Add to context (layer 1)
|
||||
│ │
|
||||
│ └─ Skills whose trigger does NOT match: skip
|
||||
│
|
||||
├─ Append triggering event payload
|
||||
│ └─ For DM triggers: always append raw message content
|
||||
│
|
||||
├─ Apply execution parameters (llm, temperature, max_tokens, tools)
|
||||
│
|
||||
└─ Send to LLM → multi-turn tool loop → response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits and Safety
|
||||
|
||||
| Limit | Default | Description |
|
||||
|---|---|---|
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs |
|
||||
| Limit | Default | Description |
|
||||
| ----------------------- | ------------- | ----------------------------- |
|
||||
| Max concurrent triggers | 16 | Prevents resource exhaustion |
|
||||
| Trigger cooldown | 60s per skill | Prevents rapid-fire execution |
|
||||
| LLM action rate limit | 10/min | Prevents runaway LLM costs |
|
||||
|
||||
---
|
||||
|
||||
## Storage on Nostr
|
||||
|
||||
| Data | Storage |
|
||||
|---|---|
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions + execution params | Tags on skill events |
|
||||
| Data | Storage |
|
||||
| -------------------------------------- | ----------------------- |
|
||||
| Skills | Kind 31123/31124 events |
|
||||
| Adopted skills | Kind 10123 event |
|
||||
| Trigger definitions + execution params | Tags on skill events |
|
||||
| Requirements declarations | Tags on skill events |
|
||||
|
||||
---
|
||||
|
||||
## Portability Guidelines
|
||||
## Portability
|
||||
|
||||
To keep skills reusable across agents/clients:
|
||||
Skills are Nostr events. Any application that can read Nostr events and call a skill which will call an llm. Skills are not specific to Didactyl or any single runtime.
|
||||
|
||||
- Prefer generic instructions over implementation-specific assumptions.
|
||||
- Treat tool names as capabilities, not platform internals.
|
||||
- Resolve unknown variables safely (empty result, no hard failure).
|
||||
- Keep app-specific tags optional (`["app","didactyl"]`).
|
||||
### Use Cases Beyond Didactyl
|
||||
|
||||
A skill should still be useful even when some variables/tools are unavailable.
|
||||
- **Word processor** — "Check spelling and grammar" button triggers a spellcheck skill
|
||||
- **Browser extension** — Highlight text, run a summarization skill
|
||||
- **Mobile app** — Voice input triggers a transcription skill
|
||||
- **Browser-based agent** — Same agent, different runtime, different available tools
|
||||
|
||||
### Portability Guidelines
|
||||
|
||||
| Guideline | Rationale |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Use `{{message}}` for user input | Universal — every app has user input |
|
||||
| Declare requirements via `requires_tool` / `requires_skill` tags | Lets apps filter to compatible skills |
|
||||
| Put default execution params as top-level tags | Any app can read `llm`, `temperature`, etc. |
|
||||
| Keep trigger tags as optional runtime hints | Apps without trigger systems ignore them |
|
||||
| Resolve unknown variables to empty | Ensures graceful degradation |
|
||||
| Prefer self-contained skills for maximum portability | Skills with `{{skill_d_tag}}` references need the adoption ecosystem |
|
||||
| Treat tool names as capabilities, not implementations | `http_fetch` works in C, browser, mobile — same capability, different implementation |
|
||||
|
||||
A skill should still be useful even when some variables, tools, or referenced skills are unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Tool architecture and complete tool catalog: [TOOLS.md](TOOLS.md)
|
||||
- Context assembly model: [CONTEXT.md](CONTEXT.md)
|
||||
- Project overview/runtime behavior: [README.md](../README.md)
|
||||
- Tool architecture and complete tool catalog:[TOOLS.md](TOOLS.md)
|
||||
- Context assembly model:[CONTEXT.md](CONTEXT.md)
|
||||
- Project overview/runtime behavior:[README.md](../README.md)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Didactyl — Tools
|
||||
|
||||
See also: [SKILLS.md](SKILLS.md)
|
||||
See also: [SKILLS.md](SKILLS.md) · [CONTEXT.md](CONTEXT.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Didactyl is a **Nostr-first sovereign AI agent** that receives commands via encrypted DMs, reasons with an LLM, and takes actions through **tools**.
|
||||
Didactyl is a **Nostr-first sovereign AI agent** that reasons with an LLM and takes actions through **tools**.
|
||||
|
||||
This document describes the tools architecture: what tools are, how they are exposed to the model, how execution loops work, what tool categories exist, and how access is gated.
|
||||
|
||||
@@ -18,32 +18,35 @@ A **skill** teaches the agent *how* to carve — the technique, the judgment, th
|
||||
|
||||
## How Tools Work
|
||||
|
||||
1. Admin sends a DM to didactyl
|
||||
2. The agent builds an LLM request with the message, context, and a JSON schema of all available tools
|
||||
3. The LLM decides whether to call a tool or respond directly
|
||||
4. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
5. The loop repeats until the LLM produces a final text response
|
||||
6. The response is sent back as a DM
|
||||
1. A trigger fires (DM, cron, subscription, webhook, or chain) — see [SKILLS.md](SKILLS.md)
|
||||
2. The agent builds the context window from triggered skills and their `{{...}}` references
|
||||
3. The agent builds an LLM request with the context, triggering event, and a JSON schema of available tools
|
||||
4. The LLM decides whether to call a tool or respond directly
|
||||
5. If a tool is called, didactyl executes it and feeds the result back to the LLM
|
||||
6. The loop repeats until the LLM produces a final text response
|
||||
7. For DM triggers, the response is sent back as a DM
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant Agent as Didactyl Agent Loop
|
||||
participant LLM as LLM API
|
||||
participant Tools as Tool Registry
|
||||
|
||||
Admin->>Agent: Encrypted DM
|
||||
Agent->>LLM: messages + tool schemas
|
||||
|
||||
loop Until final answer
|
||||
LLM->>Agent: tool_call request
|
||||
Agent->>Tools: dispatch tool
|
||||
Tools->>Agent: result JSON
|
||||
Agent->>LLM: tool result + continue
|
||||
end
|
||||
|
||||
LLM->>Agent: final text response
|
||||
Agent->>Admin: Encrypted DM reply
|
||||
```
|
||||
Trigger fires (DM, cron, subscription, webhook, chain)
|
||||
│
|
||||
├─ Build context from triggered skills
|
||||
├─ Build tool schemas (filtered by skill's tools tag)
|
||||
│
|
||||
├─ Send to LLM: context + tool schemas
|
||||
│
|
||||
│ ┌─────────────────────────────────────┐
|
||||
│ │ LLM reasons about the request │
|
||||
│ │ │
|
||||
│ │ Option A: call a tool │
|
||||
│ │ → agent executes tool │
|
||||
│ │ → feeds result back to LLM │
|
||||
│ │ → loop continues │
|
||||
│ │ │
|
||||
│ │ Option B: produce text response │
|
||||
│ │ → loop ends │
|
||||
│ └─────────────────────────────────────┘
|
||||
│
|
||||
└─ Deliver response (DM reply, chain forward, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -724,7 +727,9 @@ These examples show the JSON structure for tool calls.
|
||||
|
||||
## Security Model
|
||||
|
||||
Tool access is gated by sender tier:
|
||||
Tool access is gated at two levels:
|
||||
|
||||
### Sender Tier (DM triggers)
|
||||
|
||||
| Tier | Identity | Tools | Response |
|
||||
|------|----------|-------|----------|
|
||||
@@ -732,9 +737,42 @@ Tool access is gated by sender tier:
|
||||
| **WOT** | In admin's kind 3 contact list | None | Chat-only LLM |
|
||||
| **STRANGER** | Anyone else | None | Configurable static response |
|
||||
|
||||
### Skill Requirements (all triggers)
|
||||
|
||||
Skills declare which tools they need via `requires_tool` tags (see [SKILLS.md — Requirements Tags](SKILLS.md#requirements-tags)). During execution, only the required and optional tools declared by the skill are exposed to the LLM. If a skill has no `requires_tool` tags, all available tools are exposed.
|
||||
|
||||
---
|
||||
|
||||
## Tool Portability
|
||||
|
||||
Tool names serve as the **capability vocabulary** for cross-app skill portability. When a skill declares `["requires_tool", "http_fetch"]`, any app that provides an `http_fetch` capability can run that skill — regardless of how it implements the fetch internally.
|
||||
|
||||
### Runtime Availability
|
||||
|
||||
Not all tools are available in every runtime. Tools that require specific host capabilities:
|
||||
|
||||
| Tool | Requires |
|
||||
|------|----------|
|
||||
| `local_shell_exec` | Host OS shell access |
|
||||
| `local_file_read` | Host filesystem |
|
||||
| `local_file_write` | Host filesystem |
|
||||
| `blossom_upload` | Filesystem + HTTP |
|
||||
| `blossom_download` | Filesystem + HTTP |
|
||||
|
||||
Tools available in any runtime (including browser):
|
||||
|
||||
| Tool | Capability |
|
||||
|------|-----------|
|
||||
| `nostr_*` | Nostr relay WebSocket connections |
|
||||
| `local_http_fetch` | HTTP client |
|
||||
| `memory_save` / `memory_recall` | Nostr event storage |
|
||||
| `cashu_wallet_*` | HTTP client + Nostr storage |
|
||||
|
||||
Skills should declare `requires_tool` tags so apps can determine compatibility. See [SKILLS.md — Requirements Tags](SKILLS.md#requirements-tags).
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Skill definitions, adoption, triggers, and autonomous activation: [SKILLS.md](SKILLS.md)
|
||||
- Combined index page: [TOOLS_AND_SKILLS.md](TOOLS_AND_SKILLS.md)
|
||||
- Context assembly model: [CONTEXT.md](CONTEXT.md)
|
||||
|
||||
206
plans/trigger_scoped_skill_architecture.md
Normal file
206
plans/trigger_scoped_skill_architecture.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Implementation Plan: Trigger-Scoped Skill Architecture
|
||||
|
||||
Target spec: [docs/SKILLS.md](../docs/SKILLS.md) · [docs/CONTEXT.md](../docs/CONTEXT.md) · [docs/TOOLS.md](../docs/TOOLS.md)
|
||||
|
||||
## Current State
|
||||
|
||||
- `g_system_context` is a monolithic global string (from the default skill) prepended to every LLM call
|
||||
- `agent_on_trigger()` prepends `g_system_context` to every triggered skill execution
|
||||
- `agent_on_message()` falls through to `g_system_context` chat if no DM trigger fires
|
||||
- `trigger_manager_fire_dm()` fires all matching DM triggers independently (separate LLM calls each)
|
||||
- `trigger_manager_load_from_skills()` loads triggers from adopted skills only
|
||||
- Template variables resolve to tools only — no skill-to-skill `{{d_tag}}` resolution
|
||||
- `apply_trigger_runtime_to_llm_config()` parses `provider/model` but discards the provider
|
||||
- Skill `content` is JSON with `description` and `template` fields (spec says content IS the template)
|
||||
- `["tools", "true/false/csv"]` tag controls tool access (spec says use `requires_tool` instead)
|
||||
- `["enabled", "true/false"]` tag on triggers (spec says adoption IS enablement)
|
||||
|
||||
## Phase 0: Provider Override Fix
|
||||
|
||||
**File:** `src/trigger_manager.c`
|
||||
**Function:** `apply_trigger_runtime_to_llm_config()` (line 592)
|
||||
|
||||
Currently at line 602-611, when a slash is found in the llm_spec, only the model (after slash) is extracted. The provider (before slash) is discarded.
|
||||
|
||||
**Change:** When slash is found, also copy the provider prefix into `cfg->provider`.
|
||||
|
||||
```c
|
||||
// Current: only extracts model
|
||||
if (slash) {
|
||||
const char* model = slash + 1;
|
||||
// ... sets cfg->model only
|
||||
}
|
||||
|
||||
// New: extract both provider and model
|
||||
if (slash) {
|
||||
size_t provider_len = (size_t)(slash - spec);
|
||||
if (provider_len > 0 && provider_len < sizeof(cfg->provider)) {
|
||||
snprintf(cfg->provider, sizeof(cfg->provider), "%.*s", (int)provider_len, spec);
|
||||
}
|
||||
const char* model = slash + 1;
|
||||
// ... sets cfg->model as before
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 1: Two-Layer Context Assembly
|
||||
|
||||
### 1a. Skill-to-skill template resolution
|
||||
|
||||
**File:** `src/prompt_template.c`
|
||||
**Function:** `map_variable_tool_name()` (line 100)
|
||||
|
||||
Currently returns a tool name for known variables, or NULL for unknown ones (which resolve to empty).
|
||||
|
||||
**Change:** Add a fallback path. If the variable name doesn't match a known tool, look it up as a skill d-tag in the adoption list cache. If found, return the skill's content.
|
||||
|
||||
This requires access to the skill cache from the template resolver. Options:
|
||||
- Pass a skill lookup callback into the template builder
|
||||
- Add a `skill_content_lookup` function pointer to `tools_context_t`
|
||||
|
||||
**File:** `src/prompt_template.c`
|
||||
**Function:** `prompt_template_build_messages()` (line 37)
|
||||
|
||||
When resolving a `{{variable}}` that doesn't match a tool, call the skill lookup function to check adopted skills by d-tag.
|
||||
|
||||
### 1b. Trigger-matched context assembly
|
||||
|
||||
**File:** `src/agent.c`
|
||||
**New function:** `build_context_from_triggers()`
|
||||
|
||||
```c
|
||||
char* build_context_from_triggers(
|
||||
trigger_type_t trigger_type,
|
||||
const char* trigger_filter,
|
||||
cJSON* trigger_event,
|
||||
const char* relay_url);
|
||||
```
|
||||
|
||||
Implementation:
|
||||
1. Load the adoption list (kind `10123`) — already cached at startup
|
||||
2. For each adopted skill, check if it has a trigger matching `trigger_type` and `trigger_filter`
|
||||
3. If match: resolve the skill's template (expanding `{{...}}` references via Phase 1a)
|
||||
4. Concatenate all matched skill templates in adoption-list order
|
||||
5. Append the triggering event payload
|
||||
6. For DM triggers: always append raw message content at the end
|
||||
7. Return the assembled system prompt
|
||||
|
||||
### 1c. Replace g_system_context in trigger execution
|
||||
|
||||
**File:** `src/agent.c`
|
||||
**Function:** `agent_on_trigger()` (line 1817)
|
||||
|
||||
**Current (line 1852-1858):**
|
||||
```c
|
||||
snprintf(system_prompt, system_len, "%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
|
||||
g_system_context,
|
||||
trigger_prefix, skill_d_tag, relay, skill_content);
|
||||
```
|
||||
|
||||
**Replace with:** Call `build_context_from_triggers()` instead of prepending `g_system_context`.
|
||||
|
||||
### 1d. Skill content format change
|
||||
|
||||
**File:** `src/nostr_handler.c` and `src/config.c`
|
||||
|
||||
Currently skill content is parsed as JSON to extract `template` field. The spec says `content` IS the template (plain string).
|
||||
|
||||
**Change:** When loading a skill's content, check if it's a JSON object with a `template` field (backward compat) or a plain string (new format). Use the template/string directly.
|
||||
|
||||
## Phase 2: DM Composition with Default Handler
|
||||
|
||||
**File:** `src/agent.c`
|
||||
**Function:** `agent_on_message()` (line 2085)
|
||||
|
||||
Currently at line 2120-2128, calls `trigger_manager_fire_dm()` which fires each matching DM trigger independently. If none fire, falls through to `g_system_context` chat.
|
||||
|
||||
**Change:**
|
||||
1. Use `build_context_from_triggers()` with `TRIGGER_TYPE_DM` and the sender tier
|
||||
2. This automatically finds all DM-triggered skills in adoption-list order and composes them
|
||||
3. Always append the raw admin message at the end (default DM handler)
|
||||
4. If no DM-triggered skill exists, use a minimal built-in default: "You are an AI agent. Respond to the message."
|
||||
5. Make one LLM call with the composed context
|
||||
|
||||
**File:** `src/trigger_manager.c`
|
||||
**Function:** `trigger_manager_fire_dm()` (line 1545)
|
||||
|
||||
Currently fires all matching DM triggers independently (each gets its own LLM call).
|
||||
|
||||
**Change:** Instead of firing independently, return the list of matching DM skill d-tags (in adoption-list order). Let the caller (`agent_on_message`) compose them into a single context via `build_context_from_triggers()`.
|
||||
|
||||
New function signature:
|
||||
```c
|
||||
int trigger_manager_get_dm_skills(trigger_manager_t* mgr,
|
||||
const char* sender_pubkey_hex,
|
||||
didactyl_sender_tier_t tier,
|
||||
char** out_d_tags,
|
||||
int max_d_tags);
|
||||
```
|
||||
|
||||
## Phase 3: Trigger Discovery Independent of Adoption
|
||||
|
||||
**File:** `src/trigger_manager.c`
|
||||
**Function:** `trigger_manager_load_from_skills()` (line ~varies)
|
||||
|
||||
Currently loads triggers only from adopted skills in the `10123` list.
|
||||
|
||||
**Change:** Scan all skill events published by the agent (query own pubkey for kinds 31123/31124), not just adopted ones. Arm any skill with trigger tags. The adoption list controls `{{...}}` resolution and ordering, not trigger arming.
|
||||
|
||||
Non-adopted skills with triggers fire in isolation (no layer 2 skill references available).
|
||||
|
||||
## Phase 4: Context Compaction
|
||||
|
||||
**File:** `src/agent.c`
|
||||
**Functions:** Tool loops in `agent_on_trigger()` (line 1905) and `agent_on_message()` (line 2212)
|
||||
|
||||
**New function:**
|
||||
```c
|
||||
static int estimate_context_tokens(cJSON* messages);
|
||||
```
|
||||
|
||||
Approximate: sum character lengths of all message content fields, divide by 4.
|
||||
|
||||
**Change in tool loops:** Before each LLM call, check if `estimate_context_tokens(messages)` exceeds 70% of the model's context window. If so:
|
||||
|
||||
1. Build a summarization request: "Summarize your progress so far, including key findings and remaining work."
|
||||
2. Send to LLM with `tool_choice: "none"` (text only)
|
||||
3. Replace all tool call/result messages with a single system message containing the summary
|
||||
4. Continue the tool loop with the compacted context
|
||||
|
||||
## Phase 5: LLM Fallback Chain
|
||||
|
||||
**File:** `src/trigger_manager.c`
|
||||
**Function:** `apply_trigger_runtime_to_llm_config()` (line 592)
|
||||
|
||||
Currently parses the comma in `llm_spec` but only uses the first entry.
|
||||
|
||||
**Change:** Walk the comma-separated entries. For each:
|
||||
1. Parse `provider/model` or bare `model` or capability keyword
|
||||
2. Check if the model is available (query provider, or check a local model list)
|
||||
3. If available, use it and stop
|
||||
4. If not, try the next entry
|
||||
5. Capability keywords (`cheap`, `fast`, `best`, `default`) resolve to runtime-configured models
|
||||
|
||||
## Phase 6: Remove Obsolete Code
|
||||
|
||||
**File:** `src/agent.c`
|
||||
- Remove `g_system_context` global variable
|
||||
- Remove `g_system_context` from `agent_init()` parameter
|
||||
- Remove `g_system_context` prepend from all code paths
|
||||
|
||||
**File:** `src/nostr_handler.c`
|
||||
- Remove `g_system_context` global variable (line 28)
|
||||
- Remove `g_system_context` initialization in `nostr_handler_reconcile_startup_events()` (line 3124-3157)
|
||||
- Remove `nostr_handler_get_system_context()` function
|
||||
|
||||
**File:** `src/agent.h`
|
||||
- Update `agent_init()` signature to remove `system_context` parameter
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. **Phase 0** — safe, independent fix. No behavioral change.
|
||||
2. **Phase 1** — core change. Backward compatible if existing default skill has a DM trigger tag. The `g_system_context` is still used as fallback until Phase 6.
|
||||
3. **Phase 2** — changes DM behavior. Existing agents work if default skill has `["trigger", "dm"]`.
|
||||
4. **Phase 3** — additive. Skills without triggers are unaffected.
|
||||
5. **Phase 4** — additive. New capability, no existing behavior changes.
|
||||
6. **Phase 5** — additive. Currently only first entry is used; this adds fallback.
|
||||
7. **Phase 6** — cleanup. Only after Phases 1-2 are stable.
|
||||
395
src/agent.c
395
src/agent.c
@@ -51,6 +51,9 @@ typedef struct {
|
||||
char author_pubkey_hex[65];
|
||||
char d_tag[65];
|
||||
char* content;
|
||||
int has_trigger;
|
||||
trigger_type_t trigger_type;
|
||||
char filter_json[TRIGGER_FILTER_JSON_MAX];
|
||||
} agent_adopted_skill_t;
|
||||
|
||||
static agent_seen_msg_t g_seen_msgs[AGENT_DEBOUNCE_CACHE_SIZE];
|
||||
@@ -63,6 +66,8 @@ 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;
|
||||
|
||||
static int refresh_adopted_skills_cache_if_needed(void);
|
||||
|
||||
static uint64_t fnv1a64(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
if (!s) return h;
|
||||
@@ -1392,6 +1397,291 @@ static int parse_skill_address_tag_local(const char* addr, int* out_kind, char o
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int dm_filter_matches_tier_local(const char* filter_json, didactyl_sender_tier_t tier) {
|
||||
if (!filter_json || filter_json[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_Parse(filter_json);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* from = cJSON_GetObjectItemCaseSensitive(root, "from");
|
||||
const char* from_s = (from && cJSON_IsString(from) && from->valuestring) ? from->valuestring : "admin";
|
||||
|
||||
int match = 0;
|
||||
if (strcmp(from_s, "any") == 0) {
|
||||
match = 1;
|
||||
} else if (strcmp(from_s, "admin") == 0) {
|
||||
match = (tier == DIDACTYL_SENDER_ADMIN);
|
||||
} else if (strcmp(from_s, "wot") == 0) {
|
||||
match = (tier == DIDACTYL_SENDER_WOT);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return match;
|
||||
}
|
||||
|
||||
static const char* adopted_skill_content_lookup_by_d_tag_locked(const char* d_tag) {
|
||||
if (!d_tag || d_tag[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (int i = 0; i < g_adopted_skills_count; i++) {
|
||||
if (strcmp(g_adopted_skills[i].d_tag, d_tag) == 0) {
|
||||
return g_adopted_skills[i].content;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static const char* template_skill_lookup_callback(void* user_data, const char* d_tag) {
|
||||
(void)user_data;
|
||||
if (!d_tag || d_tag[0] == '\0') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
(void)refresh_adopted_skills_cache_if_needed();
|
||||
|
||||
const char* out = NULL;
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
out = adopted_skill_content_lookup_by_d_tag_locked(d_tag);
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* resolve_skill_references_local(const char* input) {
|
||||
if (!input) {
|
||||
return strdup("");
|
||||
}
|
||||
|
||||
size_t cap = strlen(input) + 128U;
|
||||
size_t used = 0U;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) return NULL;
|
||||
out[0] = '\0';
|
||||
|
||||
const char* p = input;
|
||||
while (*p) {
|
||||
const char* open = strstr(p, "{{");
|
||||
if (!open) {
|
||||
size_t tail = strlen(p);
|
||||
if (used + tail + 1U > cap) {
|
||||
size_t next = cap;
|
||||
while (used + tail + 1U > next) next *= 2U;
|
||||
char* grown = (char*)realloc(out, next);
|
||||
if (!grown) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out = grown;
|
||||
cap = next;
|
||||
}
|
||||
memcpy(out + used, p, tail);
|
||||
used += tail;
|
||||
out[used] = '\0';
|
||||
break;
|
||||
}
|
||||
|
||||
size_t prefix_len = (size_t)(open - p);
|
||||
if (used + prefix_len + 1U > cap) {
|
||||
size_t next = cap;
|
||||
while (used + prefix_len + 1U > next) next *= 2U;
|
||||
char* grown = (char*)realloc(out, next);
|
||||
if (!grown) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out = grown;
|
||||
cap = next;
|
||||
}
|
||||
memcpy(out + used, p, prefix_len);
|
||||
used += prefix_len;
|
||||
out[used] = '\0';
|
||||
|
||||
const char* close = strstr(open + 2, "}}");
|
||||
if (!close) {
|
||||
size_t rem = strlen(open);
|
||||
if (used + rem + 1U > cap) {
|
||||
size_t next = cap;
|
||||
while (used + rem + 1U > next) next *= 2U;
|
||||
char* grown = (char*)realloc(out, next);
|
||||
if (!grown) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out = grown;
|
||||
cap = next;
|
||||
}
|
||||
memcpy(out + used, open, rem);
|
||||
used += rem;
|
||||
out[used] = '\0';
|
||||
break;
|
||||
}
|
||||
|
||||
size_t var_len = (size_t)(close - (open + 2));
|
||||
char var[128];
|
||||
if (var_len >= sizeof(var)) var_len = sizeof(var) - 1U;
|
||||
memcpy(var, open + 2, var_len);
|
||||
var[var_len] = '\0';
|
||||
|
||||
char* start = var;
|
||||
while (*start && isspace((unsigned char)*start)) start++;
|
||||
char* end = start + strlen(start);
|
||||
while (end > start && isspace((unsigned char)end[-1])) {
|
||||
end[-1] = '\0';
|
||||
end--;
|
||||
}
|
||||
|
||||
const char* repl = "";
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
const char* found = adopted_skill_content_lookup_by_d_tag_locked(start);
|
||||
repl = found ? found : "";
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
|
||||
size_t repl_len = strlen(repl);
|
||||
if (used + repl_len + 1U > cap) {
|
||||
size_t next = cap;
|
||||
while (used + repl_len + 1U > next) next *= 2U;
|
||||
char* grown = (char*)realloc(out, next);
|
||||
if (!grown) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out = grown;
|
||||
cap = next;
|
||||
}
|
||||
memcpy(out + used, repl, repl_len);
|
||||
used += repl_len;
|
||||
out[used] = '\0';
|
||||
|
||||
p = close + 2;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* build_context_from_triggers(trigger_type_t trigger_type,
|
||||
const char* trigger_filter,
|
||||
cJSON* trigger_event,
|
||||
const char* relay_url) {
|
||||
(void)relay_url;
|
||||
(void)trigger_filter;
|
||||
|
||||
if (!g_cfg) {
|
||||
return strdup("You are an AI agent. Respond to the message.");
|
||||
}
|
||||
|
||||
(void)refresh_adopted_skills_cache_if_needed();
|
||||
|
||||
size_t cap = 4096;
|
||||
size_t used = 0;
|
||||
char* out = (char*)malloc(cap);
|
||||
if (!out) {
|
||||
return NULL;
|
||||
}
|
||||
out[0] = '\0';
|
||||
|
||||
int matched = 0;
|
||||
|
||||
didactyl_sender_tier_t tier = DIDACTYL_SENDER_STRANGER;
|
||||
const char* message = NULL;
|
||||
if (trigger_event && cJSON_IsObject(trigger_event)) {
|
||||
cJSON* tier_j = cJSON_GetObjectItemCaseSensitive(trigger_event, "tier");
|
||||
cJSON* msg_j = cJSON_GetObjectItemCaseSensitive(trigger_event, "message");
|
||||
const char* tier_s = (tier_j && cJSON_IsString(tier_j) && tier_j->valuestring) ? tier_j->valuestring : "stranger";
|
||||
if (strcmp(tier_s, "admin") == 0) tier = DIDACTYL_SENDER_ADMIN;
|
||||
else if (strcmp(tier_s, "wot") == 0) tier = DIDACTYL_SENDER_WOT;
|
||||
if (msg_j && cJSON_IsString(msg_j) && msg_j->valuestring) {
|
||||
message = msg_j->valuestring;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_adopted_skills_mutex);
|
||||
for (int i = 0; i < g_adopted_skills_count; i++) {
|
||||
const agent_adopted_skill_t* s = &g_adopted_skills[i];
|
||||
if (!s->has_trigger || s->trigger_type != trigger_type) {
|
||||
continue;
|
||||
}
|
||||
if (trigger_type == TRIGGER_TYPE_DM && !dm_filter_matches_tier_local(s->filter_json, tier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
char* expanded = resolve_skill_references_local(s->content ? s->content : "");
|
||||
const char* skill_text = expanded ? expanded : (s->content ? s->content : "");
|
||||
|
||||
size_t need = strlen("\n\n---\n\n") + strlen(skill_text) + 1U;
|
||||
if (used + need >= cap) {
|
||||
size_t next = cap;
|
||||
while (used + need >= next) next *= 2U;
|
||||
char* grown = (char*)realloc(out, next);
|
||||
if (!grown) {
|
||||
free(expanded);
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out = grown;
|
||||
cap = next;
|
||||
}
|
||||
|
||||
if (matched > 0) {
|
||||
memcpy(out + used, "\n\n---\n\n", 7);
|
||||
used += 7;
|
||||
}
|
||||
size_t sl = strlen(skill_text);
|
||||
memcpy(out + used, skill_text, sl);
|
||||
used += sl;
|
||||
out[used] = '\0';
|
||||
matched++;
|
||||
|
||||
free(expanded);
|
||||
}
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
|
||||
if (matched == 0) {
|
||||
const char* fallback = "You are an AI agent. Respond to the message.";
|
||||
size_t need = strlen(fallback) + 1U;
|
||||
if (need > cap) {
|
||||
char* grown = (char*)realloc(out, need);
|
||||
if (!grown) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out = grown;
|
||||
cap = need;
|
||||
}
|
||||
memcpy(out, fallback, need);
|
||||
used = need - 1U;
|
||||
}
|
||||
|
||||
if (trigger_type == TRIGGER_TYPE_DM && message && message[0] != '\0') {
|
||||
const char* suffix = "\n\nIncoming DM message:\n";
|
||||
size_t need = strlen(suffix) + strlen(message) + 1U;
|
||||
if (used + need >= cap) {
|
||||
size_t next = cap;
|
||||
while (used + need >= next) next *= 2U;
|
||||
char* grown = (char*)realloc(out, next);
|
||||
if (!grown) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out = grown;
|
||||
cap = next;
|
||||
}
|
||||
memcpy(out + used, suffix, strlen(suffix));
|
||||
used += strlen(suffix);
|
||||
memcpy(out + used, message, strlen(message));
|
||||
used += strlen(message);
|
||||
out[used] = '\0';
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static void clear_adopted_skills_cache_locked(void) {
|
||||
for (int i = 0; i < g_adopted_skills_count; i++) {
|
||||
free(g_adopted_skills[i].content);
|
||||
@@ -1399,6 +1689,9 @@ static void clear_adopted_skills_cache_locked(void) {
|
||||
g_adopted_skills[i].kind = 0;
|
||||
g_adopted_skills[i].author_pubkey_hex[0] = '\0';
|
||||
g_adopted_skills[i].d_tag[0] = '\0';
|
||||
g_adopted_skills[i].has_trigger = 0;
|
||||
g_adopted_skills[i].trigger_type = TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
g_adopted_skills[i].filter_json[0] = '\0';
|
||||
}
|
||||
g_adopted_skills_count = 0;
|
||||
}
|
||||
@@ -1538,6 +1831,17 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
snprintf(tmp[tmp_count].author_pubkey_hex, sizeof(tmp[tmp_count].author_pubkey_hex), "%s", skill_author);
|
||||
snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", skill_d_tag);
|
||||
tmp[tmp_count].content = strdup(content->valuestring);
|
||||
tmp[tmp_count].has_trigger = 0;
|
||||
tmp[tmp_count].trigger_type = TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
tmp[tmp_count].filter_json[0] = '\0';
|
||||
cJSON* trigger_tag = find_tag_value_string_local(skill_tags, "trigger");
|
||||
cJSON* filter_tag = find_tag_value_string_local(skill_tags, "filter");
|
||||
if (trigger_tag && cJSON_IsString(trigger_tag) && trigger_tag->valuestring && trigger_tag->valuestring[0] != '\0' &&
|
||||
filter_tag && cJSON_IsString(filter_tag) && filter_tag->valuestring && filter_tag->valuestring[0] != '\0') {
|
||||
tmp[tmp_count].has_trigger = 1;
|
||||
tmp[tmp_count].trigger_type = trigger_type_from_string(trigger_tag->valuestring);
|
||||
snprintf(tmp[tmp_count].filter_json, sizeof(tmp[tmp_count].filter_json), "%s", filter_tag->valuestring);
|
||||
}
|
||||
if (tmp[tmp_count].content) {
|
||||
tmp_count++;
|
||||
}
|
||||
@@ -1578,6 +1882,21 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
: "unknown");
|
||||
snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", d_tag_val);
|
||||
tmp[tmp_count].content = strdup(se->content);
|
||||
tmp[tmp_count].has_trigger = 0;
|
||||
tmp[tmp_count].trigger_type = TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
|
||||
tmp[tmp_count].filter_json[0] = '\0';
|
||||
if (se->tags_json && se->tags_json[0] != '\0') {
|
||||
cJSON* tags = cJSON_Parse(se->tags_json);
|
||||
cJSON* trigger_tag = tags ? find_tag_value_string_local(tags, "trigger") : NULL;
|
||||
cJSON* filter_tag = tags ? find_tag_value_string_local(tags, "filter") : NULL;
|
||||
if (trigger_tag && cJSON_IsString(trigger_tag) && trigger_tag->valuestring && trigger_tag->valuestring[0] != '\0' &&
|
||||
filter_tag && cJSON_IsString(filter_tag) && filter_tag->valuestring && filter_tag->valuestring[0] != '\0') {
|
||||
tmp[tmp_count].has_trigger = 1;
|
||||
tmp[tmp_count].trigger_type = trigger_type_from_string(trigger_tag->valuestring);
|
||||
snprintf(tmp[tmp_count].filter_json, sizeof(tmp[tmp_count].filter_json), "%s", filter_tag->valuestring);
|
||||
}
|
||||
cJSON_Delete(tags);
|
||||
}
|
||||
if (tmp[tmp_count].content) {
|
||||
tmp_count++;
|
||||
}
|
||||
@@ -1802,6 +2121,9 @@ int agent_init(didactyl_config_t* config, const char* system_context) {
|
||||
g_adopted_skills_last_refresh_at = 0;
|
||||
pthread_mutex_unlock(&g_adopted_skills_mutex);
|
||||
|
||||
g_tools_ctx.template_skill_lookup = template_skill_lookup_callback;
|
||||
g_tools_ctx.template_skill_lookup_user_data = NULL;
|
||||
|
||||
prompt_template_free(&g_prompt_template);
|
||||
g_has_prompt_template = (prompt_template_parse(g_system_context, &g_prompt_template) == 0);
|
||||
clear_context_part_names();
|
||||
@@ -1818,7 +2140,7 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
const char* skill_content,
|
||||
cJSON* triggering_event,
|
||||
const char* relay_url) {
|
||||
if (!g_cfg || !g_system_context || !skill_d_tag || !skill_content || !triggering_event) {
|
||||
if (!g_cfg || !skill_d_tag || !skill_content || !triggering_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1840,11 +2162,20 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
"- If no action is needed, explicitly say why.\n\n"
|
||||
"Skill d_tag: ";
|
||||
|
||||
size_t system_len = strlen(g_system_context) + 2 + strlen(trigger_prefix) + strlen(skill_d_tag) +
|
||||
char* composed_context = build_context_from_triggers(TRIGGER_TYPE_NOSTR_SUBSCRIPTION,
|
||||
NULL,
|
||||
triggering_event,
|
||||
relay_url);
|
||||
if (!composed_context) {
|
||||
composed_context = strdup("You are an AI agent. Respond to the trigger event.");
|
||||
}
|
||||
|
||||
size_t system_len = strlen(composed_context ? composed_context : "") + 2 + strlen(trigger_prefix) + strlen(skill_d_tag) +
|
||||
strlen("\nRelay: ") + strlen(relay) + strlen("\n\nSkill instructions:\n") +
|
||||
strlen(skill_content) + 1U;
|
||||
char* system_prompt = (char*)malloc(system_len);
|
||||
if (!system_prompt) {
|
||||
free(composed_context);
|
||||
free(event_json);
|
||||
return;
|
||||
}
|
||||
@@ -1852,11 +2183,12 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
snprintf(system_prompt,
|
||||
system_len,
|
||||
"%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
|
||||
g_system_context,
|
||||
composed_context ? composed_context : "",
|
||||
trigger_prefix,
|
||||
skill_d_tag,
|
||||
relay,
|
||||
skill_content);
|
||||
free(composed_context);
|
||||
|
||||
size_t user_len = strlen("Triggering event JSON:\n") + strlen(event_json) + 1U;
|
||||
char* user_prompt = (char*)malloc(user_len);
|
||||
@@ -2117,15 +2449,22 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
}
|
||||
}
|
||||
|
||||
if (g_trigger_manager) {
|
||||
int dm_fired = trigger_manager_fire_dm(g_trigger_manager,
|
||||
sender_pubkey_hex,
|
||||
message,
|
||||
tier,
|
||||
"dm");
|
||||
if (dm_fired > 0) {
|
||||
return;
|
||||
}
|
||||
cJSON* dm_event = cJSON_CreateObject();
|
||||
if (!dm_event) {
|
||||
return;
|
||||
}
|
||||
cJSON_AddStringToObject(dm_event, "type", "dm");
|
||||
cJSON_AddStringToObject(dm_event, "sender_pubkey", sender_pubkey_hex);
|
||||
cJSON_AddStringToObject(dm_event, "message", message);
|
||||
cJSON_AddStringToObject(dm_event, "tier",
|
||||
tier == DIDACTYL_SENDER_ADMIN ? "admin" :
|
||||
(tier == DIDACTYL_SENDER_WOT ? "wot" : "stranger"));
|
||||
cJSON_AddNumberToObject(dm_event, "created_at", (double)time(NULL));
|
||||
|
||||
char* dm_context = build_context_from_triggers(TRIGGER_TYPE_DM, NULL, dm_event, "dm");
|
||||
cJSON_Delete(dm_event);
|
||||
if (!dm_context) {
|
||||
dm_context = strdup("You are an AI agent. Respond to the message.");
|
||||
}
|
||||
|
||||
if (!allow_tools) {
|
||||
@@ -2133,13 +2472,14 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
? "You are responding to a web-of-trust contact. Keep the response helpful and concise. Tool use is disabled for this tier."
|
||||
: "You are responding in chat-only mode. Tool use is disabled.";
|
||||
|
||||
size_t ctx_len = strlen(g_system_context) + strlen("\n\n") + strlen(tier_prefix) + 1U;
|
||||
size_t ctx_len = strlen(dm_context ? dm_context : "") + strlen("\n\n") + strlen(tier_prefix) + 1U;
|
||||
char* system_for_chat = (char*)malloc(ctx_len);
|
||||
if (!system_for_chat) {
|
||||
free(dm_context);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(system_for_chat, ctx_len, "%s\n\n%s", g_system_context, tier_prefix);
|
||||
snprintf(system_for_chat, ctx_len, "%s\n\n%s", dm_context ? dm_context : "", tier_prefix);
|
||||
|
||||
size_t context_len = strlen("system:\n\nuser:\n") + strlen(system_for_chat) + strlen(message) + 1U;
|
||||
char* plain_context = (char*)malloc(context_len);
|
||||
@@ -2163,6 +2503,7 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
strlen(response) > 240 ? "..." : "");
|
||||
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, response);
|
||||
free(response);
|
||||
free(dm_context);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2172,25 +2513,14 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
return;
|
||||
}
|
||||
|
||||
char* base_messages_json = NULL;
|
||||
if (agent_build_admin_messages_json(message, tier, &base_messages_json) != 0 || !base_messages_json) {
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to initialize conversation messages.");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* messages = cJSON_Parse(base_messages_json);
|
||||
free(base_messages_json);
|
||||
if (!messages || !cJSON_IsArray(messages)) {
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to initialize conversation state.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (append_simple_message(messages, "user", message) != 0) {
|
||||
cJSON* messages = cJSON_CreateArray();
|
||||
if (!messages ||
|
||||
append_simple_message(messages, "system", dm_context ? dm_context : "You are an AI agent. Respond to the message.") != 0 ||
|
||||
append_recent_admin_dm_history(messages, message) != 0 ||
|
||||
append_simple_message(messages, "user", message) != 0) {
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
free(dm_context);
|
||||
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to initialize conversation messages.");
|
||||
return;
|
||||
}
|
||||
@@ -2225,6 +2555,7 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "LLM request failed.");
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
free(dm_context);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2277,6 +2608,7 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
llm_response_free(&resp);
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
free(dm_context);
|
||||
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "Failed to append tool result.");
|
||||
return;
|
||||
}
|
||||
@@ -2333,6 +2665,7 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
free(final_answer_owned);
|
||||
cJSON_Delete(messages);
|
||||
free(tools_json);
|
||||
free(dm_context);
|
||||
}
|
||||
|
||||
void agent_cleanup(void) {
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
// Version information (auto-updated by build system)
|
||||
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||
#define DIDACTYL_VERSION_MAJOR 0
|
||||
#define DIDACTYL_VERSION_MINOR 1
|
||||
#define DIDACTYL_VERSION_PATCH 21
|
||||
#define DIDACTYL_VERSION "v0.1.21"
|
||||
#define DIDACTYL_VERSION_MINOR 2
|
||||
#define DIDACTYL_VERSION_PATCH 0
|
||||
#define DIDACTYL_VERSION "v0.2.0"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -105,7 +105,7 @@ static const char* map_variable_tool_name(const char* var_name) {
|
||||
if (strcmp(var_name, "admin_relays") == 0) return "nostr_admin_relays";
|
||||
if (strcmp(var_name, "adopted_skills") == 0) return "adopted_skills";
|
||||
if (strcmp(var_name, "triggering_event") == 0) return "trigger_event";
|
||||
return var_name;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static char* resolve_inline_variables_local(const char* tpl, tools_context_t* tools_ctx) {
|
||||
@@ -189,6 +189,16 @@ static char* resolve_inline_variables_local(const char* tpl, tools_context_t* to
|
||||
free(tool_result);
|
||||
}
|
||||
}
|
||||
|
||||
if (!replacement_owned && tools_ctx->template_skill_lookup && var_trim[0] != '\0') {
|
||||
const char* skill_content = tools_ctx->template_skill_lookup(
|
||||
tools_ctx->template_skill_lookup_user_data,
|
||||
var_trim);
|
||||
if (skill_content && skill_content[0] != '\0') {
|
||||
replacement_owned = strdup(skill_content);
|
||||
}
|
||||
}
|
||||
|
||||
replacement = replacement_owned ? replacement_owned : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ typedef struct {
|
||||
int template_sender_tier;
|
||||
const char* template_current_user_message;
|
||||
const char* template_trigger_event_json;
|
||||
const char* (*template_skill_lookup)(void* user_data, const char* d_tag);
|
||||
void* template_skill_lookup_user_data;
|
||||
} tools_context_t;
|
||||
|
||||
int tools_init(tools_context_t* ctx, didactyl_config_t* cfg);
|
||||
|
||||
@@ -25,6 +25,8 @@ int tools_init_legacy(tools_context_t* ctx, didactyl_config_t* cfg) {
|
||||
ctx->template_sender_tier = 0;
|
||||
ctx->template_current_user_message = NULL;
|
||||
ctx->template_trigger_event_json = NULL;
|
||||
ctx->template_skill_lookup = NULL;
|
||||
ctx->template_skill_lookup_user_data = NULL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -594,15 +594,28 @@ static void apply_trigger_runtime_to_llm_config(const active_trigger_t* t, llm_c
|
||||
|
||||
if (t->llm_spec[0] != '\0') {
|
||||
const char* spec = t->llm_spec;
|
||||
while (*spec && isspace((unsigned char)*spec)) spec++;
|
||||
|
||||
const char* comma = strchr(spec, ',');
|
||||
size_t len = comma ? (size_t)(comma - spec) : strlen(spec);
|
||||
while (len > 0 && isspace((unsigned char)spec[len - 1])) len--;
|
||||
while (*spec && isspace((unsigned char)*spec)) spec++;
|
||||
|
||||
const char* slash = memchr(spec, '/', len);
|
||||
if (slash) {
|
||||
size_t provider_len = (size_t)(slash - spec);
|
||||
const char* model = slash + 1;
|
||||
size_t model_len = len - (size_t)(model - spec);
|
||||
|
||||
while (provider_len > 0 && isspace((unsigned char)spec[provider_len - 1])) provider_len--;
|
||||
while (model_len > 0 && isspace((unsigned char)*model)) {
|
||||
model++;
|
||||
model_len--;
|
||||
}
|
||||
while (model_len > 0 && isspace((unsigned char)model[model_len - 1])) model_len--;
|
||||
|
||||
if (provider_len > 0 && provider_len < sizeof(cfg->provider)) {
|
||||
snprintf(cfg->provider, sizeof(cfg->provider), "%.*s", (int)provider_len, spec);
|
||||
}
|
||||
if (model_len > 0 && model_len < sizeof(cfg->model)) {
|
||||
snprintf(cfg->model, sizeof(cfg->model), "%.*s", (int)model_len, model);
|
||||
}
|
||||
@@ -807,137 +820,61 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* adoption_json = nostr_handler_get_self_events_by_kind_json(10123);
|
||||
if (!adoption_json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* adoption_events = cJSON_Parse(adoption_json);
|
||||
free(adoption_json);
|
||||
if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0);
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
if (!list_tags || !cJSON_IsArray(list_tags)) {
|
||||
cJSON_Delete(adoption_events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int tn = cJSON_GetArraySize(list_tags);
|
||||
for (int i = 0; i < tn; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(list_tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
|
||||
int considered = 0;
|
||||
const int kinds[2] = {31123, 31124};
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) ||
|
||||
!key->valuestring || !val->valuestring || strcmp(key->valuestring, "a") != 0) {
|
||||
for (int k = 0; k < 2; k++) {
|
||||
char* skill_json = nostr_handler_get_self_events_by_kind_json(kinds[k]);
|
||||
if (!skill_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int kind = 0;
|
||||
char pubkey[65] = {0};
|
||||
char d_tag[65] = {0};
|
||||
if (parse_address_tag(val->valuestring, &kind, pubkey, d_tag) != 0) {
|
||||
cJSON* skill_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
if (!skill_events || !cJSON_IsArray(skill_events)) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_events = NULL;
|
||||
if (strcmp(pubkey, mgr->cfg->keys.public_key_hex) == 0) {
|
||||
char* skill_json = nostr_handler_get_self_events_by_kind_json(kind);
|
||||
if (skill_json) {
|
||||
cJSON* all_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
if (all_events && cJSON_IsArray(all_events)) {
|
||||
skill_events = cJSON_CreateArray();
|
||||
if (skill_events) {
|
||||
int all_n = cJSON_GetArraySize(all_events);
|
||||
for (int ai = 0; ai < all_n; ai++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(all_events, ai);
|
||||
cJSON* ev_pubkey = ev ? cJSON_GetObjectItemCaseSensitive(ev, "pubkey") : NULL;
|
||||
cJSON* ev_tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
|
||||
cJSON* ev_d = find_tag_value_string(ev_tags, "d");
|
||||
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
|
||||
strcmp(ev_pubkey->valuestring, pubkey) != 0 ||
|
||||
!ev_d || !cJSON_IsString(ev_d) || !ev_d->valuestring ||
|
||||
strcmp(ev_d->valuestring, d_tag) != 0) {
|
||||
continue;
|
||||
}
|
||||
cJSON* dup = cJSON_Duplicate(ev, 1);
|
||||
if (dup) {
|
||||
cJSON_AddItemToArray(skill_events, dup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(all_events);
|
||||
}
|
||||
} else {
|
||||
cJSON* skill_filter = cJSON_CreateObject();
|
||||
cJSON* sk_kinds = cJSON_CreateArray();
|
||||
cJSON* sk_authors = cJSON_CreateArray();
|
||||
cJSON* d_values = cJSON_CreateArray();
|
||||
if (!skill_filter || !sk_kinds || !sk_authors || !d_values) {
|
||||
cJSON_Delete(skill_filter);
|
||||
cJSON_Delete(sk_kinds);
|
||||
cJSON_Delete(sk_authors);
|
||||
cJSON_Delete(d_values);
|
||||
int sn = cJSON_GetArraySize(skill_events);
|
||||
for (int i = 0; i < sn; i++) {
|
||||
cJSON* skill_event = cJSON_GetArrayItem(skill_events, i);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(sk_kinds, cJSON_CreateNumber(kind));
|
||||
cJSON_AddItemToObject(skill_filter, "kinds", sk_kinds);
|
||||
cJSON_AddItemToArray(sk_authors, cJSON_CreateString(pubkey));
|
||||
cJSON_AddItemToObject(skill_filter, "authors", sk_authors);
|
||||
cJSON_AddItemToArray(d_values, cJSON_CreateString(d_tag));
|
||||
cJSON_AddItemToObject(skill_filter, "#d", d_values);
|
||||
cJSON_AddNumberToObject(skill_filter, "limit", 1);
|
||||
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");
|
||||
|
||||
char* skill_json = nostr_handler_query_json(skill_filter, 2000);
|
||||
cJSON_Delete(skill_filter);
|
||||
if (skill_json) {
|
||||
skill_events = cJSON_Parse(skill_json);
|
||||
free(skill_json);
|
||||
const char* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
|
||||
considered++;
|
||||
if (!d_tag || d_tag[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
int trigger_supported = trigger_s &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0 ||
|
||||
strcmp(trigger_s, "dm") == 0);
|
||||
if (!trigger_supported || !filter_s || filter_s[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(skill_events);
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
cJSON* enabled = find_tag_value_string(tags, "enabled");
|
||||
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
|
||||
|
||||
int trigger_supported = trigger_s &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0 ||
|
||||
strcmp(trigger_s, "dm") == 0);
|
||||
if (trigger_supported && filter_s && filter_s[0] != '\0') {
|
||||
if (strcmp(action_s, "template") == 0) {
|
||||
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
|
||||
}
|
||||
|
||||
const char* llm_s = NULL;
|
||||
const char* tools_s = NULL;
|
||||
int has_max_tokens = 0;
|
||||
@@ -956,14 +893,13 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
&has_seed,
|
||||
&seed);
|
||||
|
||||
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
if (trigger_manager_add(mgr,
|
||||
d_tag,
|
||||
content->valuestring,
|
||||
filter_s,
|
||||
TRIGGER_ACTION_LLM,
|
||||
trigger_s,
|
||||
is_enabled,
|
||||
1,
|
||||
llm_s,
|
||||
tools_s,
|
||||
has_max_tokens,
|
||||
@@ -979,8 +915,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
cJSON_Delete(skill_events);
|
||||
}
|
||||
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from skills", loaded);
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from self skills (considered=%d)", loaded, considered);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1542,33 +1477,22 @@ static int dm_filter_matches_tier(const char* filter_json, didactyl_sender_tier_
|
||||
return match;
|
||||
}
|
||||
|
||||
int trigger_manager_fire_dm(trigger_manager_t* mgr,
|
||||
const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
const char* source_label) {
|
||||
if (!mgr || !sender_pubkey_hex || !message) {
|
||||
int trigger_manager_get_dm_skills(trigger_manager_t* mgr,
|
||||
const char* sender_pubkey_hex,
|
||||
didactyl_sender_tier_t tier,
|
||||
char** out_d_tags,
|
||||
int max_d_tags) {
|
||||
(void)sender_pubkey_hex;
|
||||
|
||||
if (!mgr || !out_d_tags || max_d_tags <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
if (!event) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(event, "type", "dm");
|
||||
cJSON_AddStringToObject(event, "sender_pubkey", sender_pubkey_hex);
|
||||
cJSON_AddStringToObject(event, "message", message);
|
||||
cJSON_AddStringToObject(event, "tier",
|
||||
tier == DIDACTYL_SENDER_ADMIN ? "admin" :
|
||||
(tier == DIDACTYL_SENDER_WOT ? "wot" : "stranger"));
|
||||
cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
|
||||
|
||||
int fired_total = 0;
|
||||
int count = 0;
|
||||
|
||||
pthread_mutex_lock(&mgr->mutex);
|
||||
int count_snapshot = mgr->count;
|
||||
for (int i = 0; i < count_snapshot; i++) {
|
||||
for (int i = 0; i < count_snapshot && count < max_d_tags; i++) {
|
||||
active_trigger_t* t = &mgr->triggers[i];
|
||||
if (!t->enabled || t->trigger_type != TRIGGER_TYPE_DM) {
|
||||
continue;
|
||||
@@ -1577,15 +1501,11 @@ int trigger_manager_fire_dm(trigger_manager_t* mgr,
|
||||
continue;
|
||||
}
|
||||
|
||||
int fired = maybe_fire_trigger_locked(mgr, i, event, source_label ? source_label : "dm");
|
||||
if (fired > 0) {
|
||||
fired_total += fired;
|
||||
}
|
||||
out_d_tags[count++] = t->skill_d_tag;
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
cJSON_Delete(event);
|
||||
return fired_total;
|
||||
return count;
|
||||
}
|
||||
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr) {
|
||||
|
||||
@@ -105,11 +105,11 @@ int trigger_manager_fire_chains(trigger_manager_t* mgr,
|
||||
const char* source_skill_d_tag,
|
||||
cJSON* source_event,
|
||||
const char* source_label);
|
||||
int trigger_manager_fire_dm(trigger_manager_t* mgr,
|
||||
const char* sender_pubkey_hex,
|
||||
const char* message,
|
||||
didactyl_sender_tier_t tier,
|
||||
const char* source_label);
|
||||
int trigger_manager_get_dm_skills(trigger_manager_t* mgr,
|
||||
const char* sender_pubkey_hex,
|
||||
didactyl_sender_tier_t tier,
|
||||
char** out_d_tags,
|
||||
int max_d_tags);
|
||||
int trigger_manager_active_count(trigger_manager_t* mgr);
|
||||
int trigger_manager_poll(trigger_manager_t* mgr);
|
||||
char* trigger_manager_status_json(trigger_manager_t* mgr);
|
||||
|
||||
Reference in New Issue
Block a user