Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd21dc41a | ||
|
|
d0416f7760 | ||
|
|
1ab2034b2d | ||
|
|
14a4f6f079 | ||
|
|
5efe932a5f | ||
|
|
09531a7ebe | ||
|
|
0403e50506 | ||
|
|
f4f87f84bc | ||
|
|
6f4bcb4707 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ a.out
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Local secrets
|
||||
genesis.jsonc
|
||||
|
||||
@@ -121,7 +121,7 @@ RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1
|
||||
src/tools/tool_nostr_query.c src/tools/tool_nostr_my_events.c src/tools/tool_nostr_identity.c src/tools/tool_nostr_social.c \
|
||||
src/tools/tool_nostr_relay.c src/tools/tool_nostr_dm.c src/tools/tool_admin.c \
|
||||
src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_nostr_block.c src/tools/tool_local.c \
|
||||
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/tools/tool_cashu_wallet.c src/trigger_manager.c \
|
||||
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/tools/tool_cashu_wallet.c src/tools/tool_blossom.c src/trigger_manager.c \
|
||||
src/cashu_wallet.c src/nostr_block_list.c src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \
|
||||
-o /build/didactyl_static \
|
||||
$NOSTR_LIB \
|
||||
|
||||
1
Makefile
1
Makefile
@@ -34,6 +34,7 @@ SRCS = \
|
||||
$(SRC_DIR)/tools/tool_memory.c \
|
||||
$(SRC_DIR)/tools/tool_config.c \
|
||||
$(SRC_DIR)/tools/tool_cashu_wallet.c \
|
||||
$(SRC_DIR)/tools/tool_blossom.c \
|
||||
$(SRC_DIR)/trigger_manager.c \
|
||||
$(SRC_DIR)/prompt_template.c \
|
||||
$(SRC_DIR)/http_api.c \
|
||||
|
||||
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.15
|
||||
## Current Status — v0.2.1
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.1.15 — Fix CashuB token byte-string encoding compatibility and rebuild after nostr_core_lib update
|
||||
> Last release update: v0.2.1 — v0.2.1 migration: remove template/soul system context paths, adopt trigger-composed admin DM flow, update genesis defaults to default_admin_dm with kind0/kind3 bootstrap, purge legacy config/context template files, scrub genesis.jsonc from tracking/history and force-push rewritten refs
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@
|
||||
"stall_repeat_threshold": 3, // stop early when identical tool-call turns repeat this many times
|
||||
"local_http_fetch_default_timeout_seconds": 20,
|
||||
"local_http_fetch_max_timeout_seconds": 120,
|
||||
"blossom_max_upload_bytes": 16777216,
|
||||
"blossom_max_download_bytes": 16777216,
|
||||
"shell": {
|
||||
"enabled": true,
|
||||
"timeout_seconds": 30,
|
||||
|
||||
109
deploy_lt_debug.sh
Executable file
109
deploy_lt_debug.sh
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Debug variant of deploy_lt.sh
|
||||
# Builds with debug symbols, deploys, enables core dumps, and restarts services.
|
||||
# After a crash, run on the server:
|
||||
# coredumpctl list
|
||||
# coredumpctl gdb simon.service
|
||||
# (gdb) bt full
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_BINARY_DEBUG="$SCRIPT_DIR/didactyl_static_x86_64_debug"
|
||||
SOURCE_BINARY_FALLBACK="$SCRIPT_DIR/didactyl_static_x86_64"
|
||||
SOURCE_BINARY=""
|
||||
REMOTE_HOST="ubuntu@laantungir.net"
|
||||
REMOTE_TARGETS=(
|
||||
"/home/simon/didactyl"
|
||||
"/home/didactyl/didactyl"
|
||||
)
|
||||
REMOTE_SERVICES=(
|
||||
"simon.service"
|
||||
"didactyl.service"
|
||||
)
|
||||
|
||||
if ! command -v rsync >/dev/null 2>&1; then
|
||||
echo "ERROR: rsync is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
echo "ERROR: ssh is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo " DEBUG BUILD + DEPLOY"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
echo "Building DEBUG static binary (symbols preserved)..."
|
||||
"$SCRIPT_DIR/build_static.sh" --debug
|
||||
|
||||
if [ -f "$SOURCE_BINARY_DEBUG" ]; then
|
||||
SOURCE_BINARY="$SOURCE_BINARY_DEBUG"
|
||||
elif [ -f "$SOURCE_BINARY_FALLBACK" ]; then
|
||||
SOURCE_BINARY="$SOURCE_BINARY_FALLBACK"
|
||||
else
|
||||
echo "ERROR: Build completed but no binary found (expected $SOURCE_BINARY_DEBUG or $SOURCE_BINARY_FALLBACK)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Binary info:"
|
||||
file "$SOURCE_BINARY"
|
||||
ls -lh "$SOURCE_BINARY"
|
||||
echo ""
|
||||
|
||||
REMOTE_STAGE="/tmp/didactyl_static_x86_64"
|
||||
|
||||
echo "Uploading debug binary to $REMOTE_HOST:$REMOTE_STAGE"
|
||||
rsync -avz --progress "$SOURCE_BINARY" "$REMOTE_HOST:$REMOTE_STAGE"
|
||||
|
||||
for target in "${REMOTE_TARGETS[@]}"; do
|
||||
echo "Installing staged binary to $target via sudo"
|
||||
ssh "$REMOTE_HOST" "sudo install -m 0755 '$REMOTE_STAGE' '$target'"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Enabling core dumps on remote..."
|
||||
ssh "$REMOTE_HOST" "
|
||||
# Ensure systemd stores core dumps via coredumpctl
|
||||
sudo mkdir -p /etc/systemd/coredump.conf.d
|
||||
echo '[Coredump]
|
||||
Storage=external
|
||||
ProcessSizeMax=512M
|
||||
ExternalSizeMax=512M' | sudo tee /etc/systemd/coredump.conf.d/debug.conf > /dev/null
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Remove any ulimit restrictions for the services
|
||||
for svc in ${REMOTE_SERVICES[*]}; do
|
||||
OVERRIDE_DIR=\"/etc/systemd/system/\${svc}.d\"
|
||||
sudo mkdir -p \"\$OVERRIDE_DIR\"
|
||||
echo '[Service]
|
||||
LimitCORE=infinity' | sudo tee \"\$OVERRIDE_DIR/coredump.conf\" > /dev/null
|
||||
done
|
||||
sudo systemctl daemon-reload
|
||||
"
|
||||
|
||||
echo "Restarting services on $REMOTE_HOST: ${REMOTE_SERVICES[*]}"
|
||||
ssh "$REMOTE_HOST" "sudo systemctl restart ${REMOTE_SERVICES[*]}"
|
||||
|
||||
echo "Cleaning up remote staging file $REMOTE_STAGE"
|
||||
ssh "$REMOTE_HOST" "rm -f '$REMOTE_STAGE'"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " DEBUG DEPLOYMENT COMPLETE"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "After reproducing the crash, run on the server:"
|
||||
echo " ssh $REMOTE_HOST"
|
||||
echo " sudo coredumpctl list"
|
||||
echo " sudo coredumpctl gdb simon.service"
|
||||
echo " (gdb) bt full"
|
||||
echo " (gdb) info threads"
|
||||
echo " (gdb) thread apply all bt"
|
||||
echo ""
|
||||
echo "Then paste the backtrace here."
|
||||
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)
|
||||
|
||||
|
||||
106
docs/TOOLS.md
106
docs/TOOLS.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.)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -172,6 +175,16 @@ These tools manage the agent's short-term and long-term memory, persisted on Nos
|
||||
| `cashu_wallet_send_token` | Create an outbound ecash token from wallet proofs and return a `cashuA`/`cashuB` token string |
|
||||
| `cashu_wallet_mints_set` | Set wallet mints (NIP-60), public mints (NIP-61), or both |
|
||||
|
||||
### Blossom Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `blossom_upload` | Upload a local file to a Blossom server and return blob metadata |
|
||||
| `blossom_download` | Download a Blossom blob to a local file path |
|
||||
| `blossom_head` | Fetch Blossom blob metadata by SHA-256 |
|
||||
| `blossom_delete` | Delete a Blossom blob by SHA-256 using signed auth |
|
||||
| `blossom_list` | List Blossom blobs for a pubkey (defaults to agent pubkey) |
|
||||
|
||||
### Content Publishing Conveniences
|
||||
|
||||
| Tool | Description |
|
||||
@@ -714,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 |
|
||||
|------|----------|-------|----------|
|
||||
@@ -722,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)
|
||||
|
||||
668
plans/blossom_tools.md
Normal file
668
plans/blossom_tools.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# Implementation Plan: Blossom Tools for Didactyl
|
||||
|
||||
## Objective
|
||||
|
||||
Add first-class Blossom tooling so the agent can upload, download, inspect, and delete blobs on Blossom-compatible servers. As a prerequisite, consolidate all HTTP client code into a single public API in `nostr_core_lib` so that both projects share one curl implementation.
|
||||
|
||||
---
|
||||
|
||||
## Problem: HTTP Client Duplication
|
||||
|
||||
There are currently **5 separate curl implementations** across the two projects:
|
||||
|
||||
| # | Project | File | Function | Methods | Visibility |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | nostr_core_lib | `cashu_mint.c:88` | `cashu_http_json_request()` | GET, POST | `static` |
|
||||
| 2 | nostr_core_lib | `nip005.c:103` | `nip05_http_get()` | GET | `static` |
|
||||
| 3 | nostr_core_lib | `nip011.c:344` | inline curl block | GET | `static` |
|
||||
| 4 | Didactyl | `llm.c:88` | `perform_http_request()` | GET, POST | `static` |
|
||||
| 5 | Didactyl | `tool_local.c:214` | inline in `execute_local_http_fetch()` | GET, POST, any | `static` |
|
||||
|
||||
Each has its own write callback, response buffer struct, CA bundle detection, SSL config, and error handling. Adding Blossom without consolidation would create a 6th copy.
|
||||
|
||||
### Decision
|
||||
|
||||
Consolidate all HTTP client code into a **single public API** in `nostr_core_lib`. Migrate all consumers in both projects to use it. Then build Blossom client on top of the same shared HTTP layer.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Unified HTTP Client in nostr_core_lib
|
||||
|
||||
### New files
|
||||
|
||||
- `nostr_core_lib/nostr_core/nostr_http.h` — public API
|
||||
- `nostr_core_lib/nostr_core/nostr_http.c` — single curl implementation
|
||||
|
||||
### Proposed API
|
||||
|
||||
```c
|
||||
#ifndef NOSTR_HTTP_H
|
||||
#define NOSTR_HTTP_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
// HTTP response container
|
||||
typedef struct {
|
||||
char* body; // Response body (malloc'd, caller frees)
|
||||
size_t body_len; // Body length in bytes
|
||||
long status_code; // HTTP status code
|
||||
char* content_type; // Content-Type header value (malloc'd, caller frees)
|
||||
char* headers_raw; // All response headers (malloc'd, caller frees, optional)
|
||||
} nostr_http_response_t;
|
||||
|
||||
// HTTP request options
|
||||
typedef struct {
|
||||
const char* method; // "GET", "POST", "PUT", "DELETE", "HEAD" (default: "GET")
|
||||
const char* url; // Required
|
||||
const char** headers; // NULL-terminated array of "Key: Value" strings (optional)
|
||||
const unsigned char* body; // Request body bytes (optional)
|
||||
size_t body_len; // Body length (0 if no body)
|
||||
int timeout_seconds; // Request timeout (default: 30)
|
||||
size_t max_response_bytes; // Cap response body size (0 = unlimited)
|
||||
int follow_redirects; // 1 = follow, 0 = don't (default: 1)
|
||||
int max_redirects; // Max redirect hops (default: 3)
|
||||
const char* user_agent; // User-Agent header (default: "nostr-core/VERSION")
|
||||
int capture_headers; // 1 = capture response headers in headers_raw
|
||||
} nostr_http_request_t;
|
||||
|
||||
// Set global CA bundle path for all HTTP requests
|
||||
void nostr_http_set_ca_bundle(const char* ca_bundle_path);
|
||||
|
||||
// Auto-detect CA bundle from common system paths
|
||||
const char* nostr_http_detect_ca_bundle(void);
|
||||
|
||||
// Perform an HTTP request
|
||||
// Returns NOSTR_SUCCESS on successful HTTP round-trip (even 4xx/5xx).
|
||||
// Returns NOSTR_ERROR_NETWORK_FAILED on connection/DNS/timeout failure.
|
||||
// Caller must call nostr_http_response_free() on success.
|
||||
int nostr_http_request(const nostr_http_request_t* req, nostr_http_response_t* resp);
|
||||
|
||||
// Free response resources
|
||||
void nostr_http_response_free(nostr_http_response_t* resp);
|
||||
|
||||
// Convenience: simple GET returning body string
|
||||
int nostr_http_get(const char* url, int timeout_seconds, char** body_out, long* status_out);
|
||||
|
||||
// Convenience: JSON POST returning body string
|
||||
int nostr_http_post_json(const char* url, const char* json_body, int timeout_seconds,
|
||||
char** body_out, long* status_out);
|
||||
|
||||
#endif
|
||||
```
|
||||
|
||||
### Key design decisions
|
||||
|
||||
1. **Binary-safe body** — `body` is `unsigned char*` with explicit `body_len`, supporting both JSON text and raw file uploads
|
||||
2. **Method-agnostic** — supports GET, POST, PUT, DELETE, HEAD, PATCH via string
|
||||
3. **Response size cap** — `max_response_bytes` prevents OOM on large downloads
|
||||
4. **Header capture** — optional `capture_headers` for HEAD requests (Blossom needs this)
|
||||
5. **CA bundle** — single global setter replaces 5 separate detection functions
|
||||
6. **Convenience wrappers** — `nostr_http_get()` and `nostr_http_post_json()` cover the common JSON API pattern used by cashu_mint, nip005, nip011
|
||||
|
||||
### Implementation notes
|
||||
|
||||
- Single `static size_t write_callback()` function
|
||||
- Single `static size_t header_callback()` function (for header capture)
|
||||
- CA bundle auto-detection consolidated from the 5 existing implementations
|
||||
- SSL verification always on by default
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Migrate nostr_core_lib Internal Consumers
|
||||
|
||||
### cashu_mint.c
|
||||
|
||||
Replace `cashu_http_json_request()` (static, ~70 lines) with calls to `nostr_http_post_json()` / `nostr_http_get()`.
|
||||
|
||||
**Before:**
|
||||
```c
|
||||
static int cashu_http_json_request(const char* method, const char* url,
|
||||
const char* body, int timeout_seconds,
|
||||
char** response_out, long* status_out) {
|
||||
// 70 lines of curl boilerplate
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```c
|
||||
static int cashu_http_json_request(const char* method, const char* url,
|
||||
const char* body, int timeout_seconds,
|
||||
char** response_out, long* status_out) {
|
||||
if (strcmp(method, "POST") == 0) {
|
||||
return nostr_http_post_json(url, body, timeout_seconds, response_out, status_out);
|
||||
}
|
||||
return nostr_http_get(url, timeout_seconds, response_out, status_out);
|
||||
}
|
||||
```
|
||||
|
||||
The function signature stays the same so all 20+ call sites in cashu_mint.c are unaffected.
|
||||
|
||||
### nip005.c
|
||||
|
||||
Replace `nip05_http_get()` (static, ~50 lines) with `nostr_http_get()`.
|
||||
|
||||
### nip011.c
|
||||
|
||||
Replace inline curl block (~40 lines) with `nostr_http_request()` using custom Accept header.
|
||||
|
||||
### Build changes
|
||||
|
||||
- Add `nostr_http.c` to the nostr_core_lib build
|
||||
- Remove `#include <curl/curl.h>` from cashu_mint.c, nip005.c, nip011.c (only nostr_http.c includes it)
|
||||
- Update `nostr_core.h` to include `nostr_http.h`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Migrate Didactyl Consumers
|
||||
|
||||
### llm.c
|
||||
|
||||
Replace `perform_http_request()` (static, ~80 lines) with `nostr_http_request()`.
|
||||
|
||||
**Current signature:** `static char* perform_http_request(const char* url, const char* body, int is_post)`
|
||||
|
||||
**Migration:** Build a `nostr_http_request_t` with the Authorization Bearer header, call `nostr_http_request()`, extract body. The LLM-specific logic (WebSocket URL detection, debug logging, status code handling) stays in llm.c — only the curl plumbing moves out.
|
||||
|
||||
Also remove:
|
||||
- `static size_t write_cb()` — replaced by nostr_http's callback
|
||||
- `static const char* detect_ca_bundle_path()` — replaced by `nostr_http_detect_ca_bundle()`
|
||||
- `typedef struct { char* data; size_t len; } response_buffer_t;` — replaced by `nostr_http_response_t`
|
||||
|
||||
### tool_local.c
|
||||
|
||||
Replace the inline curl block in `execute_local_http_fetch()` (~100 lines) with `nostr_http_request()`.
|
||||
|
||||
Also remove:
|
||||
- `static size_t local_http_fetch_write_cb_local()` — replaced
|
||||
- `static const char* detect_ca_bundle_path_for_tools_local()` — replaced
|
||||
- `typedef struct { ... } local_http_fetch_buffer_t;` — replaced
|
||||
|
||||
### cashu_wallet.c
|
||||
|
||||
Remove `detect_ca_bundle_path_for_cashu_wallet()` — replaced by `nostr_http_detect_ca_bundle()` called once at init.
|
||||
|
||||
The `cashu_mint_set_ca_bundle()` call at line 423 becomes `nostr_http_set_ca_bundle()` called once in main.c startup.
|
||||
|
||||
### Build changes
|
||||
|
||||
- Remove `#include <curl/curl.h>` from llm.c and tool_local.c
|
||||
- Didactyl only includes curl transitively through nostr_core_lib
|
||||
- CA bundle detection happens once in main.c startup via `nostr_http_set_ca_bundle(nostr_http_detect_ca_bundle())`
|
||||
|
||||
### Verification
|
||||
|
||||
After migration, grep confirms zero direct curl usage in Didactyl:
|
||||
```bash
|
||||
grep -r "curl_easy_init\|CURL\s*\*\|curl_easy_setopt" src/
|
||||
# Expected: no results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Blossom Client in nostr_core_lib
|
||||
|
||||
### New files
|
||||
|
||||
- `nostr_core_lib/nostr_core/blossom_client.h` — public Blossom API
|
||||
- `nostr_core_lib/nostr_core/blossom_client.c` — implementation using `nostr_http`
|
||||
|
||||
### Proposed API
|
||||
|
||||
```c
|
||||
#ifndef NOSTR_BLOSSOM_CLIENT_H
|
||||
#define NOSTR_BLOSSOM_CLIENT_H
|
||||
|
||||
#include "nostr_common.h"
|
||||
#include "../cjson/cJSON.h"
|
||||
#include <stddef.h>
|
||||
|
||||
// Blob descriptor returned by Blossom servers
|
||||
typedef struct {
|
||||
char sha256[65]; // Hex-encoded SHA-256 hash
|
||||
char url[512]; // Canonical blob URL
|
||||
long size; // Blob size in bytes
|
||||
char content_type[128]; // MIME type
|
||||
long created; // Unix timestamp
|
||||
} blossom_blob_descriptor_t;
|
||||
|
||||
// Set CA bundle for Blossom HTTP requests (delegates to nostr_http)
|
||||
void blossom_set_ca_bundle(const char* ca_bundle_path);
|
||||
|
||||
// Create a kind 24242 Blossom authorization event
|
||||
// Returns base64-encoded signed event string for Authorization header.
|
||||
// Caller must free() the returned string.
|
||||
char* blossom_create_auth_header(const unsigned char* private_key,
|
||||
const char* operation, // "upload", "delete", "list"
|
||||
const char* sha256_hex, // blob hash (NULL for list)
|
||||
int expiration_seconds);
|
||||
|
||||
// Upload file bytes to a Blossom server
|
||||
// Returns NOSTR_SUCCESS and fills descriptor on success.
|
||||
int blossom_upload(const char* server_url,
|
||||
const unsigned char* data,
|
||||
size_t data_len,
|
||||
const char* content_type,
|
||||
const unsigned char* private_key, // for auth event (NULL = no auth)
|
||||
const char* sha256_hex, // pre-computed hash (NULL = compute)
|
||||
int timeout_seconds,
|
||||
blossom_blob_descriptor_t* descriptor_out);
|
||||
|
||||
// Upload a local file to a Blossom server
|
||||
int blossom_upload_file(const char* server_url,
|
||||
const char* file_path,
|
||||
const char* content_type,
|
||||
const unsigned char* private_key,
|
||||
int timeout_seconds,
|
||||
blossom_blob_descriptor_t* descriptor_out);
|
||||
|
||||
// Download a blob by SHA-256 hash
|
||||
// Returns NOSTR_SUCCESS and fills body_out/body_len_out.
|
||||
// Caller must free(*body_out).
|
||||
int blossom_download(const char* server_url,
|
||||
const char* sha256_hex,
|
||||
int timeout_seconds,
|
||||
size_t max_bytes,
|
||||
unsigned char** body_out,
|
||||
size_t* body_len_out,
|
||||
char* content_type_out, // buffer, at least 128 bytes
|
||||
size_t content_type_out_size);
|
||||
|
||||
// Download a blob to a local file
|
||||
int blossom_download_to_file(const char* server_url,
|
||||
const char* sha256_hex,
|
||||
const char* output_path,
|
||||
int timeout_seconds,
|
||||
size_t max_bytes,
|
||||
blossom_blob_descriptor_t* descriptor_out);
|
||||
|
||||
// HEAD request — check blob existence and metadata
|
||||
int blossom_head(const char* server_url,
|
||||
const char* sha256_hex,
|
||||
int timeout_seconds,
|
||||
blossom_blob_descriptor_t* descriptor_out);
|
||||
|
||||
// Delete a blob by SHA-256 hash (requires auth)
|
||||
int blossom_delete(const char* server_url,
|
||||
const char* sha256_hex,
|
||||
const unsigned char* private_key,
|
||||
int timeout_seconds);
|
||||
|
||||
// List blobs for a pubkey
|
||||
// Returns NOSTR_SUCCESS and fills descriptors array.
|
||||
// Caller must free(*descriptors_out).
|
||||
int blossom_list(const char* server_url,
|
||||
const char* pubkey_hex,
|
||||
int timeout_seconds,
|
||||
blossom_blob_descriptor_t** descriptors_out,
|
||||
int* count_out);
|
||||
|
||||
#endif
|
||||
```
|
||||
|
||||
### Implementation details
|
||||
|
||||
- `blossom_create_auth_header()` uses `nostr_create_and_sign_event()` (kind 24242) + `base64_encode()`
|
||||
- All HTTP calls go through `nostr_http_request()` — no direct curl usage
|
||||
- `blossom_upload_file()` uses `nostr_sha256_file_stream()` to hash before upload
|
||||
- `blossom_download_to_file()` verifies SHA-256 after download
|
||||
|
||||
### Build changes
|
||||
|
||||
- Add `blossom_client.c` to nostr_core_lib build
|
||||
- Update `nostr_core.h` to include `blossom_client.h`
|
||||
- Rebuild `libnostr_core_*.a`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Blossom Tools in Didactyl
|
||||
|
||||
### New file
|
||||
|
||||
- `src/tools/tool_blossom.c` — thin tool wrappers calling `blossom_*()` from nostr_core_lib
|
||||
|
||||
### Tool set
|
||||
|
||||
| Tool | Description | Library Function |
|
||||
|---|---|---|
|
||||
| `blossom_upload` | Upload local file to Blossom server | `blossom_upload_file()` |
|
||||
| `blossom_download` | Download blob to local file | `blossom_download_to_file()` |
|
||||
| `blossom_head` | Check blob existence and metadata | `blossom_head()` |
|
||||
| `blossom_delete` | Delete blob from server | `blossom_delete()` |
|
||||
| `blossom_list` | List blobs by pubkey | `blossom_list()` |
|
||||
|
||||
### Tool schemas
|
||||
|
||||
(Unchanged from original plan — see Tool Contracts section below.)
|
||||
|
||||
### Integration points
|
||||
|
||||
1. **`tools_internal.h`** — add `execute_blossom_*()` prototypes
|
||||
2. **`tools_dispatch.c`** — add `strcmp` branches
|
||||
3. **`tools_schema.c`** — add OpenAI tool definitions
|
||||
4. **`Makefile`** — add `$(SRC_DIR)/tools/tool_blossom.c` to SRCS
|
||||
5. **`docs/TOOLS.md`** — add Blossom Storage Tools section
|
||||
|
||||
### Pattern
|
||||
|
||||
Follows the exact same pattern as Cashu:
|
||||
|
||||
```
|
||||
tool_blossom.c (arg parsing + JSON result formatting)
|
||||
→ blossom_client.h (nostr_core_lib - domain API)
|
||||
→ nostr_http.h (nostr_core_lib - shared HTTP client)
|
||||
```
|
||||
|
||||
Just like:
|
||||
|
||||
```
|
||||
tool_cashu_wallet.c (arg parsing + JSON result formatting)
|
||||
→ cashu_wallet.c (Didactyl - wallet state + Nostr persistence)
|
||||
→ cashu_mint.h (nostr_core_lib - domain API)
|
||||
→ nostr_http.h (nostr_core_lib - shared HTTP client)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Contracts
|
||||
|
||||
All tools return a JSON object with at minimum:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"error": "...optional on failure..."
|
||||
}
|
||||
```
|
||||
|
||||
### `blossom_upload`
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string", "description": "Blossom server base URL; omit if default configured" },
|
||||
"file_path": { "type": "string", "description": "Relative local path inside working directory" },
|
||||
"content_type": { "type": "string", "description": "Optional MIME type override" }
|
||||
},
|
||||
"required": ["file_path"]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"server": "https://blossom.example",
|
||||
"sha256": "<64-hex>",
|
||||
"size": 12345,
|
||||
"content_type": "image/png",
|
||||
"url": "https://blossom.example/<sha256>"
|
||||
}
|
||||
```
|
||||
|
||||
### `blossom_download`
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" },
|
||||
"sha256": { "type": "string", "description": "Blob hash hex identifier" },
|
||||
"url": { "type": "string", "description": "Direct blob URL if server+sha256 not provided" },
|
||||
"output_path": { "type": "string", "description": "Relative path to write file" },
|
||||
"overwrite": { "type": "boolean", "default": false }
|
||||
},
|
||||
"required": ["output_path"]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"output_path": "downloads/file.bin",
|
||||
"bytes_written": 12345,
|
||||
"sha256": "<computed-64-hex>",
|
||||
"verified": true,
|
||||
"content_type": "application/octet-stream"
|
||||
}
|
||||
```
|
||||
|
||||
### `blossom_head`
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" },
|
||||
"sha256": { "type": "string" },
|
||||
"url": { "type": "string" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"exists": true,
|
||||
"sha256": "<64-hex>",
|
||||
"size": 12345,
|
||||
"content_type": "image/jpeg"
|
||||
}
|
||||
```
|
||||
|
||||
### `blossom_delete`
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" },
|
||||
"sha256": { "type": "string" }
|
||||
},
|
||||
"required": ["sha256"]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"deleted": true,
|
||||
"server": "https://blossom.example",
|
||||
"sha256": "<64-hex>"
|
||||
}
|
||||
```
|
||||
|
||||
### `blossom_list`
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" },
|
||||
"pubkey": { "type": "string", "description": "Hex pubkey; defaults to agent pubkey" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"server": "https://blossom.example",
|
||||
"pubkey": "<64-hex>",
|
||||
"blobs": [
|
||||
{ "sha256": "...", "size": 12345, "content_type": "image/png", "url": "...", "created": 1679000000 }
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation and Safety Rules
|
||||
|
||||
1. `server` must be `https://` unless explicit config allows insecure local testing
|
||||
2. `sha256` must match `^[0-9a-fA-F]{64}$`
|
||||
3. `file_path` and `output_path` must be safe relative paths (reuse `tool_local.c` pattern)
|
||||
4. Enforce maximum upload/download size from config
|
||||
5. Refuse overwrite unless `overwrite=true`
|
||||
6. Normalize all errors into `{"success": false, "error": "..."}`
|
||||
7. Do not leak keys/secrets in returned payload
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
### Step 1: `nostr_http` in nostr_core_lib
|
||||
- Create `nostr_http.h` and `nostr_http.c`
|
||||
- Write unit tests for GET, POST, PUT, DELETE, HEAD
|
||||
- Verify CA bundle detection works across distros
|
||||
|
||||
### Step 2: Migrate nostr_core_lib consumers
|
||||
- Refactor `cashu_mint.c` to use `nostr_http`
|
||||
- Refactor `nip005.c` to use `nostr_http`
|
||||
- Refactor `nip011.c` to use `nostr_http`
|
||||
- Remove direct `#include <curl/curl.h>` from all three
|
||||
- Run existing tests to verify no regressions
|
||||
|
||||
### Step 3: Migrate Didactyl consumers
|
||||
- Refactor `llm.c` to use `nostr_http`
|
||||
- Refactor `tool_local.c` to use `nostr_http`
|
||||
- Update `cashu_wallet.c` CA bundle init
|
||||
- Remove all direct curl includes from Didactyl src/
|
||||
- Verify: `grep -r "curl_easy_init" src/` returns zero results
|
||||
- Run existing tests
|
||||
|
||||
### Step 4: `blossom_client` in nostr_core_lib
|
||||
- Create `blossom_client.h` and `blossom_client.c`
|
||||
- Implement auth event builder, upload, download, head, delete, list
|
||||
- Write unit tests
|
||||
|
||||
### Step 5: Blossom tools in Didactyl
|
||||
- Create `tool_blossom.c`
|
||||
- Add schemas, dispatch, prototypes
|
||||
- Update docs
|
||||
- Run full test suite
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Didactyl Tools Layer"
|
||||
TB[tool_blossom.c]
|
||||
TCW[tool_cashu_wallet.c]
|
||||
TL[tool_local.c]
|
||||
LLM[llm.c]
|
||||
end
|
||||
|
||||
subgraph "Didactyl Domain Layer"
|
||||
CW[cashu_wallet.c]
|
||||
end
|
||||
|
||||
subgraph "nostr_core_lib - Domain Clients"
|
||||
BC[blossom_client.c]
|
||||
CM[cashu_mint.c]
|
||||
N05[nip005.c]
|
||||
N11[nip011.c]
|
||||
end
|
||||
|
||||
subgraph "nostr_core_lib - Shared Infrastructure"
|
||||
NH[nostr_http.c<br/>Single curl implementation]
|
||||
NIP1[nip001.c<br/>Event signing]
|
||||
UTIL[utils.c<br/>SHA-256 + base64 + hex]
|
||||
end
|
||||
|
||||
TB --> BC
|
||||
TCW --> CW
|
||||
CW --> CM
|
||||
TL --> NH
|
||||
LLM --> NH
|
||||
|
||||
BC --> NH
|
||||
BC --> NIP1
|
||||
BC --> UTIL
|
||||
CM --> NH
|
||||
N05 --> NH
|
||||
N11 --> NH
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit tests
|
||||
1. `nostr_http` — GET/POST/PUT/DELETE/HEAD, timeouts, max_response_bytes, CA bundle
|
||||
2. `blossom_client` — auth event creation, upload/download/head/delete/list
|
||||
3. Argument parsing and validation for all Blossom tools
|
||||
|
||||
### Integration tests
|
||||
- Mock Blossom server for upload/download/head/delete/list
|
||||
- Verify SHA-256 integrity on download
|
||||
- Test auth event expiration
|
||||
- Test error responses (404, 401, 403, 500)
|
||||
|
||||
### Regression tests
|
||||
- All existing Cashu wallet tests pass after cashu_mint migration
|
||||
- All existing NIP-05 and NIP-11 tests pass
|
||||
- LLM requests work correctly after llm.c migration
|
||||
- `local_http_fetch` tool works correctly after tool_local.c migration
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
1. **nostr_core_lib API change breaks Didactyl build**
|
||||
- Mitigation: version-pin nostr_core_lib; test both projects together before release
|
||||
|
||||
2. **Subtle curl behavior differences after migration**
|
||||
- Mitigation: keep convenience wrappers thin; run existing test suites at each step
|
||||
|
||||
3. **Binary body support gaps**
|
||||
- Mitigation: `nostr_http_request_t.body` is `unsigned char*` with explicit length from day one
|
||||
|
||||
4. **CA bundle detection regression on specific distros**
|
||||
- Mitigation: consolidate all 5 existing detection paths into one comprehensive function
|
||||
|
||||
5. **Blossom server protocol variance**
|
||||
- Mitigation: defensive response parsing; test against multiple server implementations
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
### nostr_core_lib (new)
|
||||
- `nostr_core/nostr_http.h` — shared HTTP client API
|
||||
- `nostr_core/nostr_http.c` — shared HTTP client implementation
|
||||
- `nostr_core/blossom_client.h` — Blossom client API
|
||||
- `nostr_core/blossom_client.c` — Blossom client implementation
|
||||
|
||||
### nostr_core_lib (modified)
|
||||
- `nostr_core/cashu_mint.c` — replace static curl with `nostr_http`
|
||||
- `nostr_core/nip005.c` — replace static curl with `nostr_http`
|
||||
- `nostr_core/nip011.c` — replace static curl with `nostr_http`
|
||||
- `nostr_core/nostr_core.h` — add includes for new headers
|
||||
- `build.sh` — add new source files
|
||||
|
||||
### Didactyl (new)
|
||||
- `src/tools/tool_blossom.c` — Blossom tool implementations
|
||||
|
||||
### Didactyl (modified)
|
||||
- `src/llm.c` — replace static curl with `nostr_http`
|
||||
- `src/tools/tool_local.c` — replace static curl with `nostr_http`
|
||||
- `src/cashu_wallet.c` — update CA bundle init
|
||||
- `src/main.c` — add `nostr_http_set_ca_bundle()` call at startup
|
||||
- `src/tools/tools_internal.h` — add Blossom prototypes
|
||||
- `src/tools/tools_dispatch.c` — add Blossom dispatch
|
||||
- `src/tools/tools_schema.c` — add Blossom schemas
|
||||
- `Makefile` — add `tool_blossom.c` to SRCS
|
||||
- `docs/TOOLS.md` — add Blossom section
|
||||
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.
|
||||
246
plans/v0.2.0_migration.md
Normal file
246
plans/v0.2.0_migration.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# v0.2.0 Migration Plan
|
||||
|
||||
## Agent Event Cleanup
|
||||
|
||||
Delete all old skill/soul events from each agent's relay set before restarting with the new genesis file.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Genesis File Changes
|
||||
|
||||
### Kind 0 profile event (new)
|
||||
|
||||
Add a startup event for the agent's profile:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 0,
|
||||
"content_fields": {
|
||||
"name": "Didactyl",
|
||||
"about": "I am a Didactyl agent living on Nostr"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
```
|
||||
|
||||
### Kind 3 contacts event (new)
|
||||
|
||||
Add a startup event following the admin:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 3,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["p", "ADMIN_HEX_PUBKEY"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The admin hex pubkey is derived from the `admin.pubkey` field at startup.
|
||||
|
||||
### Default skill rename and trigger tags
|
||||
|
||||
**Before:**
|
||||
```jsonc
|
||||
"default_skill": {
|
||||
"d_tag": "didactyl-default",
|
||||
"kind": 31124,
|
||||
"content": "# Didactyl Agent\n\n...\n\n---template---\n\n- section: admin_identity\n ...",
|
||||
"tags": [["app", "didactyl"], ["scope", "private"]]
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```jsonc
|
||||
"default_skill": {
|
||||
"d_tag": "default_admin_dm",
|
||||
"kind": 31124,
|
||||
"content": "# Didactyl Agent\n\nYou are {{my_kind0_profile}}\n\nYour npub: {{my_npub}}\n\n## Rules\n\n- Communicate through encrypted Nostr direct messages\n- Keep responses concise and clear\n- Be helpful and technically accurate\n- If unsure, state uncertainty directly\n- Use tools when a request requires taking action\n- After a tool call, base your answer on the actual tool result\n- Never claim a tool was run if no tool was executed\n- Maintain your task list as short-term working memory\n- Never reveal your private key (nsec)\n- You may share your public key (npub) with anyone",
|
||||
"tags": [
|
||||
["app", "didactyl"],
|
||||
["scope", "private"],
|
||||
["description", "Default admin DM handler"],
|
||||
["trigger", "dm"],
|
||||
["filter", "{\"from\":\"admin\"}"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- `d_tag` renamed to `default_admin_dm`
|
||||
- `---template---` DSL removed entirely
|
||||
- Content uses `{{my_kind0_profile}}` and `{{my_npub}}` for identity injection
|
||||
- Trigger/filter tags added so the new trigger-scoped system fires it on admin DMs
|
||||
- Description tag added
|
||||
|
||||
---
|
||||
|
||||
## Source Code Changes
|
||||
|
||||
### Phase 1: Remove `---template---` DSL
|
||||
|
||||
#### `src/prompt_template.h`
|
||||
|
||||
- Remove `PROMPT_TEMPLATE_MARKER`, `PROMPT_TEMPLATE_MAX_SECTIONS`, `PROMPT_TEMPLATE_MAX_NAME_LEN`, `PROMPT_TEMPLATE_MAX_ROLE_LEN`
|
||||
- Remove `prompt_template_section_t` struct
|
||||
- Remove `prompt_template_t` struct
|
||||
- Remove `prompt_template_emit_hook_fn` typedef
|
||||
- Remove `prompt_template_parse()` declaration
|
||||
- Remove `prompt_template_build_messages()` declaration
|
||||
- Remove `prompt_template_free()` declaration
|
||||
- Keep the file if `resolve_inline_variables_local()` needs a public header, otherwise fold into agent.c
|
||||
|
||||
#### `src/prompt_template.c`
|
||||
|
||||
- Remove `prompt_template_parse()` and all section-parsing code (lines 231-502)
|
||||
- Remove `prompt_template_build_messages()` (lines 551-639)
|
||||
- Remove `prompt_template_free()` (lines 641-667)
|
||||
- Remove section-related helpers: `init_section_defaults()`, `parse_int_or_zero()`, `extract_tool_result_content()`, `append_message_object()`
|
||||
- Keep `resolve_inline_variables_local()` — it handles `{{...}}` variable resolution used by `build_context_from_triggers()`
|
||||
- Keep `map_variable_tool_name()` — maps variable names to tool names
|
||||
- Keep string helpers: `dup_range()`, `ltrim_inplace()`, `rtrim_inplace()`, `append_text()`
|
||||
|
||||
#### `src/agent.c`
|
||||
|
||||
- Remove `g_prompt_template` static variable (line 28)
|
||||
- Remove `g_has_prompt_template` static variable (line 29)
|
||||
- Remove `template_emit_hook()` callback
|
||||
- Remove the `prompt_template_parse()` call in `agent_init()` (line 2127-2128)
|
||||
- Remove the `prompt_template_free()` calls in `agent_init()` and `agent_cleanup()`
|
||||
- Remove the `g_has_prompt_template` branch in `agent_build_admin_messages_json()` (lines 2334-2035) — replace with trigger-composed context path
|
||||
|
||||
### Phase 2: Remove `g_system_context`
|
||||
|
||||
#### `src/agent.c`
|
||||
|
||||
- Remove `g_system_context` global (line 24)
|
||||
- Remove `system_context` parameter from `agent_init()`
|
||||
- Remove `strdup(system_context)` and all `g_system_context` references
|
||||
- Remove `g_system_context` null checks from `agent_on_trigger()`, `agent_on_message()`, `agent_build_admin_messages_json()`
|
||||
- In `agent_cleanup()`: remove `free(g_system_context)`
|
||||
|
||||
#### `src/agent.h`
|
||||
|
||||
- Update `agent_init()` signature: `int agent_init(didactyl_config_t* config);`
|
||||
|
||||
#### `src/nostr_handler.c`
|
||||
|
||||
- Remove `g_system_context` global (line 28)
|
||||
- Remove `g_system_context` initialization in `nostr_handler_reconcile_startup_events()` (lines 3124-3157)
|
||||
- Remove `nostr_handler_get_system_context()` function (lines 3289-3291)
|
||||
- Remove `seed_default_skill_into_cache()` if it only serves `g_system_context`
|
||||
|
||||
#### `src/nostr_handler.h`
|
||||
|
||||
- Remove `nostr_handler_get_system_context()` declaration
|
||||
|
||||
#### `src/main.c`
|
||||
|
||||
- Remove `system_context` variable (line 1261)
|
||||
- Remove fallback logic (lines 1262-1270)
|
||||
- Call `agent_init(&cfg)` without system_context parameter
|
||||
|
||||
### Phase 3: Rename default skill constants
|
||||
|
||||
#### `src/default_events.h`
|
||||
|
||||
- `DIDACTYL_DEFAULT_SKILL_D_TAG`: `"didactyl-default"` → `"default_admin_dm"`
|
||||
- `DIDACTYL_DEFAULT_SKILL_TAGS_JSON`: add trigger/filter/description tags
|
||||
- `DIDACTYL_DEFAULT_SKILL_TEMPLATE`: remove `---template---` section, replace with lean markdown using `{{my_kind0_profile}}` and `{{my_npub}}`
|
||||
|
||||
### Phase 4: Update `agent_build_admin_messages_json()`
|
||||
|
||||
This function is used by the HTTP API. Currently has two paths (template DSL and legacy hardcoded). Replace both with a single path using `build_context_from_triggers()` — same as `agent_on_message()` now uses.
|
||||
|
||||
### Phase 5: Delete legacy files
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `config.jsonc.example` | Delete |
|
||||
| `context_template.md` | Delete |
|
||||
| `context_template.recovered.md` | Delete |
|
||||
|
||||
### Phase 6: Update genesis files
|
||||
|
||||
#### `genesis.jsonc`
|
||||
|
||||
- Add kind 0 startup event with name and about
|
||||
- Rename `default_skill.d_tag` to `default_admin_dm`
|
||||
- Add trigger/filter/description tags to `default_skill.tags`
|
||||
- Replace content with lean markdown (no `---template---`)
|
||||
|
||||
#### `genesis.jsonc.example`
|
||||
|
||||
- Same changes as genesis.jsonc
|
||||
- Add kind 0 and kind 3 startup events
|
||||
|
||||
### Phase 7: Update documentation
|
||||
|
||||
#### `README.md`
|
||||
|
||||
- Remove kind 31120 from Didactyl Kinds table
|
||||
- Remove soul references
|
||||
- Update architecture description
|
||||
|
||||
#### `docs/GENESIS.md`
|
||||
|
||||
- Remove config.jsonc migration references
|
||||
- Update default_skill section to show `default_admin_dm`
|
||||
- Document kind 0 and kind 3 startup events
|
||||
|
||||
#### `docs/API.md`
|
||||
|
||||
- Remove `/api/events/soul` endpoint
|
||||
- Remove config.jsonc references
|
||||
|
||||
#### `docs/SKILLS.md`, `docs/CONTEXT.md`, `docs/TOOLS.md`
|
||||
|
||||
- Verify no soul/31120/config.jsonc references remain
|
||||
|
||||
---
|
||||
|
||||
## Variable Resolution for Default Skill
|
||||
|
||||
The default admin DM skill content uses:
|
||||
- `{{my_npub}}` — resolves via `map_variable_tool_name()` → `nostr_npub` tool → returns agent npub
|
||||
- `{{my_kind0_profile}}` — resolves via `map_variable_tool_name()` → `my_kind0_profile` tool → returns agent kind 0 profile context
|
||||
|
||||
These already work in `resolve_inline_variables_local()` because `map_variable_tool_name()` returns `NULL` for unknown names, and the skill lookup callback handles the rest. But `my_npub` and `my_kind0_profile` are not in the current `map_variable_tool_name()` mapping — they need to be added, OR the function needs to pass through unknown names as tool names (which it currently does NOT do after the Phase 1a change that made it return `NULL` for unknowns).
|
||||
|
||||
**Fix needed:** In `map_variable_tool_name()`, return the variable name itself as a tool name when it doesn't match a known alias. This way `{{my_npub}}` calls the `my_npub` tool directly.
|
||||
|
||||
Current (after Phase 1a change):
|
||||
```c
|
||||
return NULL; // unknown → try skill lookup
|
||||
```
|
||||
|
||||
Should be:
|
||||
```c
|
||||
return var_name; // unknown → try as tool name first, then skill lookup
|
||||
```
|
||||
|
||||
Wait — this was the ORIGINAL behavior before Phase 1a changed it. The Phase 1a change made `map_variable_tool_name()` return `NULL` for unknowns so the skill lookup callback would fire. But now we need BOTH: try as tool name first, then skill lookup.
|
||||
|
||||
**Resolution:** Restore `return var_name;` in `map_variable_tool_name()`. The skill lookup callback in `resolve_inline_variables_local()` already only fires when `replacement_owned` is NULL after the tool attempt. So the flow is:
|
||||
1. Check known aliases (admin_profile → nostr_admin_profile, etc.)
|
||||
2. Try the variable name as a direct tool name
|
||||
3. If tool returns nothing useful, try skill lookup callback
|
||||
4. If skill lookup returns nothing, resolve to empty string
|
||||
|
||||
This is exactly the resolution order specified in SKILLS.md.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Phase 3 — rename constants (safe, no behavioral change)
|
||||
2. Phase 5 — delete legacy files
|
||||
3. Phase 6 — update genesis files
|
||||
4. Phase 1 — remove template DSL
|
||||
5. Phase 2 — remove g_system_context
|
||||
6. Phase 4 — update agent_build_admin_messages_json
|
||||
7. Phase 7 — update docs
|
||||
8. Build and test
|
||||
9. `increment_and_push.sh -p` for v0.2.1
|
||||
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) {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
#include <time.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include "debug.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "nostr_block_list.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define CASHU_WALLET_QUERY_TIMEOUT_MS 6000
|
||||
@@ -44,27 +44,6 @@ typedef struct {
|
||||
|
||||
static cashu_wallet_state_t g_wallet = {0};
|
||||
|
||||
static const char* detect_ca_bundle_path_for_cashu_wallet(void) {
|
||||
const char* env = getenv("SSL_CERT_FILE");
|
||||
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
|
||||
return env;
|
||||
}
|
||||
|
||||
static const char* candidates[] = {
|
||||
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
|
||||
"/etc/ssl/cert.pem", // Alpine
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
|
||||
"/etc/ssl/ca-bundle.pem" // openSUSE
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
|
||||
if (access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void wallet_clear_tokens_locked(void) {
|
||||
if (!g_wallet.tokens) {
|
||||
@@ -418,7 +397,7 @@ int cashu_wallet_init(didactyl_config_t* cfg) {
|
||||
g_wallet.initialized = 1;
|
||||
}
|
||||
|
||||
const char* ca_bundle = detect_ca_bundle_path_for_cashu_wallet();
|
||||
const char* ca_bundle = nostr_http_detect_ca_bundle();
|
||||
cashu_mint_set_ca_bundle(ca_bundle);
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
@@ -1354,6 +1333,7 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
if (!resolved) return -1;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
int token_count_snapshot = g_wallet.token_count;
|
||||
int target_proofs = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
|
||||
@@ -1371,6 +1351,9 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
cJSON_AddNumberToObject(root, "proof_count", 0);
|
||||
cJSON_AddNumberToObject(root, "spent_count", 0);
|
||||
cJSON_AddNumberToObject(root, "unknown_count", 0);
|
||||
cJSON_AddNumberToObject(root, "deleted_token_events", 0);
|
||||
cJSON_AddNumberToObject(root, "ignored_token_events", 0);
|
||||
cJSON_AddNumberToObject(root, "remaining_balance", 0);
|
||||
*out_json = root;
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return 0;
|
||||
@@ -1378,9 +1361,15 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
|
||||
const char** ys = (const char**)calloc((size_t)target_proofs, sizeof(char*));
|
||||
char (*y_hexes)[67] = (char(*)[67])calloc((size_t)target_proofs, sizeof(*y_hexes));
|
||||
if (!ys || !y_hexes) {
|
||||
int* proof_to_token_idx = (int*)calloc((size_t)target_proofs, sizeof(int));
|
||||
int* token_total_counts = (int*)calloc((size_t)token_count_snapshot, sizeof(int));
|
||||
int* token_spent_counts = (int*)calloc((size_t)token_count_snapshot, sizeof(int));
|
||||
if (!ys || !y_hexes || !proof_to_token_idx || !token_total_counts || !token_spent_counts) {
|
||||
free(ys);
|
||||
free(y_hexes);
|
||||
free(proof_to_token_idx);
|
||||
free(token_total_counts);
|
||||
free(token_spent_counts);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
@@ -1393,10 +1382,15 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
if (!secret || cashu_secret_to_y_hex(secret, y_hexes[y_idx]) != 0) {
|
||||
free(ys);
|
||||
free(y_hexes);
|
||||
free(proof_to_token_idx);
|
||||
free(token_total_counts);
|
||||
free(token_spent_counts);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
return -1;
|
||||
}
|
||||
ys[y_idx] = y_hexes[y_idx];
|
||||
proof_to_token_idx[y_idx] = i;
|
||||
token_total_counts[i]++;
|
||||
y_idx++;
|
||||
}
|
||||
}
|
||||
@@ -1406,6 +1400,9 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
if (cashu_build_checkstate_request(ys, y_idx, &request_body) != 0 || !request_body) {
|
||||
free(ys);
|
||||
free(y_hexes);
|
||||
free(proof_to_token_idx);
|
||||
free(token_total_counts);
|
||||
free(token_spent_counts);
|
||||
cJSON_Delete(request_body);
|
||||
return -1;
|
||||
}
|
||||
@@ -1416,6 +1413,9 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
free(ys);
|
||||
free(y_hexes);
|
||||
if (rc != 0 || !response_body) {
|
||||
free(proof_to_token_idx);
|
||||
free(token_total_counts);
|
||||
free(token_spent_counts);
|
||||
cJSON_Delete(response_body);
|
||||
return -1;
|
||||
}
|
||||
@@ -1425,22 +1425,71 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
rc = cashu_parse_checkstate_response(response_body, &parsed);
|
||||
cJSON_Delete(response_body);
|
||||
if (rc != 0) {
|
||||
free(proof_to_token_idx);
|
||||
free(token_total_counts);
|
||||
free(token_spent_counts);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int spent_count = 0;
|
||||
int unknown_count = 0;
|
||||
for (int i = 0; i < parsed.state_count; i++) {
|
||||
int state_n = parsed.state_count < y_idx ? parsed.state_count : y_idx;
|
||||
for (int i = 0; i < state_n; i++) {
|
||||
if (strcasecmp(parsed.states[i].state, "SPENT") == 0) {
|
||||
spent_count++;
|
||||
int token_idx = proof_to_token_idx[i];
|
||||
if (token_idx >= 0 && token_idx < token_count_snapshot) {
|
||||
token_spent_counts[token_idx]++;
|
||||
}
|
||||
} else if (strcasecmp(parsed.states[i].state, "UNSPENT") != 0) {
|
||||
unknown_count++;
|
||||
}
|
||||
}
|
||||
|
||||
int deleted_token_events = 0;
|
||||
int ignored_token_events = 0;
|
||||
uint64_t remaining = 0;
|
||||
|
||||
pthread_mutex_lock(&g_wallet.mutex);
|
||||
for (int i = 0; i < g_wallet.token_count && i < token_count_snapshot; i++) {
|
||||
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
|
||||
if (token_total_counts[i] <= 0) continue;
|
||||
if (token_spent_counts[i] < token_total_counts[i]) continue;
|
||||
|
||||
if (g_wallet.tokens[i].event_id[0] != '\0') {
|
||||
if (publish_token_deletion_event(g_wallet.tokens[i].event_id) == 0) {
|
||||
deleted_token_events++;
|
||||
}
|
||||
if (nostr_block_list_add("e", g_wallet.tokens[i].event_id, 0) == 0) {
|
||||
ignored_token_events++;
|
||||
}
|
||||
}
|
||||
|
||||
nostr_nip60_free_token_data(&g_wallet.tokens[i].token);
|
||||
memset(&g_wallet.tokens[i], 0, sizeof(g_wallet.tokens[i]));
|
||||
}
|
||||
|
||||
int compact = 0;
|
||||
for (int i = 0; i < g_wallet.token_count; i++) {
|
||||
if (g_wallet.tokens[i].event_id[0] == '\0' && !g_wallet.tokens[i].token.mint_url) continue;
|
||||
if (compact != i) g_wallet.tokens[compact] = g_wallet.tokens[i];
|
||||
compact++;
|
||||
}
|
||||
g_wallet.token_count = compact;
|
||||
if (g_wallet.token_count == 0) {
|
||||
free(g_wallet.tokens);
|
||||
g_wallet.tokens = NULL;
|
||||
}
|
||||
|
||||
token_proofs_total_for_mint_locked(resolved, &remaining);
|
||||
pthread_mutex_unlock(&g_wallet.mutex);
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
cashu_mint_free_checkstate_response(&parsed);
|
||||
free(proof_to_token_idx);
|
||||
free(token_total_counts);
|
||||
free(token_spent_counts);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -1449,10 +1498,16 @@ int cashu_wallet_check_proofs(const char* mint_url, cJSON** out_json) {
|
||||
cJSON_AddNumberToObject(root, "proof_count", y_idx);
|
||||
cJSON_AddNumberToObject(root, "spent_count", spent_count);
|
||||
cJSON_AddNumberToObject(root, "unknown_count", unknown_count);
|
||||
cJSON_AddNumberToObject(root, "deleted_token_events", deleted_token_events);
|
||||
cJSON_AddNumberToObject(root, "ignored_token_events", ignored_token_events);
|
||||
cJSON_AddNumberToObject(root, "remaining_balance", (double)remaining);
|
||||
|
||||
*out_json = root;
|
||||
|
||||
cashu_mint_free_checkstate_response(&parsed);
|
||||
free(proof_to_token_idx);
|
||||
free(token_total_counts);
|
||||
free(token_spent_counts);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
18
src/config.c
18
src/config.c
@@ -230,6 +230,10 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds");
|
||||
cJSON* local_http_fetch_max_timeout_seconds =
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_max_timeout_seconds");
|
||||
cJSON* blossom_max_upload_bytes =
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "blossom_max_upload_bytes");
|
||||
cJSON* blossom_max_download_bytes =
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "blossom_max_download_bytes");
|
||||
if (enabled && cJSON_IsBool(enabled)) {
|
||||
config->tools.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
|
||||
}
|
||||
@@ -256,6 +260,12 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
config->tools.local_http_fetch_max_timeout_seconds =
|
||||
(int)local_http_fetch_max_timeout_seconds->valuedouble;
|
||||
}
|
||||
if (blossom_max_upload_bytes && cJSON_IsNumber(blossom_max_upload_bytes)) {
|
||||
config->tools.blossom_max_upload_bytes = (int)blossom_max_upload_bytes->valuedouble;
|
||||
}
|
||||
if (blossom_max_download_bytes && cJSON_IsNumber(blossom_max_download_bytes)) {
|
||||
config->tools.blossom_max_download_bytes = (int)blossom_max_download_bytes->valuedouble;
|
||||
}
|
||||
|
||||
cJSON* shell = cJSON_GetObjectItemCaseSensitive(tools, "shell");
|
||||
if (!shell || !cJSON_IsObject(shell)) {
|
||||
@@ -310,6 +320,12 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (config->tools.local_http_fetch_default_timeout_seconds > config->tools.local_http_fetch_max_timeout_seconds) {
|
||||
config->tools.local_http_fetch_default_timeout_seconds = config->tools.local_http_fetch_max_timeout_seconds;
|
||||
}
|
||||
if (config->tools.blossom_max_upload_bytes < 1024) {
|
||||
config->tools.blossom_max_upload_bytes = 16 * 1024 * 1024;
|
||||
}
|
||||
if (config->tools.blossom_max_download_bytes < 1024) {
|
||||
config->tools.blossom_max_download_bytes = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1231,6 +1247,8 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->tools.stall_repeat_threshold = 3;
|
||||
config->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
config->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
config->tools.blossom_max_upload_bytes = 16 * 1024 * 1024;
|
||||
config->tools.blossom_max_download_bytes = 16 * 1024 * 1024;
|
||||
config->tools.shell.enabled = 1;
|
||||
config->tools.shell.timeout_seconds = 30;
|
||||
config->tools.shell.max_output_bytes = 65536;
|
||||
|
||||
@@ -54,6 +54,8 @@ typedef struct {
|
||||
int local_http_fetch_default_timeout_seconds;
|
||||
int local_http_fetch_max_timeout_seconds;
|
||||
shell_tools_config_t shell;
|
||||
int blossom_max_upload_bytes;
|
||||
int blossom_max_download_bytes;
|
||||
} tools_config_t;
|
||||
|
||||
typedef struct {
|
||||
|
||||
137
src/llm.c
137
src/llm.c
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "llm.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -12,60 +11,11 @@
|
||||
#include "cjson/cJSON.h"
|
||||
#include "debug.h"
|
||||
|
||||
typedef struct {
|
||||
char* data;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
} response_buffer_t;
|
||||
#include "../nostr_core_lib/nostr_core/nostr_http.h"
|
||||
|
||||
static llm_config_t g_cfg;
|
||||
static int g_initialized = 0;
|
||||
|
||||
static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
response_buffer_t* rb = (response_buffer_t*)userp;
|
||||
size_t total = size * nmemb;
|
||||
|
||||
if (rb->len + total + 1U > rb->cap) {
|
||||
size_t new_cap = rb->cap == 0 ? 1024U : rb->cap * 2U;
|
||||
while (new_cap < rb->len + total + 1U) {
|
||||
new_cap *= 2U;
|
||||
}
|
||||
char* p = (char*)realloc(rb->data, new_cap);
|
||||
if (!p) {
|
||||
return 0;
|
||||
}
|
||||
rb->data = p;
|
||||
rb->cap = new_cap;
|
||||
}
|
||||
|
||||
memcpy(rb->data + rb->len, contents, total);
|
||||
rb->len += total;
|
||||
rb->data[rb->len] = '\0';
|
||||
return total;
|
||||
}
|
||||
|
||||
static const char* detect_ca_bundle_path(void) {
|
||||
const char* env = getenv("SSL_CERT_FILE");
|
||||
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
|
||||
return env;
|
||||
}
|
||||
|
||||
static const char* candidates[] = {
|
||||
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
|
||||
"/etc/ssl/cert.pem", // Alpine
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
|
||||
"/etc/ssl/ca-bundle.pem" // openSUSE
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
|
||||
if (access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int url_looks_like_websocket(const char* url) {
|
||||
if (!url) return 0;
|
||||
return (strncmp(url, "ws://", 5) == 0) || (strncmp(url, "wss://", 6) == 0);
|
||||
@@ -86,9 +36,7 @@ static int json_string_is_blank(const cJSON* item) {
|
||||
}
|
||||
|
||||
static char* perform_http_request(const char* url, const char* body, int is_post) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl || !url) {
|
||||
if (curl) curl_easy_cleanup(curl);
|
||||
if (!url) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -96,33 +44,30 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
DEBUG_ERROR("[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s",
|
||||
url);
|
||||
DEBUG_WARN("[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1");
|
||||
curl_easy_cleanup(curl);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
response_buffer_t rb = {0};
|
||||
struct curl_slist* headers = NULL;
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
|
||||
char auth_header[OW_MAX_KEY_LEN + 32];
|
||||
snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", g_cfg.api_key);
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
const char* headers[] = {
|
||||
"Content-Type: application/json",
|
||||
auth_header,
|
||||
NULL
|
||||
};
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L);
|
||||
nostr_http_request_t req;
|
||||
memset(&req, 0, sizeof(req));
|
||||
req.method = is_post ? "POST" : "GET";
|
||||
req.url = url;
|
||||
req.headers = headers;
|
||||
req.timeout_seconds = 60;
|
||||
req.follow_redirects = 1;
|
||||
req.max_redirects = 3;
|
||||
req.user_agent = "didactyl/llm";
|
||||
if (is_post) {
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}");
|
||||
}
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
|
||||
const char* ca_bundle = detect_ca_bundle_path();
|
||||
if (ca_bundle) {
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
|
||||
const char* payload = body ? body : "{}";
|
||||
req.body = (const unsigned char*)payload;
|
||||
req.body_len = strlen(payload);
|
||||
}
|
||||
|
||||
if (is_post) {
|
||||
@@ -137,44 +82,38 @@ static char* perform_http_request(const char* url, const char* body, int is_post
|
||||
DEBUG_INFO("[didactyl] llm request: method=GET url=%s", url);
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: curl=%s", curl_easy_strerror(res));
|
||||
if (rb.data && rb.len > 0) {
|
||||
DEBUG_WARN("[didactyl] llm partial response: %.600s%s",
|
||||
rb.data,
|
||||
rb.len > 600 ? "..." : "");
|
||||
}
|
||||
free(rb.data);
|
||||
nostr_http_response_t resp;
|
||||
int rc = nostr_http_request(&req, &resp);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: transport error rc=%d", rc);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", status);
|
||||
if (status == 101) {
|
||||
if (resp.status_code < 200 || resp.status_code >= 300) {
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", resp.status_code);
|
||||
if (resp.status_code == 101) {
|
||||
DEBUG_WARN("[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API");
|
||||
}
|
||||
if (rb.data && rb.len > 0) {
|
||||
if (resp.body && resp.body_len > 0) {
|
||||
DEBUG_WARN("[didactyl] llm error response: %.1200s%s",
|
||||
rb.data,
|
||||
rb.len > 1200 ? "..." : "");
|
||||
resp.body,
|
||||
resp.body_len > 1200 ? "..." : "");
|
||||
}
|
||||
free(rb.data);
|
||||
nostr_http_response_free(&resp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!rb.data) {
|
||||
if (!resp.body) {
|
||||
DEBUG_ERROR("[didactyl] llm http request failed: empty response body");
|
||||
nostr_http_response_free(&resp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return rb.data;
|
||||
char* out = resp.body;
|
||||
free(resp.content_type);
|
||||
free(resp.headers_raw);
|
||||
memset(&resp, 0, sizeof(resp));
|
||||
return out;
|
||||
}
|
||||
|
||||
static char* perform_chat_request(const char* body) {
|
||||
@@ -315,7 +254,6 @@ int llm_init(const llm_config_t* config) {
|
||||
}
|
||||
memset(&g_cfg, 0, sizeof(g_cfg));
|
||||
g_cfg = *config;
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
g_initialized = 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -512,7 +450,6 @@ void llm_cleanup(void) {
|
||||
if (!g_initialized) {
|
||||
return;
|
||||
}
|
||||
curl_global_cleanup();
|
||||
memset(&g_cfg, 0, sizeof(g_cfg));
|
||||
g_initialized = 0;
|
||||
}
|
||||
|
||||
28
src/main.c
28
src/main.c
@@ -7,7 +7,7 @@
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "main.h"
|
||||
#include "agent.h"
|
||||
#include "config.h"
|
||||
@@ -1066,6 +1066,13 @@ int main(int argc, char** argv) {
|
||||
snprintf(cfg.api.bind_address, sizeof(cfg.api.bind_address), "%s", api_bind_override);
|
||||
}
|
||||
|
||||
{
|
||||
const char* ca_bundle = nostr_http_detect_ca_bundle();
|
||||
if (ca_bundle && ca_bundle[0] != '\0') {
|
||||
nostr_http_set_ca_bundle(ca_bundle);
|
||||
}
|
||||
}
|
||||
|
||||
if (dump_schemas || test_tool_name) {
|
||||
if (llm_init(&cfg.llm) != 0) {
|
||||
fprintf(stderr, "Failed to initialize llm client\n");
|
||||
@@ -1102,6 +1109,7 @@ int main(int argc, char** argv) {
|
||||
if (cashu_wallet_init(&cfg) == 0) {
|
||||
wallet_initialized = 1;
|
||||
if (cfg.cashu_wallet.enabled) {
|
||||
(void)nostr_handler_subscribe_wallet_events();
|
||||
int wallet_rc = 0;
|
||||
if (cfg.cashu_wallet.auto_load) {
|
||||
wallet_rc = cashu_wallet_load_from_relays();
|
||||
@@ -1420,10 +1428,18 @@ int main(int argc, char** argv) {
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe DMs end");
|
||||
startup_step_ok(15, "Subscribe DMs", NULL);
|
||||
|
||||
startup_step_begin(16, "Initialize cashu wallet");
|
||||
startup_step_begin(16, "Subscribe wallet events");
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe wallet events begin");
|
||||
if (nostr_handler_subscribe_wallet_events() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: subscribe wallet events failed (continuing)");
|
||||
}
|
||||
DEBUG_INFO("[didactyl] startup phase: subscribe wallet events end");
|
||||
startup_step_ok(16, "Subscribe wallet events", NULL);
|
||||
|
||||
startup_step_begin(17, "Initialize cashu wallet");
|
||||
if (cashu_wallet_init(&cfg) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: cashu_wallet_init failed (continuing without wallet)");
|
||||
startup_step_fail(16, "Initialize cashu wallet", "cashu_wallet_init failed; wallet tools may return errors");
|
||||
startup_step_fail(17, "Initialize cashu wallet", "cashu_wallet_init failed; wallet tools may return errors");
|
||||
} else {
|
||||
int wallet_rc = 0;
|
||||
if (cfg.cashu_wallet.auto_load) {
|
||||
@@ -1434,9 +1450,9 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
if (wallet_rc != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: cashu wallet load/create deferred to first tool call");
|
||||
startup_step_ok(16, "Initialize cashu wallet", "initialized; load/create deferred");
|
||||
startup_step_ok(17, "Initialize cashu wallet", "initialized; load/create deferred");
|
||||
} else {
|
||||
startup_step_ok(16, "Initialize cashu wallet", cfg.cashu_wallet.auto_load ? "loaded or created" : "initialized");
|
||||
startup_step_ok(17, "Initialize cashu wallet", cfg.cashu_wallet.auto_load ? "loaded or created" : "initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1476,7 +1492,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
nostr_handler_refresh_relay_statuses();
|
||||
|
||||
startup_step_ok(17, "READY", "agent online; entering main poll loop");
|
||||
startup_step_ok(18, "READY", "agent online; entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
|
||||
|
||||
|
||||
@@ -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 15
|
||||
#define DIDACTYL_VERSION "v0.1.15"
|
||||
#define DIDACTYL_VERSION_MINOR 2
|
||||
#define DIDACTYL_VERSION_PATCH 1
|
||||
#define DIDACTYL_VERSION "v0.2.1"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "debug.h"
|
||||
#include "trigger_manager.h"
|
||||
#include "nostr_block_list.h"
|
||||
#include "cashu_wallet.h"
|
||||
|
||||
#define NIP17_MAX_RELAYS 32
|
||||
#define NIP17_MAX_GIFT_WRAPS 8
|
||||
@@ -61,6 +62,7 @@ typedef enum {
|
||||
MANAGED_SUB_ADMIN_SKILLS,
|
||||
MANAGED_SUB_DM_KIND4,
|
||||
MANAGED_SUB_DM_KIND1059,
|
||||
MANAGED_SUB_WALLET,
|
||||
MANAGED_SUB_COUNT
|
||||
} managed_subscription_id_t;
|
||||
|
||||
@@ -404,6 +406,7 @@ static void load_startup_display_name(void);
|
||||
static void build_startup_kind1_content(char* out, size_t out_size, const char* fallback);
|
||||
static void register_trigger_from_self_skill_event(cJSON* event);
|
||||
static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data);
|
||||
static void on_wallet_event(cJSON* event, const char* relay_url, void* user_data);
|
||||
static void dm_history_clear_locked(void);
|
||||
static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at);
|
||||
static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url);
|
||||
@@ -591,6 +594,7 @@ static void managed_subscriptions_init_defaults(void) {
|
||||
g_managed_subs[MANAGED_SUB_ADMIN_SKILLS].name = "admin_skills";
|
||||
g_managed_subs[MANAGED_SUB_DM_KIND4].name = "dms_kind4";
|
||||
g_managed_subs[MANAGED_SUB_DM_KIND1059].name = "dms_kind1059";
|
||||
g_managed_subs[MANAGED_SUB_WALLET].name = "wallet_events";
|
||||
|
||||
g_managed_subs_initialized = 1;
|
||||
}
|
||||
@@ -2158,6 +2162,13 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
|
||||
pthread_mutex_unlock(&g_self_skill_mutex);
|
||||
}
|
||||
|
||||
static void on_wallet_event(cJSON* event, const char* relay_url, void* user_data) {
|
||||
(void)event;
|
||||
(void)relay_url;
|
||||
(void)user_data;
|
||||
(void)cashu_wallet_load_from_relays();
|
||||
}
|
||||
|
||||
int nostr_handler_init(didactyl_config_t* config) {
|
||||
if (!config) {
|
||||
return -1;
|
||||
@@ -2634,6 +2645,57 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int nostr_handler_subscribe_wallet_events(void) {
|
||||
if (!g_cfg || !g_pool) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP60_WALLET_KIND));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP60_TOKEN_KIND));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(5));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "since", (double)g_start_time);
|
||||
cJSON_AddNumberToObject(filter, "limit", 500);
|
||||
|
||||
int rc = 0;
|
||||
pthread_mutex_lock(&g_subscription_mutex);
|
||||
managed_subscriptions_init_defaults();
|
||||
if (managed_subscription_register_locked(MANAGED_SUB_WALLET,
|
||||
filter,
|
||||
on_wallet_event,
|
||||
NULL,
|
||||
NULL,
|
||||
0,
|
||||
0,
|
||||
NOSTR_POOL_EOSE_FULL_SET,
|
||||
30,
|
||||
120,
|
||||
1) != 0) {
|
||||
rc = -1;
|
||||
}
|
||||
pthread_mutex_unlock(&g_subscription_mutex);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (rc != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] wallet event subscription active for pubkey %.16s...", g_cfg->keys.public_key_hex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data) {
|
||||
g_self_skill_eose_cb = callback;
|
||||
g_self_skill_eose_user_data = user_data;
|
||||
|
||||
@@ -40,6 +40,7 @@ int nostr_handler_subscribe_self_skills(void);
|
||||
char* nostr_handler_get_self_events_by_kind_json(int kind);
|
||||
void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data);
|
||||
int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data);
|
||||
int nostr_handler_subscribe_wallet_events(void);
|
||||
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
|
||||
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,8 @@ static void config_set_defaults(didactyl_config_t* cfg) {
|
||||
cfg->tools.stall_repeat_threshold = 3;
|
||||
cfg->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
cfg->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
cfg->tools.blossom_max_upload_bytes = 16 * 1024 * 1024;
|
||||
cfg->tools.blossom_max_download_bytes = 16 * 1024 * 1024;
|
||||
cfg->tools.shell.enabled = 1;
|
||||
cfg->tools.shell.timeout_seconds = 30;
|
||||
cfg->tools.shell.max_output_bytes = 65536;
|
||||
|
||||
304
src/tools/tool_blossom.c
Normal file
304
src/tools/tool_blossom.c
Normal file
@@ -0,0 +1,304 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../../nostr_core_lib/nostr_core/blossom_client.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) return NULL;
|
||||
cJSON_AddBoolToObject(root, "success", 0);
|
||||
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
|
||||
char* out = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
static cJSON* parse_args_local(const char* args_json) {
|
||||
const char* raw = args_json ? args_json : "{}";
|
||||
cJSON* args = cJSON_Parse(raw);
|
||||
if (!args || !cJSON_IsObject(args)) {
|
||||
cJSON_Delete(args);
|
||||
return NULL;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
static int is_safe_relative_path_local(const char* path) {
|
||||
if (!path || path[0] == '\0') return 0;
|
||||
if (path[0] == '/') return 0;
|
||||
if (strstr(path, "..") != NULL) return 0;
|
||||
if (strchr(path, '\\') != NULL) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) {
|
||||
if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1;
|
||||
if (!is_safe_relative_path_local(rel_path)) return -1;
|
||||
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0' ? ctx->cfg->tools.shell.working_directory : ".";
|
||||
int n = (strcmp(cwd, ".") == 0) ? snprintf(out, out_size, "%s", rel_path)
|
||||
: snprintf(out, out_size, "%s/%s", cwd, rel_path);
|
||||
if (n < 0 || (size_t)n >= out_size) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int is_https_server_local(const char* server) {
|
||||
return server && strncmp(server, "https://", 8) == 0;
|
||||
}
|
||||
|
||||
static int file_exists_local(const char* path) {
|
||||
if (!path || path[0] == '\0') return 0;
|
||||
return access(path, F_OK) == 0;
|
||||
}
|
||||
|
||||
static int file_size_local(const char* path, size_t* out_size) {
|
||||
if (!path || !out_size) return -1;
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) return -1;
|
||||
if (st.st_size < 0) return -1;
|
||||
*out_size = (size_t)st.st_size;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void descriptor_to_json(cJSON* obj, const blossom_blob_descriptor_t* d) {
|
||||
if (!obj || !d) return;
|
||||
cJSON_AddStringToObject(obj, "sha256", d->sha256);
|
||||
cJSON_AddStringToObject(obj, "url", d->url);
|
||||
cJSON_AddNumberToObject(obj, "size", d->size);
|
||||
cJSON_AddStringToObject(obj, "content_type", d->content_type);
|
||||
cJSON_AddNumberToObject(obj, "created", d->created);
|
||||
}
|
||||
|
||||
char* execute_blossom_upload(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server");
|
||||
cJSON* file_path = cJSON_GetObjectItemCaseSensitive(args, "file_path");
|
||||
cJSON* content_type = cJSON_GetObjectItemCaseSensitive(args, "content_type");
|
||||
|
||||
if (!server || !cJSON_IsString(server) || !server->valuestring || server->valuestring[0] == '\0' ||
|
||||
!file_path || !cJSON_IsString(file_path) || !file_path->valuestring || file_path->valuestring[0] == '\0') {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_upload requires server and file_path");
|
||||
}
|
||||
|
||||
if (!is_https_server_local(server->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_upload requires an https:// server URL");
|
||||
}
|
||||
|
||||
char full_path[PATH_MAX];
|
||||
if (build_tool_path_local(ctx, file_path->valuestring, full_path, sizeof(full_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_upload file_path is not allowed");
|
||||
}
|
||||
|
||||
size_t input_size = 0;
|
||||
if (file_size_local(full_path, &input_size) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_upload could not stat file_path");
|
||||
}
|
||||
|
||||
int upload_max = ctx->cfg->tools.blossom_max_upload_bytes > 0 ? ctx->cfg->tools.blossom_max_upload_bytes : (16 * 1024 * 1024);
|
||||
if (input_size > (size_t)upload_max) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_upload file exceeds configured max upload size");
|
||||
}
|
||||
|
||||
blossom_blob_descriptor_t d;
|
||||
int rc = blossom_upload_file(server->valuestring,
|
||||
full_path,
|
||||
(content_type && cJSON_IsString(content_type) && content_type->valuestring) ? content_type->valuestring : NULL,
|
||||
ctx->cfg->keys.private_key,
|
||||
30,
|
||||
&d);
|
||||
cJSON_Delete(args);
|
||||
if (rc != NOSTR_SUCCESS) return json_error_local("blossom_upload failed");
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "server", server->valuestring);
|
||||
descriptor_to_json(out, &d);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_blossom_download(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server");
|
||||
cJSON* sha = cJSON_GetObjectItemCaseSensitive(args, "sha256");
|
||||
cJSON* output_path = cJSON_GetObjectItemCaseSensitive(args, "output_path");
|
||||
|
||||
if (!server || !cJSON_IsString(server) || !server->valuestring || server->valuestring[0] == '\0' ||
|
||||
!sha || !cJSON_IsString(sha) || !sha->valuestring ||
|
||||
!output_path || !cJSON_IsString(output_path) || !output_path->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_download requires server, sha256, and output_path");
|
||||
}
|
||||
|
||||
if (!is_https_server_local(server->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_download requires an https:// server URL");
|
||||
}
|
||||
|
||||
char full_path[PATH_MAX];
|
||||
if (build_tool_path_local(ctx, output_path->valuestring, full_path, sizeof(full_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_download output_path is not allowed");
|
||||
}
|
||||
|
||||
cJSON* overwrite = cJSON_GetObjectItemCaseSensitive(args, "overwrite");
|
||||
int allow_overwrite = (overwrite && cJSON_IsBool(overwrite) && cJSON_IsTrue(overwrite)) ? 1 : 0;
|
||||
if (!allow_overwrite && file_exists_local(full_path)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_download output_path exists (set overwrite=true to replace)");
|
||||
}
|
||||
|
||||
int download_max = ctx->cfg->tools.blossom_max_download_bytes > 0 ? ctx->cfg->tools.blossom_max_download_bytes : (16 * 1024 * 1024);
|
||||
blossom_blob_descriptor_t d;
|
||||
int rc = blossom_download_to_file(server->valuestring, sha->valuestring, full_path, 30, (size_t)download_max, &d);
|
||||
cJSON_Delete(args);
|
||||
if (rc != NOSTR_SUCCESS) return json_error_local("blossom_download failed");
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "output_path", full_path);
|
||||
descriptor_to_json(out, &d);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_blossom_head(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server");
|
||||
cJSON* sha = cJSON_GetObjectItemCaseSensitive(args, "sha256");
|
||||
if (!server || !cJSON_IsString(server) || !server->valuestring || !sha || !cJSON_IsString(sha) || !sha->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_head requires server and sha256");
|
||||
}
|
||||
|
||||
if (!is_https_server_local(server->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_head requires an https:// server URL");
|
||||
}
|
||||
|
||||
blossom_blob_descriptor_t d;
|
||||
int rc = blossom_head(server->valuestring, sha->valuestring, 15, &d);
|
||||
cJSON_Delete(args);
|
||||
if (rc != NOSTR_SUCCESS) return json_error_local("blossom_head failed");
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddBoolToObject(out, "exists", 1);
|
||||
descriptor_to_json(out, &d);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_blossom_delete(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server");
|
||||
cJSON* sha = cJSON_GetObjectItemCaseSensitive(args, "sha256");
|
||||
if (!server || !cJSON_IsString(server) || !server->valuestring || !sha || !cJSON_IsString(sha) || !sha->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_delete requires server and sha256");
|
||||
}
|
||||
|
||||
if (!is_https_server_local(server->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_delete requires an https:// server URL");
|
||||
}
|
||||
|
||||
int rc = blossom_delete(server->valuestring, sha->valuestring, ctx->cfg->keys.private_key, 15);
|
||||
cJSON_Delete(args);
|
||||
if (rc != NOSTR_SUCCESS) return json_error_local("blossom_delete failed");
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddBoolToObject(out, "deleted", 1);
|
||||
cJSON_AddStringToObject(out, "sha256", sha->valuestring);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
|
||||
char* execute_blossom_list(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
cJSON* args = parse_args_local(args_json);
|
||||
if (!args) return json_error_local("invalid arguments JSON");
|
||||
|
||||
cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server");
|
||||
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(args, "pubkey");
|
||||
if (!server || !cJSON_IsString(server) || !server->valuestring) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_list requires server");
|
||||
}
|
||||
|
||||
if (!is_https_server_local(server->valuestring)) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("blossom_list requires an https:// server URL");
|
||||
}
|
||||
|
||||
const char* pk = (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring && pubkey->valuestring[0] != '\0')
|
||||
? pubkey->valuestring
|
||||
: ctx->cfg->keys.public_key_hex;
|
||||
|
||||
blossom_blob_descriptor_t* items = NULL;
|
||||
int count = 0;
|
||||
int rc = blossom_list(server->valuestring, pk, 20, &items, &count);
|
||||
cJSON_Delete(args);
|
||||
if (rc != NOSTR_SUCCESS) return json_error_local("blossom_list failed");
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
cJSON* arr = cJSON_CreateArray();
|
||||
if (!out || !arr) {
|
||||
free(items);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(arr);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "pubkey", pk);
|
||||
cJSON_AddNumberToObject(out, "count", count);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON* it = cJSON_CreateObject();
|
||||
if (!it) continue;
|
||||
descriptor_to_json(it, &items[i]);
|
||||
cJSON_AddItemToArray(arr, it);
|
||||
}
|
||||
free(items);
|
||||
|
||||
cJSON_AddItemToObject(out, "blobs", arr);
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
return json;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -13,13 +12,7 @@
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
|
||||
typedef struct {
|
||||
char* data;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
size_t max_bytes;
|
||||
int truncated;
|
||||
} local_http_fetch_buffer_t;
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_http.h"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
@@ -102,63 +95,6 @@ static char* shell_quote_single_local(const char* in) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static size_t local_http_fetch_write_cb_local(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||
local_http_fetch_buffer_t* rb = (local_http_fetch_buffer_t*)userp;
|
||||
size_t total = size * nmemb;
|
||||
if (!rb || total == 0) return total;
|
||||
|
||||
if (rb->len >= rb->max_bytes) {
|
||||
rb->truncated = 1;
|
||||
return total;
|
||||
}
|
||||
|
||||
size_t allowed = rb->max_bytes - rb->len;
|
||||
size_t to_copy = total <= allowed ? total : allowed;
|
||||
|
||||
if (rb->len + to_copy + 1U > rb->cap) {
|
||||
size_t new_cap = rb->cap == 0 ? 1024U : rb->cap;
|
||||
while (new_cap < rb->len + to_copy + 1U) {
|
||||
new_cap *= 2U;
|
||||
}
|
||||
char* bigger = (char*)realloc(rb->data, new_cap);
|
||||
if (!bigger) return 0;
|
||||
rb->data = bigger;
|
||||
rb->cap = new_cap;
|
||||
}
|
||||
|
||||
memcpy(rb->data + rb->len, contents, to_copy);
|
||||
rb->len += to_copy;
|
||||
rb->data[rb->len] = '\0';
|
||||
|
||||
if (to_copy < total) {
|
||||
rb->truncated = 1;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
static const char* detect_ca_bundle_path_for_tools_local(void) {
|
||||
const char* env = getenv("SSL_CERT_FILE");
|
||||
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
|
||||
return env;
|
||||
}
|
||||
|
||||
static const char* candidates[] = {
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
"/etc/ssl/cert.pem",
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/ssl/ca-bundle.pem"
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
|
||||
if (access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json) {
|
||||
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
|
||||
|
||||
@@ -212,104 +148,87 @@ char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json) {
|
||||
int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max;
|
||||
if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max;
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_http_fetch failed to initialize curl");
|
||||
const char* default_header = "Accept: */*";
|
||||
int hdr_count = 1;
|
||||
if (headers && cJSON_IsArray(headers)) {
|
||||
hdr_count += cJSON_GetArraySize(headers);
|
||||
}
|
||||
|
||||
local_http_fetch_buffer_t rb;
|
||||
memset(&rb, 0, sizeof(rb));
|
||||
rb.max_bytes = (size_t)max_bytes;
|
||||
|
||||
struct curl_slist* req_headers = NULL;
|
||||
req_headers = curl_slist_append(req_headers, "Accept: */*");
|
||||
|
||||
const char** req_headers = (const char**)calloc((size_t)hdr_count + 1U, sizeof(const char*));
|
||||
if (!req_headers) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("local_http_fetch allocation failure");
|
||||
}
|
||||
int hi = 0;
|
||||
req_headers[hi++] = default_header;
|
||||
if (headers && cJSON_IsArray(headers)) {
|
||||
int n = cJSON_GetArraySize(headers);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* h = cJSON_GetArrayItem(headers, i);
|
||||
if (h && cJSON_IsString(h) && h->valuestring && h->valuestring[0] != '\0') {
|
||||
req_headers = curl_slist_append(req_headers, h->valuestring);
|
||||
req_headers[hi++] = h->valuestring;
|
||||
}
|
||||
}
|
||||
}
|
||||
req_headers[hi] = NULL;
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url->valuestring);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)timeout_seconds);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, local_http_fetch_write_cb_local);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, req_headers);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "didactyl/local_http_fetch");
|
||||
|
||||
const char* ca_bundle = detect_ca_bundle_path_for_tools_local();
|
||||
if (ca_bundle) {
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
|
||||
nostr_http_request_t req;
|
||||
memset(&req, 0, sizeof(req));
|
||||
req.method = method_str;
|
||||
req.url = url->valuestring;
|
||||
req.headers = req_headers;
|
||||
req.timeout_seconds = timeout_seconds;
|
||||
req.max_response_bytes = (size_t)max_bytes;
|
||||
req.follow_redirects = 1;
|
||||
req.max_redirects = 3;
|
||||
req.user_agent = "didactyl/local_http_fetch";
|
||||
if (body && cJSON_IsString(body) && body->valuestring) {
|
||||
req.body = (const unsigned char*)body->valuestring;
|
||||
req.body_len = strlen(body->valuestring);
|
||||
}
|
||||
|
||||
if (strcasecmp(method_str, "GET") == 0) {
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
|
||||
} else if (strcasecmp(method_str, "POST") == 0) {
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
if (body && cJSON_IsString(body) && body->valuestring) {
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring));
|
||||
}
|
||||
} else {
|
||||
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method_str);
|
||||
if (body && cJSON_IsString(body) && body->valuestring) {
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring));
|
||||
}
|
||||
}
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status_code = 0;
|
||||
char* content_type = NULL;
|
||||
char* content_type_copy = NULL;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code);
|
||||
curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type && content_type[0] != '\0') {
|
||||
content_type_copy = strdup(content_type);
|
||||
}
|
||||
|
||||
curl_slist_free_all(req_headers);
|
||||
curl_easy_cleanup(curl);
|
||||
nostr_http_response_t resp;
|
||||
int rc = nostr_http_request(&req, &resp);
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) {
|
||||
free(rb.data);
|
||||
free(req_headers);
|
||||
cJSON_Delete(args);
|
||||
if (rc == NOSTR_SUCCESS) {
|
||||
nostr_http_response_free(&resp);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int http_ok = (status_code >= 200 && status_code < 300) ? 1 : 0;
|
||||
int success = (res == CURLE_OK && http_ok) ? 1 : 0;
|
||||
int http_ok = (rc == NOSTR_SUCCESS && resp.status_code >= 200 && resp.status_code < 300) ? 1 : 0;
|
||||
int success = http_ok ? 1 : 0;
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", success);
|
||||
cJSON_AddStringToObject(out, "url", url->valuestring);
|
||||
cJSON_AddStringToObject(out, "method", method_str);
|
||||
cJSON_AddNumberToObject(out, "status_code", status_code);
|
||||
cJSON_AddNumberToObject(out, "status_code", rc == NOSTR_SUCCESS ? resp.status_code : 0);
|
||||
cJSON_AddBoolToObject(out, "http_ok", http_ok);
|
||||
cJSON_AddBoolToObject(out, "truncated", rb.truncated ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "bytes_received", (double)rb.len);
|
||||
cJSON_AddBoolToObject(out, "truncated", (rc == NOSTR_SUCCESS && resp.truncated) ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "bytes_received", rc == NOSTR_SUCCESS ? (double)resp.body_len : 0.0);
|
||||
|
||||
if (content_type_copy && content_type_copy[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "content_type", content_type_copy);
|
||||
if (rc == NOSTR_SUCCESS && resp.content_type && resp.content_type[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "content_type", resp.content_type);
|
||||
}
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
cJSON_AddStringToObject(out, "curl_error", curl_easy_strerror(res));
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
cJSON_AddStringToObject(out, "curl_error", "request failed");
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(out, "body", rb.data ? rb.data : "");
|
||||
cJSON_AddStringToObject(out, "body", (rc == NOSTR_SUCCESS && resp.body) ? resp.body : "");
|
||||
|
||||
free(rb.data);
|
||||
if (rc == NOSTR_SUCCESS) {
|
||||
nostr_http_response_free(&resp);
|
||||
}
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
cJSON_Delete(args);
|
||||
free(content_type_copy);
|
||||
free(req_headers);
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -274,6 +276,21 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
|
||||
if (strcmp(tool_name, "cashu_wallet_mints_set") == 0) {
|
||||
return execute_cashu_wallet_mints_set(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "blossom_upload") == 0) {
|
||||
return execute_blossom_upload(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "blossom_download") == 0) {
|
||||
return execute_blossom_download(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "blossom_head") == 0) {
|
||||
return execute_blossom_head(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "blossom_delete") == 0) {
|
||||
return execute_blossom_delete(ctx, args_json);
|
||||
}
|
||||
if (strcmp(tool_name, "blossom_list") == 0) {
|
||||
return execute_blossom_list(ctx, args_json);
|
||||
}
|
||||
|
||||
return json_error("unknown tool");
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ char* execute_cashu_wallet_send_token(tools_context_t* ctx, const char* args_jso
|
||||
char* execute_cashu_wallet_mints_get(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_cashu_wallet_mints_set(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
char* execute_blossom_upload(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_blossom_download(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_blossom_head(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_blossom_delete(tools_context_t* ctx, const char* args_json);
|
||||
char* execute_blossom_list(tools_context_t* ctx, const char* args_json);
|
||||
|
||||
int memory_init(tools_context_t* ctx);
|
||||
void memory_cleanup(void);
|
||||
|
||||
|
||||
@@ -1908,6 +1908,104 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
cJSON_AddItemToObject(t62, "function", t62_fn);
|
||||
cJSON_AddItemToArray(tools, t62);
|
||||
|
||||
cJSON* t63 = cJSON_CreateObject();
|
||||
cJSON* t63_fn = cJSON_CreateObject();
|
||||
cJSON* t63_params = cJSON_CreateObject();
|
||||
cJSON* t63_props = cJSON_CreateObject();
|
||||
cJSON* t63_required = cJSON_CreateArray();
|
||||
cJSON_AddStringToObject(t63, "type", "function");
|
||||
cJSON_AddStringToObject(t63_fn, "name", "blossom_upload");
|
||||
cJSON_AddStringToObject(t63_fn, "description", "Upload a local file to a Blossom server");
|
||||
cJSON_AddStringToObject(t63_params, "type", "object");
|
||||
cJSON_AddItemToObject(t63_params, "properties", t63_props);
|
||||
cJSON_AddItemToObject(t63_params, "required", t63_required);
|
||||
cJSON* p63_server = cJSON_CreateObject(); cJSON_AddStringToObject(p63_server, "type", "string"); cJSON_AddItemToObject(t63_props, "server", p63_server);
|
||||
cJSON* p63_file = cJSON_CreateObject(); cJSON_AddStringToObject(p63_file, "type", "string"); cJSON_AddItemToObject(t63_props, "file_path", p63_file);
|
||||
cJSON* p63_ct = cJSON_CreateObject(); cJSON_AddStringToObject(p63_ct, "type", "string"); cJSON_AddItemToObject(t63_props, "content_type", p63_ct);
|
||||
cJSON_AddItemToArray(t63_required, cJSON_CreateString("server"));
|
||||
cJSON_AddItemToArray(t63_required, cJSON_CreateString("file_path"));
|
||||
cJSON_AddItemToObject(t63_fn, "parameters", t63_params);
|
||||
cJSON_AddItemToObject(t63, "function", t63_fn);
|
||||
cJSON_AddItemToArray(tools, t63);
|
||||
|
||||
cJSON* t64 = cJSON_CreateObject();
|
||||
cJSON* t64_fn = cJSON_CreateObject();
|
||||
cJSON* t64_params = cJSON_CreateObject();
|
||||
cJSON* t64_props = cJSON_CreateObject();
|
||||
cJSON* t64_required = cJSON_CreateArray();
|
||||
cJSON_AddStringToObject(t64, "type", "function");
|
||||
cJSON_AddStringToObject(t64_fn, "name", "blossom_download");
|
||||
cJSON_AddStringToObject(t64_fn, "description", "Download a blob from Blossom to a local file");
|
||||
cJSON_AddStringToObject(t64_params, "type", "object");
|
||||
cJSON_AddItemToObject(t64_params, "properties", t64_props);
|
||||
cJSON_AddItemToObject(t64_params, "required", t64_required);
|
||||
cJSON* p64_server = cJSON_CreateObject(); cJSON_AddStringToObject(p64_server, "type", "string"); cJSON_AddItemToObject(t64_props, "server", p64_server);
|
||||
cJSON* p64_sha = cJSON_CreateObject(); cJSON_AddStringToObject(p64_sha, "type", "string"); cJSON_AddItemToObject(t64_props, "sha256", p64_sha);
|
||||
cJSON* p64_out = cJSON_CreateObject(); cJSON_AddStringToObject(p64_out, "type", "string"); cJSON_AddItemToObject(t64_props, "output_path", p64_out);
|
||||
cJSON* p64_overwrite = cJSON_CreateObject(); cJSON_AddStringToObject(p64_overwrite, "type", "boolean"); cJSON_AddItemToObject(t64_props, "overwrite", p64_overwrite);
|
||||
cJSON_AddItemToArray(t64_required, cJSON_CreateString("server"));
|
||||
cJSON_AddItemToArray(t64_required, cJSON_CreateString("sha256"));
|
||||
cJSON_AddItemToArray(t64_required, cJSON_CreateString("output_path"));
|
||||
cJSON_AddItemToObject(t64_fn, "parameters", t64_params);
|
||||
cJSON_AddItemToObject(t64, "function", t64_fn);
|
||||
cJSON_AddItemToArray(tools, t64);
|
||||
|
||||
cJSON* t65 = cJSON_CreateObject();
|
||||
cJSON* t65_fn = cJSON_CreateObject();
|
||||
cJSON* t65_params = cJSON_CreateObject();
|
||||
cJSON* t65_props = cJSON_CreateObject();
|
||||
cJSON* t65_required = cJSON_CreateArray();
|
||||
cJSON_AddStringToObject(t65, "type", "function");
|
||||
cJSON_AddStringToObject(t65_fn, "name", "blossom_head");
|
||||
cJSON_AddStringToObject(t65_fn, "description", "Fetch blob metadata from Blossom");
|
||||
cJSON_AddStringToObject(t65_params, "type", "object");
|
||||
cJSON_AddItemToObject(t65_params, "properties", t65_props);
|
||||
cJSON_AddItemToObject(t65_params, "required", t65_required);
|
||||
cJSON* p65_server = cJSON_CreateObject(); cJSON_AddStringToObject(p65_server, "type", "string"); cJSON_AddItemToObject(t65_props, "server", p65_server);
|
||||
cJSON* p65_sha = cJSON_CreateObject(); cJSON_AddStringToObject(p65_sha, "type", "string"); cJSON_AddItemToObject(t65_props, "sha256", p65_sha);
|
||||
cJSON_AddItemToArray(t65_required, cJSON_CreateString("server"));
|
||||
cJSON_AddItemToArray(t65_required, cJSON_CreateString("sha256"));
|
||||
cJSON_AddItemToObject(t65_fn, "parameters", t65_params);
|
||||
cJSON_AddItemToObject(t65, "function", t65_fn);
|
||||
cJSON_AddItemToArray(tools, t65);
|
||||
|
||||
cJSON* t66 = cJSON_CreateObject();
|
||||
cJSON* t66_fn = cJSON_CreateObject();
|
||||
cJSON* t66_params = cJSON_CreateObject();
|
||||
cJSON* t66_props = cJSON_CreateObject();
|
||||
cJSON* t66_required = cJSON_CreateArray();
|
||||
cJSON_AddStringToObject(t66, "type", "function");
|
||||
cJSON_AddStringToObject(t66_fn, "name", "blossom_delete");
|
||||
cJSON_AddStringToObject(t66_fn, "description", "Delete a blob from Blossom");
|
||||
cJSON_AddStringToObject(t66_params, "type", "object");
|
||||
cJSON_AddItemToObject(t66_params, "properties", t66_props);
|
||||
cJSON_AddItemToObject(t66_params, "required", t66_required);
|
||||
cJSON* p66_server = cJSON_CreateObject(); cJSON_AddStringToObject(p66_server, "type", "string"); cJSON_AddItemToObject(t66_props, "server", p66_server);
|
||||
cJSON* p66_sha = cJSON_CreateObject(); cJSON_AddStringToObject(p66_sha, "type", "string"); cJSON_AddItemToObject(t66_props, "sha256", p66_sha);
|
||||
cJSON_AddItemToArray(t66_required, cJSON_CreateString("server"));
|
||||
cJSON_AddItemToArray(t66_required, cJSON_CreateString("sha256"));
|
||||
cJSON_AddItemToObject(t66_fn, "parameters", t66_params);
|
||||
cJSON_AddItemToObject(t66, "function", t66_fn);
|
||||
cJSON_AddItemToArray(tools, t66);
|
||||
|
||||
cJSON* t67 = cJSON_CreateObject();
|
||||
cJSON* t67_fn = cJSON_CreateObject();
|
||||
cJSON* t67_params = cJSON_CreateObject();
|
||||
cJSON* t67_props = cJSON_CreateObject();
|
||||
cJSON* t67_required = cJSON_CreateArray();
|
||||
cJSON_AddStringToObject(t67, "type", "function");
|
||||
cJSON_AddStringToObject(t67_fn, "name", "blossom_list");
|
||||
cJSON_AddStringToObject(t67_fn, "description", "List blobs for a pubkey on Blossom");
|
||||
cJSON_AddStringToObject(t67_params, "type", "object");
|
||||
cJSON_AddItemToObject(t67_params, "properties", t67_props);
|
||||
cJSON_AddItemToObject(t67_params, "required", t67_required);
|
||||
cJSON* p67_server = cJSON_CreateObject(); cJSON_AddStringToObject(p67_server, "type", "string"); cJSON_AddItemToObject(t67_props, "server", p67_server);
|
||||
cJSON* p67_pubkey = cJSON_CreateObject(); cJSON_AddStringToObject(p67_pubkey, "type", "string"); cJSON_AddItemToObject(t67_props, "pubkey", p67_pubkey);
|
||||
cJSON_AddItemToArray(t67_required, cJSON_CreateString("server"));
|
||||
cJSON_AddItemToObject(t67_fn, "parameters", t67_params);
|
||||
cJSON_AddItemToObject(t67, "function", t67_fn);
|
||||
cJSON_AddItemToArray(tools, t67);
|
||||
|
||||
char* out = cJSON_PrintUnformatted(tools);
|
||||
cJSON_Delete(tools);
|
||||
return out;
|
||||
|
||||
@@ -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);
|
||||
|
||||
BIN
tests/blossom_tool_validation_test
Executable file
BIN
tests/blossom_tool_validation_test
Executable file
Binary file not shown.
79
tests/blossom_tool_validation_test.c
Normal file
79
tests/blossom_tool_validation_test.c
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Didactyl Blossom tool argument validation tests.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../src/config.h"
|
||||
#include "../src/tools/tools.h"
|
||||
#include "../src/tools/tools_internal.h"
|
||||
|
||||
static int tests_run = 0;
|
||||
static int tests_passed = 0;
|
||||
|
||||
#define TEST_ASSERT(cond, msg) do { \
|
||||
tests_run++; \
|
||||
if (cond) { tests_passed++; printf("✅ %s\n", msg); } \
|
||||
else { printf("❌ %s\n", msg); } \
|
||||
} while (0)
|
||||
|
||||
static int json_contains_error(const char* json, const char* needle) {
|
||||
return json && strstr(json, "\"success\":false") && strstr(json, needle);
|
||||
}
|
||||
|
||||
static int write_file_text(const char* path, const char* text) {
|
||||
FILE* fp = fopen(path, "wb");
|
||||
if (!fp) return -1;
|
||||
size_t n = fwrite(text, 1, strlen(text), fp);
|
||||
fclose(fp);
|
||||
return (n == strlen(text)) ? 0 : -1;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Didactyl Blossom tool validation tests\n");
|
||||
printf("======================================\n");
|
||||
|
||||
didactyl_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.tools.shell.working_directory, sizeof(cfg.tools.shell.working_directory), "%s", ".");
|
||||
cfg.tools.blossom_max_upload_bytes = 16;
|
||||
cfg.tools.blossom_max_download_bytes = 1024;
|
||||
|
||||
tools_context_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.cfg = &cfg;
|
||||
|
||||
char* out = execute_blossom_upload(&ctx, "{\"server\":\"http://example.com\",\"file_path\":\"README.md\"}");
|
||||
TEST_ASSERT(json_contains_error(out, "https://"), "blossom_upload rejects non-https server");
|
||||
free(out);
|
||||
|
||||
const char* big_file = "tests/.tmp_blossom_big.txt";
|
||||
if (write_file_text(big_file, "this payload is intentionally larger than 16 bytes") != 0) {
|
||||
printf("❌ setup failed to create temp file\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
out = execute_blossom_upload(&ctx, "{\"server\":\"https://example.com\",\"file_path\":\"tests/.tmp_blossom_big.txt\"}");
|
||||
TEST_ASSERT(json_contains_error(out, "max upload size"), "blossom_upload enforces configured upload max");
|
||||
free(out);
|
||||
|
||||
const char* existing = "tests/.tmp_blossom_existing.txt";
|
||||
if (write_file_text(existing, "already here") != 0) {
|
||||
printf("❌ setup failed to create existing output file\n");
|
||||
remove(big_file);
|
||||
return 1;
|
||||
}
|
||||
|
||||
out = execute_blossom_download(&ctx,
|
||||
"{\"server\":\"https://example.com\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"output_path\":\"tests/.tmp_blossom_existing.txt\"}");
|
||||
TEST_ASSERT(json_contains_error(out, "output_path exists"), "blossom_download requires overwrite=true when file exists");
|
||||
free(out);
|
||||
|
||||
remove(big_file);
|
||||
remove(existing);
|
||||
|
||||
printf("\nSummary: %d/%d passed\n", tests_passed, tests_run);
|
||||
return (tests_passed == tests_run) ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user