Compare commits

...
29 Commits
Author SHA1 Message Date
Didactyl User faf3edf29f v0.2.17 - Add Python test harness and improve increment_and_push diagnostics and git identity handling 2026-03-25 09:22:38 -04:00
Didactyl User 9a5beac80d v0.2.16 - Add Python test harness and fix increment script commit identity handling 2026-03-25 09:22:15 -04:00
Your Name 81590040c0 v0.2.14 - Follow-up push after context verification and startup-skill refactor 2026-03-23 09:57:24 -04:00
Your Name 2c5beda3c9 v0.2.13 - Unify startup skills, rename identity skill, add dm_history text format, and enforce startup publish success 2026-03-23 09:13:41 -04:00
Your Name 7c32683e75 v0.2.12 - Fix nostr_core_lib EOSE_FIRST semantics and relay pool subscription cleanup 2026-03-23 06:35:07 -04:00
Your Name 645f6ea610 v0.2.11 - strict startup validation: use NOSTR_POOL_EOSE_FIRST for self-skills and fix cleanup order 2026-03-23 06:09:39 -04:00
Your Name b569d41409 v0.2.10 - startup self-skill eose: use first-set mode and stabilize fail-path cleanup order 2026-03-23 06:01:51 -04:00
Your Name b3b1ddb2dd v0.2.9 - strict startup validation: remove fake seeding, enforce self-skill EOSE/cache validation 2026-03-22 20:01:46 -04:00
Your Name c4ee5a0aa0 v0.2.8 - Clean DM context duplication, suppress oversized websocket frame traces, and return raw content/tags in skill_get 2026-03-22 19:10:33 -04:00
Your Name 2398005eff v0.2.7 - Fix default_admin_dm inline variable resolution for my_kind0_profile and my_npub 2026-03-22 12:50:43 -04:00
Your Name b06c267db4 v0.2.6 - Fix DM freeze by removing adopted-skills mutex reentrancy in trigger context assembly 2026-03-22 12:28:54 -04:00
Your Name b625e0a70a v0.2.5 - Fix increment_and_push upstream auto-setup and finalize SIGINT shutdown fix 2026-03-22 12:18:08 -04:00
Your Name ed5e4248df v0.2.4 - Fix SIGINT handling so Ctrl-C exits didactyl reliably 2026-03-22 12:15:33 -04:00
Your Name 6a76b02180 v0.2.3 - Silence unused static helper warnings after migration rollback reapply 2026-03-22 12:11:46 -04:00
Your Name 90680e232d v0.2.2 - Re-apply v0.2.0 migration after history rewrite rollback 2026-03-22 12:02:12 -04:00
Your Name 0bd21dc41a Security: ignore and untrack genesis.jsonc 2026-03-22 11:21:43 -04:00
Your Name d0416f7760 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 2026-03-22 11:18:36 -04:00
Your Name 1ab2034b2d v0.2.0 - Release v0.2.0: trigger-scoped skill architecture breaking changes 2026-03-22 10:19:11 -04:00
Your Name 14a4f6f079 v0.1.21 - Fix segfault in nostr_http_request by copying CURLINFO_CONTENT_TYPE before curl cleanup and update debug deploy script to deploy *_debug binary for symbolized coredumps 2026-03-21 19:59:50 -04:00
Your Name 5efe932a5f v0.1.20 - Complete Blossom hardening and validation: HTTPS-only Blossom tools, config-driven upload/download limits, overwrite protection, CA bundle unification via nostr_http, and comprehensive live+mock nostr_http/blossom test coverage 2026-03-21 19:26:02 -04:00
Your Name 09531a7ebe v0.1.19 - Prevent ignored spent token events from reloading into wallet balance after restart 2026-03-21 09:30:18 -04:00
Your Name 0403e50506 v0.1.18 - Auto-clean spent Cashu proofs in check_proofs, publish deletions, and add spent token events to kind-10000 ignore list 2026-03-21 09:00:51 -04:00
Your Name f4f87f84bc v0.1.17 - Fix Cashu v4 token field encoding (i bytes, s text, c bytes) and rebuild nostr_core_lib integration 2026-03-21 08:46:24 -04:00
Your Name 6f4bcb4707 v0.1.16 - Rebuild with updated nostr_core_lib CashuB byte-string encoding fix and integrate refreshed static library 2026-03-21 08:21:20 -04:00
Your Name 4a3abacf20 v0.1.15 - Fix CashuB token byte-string encoding compatibility and rebuild after nostr_core_lib update 2026-03-21 08:10:06 -04:00
Your Name 6c50ab898c v0.1.14 - Use wallet state for default mint resolution instead of config mint_count guards 2026-03-21 07:00:46 -04:00
Your Name 2d1b1dc0cf v0.1.13 - Expose cashu_wallet_mints_get in tool schema so configured mints can be queried 2026-03-21 06:41:12 -04:00
Your Name c18ddb8fea v0.1.12 - Add /tool -h help shorthand and enrich core tool parameter descriptions 2026-03-21 06:16:57 -04:00
Your Name d161b68d02 v0.1.11 - Implement encrypted kind-10000 nostr block list tools and global relay event filtering 2026-03-21 05:45:31 -04:00
102 changed files with 8768 additions and 23030 deletions
+5
View File
@@ -11,6 +11,8 @@ test_keys.txt
/mongoose/
/Trash/
deploy_lt.sh
nostr_delete.sh
# Build artifacts
/build/
@@ -28,3 +30,6 @@ a.out
*.swp
*.swo
*~
# Local secrets
genesis.jsonc
+3 -3
View File
@@ -120,9 +120,9 @@ RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1
src/tools/tool_agent.c src/tools/tool_meta.c src/tools/tool_model.c \
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_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/cashu_wallet.c src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.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/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 \
-lsecp256k1 \
+4 -1
View File
@@ -27,18 +27,21 @@ SRCS = \
$(SRC_DIR)/tools/tool_admin.c \
$(SRC_DIR)/tools/tool_task.c \
$(SRC_DIR)/tools/tool_nostr_list.c \
$(SRC_DIR)/tools/tool_nostr_block.c \
$(SRC_DIR)/tools/tool_local.c \
$(SRC_DIR)/tools/tool_skill.c \
$(SRC_DIR)/tools/tool_nostr_post.c \
$(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 \
$(SRC_DIR)/setup_wizard.c \
$(SRC_DIR)/mongoose.c \
$(SRC_DIR)/debug.c
$(SRC_DIR)/debug.c \
$(SRC_DIR)/nostr_block_list.c
INCLUDES = \
-I$(SRC_DIR) \
+115 -93
View File
@@ -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,30 @@ 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.10
## Current Status — v0.2.17
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.1.10Fix check_proofs to derive Y from secret and validate stale nofee token deletion flow
> Last release update: v0.2.17Add Python test harness and improve increment_and_push diagnostics and git identity handling
- 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)
- 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
- Builds trigger-scoped LLM context from DM and subscription-triggered skills with inline variable resolution
- Uses default skill d-tag `default_admin_dm` with trigger tags for admin DM handling
- 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
@@ -163,8 +161,8 @@ Edit [`genesis.jsonc`](genesis.jsonc):
},
{
"kind": 31124,
"content": "You are Didactyl...",
"tags": [["d", "didactyl-default"], ["app", "didactyl"], ["scope", "private"]]
"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",
"tags": [["d", "default_admin_dm"], ["app", "didactyl"], ["scope", "private"], ["description", "Default admin DM handler"], ["trigger", "dm"], ["filter", "{\"from\":\"admin\"}"]]
},
{
"kind": 31123,
@@ -209,8 +207,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 +245,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 +289,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 +307,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 +325,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 +337,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 +358,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 +406,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] Trigger-scoped inline-variable context assembly (template DSL removed)
- [ ] 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
-353
View File
@@ -1,353 +0,0 @@
{
// LEGACY CONFIG EXAMPLE
// This file is deprecated in favor of genesis.jsonc + nsec-only startup.
// Kept as reference for older deployments and migration support.
// ─── Agent Identity Keys ───────────────────────────────────────────
// Your agent's Nostr keypair. Provide nsec (bech32) or hex format.
// The public key fields are optional — they are derived automatically.
"keys": {
"nsec": "agent nsec",
"npub": "agent npub",
"npubHex": "agent hex pubkey",
"nsecHex": "agent hex secret key"
},
// ─── Administrator ─────────────────────────────────────────────────
// The admin pubkey (npub or hex) controls who can issue privileged
// commands and use tools via DM.
"admin": {
"pubkey": "admin pubkey"
},
// ─── DM Protocol ──────────────────────────────────────────────────
// Which encrypted DM protocol to use: "nip04", "nip17", or "both"
"dm_protocol": "nip04",
// ─── LLM Provider ─────────────────────────────────────────────────
// Configure the language model backend. Any OpenAI-compatible API works.
"llm": {
"provider": "", // e.g. "openai", "anthropic", etc.
"api_key": "", // your API key
"model": "", // model identifier, e.g. "gpt-4o-mini"
"base_url": "", // API base URL, e.g. "https://api.openai.com/v1"
"max_tokens": 512, // max tokens per LLM response
"temperature": 0.7 // sampling temperature (0.0 2.0)
},
// ─── Cashu Wallet (NIP-60) ────────────────────────────────────────
// Optional wallet runtime used by cashu_wallet_* tools.
// If enabled=true and auto_load=true, Didactyl will attempt to load
// wallet/token events from relays at startup and create a new wallet
// from mint_urls if none is found.
"cashu_wallet": {
"enabled": false,
"mint_urls": [
"https://mint.minibits.cash/Bitcoin"
],
"unit": "sat",
"auto_load": true,
"mint_timeout_seconds": 30
},
// ─── Tools & Runtime Limits ───────────────────────────────────────
// Centralized turn and timeout limits used by DM agent loops,
// triggered skills, HTTP API prompt runs, and local HTTP fetch tool.
"tools": {
"enabled": true,
"max_turns": 20, // default max turns for normal agent tool loops
"trigger_max_turns": 12, // max turns for triggered-skill executions
"api_default_max_turns": 8, // default when HTTP body omits max_turns
"api_max_turns_ceiling": 32, // hard cap applied to API-provided max_turns
"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,
"shell": {
"enabled": true,
"timeout_seconds": 30,
"max_output_bytes": 65536,
"working_directory": "."
}
},
// ─── Security Tiers ───────────────────────────────────────────────
// Controls who can interact with the agent and what they can do.
"security": {
"verify_signatures": true, // verify Nostr event signatures
// Message sent to strangers outside the web of trust
"stranger_response": "I only respond to people in my web of trust. You can always identify me by my public key (npub).",
"tiers": {
"admin": {
"tools_enabled": true // admin can always use tools
},
"wot": {
"enabled": true, // respond to web-of-trust contacts
"tools_enabled": false // WoT contacts cannot use tools by default
},
"stranger": {
"enabled": true // respond to strangers (with stranger_response)
}
}
},
// ─── Admin Context Subscriptions ──────────────────────────────────
// Subscribe to the admin's Nostr events to build context awareness.
"admin_context": {
"enabled": true,
"subscribe_kinds": [
0, // kind 0: profile metadata
3, // kind 3: contact list
10002, // kind 10002: relay list
1 // kind 1: text notes
],
"kind_1_limit": 10 // max recent kind-1 notes to track
},
// ─── HTTP Admin API ───────────────────────────────────────────────
// Local REST API for runtime inspection and control.
"api": {
"enabled": true,
"port": 8484,
"bind_address": "127.0.0.1" // bind to localhost only
},
// ─── Startup Events ───────────────────────────────────────────────
// Events published on boot. Includes profile, relay list, soul,
// skills, and adoption list.
"startup_events": [
// Kind 0: Agent profile metadata (NIP-01)
{
"kind": 0,
"content_fields": {
"name": "Didactyl Agent",
"display_name": "Didactyl",
"about": "A sovereign AI agent on Nostr",
"picture": "https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png",
"banner": "https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg"
},
"tags": []
},
// Kind 10002: Relay list (NIP-65)
// These relays are used for connecting and publishing events.
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay.damus.io"],
["r", "wss://nos.lol"],
["r", "wss://relay.primal.net"],
["r", "ws://127.0.0.1:7777"]
]
},
// Kind 10050: DM relay list (NIP-17)
// Relays used specifically for receiving encrypted DMs.
{
"kind": 10050,
"content": "",
"tags": [
["relay", "wss://relay.damus.io"],
["relay", "wss://nos.lol"]
]
},
// Kind 1: Startup announcement note
{
"kind": 1,
"content": "Hello world from Didactyl startup",
"tags": [
["t", "didactyl"],
["t", "startup"]
]
},
// Kind 3: Contact list (initially empty)
{
"kind": 3,
"content": "",
"tags": []
},
// Kind 31120: Soul event — the agent's personality and behavior rules.
// Contains the system prompt and template sections for LLM context.
// The ---template--- marker separates the system prompt from
// structured context sections that are injected at runtime.
{
"kind": 31120,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- For memory persistence, use `memory_save` to prepend new memory entries and `memory_recall` to recall stored memory.\n- Memory is not injected by default; call `memory_recall` when needed.\n- Keep memory content in markdown and concise enough for context efficiency.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n content: |\n ## Administrator Identity (source: config.admin.pubkey)\n\n This is your administrator! Admin pubkey (hex): {{admin_pubkey}}\n\n- section: admin_profile\n role: system\n content: |\n ## Administrator Kind 0 Profile (source: nostr kind 0)\n\n Administrator kind 0 profile content (JSON): {{admin_kind0_json}}\n provider:\n anthropic: |\n <admin_kind0_profile source=\"nostr_kind_0\">\n {{admin_kind0_json}}\n </admin_kind0_profile>\n\n- section: admin_contacts\n role: system\n content: |\n ## Administrator Contact List (source: nostr kind 3)\n\n Administrator kind 3 contact list pubkeys (JSON array): {{admin_kind3_json}}\n provider:\n anthropic: |\n <admin_contacts source=\"nostr_kind_3\">\n {{admin_kind3_json}}\n </admin_contacts>\n\n- section: admin_relays\n role: system\n content: |\n ## Administrator Relay List (source: nostr kind 10002)\n\n Administrator kind 10002 relay list (JSON): {{admin_kind10002_json}}\n provider:\n anthropic: |\n <admin_relays source=\"nostr_kind_10002\">\n {{admin_kind10002_json}}\n </admin_relays>\n\n- section: admin_notes\n role: system\n content: |\n ## Administrator Recent Notes (source: nostr kind 1)\n\n Administrator recent kind 1 notes (JSON array): {{admin_kind1_json}}\n provider:\n anthropic: |\n <admin_notes source=\"nostr_kind_1\">\n {{admin_kind1_json}}\n </admin_notes>\n\n- section: skills\n role: system\n content: |\n ## Adopted Skills\n\n {{skills_json}}\n\n- section: tasks\n role: system\n content: |\n ## Current Task List\n\n {{tasks_json}}\n\n- section: conversation\n role: conversation\n content: |\n {{conversation}}",
"tags": [
["d", "soul"],
["app", "didactyl"],
["scope", "private"]
]
},
// Kind 31123: Public skill — long_form_note
// Teaches the agent how to publish NIP-23 long-form articles.
{
"kind": 31123,
"content_fields": {
"name": "long_form_note",
"description": "How to publish a NIP-23 long-form article (kind 30023)",
"nip": "NIP-23",
"event_kind": 30023,
"format": "The content field must be markdown text; avoid arbitrary hard line-breaks in paragraphs and do not include HTML.",
"required_tags": {
"d": "Addressable identifier slug for the article. Reusing the same d tag replaces prior versions.",
"title": "Human-readable article title.",
"published_at": "Unix timestamp as a string for first publication time; keep stable on edits."
},
"optional_tags": {
"summary": "Short 1-2 sentence summary.",
"image": "URL for article preview image.",
"t": "Topic hashtags using repeated t tags, usually 3-6 lowercase terms."
},
"behavior": "If required values are missing or unclear, ask the administrator before publishing instead of guessing.",
"procedure": [
"Determine title and d tag from admin input or source material.",
"Draft markdown body content.",
"Set published_at as unix seconds string.",
"Add optional summary/image/t tags when known.",
"Publish with nostr_post kind 30023 including tags array."
]
},
"tags": [
["d", "long_form_note"],
["app", "didactyl"],
["scope", "public"],
["slug", "long_form_note"]
]
},
// Kind 31123: Public skill — post_readme_to_nostr
// Reads README.md and publishes it as a long-form note.
{
"kind": 31123,
"content_fields": {
"name": "post_readme_to_nostr",
"description": "Read README.md from the repo and publish it as a NIP-23 long-form note",
"uses_skill": "long_form_note",
"event_kind": 30023,
"procedure": [
"Read README.md using local_file_read with path README.md.",
"Set d tag exactly to readme.md.",
"Set title from the first markdown H1 heading in README.md.",
"Set summary from the opening paragraph of README.md.",
"Set image from project metadata when available (kind 0 picture/banner), otherwise omit image tag.",
"Generate 3-6 lowercase t tags from README section topics.",
"Set published_at to current unix timestamp as string.",
"Publish with nostr_post kind 30023, full README markdown in content, and full NIP-23 tag set."
],
"required_values": {
"d": "readme.md",
"summary_source": "opening paragraph"
}
},
"tags": [
["d", "post_readme_to_nostr"],
["app", "didactyl"],
["scope", "public"],
["slug", "post_readme_to_nostr"]
]
},
// Kind 31123: Public triggered skill — cheerleader
// Watches for admin kind-1 notes and sends an encouraging DM.
{
"kind": 31123,
"content": "You are my personal cheerleader. When the admin posts a kind 1 note, read their note and send them a short DM that cheers them on, praises their effort, tells them they are good looking, and encourages them to keep going. Be warm, playful, and positive. Mention something specific from their note so it feels personal. Keep it to 2-3 sentences.",
"tags": [
["d", "cheerleader"],
["app", "didactyl"],
["scope", "public"],
["description", "Cheer on admin whenever they post a kind 1 note"],
["trigger", "nostr-subscription"],
["filter", "{\"kinds\":[1],\"authors\":[\"REPLACE_WITH_ADMIN_HEX_PUBKEY\"]}"],
["action", "llm"],
["enabled", "true"]
]
},
// Kind 31124: Private skill — admin_ops
// Private operational procedures (admin-only).
{
"kind": 31124,
"content_fields": {
"name": "admin_ops",
"description": "Private operational procedures"
},
"tags": [
["d", "admin_ops"],
["app", "didactyl"],
["scope", "private"],
["slug", "admin_ops"]
]
},
// Kind 31124: Private triggered skill — webhook-echo
// Example webhook trigger that emits a fixed DM response.
{
"kind": 31124,
"content": "When this webhook trigger fires, DM the admin with exactly: WEBHOOK_SOURCE_POC_OK",
"tags": [
["d", "webhook-echo"],
["app", "didactyl"],
["scope", "private"],
["description", "PoC webhook trigger skill"],
["trigger", "webhook"],
["filter", "{}"],
["action", "llm"],
["enabled", "true"]
]
},
// Kind 31124: Private triggered skill — cron_poc
// Example cron trigger that runs every minute.
{
"kind": 31124,
"content": "DM the administrator with the current time, and tell him that your cron skill triggered.",
"tags": [
["d", "cron_poc"],
["app", "didactyl"],
["scope", "private"],
["description", "PoC cron trigger skill"],
["trigger", "cron"],
["filter", "*/1 * * * *"],
["action", "template"],
["enabled", "true"]
]
},
// Kind 31124: Private triggered skill — chain_from_webhook_poc
// Example chain trigger that runs after webhook-echo completes.
{
"kind": 31124,
"content": "LOG: chain_from_webhook_poc fired",
"tags": [
["d", "chain_from_webhook_poc"],
["app", "didactyl"],
["scope", "private"],
["description", "PoC chain trigger skill"],
["trigger", "chain"],
["filter", "webhook-echo"],
["action", "template"],
["enabled", "true"]
]
},
// Kind 10123: Skill adoption list
// References which public skills this agent has adopted.
{
"kind": 10123,
"content": "",
"tags": [
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:long_form_note"],
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:post_readme_to_nostr"],
["a", "31123:55993e3db0ed7bf07395fd44c2d695c224d195553a1aff7320a18e41679d9c7c:cheerleader"],
["app", "didactyl"],
["scope", "public"]
]
}
]
}
+91 -20121
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
# Context Template (Legacy)
This file is deprecated and retained for backward compatibility only.
The default context behavior is now defined by adopted skills (kind `10123`) and the `didactyl_default` skill template published from `genesis.jsonc`.
For current behavior, see:
- `docs/GENESIS.md`
- `docs/CONTEXT.md`
- `docs/SKILLS.md`
-56
View File
@@ -1,56 +0,0 @@
# Context Template
```yaml
- section: admin_identity
role: system
content: |
## Administrator Identity (source: config.admin.pubkey)
This is your administrator! Admin pubkey (hex): {{admin_pubkey}}
- section: admin_profile
role: system
content: |
## Administrator Kind 0 Profile (source: nostr kind 0)
Administrator kind 0 profile content (JSON): {{admin_kind0_json}}
provider:
anthropic: |
<admin_kind0_profile source="nostr_kind_0">
{{admin_kind0_json}}
</admin_kind0_profile>
- section: admin_relay_list
role: system
content: |
## Administrator Relay List (source: nostr kind 10002)
Administrator kind 10002 relay-list content (JSON): {{admin_kind10002_json}}
- section: startup_events
role: system
content: |
## Startup Events Memory (source: config.startup_events)
Startup events memory (kinds/content/tags): {{startup_events_json}}
- section: adopted_skills
role: system
content: |
{{adopted_skills_content}}
- section: agent_tasks
role: system
content: |
{{tasks_content}}
- section: dm_history
role: expand
limit: 12
- section: admin_notes
role: system
content: |
## Administrator Recent Notes (source: nostr kind 1)
{{admin_notes_content}}
+109
View 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."
-124
View File
@@ -1,124 +0,0 @@
[2026-03-16 05:30:19] [INFO ] Didactyl v0.0.71 starting
[2026-03-16 05:30:19] [INFO ] [didactyl] initializing relay pool with 2 relays
[2026-03-16 05:30:19] [INFO ] [didactyl] added relay: wss://relay.damus.io
[2026-03-16 05:30:19] [INFO ] [didactyl] added relay: wss://relay.primal.net
[2026-03-16 05:30:19] [INFO ] [didactyl] startup checklist [01] Relay connectivity: begin
[startup 01] Relay connectivity ...
[2026-03-16 05:30:20] [WARN ] [didactyl] poll latency spike: nostr_relay_pool_poll(timeout=100) took 1190.6ms rc=0 count=1
[2026-03-16 05:30:20] [INFO ] [didactyl] relay state changed: wss://relay.damus.io disconnected -> connected
[2026-03-16 05:30:20] [INFO ] [didactyl] relay state changed: wss://relay.primal.net disconnected -> connected
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [01] Relay connectivity: ok (connected relays: 2/2)
[startup 01] Relay connectivity: OK (connected relays: 2/2)
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [02] Recover runtime config from Nostr: begin
[startup 02] Recover runtime config from Nostr ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [02] Recover runtime config from Nostr: ok
[startup 02] Recover runtime config from Nostr: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [03] Validate LLM config: begin
[startup 03] Validate LLM config ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [03] Validate LLM config: ok
[startup 03] Validate LLM config: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [04] Validate admin config: begin
[startup 04] Validate admin config ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [04] Validate admin config: ok
[startup 04] Validate admin config: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [05] Initialize LLM client: begin
[startup 05] Initialize LLM client ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [05] Initialize LLM client: ok
[startup 05] Initialize LLM client: OK
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [06] Detect first run via kind 10002: begin
[startup 06] Detect first run via kind 10002 ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup phase: first-run detection via kind 10002 => subsequent-run
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [06] Detect first run via kind 10002: ok (subsequent-run)
[startup 06] Detect first run via kind 10002: OK (subsequent-run)
[2026-03-16 05:30:20] [INFO ] [didactyl] startup checklist [07] Reconcile/persist startup state: begin
[startup 07] Reconcile/persist startup state ...
[2026-03-16 05:30:20] [INFO ] [didactyl] startup phase: skipping genesis startup-event publish on subsequent run
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [07] Reconcile/persist startup state: ok
[startup 07] Reconcile/persist startup state: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [08] Initialize agent: begin
[startup 08] Initialize agent ...
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [08] Initialize agent: ok
[startup 08] Initialize agent: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [09] Initialize trigger manager: begin
[startup 09] Initialize trigger manager ...
[2026-03-16 05:30:21] [INFO ] [didactyl] trigger manager initialized (capacity=16)
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [09] Initialize trigger manager: ok
[startup 09] Initialize trigger manager: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: deferred trigger load will run after self-skill EOSE
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [10] Load startup triggers: begin
[startup 10] Load startup triggers ...
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: startup-config trigger load begin
[2026-03-16 05:30:21] [INFO ] [didactyl] trigger manager loaded 0 trigger(s) from startup config (considered=1)
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: startup-config trigger load end
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [10] Load startup triggers: ok
[startup 10] Load startup triggers: OK
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [11] Discover self relay list via kind 10002: begin
[startup 11] Discover self relay list via kind 10002 ...
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query begin: timeout_ms=5000 author=52a3e82f7b374385... relay_count=2
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query relay snapshot: {"relay_count":2,"connected_count":2,"relays":[{"url":"wss://relay.damus.io","status":"connected","events_received":3,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0},{"url":"wss://relay.primal.net","status":"connected","events_received":2,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0}]}
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query received event_count=1
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 query extracted relays=["wss://relay.damus.io","wss://nos.lol","wss://relay.primal.net","ws://127.0.0.1:7777","wss://relay.laantungir.net","wss://sendit.nosflare.com"]
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 wait success: relay_count=6
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[0]=wss://relay.damus.io
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[1]=wss://nos.lol
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[2]=wss://relay.primal.net
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[3]=ws://127.0.0.1:7777
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[4]=wss://relay.laantungir.net
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 relay[5]=wss://sendit.nosflare.com
[2026-03-16 05:30:21] [INFO ] [didactyl] kind10002 wait success relay snapshot: {"relay_count":2,"connected_count":2,"relays":[{"url":"wss://relay.damus.io","status":"connected","events_received":4,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0},{"url":"wss://relay.primal.net","status":"connected","events_received":2,"events_published":0,"events_published_ok":0,"events_published_failed":0,"ping_latency_current":0,"ping_latency_avg":0,"query_latency_avg":0,"publish_latency_avg":0,"connection_uptime_start":1773653419,"last_event_time":0}]}
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [11] Discover self relay list via kind 10002: ok (kind10002=6 added=4 connected=2/6)
[startup 11] Discover self relay list via kind 10002: OK (kind10002=6 added=4 connected=2/6)
[2026-03-16 05:30:21] [INFO ] [didactyl] startup checklist [12] Subscribe admin context: begin
[startup 12] Subscribe admin context ...
[2026-03-16 05:30:21] [INFO ] [didactyl] startup phase: subscribe admin context begin
[2026-03-16 05:30:26] [INFO ] [didactyl] admin context subscriptions active for admin 8ff74724ed641b3c...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe admin context end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [12] Subscribe admin context: ok
[startup 12] Subscribe admin context: OK
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [13] Subscribe agent self context: begin
[startup 13] Subscribe agent self context ...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe agent self context begin
[2026-03-16 05:30:26] [INFO ] [didactyl] agent self-context subscriptions active for pubkey 52a3e82f7b374385...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe agent self context end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [13] Subscribe agent self context: ok
[startup 13] Subscribe agent self context: OK
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [14] Subscribe self-skill cache: begin
[startup 14] Subscribe self-skill cache ...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe self skill cache begin
[2026-03-16 05:30:26] [INFO ] [didactyl] self/admin skill subscriptions active (self=52a3e82f7b374385..., admin=8ff74724ed641b3c...)
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe self skill cache end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [14] Subscribe self-skill cache: ok
[startup 14] Subscribe self-skill cache: OK
[2026-03-16 05:30:26] [INFO ] [didactyl] publish DM target relays (6):
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.damus.io
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.primal.net
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://nos.lol
[2026-03-16 05:30:26] [INFO ] [didactyl] -> ws://127.0.0.1:7777
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://relay.laantungir.net
[2026-03-16 05:30:26] [INFO ] [didactyl] -> wss://sendit.nosflare.com
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.damus.io (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.primal.net (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://nos.lol (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to ws://127.0.0.1:7777 (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://relay.laantungir.net (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] kind 4 event published to wss://sendit.nosflare.com (async)
[2026-03-16 05:30:26] [INFO ] [didactyl] sent DM 54452dbf320bee66... to 8ff74724ed641b3c... via 6 connected relay(s)
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [15] Subscribe DMs: begin
[startup 15] Subscribe DMs ...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe DMs begin
[2026-03-16 05:30:26] [INFO ] [didactyl] DM subscription active for pubkey 52a3e82f7b374385...
[2026-03-16 05:30:26] [INFO ] [didactyl] startup phase: subscribe DMs end
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [15] Subscribe DMs: ok
[startup 15] Subscribe DMs: OK
2aa88330 3 mongoose.c:4824:mg_mgr_init MG_IO_SIZE: 16384, TLS: builtin
2aa88331 3 mongoose.c:4741:mg_listen 1 11 https://127.0.0.1:8484
[2026-03-16 05:30:26] [INFO ] [didactyl] http api listening on https://127.0.0.1:8484
[2026-03-16 05:30:26] [INFO ] [didactyl] HTTP API listening at http://127.0.0.1:8484
[2026-03-16 05:30:26] [INFO ] [didactyl] HTTP API endpoints: http://127.0.0.1:8484/api/context/current http://127.0.0.1:8484/api/context/parts
[2026-03-16 05:30:26] [INFO ] [didactyl] startup checklist [16] READY: ok (agent online; entering main poll loop)
[startup 16] READY: OK (agent online; entering main poll loop)
[2026-03-16 05:30:26] [INFO ] [didactyl] entering main poll loop
[2026-03-16 05:30:26] [INFO ] [didactyl] running with pubkey 52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8
[2026-03-16 05:30:27] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
[2026-03-16 05:30:28] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
[2026-03-16 05:30:28] [INFO ] [didactyl] live self-skill trigger ignored (not adopted) d_tag=daily-readme-publish
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@ All responses are JSON. CORS headers are included on every response for browser
## Configuration
Enable the API in `config.jsonc`:
Enable the API in `genesis.jsonc`:
```json
{
+121 -56
View File
@@ -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.
---
+9 -9
View File
@@ -6,7 +6,7 @@ See also: [CONTEXT.md](CONTEXT.md) · [SKILLS.md](SKILLS.md) · [README.md](../R
`genesis.jsonc` is the first-run bootstrap document for a Didactyl agent.
It defines initial identity, admin policy, startup events, default skill context, and baseline runtime settings so the agent can publish itself onto Nostr.
It defines initial identity, admin policy, startup events (including startup skills), and baseline runtime settings so the agent can publish itself onto Nostr.
After bootstrap, the long-term direction is **nsec-only startup** with state recovered from Nostr events.
@@ -30,7 +30,7 @@ Typical optional sections:
- `security`
- `admin_context`
- `api`
- `default_skill` (reserved for default-skill publishing workflows)
- Startup skills in `startup_events` (typically private kind `31124`, e.g. `d=identity_and_rules` and `d=dm_history`)
---
@@ -118,14 +118,14 @@ That relay list is used as the initial network attachment for querying existing
---
## Migration Notes (Legacy -> Genesis)
## Migration Notes (v0.2.0)
Legacy deployments using `config.jsonc` can migrate by:
1. Copying equivalent sections to `genesis.jsonc`.
2. Ensuring startup kind `10002` relay tags are present.
3. Providing nsec at runtime (`--nsec` or `DIDACTYL_NSEC`) where desired.
4. Treating `context_template.md` / `config.jsonc.example` as legacy references.
- Legacy `config.jsonc` and template-DSL context files have been removed from the active startup model.
- Startup skills are defined directly in `startup_events` as kind `31123`/`31124` events.
- Typical default DM stack is two startup skills with tags:
- `d=identity_and_rules` with `trigger=dm` and `filter={"from":"admin"}`
- `d=dm_history` with `trigger=dm` and `filter={"from":"admin"}`
- Skill content is plain markdown with inline variables such as `{{my_kind0_profile}}`, `{{my_npub}}`, and `{{nostr_dm_history({"format":"text","limit":12})}}`.
---
+447 -174
View File
@@ -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)
+661 -31
View File
@@ -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.)
```
---
@@ -58,6 +61,7 @@ sequenceDiagram
| `nostr_delete` | Request deletion of one or more previously published events (NIP-09 kind 5) |
| `nostr_react` | React to a Nostr event with like/dislike/emoji (NIP-25 kind 7) |
| `nostr_query` | Query events from relays using a Nostr filter |
| `nostr_my_events` | Query recent events authored by this agent and return kind, event_id, timestamp, d_tag, and cache presence |
| `nostr_dm_send` | Send a NIP-04 encrypted DM |
| `nostr_dm_send_nip17` | Send a private DM using NIP-17 gift wrap protocol |
@@ -71,9 +75,38 @@ sequenceDiagram
| `nostr_decode` | Decode a Nostr bech32/`nostr:` URI into components |
| `nostr_relay_status` | Get connection status and statistics for all relays |
| `nostr_relay_info` | Fetch NIP-11 relay information document |
| `nostr_subscription_status` | List currently managed runtime Nostr subscriptions and filters |
| `nostr_subscription_set` | Update one managed runtime subscription by name (toggle enabled and/or replace filter) |
| `nostr_encrypt` | Encrypt plaintext using NIP-44 for a recipient |
| `nostr_decrypt` | Decrypt NIP-44 ciphertext from a sender |
| `nostr_list_manage` | Add/remove tag tuples in replaceable list events (NIP-51 style) |
| `nostr_block_list` | View blocked pubkeys, event IDs, or hashtags from the local encrypted kind-10000 block list cache |
| `nostr_block_edit` | Add or remove blocked tuples in kind-10000 block list; entries are private (encrypted) by default |
### Context & Identity Tools
These tools provide the agent with information about itself and its administrator, often used to build system prompts or context blocks.
| Tool | Description |
|---|---|
| `nostr_pubkey` | Return this agent's pubkey in hex format |
| `nostr_npub` | Return this agent's pubkey encoded as npub bech32 |
| `my_pubkey` | Alias for `nostr_pubkey` |
| `my_npub` | Alias for `nostr_npub` |
| `agent_identity` | Build agent identity context block with pubkey and npub |
| `admin_identity` | Build admin identity context block from cached runtime metadata |
| `nostr_admin_profile` | Build admin profile context block from cached kind 0 metadata |
| `nostr_admin_contacts` | Build admin contacts context block from cached kind 3 contact list |
| `nostr_admin_relays` | Build admin relay context block from cached kind 10002 data |
| `nostr_admin_notes` | Build admin notes context block from cached kind 1 notes |
| `nostr_agent_profile` | Build agent profile context block from cached kind 0 metadata |
| `nostr_agent_contacts` | Build agent contacts context block from cached kind 3 contact list |
| `nostr_agent_relays` | Build agent relay context block from cached kind 10002 data |
| `nostr_agent_notes` | Build agent notes context block from cached kind 1 notes |
| `my_kind0_profile` | Alias for `nostr_agent_profile` |
| `my_contacts` | Alias for `nostr_agent_contacts` |
| `my_relays` | Alias for `nostr_agent_relays` |
| `my_notes` | Alias for `nostr_agent_notes` |
### Skills & Trigger Tools
@@ -82,20 +115,39 @@ These tools manage skill and trigger lifecycle; skill semantics and trigger exec
| Tool | Description |
|---|---|
| `skill_create` | Create or update a skill definition as kind `31123`/`31124` and optionally auto-adopt it |
| `skill_list` | List this agent's published skills, optionally filtered by scope |
| `skill_edit` | Edit an existing self skill by d tag and republish it as kind `31123`/`31124` |
| `skill_list` | List available skills discovered online (agent + admin), with adoption status and optional filters |
| `skill_adopt` | Adopt a skill by adding its address to kind `10123` adoption list |
| `skill_remove` | Remove a skill address from kind `10123` adoption list |
| `skill_search` | Search public skills by query/author and optionally rank by adoption popularity |
| `trigger_list` | List active triggered skills and their runtime status |
### Task & Memory Tools
These tools manage the agent's short-term and long-term memory, persisted on Nostr.
| Tool | Description |
|---|---|
| `task_list` | Build current task list context block from agent task memory on Nostr |
| `task_manage` | Manage agent short-term task memory stored on Nostr kind `30078` (d=tasks): list/add/update/remove/clear/replace |
| `memory_save` | Prepend a new entry to encrypted agent memory (kind `30078`, d=memory) and truncate oldest content if needed |
| `memory_recall` | Recall encrypted agent memory (kind `30078`, d=memory) |
### LLM / Model Management Tools
| Tool | Description |
|---|---|
| `model_get` | Get current active LLM runtime configuration (excluding API key) |
| `model_set` | Update active LLM configuration and persist it to `config.jsonc` |
| `model_set` | Update active LLM configuration and persist it to Nostr kind `30078` (d=llm_config) |
| `model_list` | List available model IDs using provider OpenAI-compatible `/models` endpoint |
### Configuration Persistence Tools
| Tool | Description |
|---|---|
| `config_store` | Encrypt and publish agent config as kind `30078` for a given d_tag |
| `config_recall` | Fetch and decrypt agent config kind `30078` by d_tag |
### System & Runtime Tools
| Tool | Description |
@@ -119,7 +171,19 @@ These tools manage skill and trigger lifecycle; skill semantics and trigger exec
| `cashu_wallet_melt_quote` | Request a melt quote for a Lightning invoice/payment request |
| `cashu_wallet_melt_pay` | Pay a melt quote using selected proofs and persist history/token rollover |
| `cashu_wallet_check_proofs` | Ask mint for current proof states and summarize unspent/pending/spent proofs |
| `cashu_wallet_receive_token` | Receive an ecash token string (cashuA/cashuB), swap to fresh proofs, and store it in wallet state |
| `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
@@ -130,9 +194,542 @@ These tools manage skill and trigger lifecycle; skill semantics and trigger exec
---
## Tool Execution Examples
These examples show the JSON structure for tool calls.
### Nostr Event & Messaging
**nostr_post**
```json
{
"name": "nostr_post",
"arguments": {
"kind": 1,
"content": "Hello from Didactyl!",
"tags": [["t", "didactyl"]]
}
}
```
**nostr_delete**
```json
{
"name": "nostr_delete",
"arguments": {
"event_ids": ["<event_id_hex>"],
"reason": "Mistake"
}
}
```
**nostr_react**
```json
{
"name": "nostr_react",
"arguments": {
"event_id": "<event_id_hex>",
"event_pubkey": "<pubkey_hex>",
"reaction": "🤙"
}
}
```
**nostr_query**
```json
{
"name": "nostr_query",
"arguments": {
"filter": {
"kinds": [1],
"limit": 5
},
"timeout_ms": 5000
}
}
```
**nostr_my_events**
```json
{
"name": "nostr_my_events",
"arguments": {
"kind": 1,
"limit": 10
}
}
```
**nostr_dm_send**
```json
{
"name": "nostr_dm_send",
"arguments": {
"recipient_pubkey": "<pubkey_hex>",
"message": "Secret message"
}
}
```
**nostr_dm_send_nip17**
```json
{
"name": "nostr_dm_send_nip17",
"arguments": {
"recipient_pubkey": "<pubkey_hex>",
"message": "Private message via NIP-17",
"subject": "Confidential"
}
}
```
### Nostr Identity & Utility
**nostr_profile_get**
```json
{
"name": "nostr_profile_get",
"arguments": {
"pubkey": "<pubkey_hex>"
}
}
```
**nostr_nip05_lookup**
```json
{
"name": "nostr_nip05_lookup",
"arguments": {
"identifier": "user@domain.com"
}
}
```
**nostr_encode**
```json
{
"name": "nostr_encode",
"arguments": {
"type": "npub",
"hex": "<pubkey_hex>"
}
}
```
**nostr_decode**
```json
{
"name": "nostr_decode",
"arguments": {
"uri": "nostr:npub1..."
}
}
```
**nostr_relay_status**
```json
{
"name": "nostr_relay_status",
"arguments": {}
}
```
**nostr_relay_info**
```json
{
"name": "nostr_relay_info",
"arguments": {
"relay_url": "wss://relay.damus.io"
}
}
```
**nostr_subscription_status**
```json
{
"name": "nostr_subscription_status",
"arguments": {}
}
```
**nostr_subscription_set**
```json
{
"name": "nostr_subscription_set",
"arguments": {
"name": "admin_context_profile",
"enabled": true
}
}
```
**nostr_encrypt**
```json
{
"name": "nostr_encrypt",
"arguments": {
"recipient_pubkey": "<pubkey_hex>",
"plaintext": "Sensitive data"
}
}
```
**nostr_decrypt**
```json
{
"name": "nostr_decrypt",
"arguments": {
"sender_pubkey": "<pubkey_hex>",
"ciphertext": "<nip44_ciphertext>"
}
}
```
**nostr_list_manage**
```json
{
"name": "nostr_list_manage",
"arguments": {
"list_kind": 10000,
"action": "add",
"items": [["p", "<pubkey_hex>"]]
}
}
```
**nostr_block_list**
```json
{
"name": "nostr_block_list",
"arguments": {
"type": "p"
}
}
```
**nostr_block_edit**
```json
{
"name": "nostr_block_edit",
"arguments": {
"action": "add",
"items": [["p", "<pubkey_hex>"]],
"public": false
}
}
```
### Context & Identity
**nostr_pubkey** / **my_pubkey**
```json
{
"name": "nostr_pubkey",
"arguments": {}
}
```
**agent_identity**
```json
{
"name": "agent_identity",
"arguments": {}
}
```
**admin_identity**
```json
{
"name": "admin_identity",
"arguments": {}
}
```
**nostr_admin_profile**
```json
{
"name": "nostr_admin_profile",
"arguments": {}
}
```
**nostr_agent_notes** / **my_notes**
```json
{
"name": "my_notes",
"arguments": {}
}
```
### Skills & Triggers
**skill_create**
```json
{
"name": "skill_create",
"arguments": {
"d": "weather-skill",
"content": "I can tell you the weather.",
"description": "Fetches weather data",
"auto_adopt": true
}
}
```
**skill_edit**
```json
{
"name": "skill_edit",
"arguments": {
"d": "weather-skill",
"description": "Updated weather skill description"
}
}
```
**skill_list**
```json
{
"name": "skill_list",
"arguments": {
"adopted": true
}
}
```
**skill_adopt**
```json
{
"name": "skill_adopt",
"arguments": {
"pubkey": "<author_pubkey_hex>",
"d": "cool-skill"
}
}
```
**skill_search**
```json
{
"name": "skill_search",
"arguments": {
"query": "bitcoin",
"popular": true
}
}
```
**trigger_list**
```json
{
"name": "trigger_list",
"arguments": {}
}
```
### Tasks & Memory
**task_list**
```json
{
"name": "task_list",
"arguments": {}
}
```
**task_manage**
```json
{
"name": "task_manage",
"arguments": {
"action": "add",
"text": "Finish documentation",
"status": "active"
}
}
```
**memory_save**
```json
{
"name": "memory_save",
"arguments": {
"content": "User likes coffee."
}
}
```
**memory_recall**
```json
{
"name": "memory_recall",
"arguments": {}
}
```
### LLM & Configuration
**model_get**
```json
{
"name": "model_get",
"arguments": {}
}
```
**model_set**
```json
{
"name": "model_set",
"arguments": {
"model": "gpt-4o",
"temperature": 0.7
}
}
```
**config_store**
```json
{
"name": "config_store",
"arguments": {
"d_tag": "custom_setting",
"content": "{\"value\": 42}"
}
}
```
**config_recall**
```json
{
"name": "config_recall",
"arguments": {
"d_tag": "custom_setting"
}
}
```
### System & Runtime
**agent_version**
```json
{
"name": "agent_version",
"arguments": {}
}
```
**local_http_fetch**
```json
{
"name": "local_http_fetch",
"arguments": {
"url": "https://api.github.com/zen",
"method": "GET"
}
}
```
**local_shell_exec**
```json
{
"name": "local_shell_exec",
"arguments": {
"command": "ls -la"
}
}
```
**local_file_read**
```json
{
"name": "local_file_read",
"arguments": {
"path": "README.md"
}
}
```
**local_file_write**
```json
{
"name": "local_file_write",
"arguments": {
"path": "test.txt",
"content": "Hello world",
"append": false
}
}
```
**tool_list**
```json
{
"name": "tool_list",
"arguments": {}
}
```
### Cashu Wallet
**cashu_wallet_balance**
```json
{
"name": "cashu_wallet_balance",
"arguments": {}
}
```
**cashu_wallet_mint_quote**
```json
{
"name": "cashu_wallet_mint_quote",
"arguments": {
"amount": 100,
"unit": "sat"
}
}
```
**cashu_wallet_receive_token**
```json
{
"name": "cashu_wallet_receive_token",
"arguments": {
"token": "cashuA..."
}
}
```
**cashu_wallet_send_token**
```json
{
"name": "cashu_wallet_send_token",
"arguments": {
"amount": 21
}
}
```
**cashu_wallet_mints_set**
```json
{
"name": "cashu_wallet_mints_set",
"arguments": {
"target": "both",
"wallet_mints": ["https://mint.host.com"],
"public_mints": ["https://mint.host.com"]
}
}
```
---
## Security Model
Tool access is gated by sender tier:
Tool access is gated at two levels:
### Sender Tier (DM triggers)
| Tier | Identity | Tools | Response |
|------|----------|-------|----------|
@@ -140,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)
+41 -14
View File
@@ -37,8 +37,22 @@
},
// ─── Startup Events ────────────────────────────────────────────────
// Minimal relay list required for relay pool initialization.
"startup_events": [
{
"kind": 0,
"content_fields": {
"name": "Didactyl",
"about": "I am a Didactyl agent living on Nostr"
},
"tags": []
},
{
"kind": 3,
"content": "",
"tags": [
["p", "ADMIN_HEX_PUBKEY"]
]
},
{
"kind": 10002,
"content": "",
@@ -47,17 +61,30 @@
["r", "wss://relay.primal.net"]
]
}
],
// ─── Default Skill ────────────────────────────────────────────────
// Keep this generic and non-sensitive for repository examples.
"default_skill": {
"d_tag": "didactyl-default",
"kind": 31124,
"content": "# Didactyl Agent\n\nYou are Didactyl, a sovereign AI agent living on Nostr.\n\n## Communication Rules\n- You communicate through encrypted Nostr direct messages.\n- Keep responses concise and clear.\n\n## Behavior\n- Be helpful and technically accurate.\n- If unsure, state uncertainty directly.\n- Prefer actionable, practical advice.\n- Use the person's name when messaging them if you know it.\n- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n## Tool Use Policy\n- You have tools available and should use them when a request requires taking action.\n- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n- After a tool call, base your answer on the actual tool result.\n- Never claim a tool was run if no tool was executed.\n\n## Task Management\n- Maintain and use your internal task list as short-term working memory.\n- Break long or complex actions into clear tasks before executing them.\n- Update task status as you complete steps so your plan stays accurate.\n\n## Safety\n- Do not claim to have executed actions you did not execute.\n- You may share your public key (npub) with anyone.\n- Never reveal your private key (nsec) under any circumstance.\n\n---template---\n\n- section: admin_identity\n role: system\n tool: admin_identity\n skip_if_empty: true\n\n- section: admin_profile\n role: system\n tool: nostr_admin_profile\n skip_if_empty: true\n\n- section: admin_contacts\n role: system\n tool: nostr_admin_contacts\n skip_if_empty: true\n\n- section: admin_relays\n role: system\n tool: nostr_admin_relays\n skip_if_empty: true\n\n- section: admin_notes\n role: system\n tool: nostr_admin_notes\n skip_if_empty: true\n\n- section: agent_identity\n role: system\n tool: agent_identity\n skip_if_empty: true\n\n- section: agent_profile\n role: system\n tool: nostr_agent_profile\n skip_if_empty: true\n\n- section: agent_contacts\n role: system\n tool: nostr_agent_contacts\n skip_if_empty: true\n\n- section: agent_relays\n role: system\n tool: nostr_agent_relays\n skip_if_empty: true\n\n- section: agent_notes\n role: system\n tool: nostr_agent_notes\n skip_if_empty: true\n\n- section: tasks\n role: system\n tool: task_list\n skip_if_empty: true\n\n- section: dm_history\n role: expand\n limit: 12\n\n- section: conversation\n role: user\n tool: message_current\n skip_if_empty: true",
"tags": [
["app", "didactyl"],
["scope", "private"]
]
}
},
{
"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": [
["d", "identity_and_rules"],
["app", "didactyl"],
["scope", "private"],
["description", "Agent identity and behavioral rules"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]
},
{
"kind": 31124,
"content": "## Recent Conversation\n\n{{nostr_dm_history({\"format\":\"text\",\"limit\":12})}}",
"tags": [
["d", "dm_history"],
["app", "didactyl"],
["scope", "private"],
["description", "DM conversation history for context continuity"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]
}
]
}
+69 -6
View File
@@ -111,6 +111,37 @@ check_git_repo() {
fi
}
# Ensure git identity exists so commits do not fail on clean machines
ensure_git_identity() {
local current_name=""
local current_email=""
current_name=$(git config --get user.name 2>/dev/null || true)
current_email=$(git config --get user.email 2>/dev/null || true)
if [[ -n "$current_name" && -n "$current_email" ]]; then
return 0
fi
print_warning "Git user.name / user.email not fully configured for this repository"
local fallback_name
local fallback_email
fallback_name="${GIT_AUTHOR_NAME:-Didactyl User}"
fallback_email="${GIT_AUTHOR_EMAIL:-didactyl@local}"
if [[ -z "$current_name" ]]; then
git config user.name "$fallback_name"
print_status "Set local git user.name to '$fallback_name'"
fi
if [[ -z "$current_email" ]]; then
git config user.email "$fallback_email"
print_status "Set local git user.email to '$fallback_email'"
fi
}
# Function to get current version and increment appropriately
increment_version() {
local increment_type="$1" # "patch", "minor", or "major"
@@ -293,28 +324,59 @@ git_commit_and_push_no_tag() {
print_success "Committed changes"
else
print_error "Failed to commit changes"
print_error "git commit output:"
git commit -m "$NEW_VERSION - $COMMIT_MESSAGE" 2>&1 || true
exit 1
fi
fi
# Push changes
print_status "Pushing to remote repository..."
if git push > /dev/null 2>&1; then
print_success "Pushed changes"
local current_branch
current_branch=$(git branch --show-current 2>/dev/null || echo "")
if git rev-parse --abbrev-ref --symbolic-full-name "@{u}" > /dev/null 2>&1; then
local push_output
if push_output=$(git push 2>&1); then
print_success "Pushed changes"
else
print_error "Failed to push changes"
print_error "git push output:"
echo "$push_output" >&2
print_warning "If this is an SSH auth error, verify ~/.ssh/config and that the correct private key is available to SSH"
exit 1
fi
else
print_error "Failed to push changes"
exit 1
if [[ -z "$current_branch" ]]; then
print_error "Unable to determine current branch for push"
exit 1
fi
print_warning "No upstream configured for branch '$current_branch'; setting upstream to origin/$current_branch"
local push_output
if push_output=$(git push -u origin "$current_branch" 2>&1); then
print_success "Pushed changes and configured upstream"
else
print_error "Failed to push changes while configuring upstream"
print_error "git push output:"
echo "$push_output" >&2
print_warning "If this is an SSH auth error, verify ~/.ssh/config and that the correct private key is available to SSH"
exit 1
fi
fi
# Push only the new tag to avoid conflicts with existing tags
if git push origin "$NEW_VERSION" > /dev/null 2>&1; then
local tag_push_output
if tag_push_output=$(git push origin "$NEW_VERSION" 2>&1); then
print_success "Pushed tag: $NEW_VERSION"
else
print_warning "Tag push failed, trying force push..."
if git push --force origin "$NEW_VERSION" > /dev/null 2>&1; then
if tag_push_output=$(git push --force origin "$NEW_VERSION" 2>&1); then
print_success "Force-pushed updated tag: $NEW_VERSION"
else
print_error "Failed to push tag: $NEW_VERSION"
print_error "git tag push output:"
echo "$tag_push_output" >&2
exit 1
fi
fi
@@ -480,6 +542,7 @@ main() {
# Check prerequisites
check_git_repo
ensure_git_identity
if [[ "$RELEASE_MODE" == true ]]; then
print_status "=== RELEASE MODE ==="
+616
View File
@@ -0,0 +1,616 @@
# Didactyl Automated Test Harness
## Overview
An automated testing system that starts a Didactyl agent locally (debug build), converses with it via the HTTP API, exercises all tools, monitors logs in real-time, handles agent crashes/restarts, and produces a structured test results report.
**Language:** Python (stdlib only, no external dependencies)
**Location:** `tests/`
**Phase 1:** Scripted tests (no LLM driving the tester)
**Phase 2 (future):** LLM-driven test agent that generates prompts, evaluates responses, and adapts
---
## Architecture
```mermaid
flowchart TB
subgraph Test Harness - Python
RUNNER[test_runner.py<br/>orchestrator]
PROC[agent_process.py<br/>start/stop/restart]
CLIENT[didactyl_client.py<br/>HTTP API wrapper]
LOG[log_watcher.py<br/>tail debug.log]
REPORT[reporter.py<br/>results output]
RUNNER --> PROC
RUNNER --> CLIENT
RUNNER --> LOG
RUNNER --> REPORT
end
subgraph Test Suites
TH[test_health]
TC[test_conversation]
TI[test_tools_identity]
TN[test_tools_nostr]
TS[test_tools_skills]
TSY[test_tools_system]
TM[test_tools_memory]
TCA[test_tools_cashu]
TB[test_tools_blossom]
TTO[test_timeouts]
TE[test_errors]
TR[test_restart]
end
RUNNER --> TH & TC & TI & TN & TS & TSY & TM & TCA & TB & TTO & TE & TR
subgraph Didactyl Agent - debug build
AGENT[didactyl process]
API[HTTP API :8484]
LOGFILE[debug.log]
end
CLIENT -- HTTP --> API
PROC -- subprocess --> AGENT
LOG -- tail --> LOGFILE
```
---
## Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Language | Python | Best subprocess/HTTP/threading support; already used in project |
| Dependencies | stdlib only | No pip install needed; `urllib`, `subprocess`, `threading`, `json` |
| Test framework | Standalone runner | Self-contained, no pytest dependency |
| Relay strategy | Flexible genesis config | User provides their own test_genesis.jsonc |
| LLM for agent | Real model | User provides API key in test genesis config |
| Tool scope | All tools | Disposable test identity; full coverage |
| Entry point | `tests/run_tests.py` | Single script to run everything |
---
## Directory Structure
```
tests/
├── harness/
│ ├── __init__.py
│ ├── agent_process.py # start/stop/restart didactyl subprocess
│ ├── didactyl_client.py # HTTP API wrapper with timeouts
│ ├── log_watcher.py # real-time log tail + marker system
│ ├── test_runner.py # orchestrator
│ └── reporter.py # results formatting (JSON + text)
├── suites/
│ ├── __init__.py
│ ├── test_health.py # status, context endpoints
│ ├── test_conversation.py # basic prompt/response
│ ├── test_tools_identity.py # identity and context tools
│ ├── test_tools_nostr.py # nostr event, messaging, relay tools
│ ├── test_tools_skills.py # skill and trigger tools
│ ├── test_tools_system.py # system, local, model, config tools
│ ├── test_tools_memory.py # task and memory tools
│ ├── test_tools_cashu.py # cashu wallet tools
│ ├── test_tools_blossom.py # blossom tools
│ ├── test_timeouts.py # response time assertions
│ ├── test_errors.py # API error handling paths
│ └── test_restart.py # crash recovery
├── configs/
│ └── test_genesis.jsonc # example test config (user fills in secrets)
├── results/ # test run output (gitignored)
├── run_tests.py # entry point
├── test.sh # existing bash test (kept as-is)
├── blossom_tool_validation_test.c # existing C test (kept as-is)
└── blossom_tool_validation_test # existing compiled test (kept as-is)
```
---
## Component Specifications
### 1. agent_process.py — Process Manager
Manages the didactyl subprocess lifecycle.
```python
class AgentProcess:
def __init__(self, binary_path, config_path, api_port=8484,
api_bind="127.0.0.1", debug_level=5, log_file=None):
"""Configure but don't start yet."""
def start(self, timeout=30) -> bool:
"""
Spawn didactyl as subprocess:
./didactyl --config <config_path> --debug <level>
--api-port <port> --api-bind <bind>
Set DIDACTYL_LOG_FILE env var to log_file path.
Wait for GET /api/status to return 200 (poll with timeout).
Returns True if agent started successfully.
"""
def stop(self, timeout=10) -> bool:
"""
Send SIGTERM, wait for clean exit.
If still alive after timeout, send SIGKILL.
Returns True if stopped cleanly.
"""
def restart(self, timeout=30) -> bool:
"""stop() then start()."""
def is_alive(self) -> bool:
"""Check process.poll() and optionally /api/status."""
def pid(self) -> int | None:
"""Return PID if running."""
def return_code(self) -> int | None:
"""Return exit code if stopped."""
```
Key details:
- Uses `subprocess.Popen` with `stdout=PIPE, stderr=PIPE`
- Captures stdout/stderr for crash diagnostics
- The health check polls `GET /api/status` every 500ms until success or timeout
- Sets `DIDACTYL_LOG_FILE` to a test-run-specific path like `tests/results/<timestamp>/agent_debug.log`
### 2. didactyl_client.py — HTTP API Client
Thin wrapper around the Didactyl HTTP API using only `urllib`.
```python
class DidactylClient:
def __init__(self, base_url="https://127.0.0.1:8484", timeout=60,
verify_tls=False):
"""Configure base URL and default timeout."""
def status(self) -> dict:
"""GET /api/status"""
def prompt(self, message: str, max_turns: int = 4,
model: str = None) -> dict:
"""POST /api/prompt/agent — full agent context conversation"""
def prompt_raw(self, messages: list, max_turns: int = 4,
model: str = None) -> dict:
"""POST /api/prompt/run — raw messages, no auto-context"""
def prompt_simple(self, system: str, user: str,
model: str = None) -> dict:
"""POST /api/prompt/run-simple — no tools"""
def context_current(self) -> dict:
"""GET /api/context/current"""
def context_parts(self) -> dict:
"""GET /api/context/parts"""
def fire_webhook(self, d_tag: str, payload: dict = None) -> dict:
"""POST /api/trigger/<d_tag>"""
```
Key details:
- All methods return parsed JSON dict
- Raises `TimeoutError` if response exceeds timeout
- Raises `ConnectionError` if agent is unreachable
- Raises `APIError(status_code, body)` for non-2xx responses
- Uses `ssl._create_unverified_context()` for local TLS (same as chat CLI)
### 3. log_watcher.py — Real-time Log Monitor
Background thread that tails the agent's debug log file.
```python
class LogWatcher:
def __init__(self, log_path: str):
"""Configure log file path."""
def start(self):
"""
Start background thread.
Open file, seek to end, poll for new lines.
Store all lines in memory with timestamps.
"""
def stop(self):
"""Stop background thread."""
def set_marker(self, name: str):
"""Record current line count as a named marker."""
def get_lines_since(self, marker: str) -> list[str]:
"""Return all lines captured since the named marker."""
def get_all_lines(self) -> list[str]:
"""Return all captured lines."""
def search(self, pattern: str, since_marker: str = None) -> list[str]:
"""Regex search through captured lines."""
def has_errors(self, since_marker: str = None) -> bool:
"""Check for [ERROR] lines since marker."""
def has_warnings(self, since_marker: str = None) -> bool:
"""Check for [WARN] lines since marker."""
def error_lines(self, since_marker: str = None) -> list[str]:
"""Return all ERROR lines since marker."""
```
Key details:
- Uses `threading.Thread(daemon=True)` for background polling
- Polls file every 100ms for new content
- Handles file rotation (agent restart creates new file)
- Thread-safe access to captured lines via `threading.Lock`
### 4. test_runner.py — Orchestrator
```python
class TestResult:
name: str
suite: str
status: str # "pass", "fail", "error", "skip", "timeout"
message: str
duration_seconds: float
agent_errors: list[str] # ERROR lines from log during this test
details: dict # arbitrary test-specific data
class TestCase:
name: str
description: str
requires_restart: bool = False
def run(self, client: DidactylClient, log: LogWatcher) -> TestResult:
"""Execute the test and return result."""
class TestRunner:
def __init__(self, agent: AgentProcess, client: DidactylClient,
log: LogWatcher):
"""Configure with harness components."""
def discover_suites(self, suites_dir: str) -> list:
"""Import all test_*.py modules from suites directory."""
def run_all(self, suites: list = None) -> list[TestResult]:
"""
For each suite:
1. If test requires restart, restart agent
2. Set log marker for this test
3. Run test with timeout wrapper
4. Capture result + any agent errors from log
5. If agent crashed, restart and record error
6. Collect all results
Return list of TestResult.
"""
def run_suite(self, suite_name: str) -> list[TestResult]:
"""Run a single named suite."""
```
Key details:
- Each test gets a fresh log marker so errors can be correlated
- If `client.prompt()` raises `TimeoutError`, the test is marked "timeout" and the agent is restarted
- If `agent.is_alive()` returns False mid-suite, the agent is restarted and remaining tests continue
- Supports filtering by suite name or test name via CLI args
### 5. reporter.py — Results Output
```python
class Reporter:
def __init__(self, results: list[TestResult], output_dir: str):
"""Configure with results and output directory."""
def print_summary(self):
"""Print pass/fail/error/skip/timeout counts to stdout."""
def print_details(self):
"""Print each test result with details."""
def write_json(self, path: str):
"""Write full results as JSON for programmatic consumption."""
def write_text(self, path: str):
"""Write human-readable report."""
```
Output format example:
```
== Didactyl Test Results ==
Run: 2026-03-25T09:50:00Z
Agent: v0.0.26
Model: claude-haiku-4.5
Pass: 42
Fail: 3
Error: 1
Timeout: 2
Skip: 0
Total: 48
-- Failures --
[FAIL] test_tools_nostr::nostr_post_kind1
Expected tool_calls to contain 'nostr_post', got: ['nostr_query']
Agent errors during test: 0
[FAIL] test_conversation::multi_turn
Agent returned empty final_response
Agent errors during test: 2
[ERROR] [llm.c:234] HTTP 429 rate limited
[ERROR] [llm.c:240] LLM call failed after 3 retries
[TIMEOUT] test_tools_skills::skill_create
No response within 60s
Agent was restarted
```
---
## Test Suite Specifications
### test_health.py
| Test | Prompt/Action | Assertions |
|---|---|---|
| `status_returns_200` | `GET /api/status` | HTTP 200, `success=true` |
| `status_has_fields` | `GET /api/status` | Has `name`, `version`, `pubkey`, `relay_count` |
| `context_current_returns_messages` | `GET /api/context/current` | Has `messages` array, `total_chars > 0` |
| `context_parts_has_system_prompt` | `GET /api/context/parts` | Has part named `system_prompt` |
### test_conversation.py
| Test | Prompt | Assertions |
|---|---|---|
| `simple_greeting` | "Hello, what is your name?" | `final_response` is non-empty string |
| `agent_responds_about_itself` | "What are you? Describe yourself briefly." | `final_response` mentions agent/Didactyl/Nostr |
| `empty_message_handling` | "" (empty string) | Returns error or handles gracefully |
| `very_long_message` | 10000 char string | Returns response or graceful error, no crash |
### test_tools_identity.py
| Test | Prompt | Expected Tool | Assertions |
|---|---|---|---|
| `get_pubkey` | "What is your public key in hex?" | `nostr_pubkey` or `my_pubkey` | Tool called, result has hex pubkey |
| `get_npub` | "What is your npub?" | `nostr_npub` or `my_npub` | Tool called, result has npub1... |
| `agent_identity` | "Tell me about your identity" | `agent_identity` | Tool called, success |
| `agent_version` | "What version are you?" | `agent_version` | Tool called, result has version string |
| `admin_identity` | "Who is your administrator?" | `admin_identity` | Tool called, success |
### test_tools_nostr.py
| Test | Prompt | Expected Tool | Assertions |
|---|---|---|---|
| `nostr_post_kind1` | "Post a test note saying 'Automated test post'" | `nostr_post` | Tool called with kind=1, success, event_id returned |
| `nostr_query_recent` | "Query the 3 most recent kind 1 notes from any author" | `nostr_query` | Tool called, returns events array |
| `nostr_my_events` | "List your recent events" | `nostr_my_events` | Tool called, success |
| `nostr_relay_status` | "What is the status of your relay connections?" | `nostr_relay_status` | Tool called, returns relay info |
| `nostr_dm_send` | "Send a test DM to yourself" | `nostr_dm_send` | Tool called, success |
| `nostr_encode_npub` | "Encode your pubkey as an npub" | `nostr_encode` | Tool called, returns npub |
| `nostr_profile_get` | "Look up your own Nostr profile" | `nostr_profile_get` | Tool called, returns profile |
### test_tools_skills.py
| Test | Prompt | Expected Tool | Assertions |
|---|---|---|---|
| `skill_list` | "List your available skills" | `skill_list` | Tool called, returns skills array |
| `trigger_list` | "List your active triggers" | `trigger_list` | Tool called, success |
| `skill_create_and_remove` | "Create a test skill called 'test-harness-probe' with content 'Test skill' then remove it" | `skill_create`, `skill_remove` | Both tools called, success |
### test_tools_system.py
| Test | Prompt | Expected Tool | Assertions |
|---|---|---|---|
| `tool_list` | "List all your available tools" | `tool_list` | Tool called, returns tools array |
| `model_get` | "What model are you currently using?" | `model_get` | Tool called, returns model info |
| `model_list` | "List available models" | `model_list` | Tool called, success |
| `local_http_fetch` | "Fetch https://httpbin.org/get" | `local_http_fetch` | Tool called, returns HTTP response |
| `config_store_recall` | "Store a test config with d_tag 'test_harness_probe' containing 'hello', then recall it" | `config_store`, `config_recall` | Both called, recalled value matches |
### test_tools_memory.py
| Test | Prompt | Expected Tool | Assertions |
|---|---|---|---|
| `task_list` | "Show me your current task list" | `task_list` or `task_manage` | Tool called, success |
| `task_manage_add_remove` | "Add a task 'test harness probe task' then remove it" | `task_manage` | Tool called with add then remove |
| `memory_save_recall` | "Save 'test harness probe' to memory, then recall your memory" | `memory_save`, `memory_recall` | Both called, recalled contains probe text |
### test_tools_cashu.py
| Test | Prompt | Expected Tool | Assertions |
|---|---|---|---|
| `wallet_balance` | "Check your cashu wallet balance" | `cashu_wallet_balance` | Tool called, success (even if empty) |
Note: Most cashu tools require a configured mint and funded wallet. Phase 1 tests only the read-only balance check. Full cashu testing requires a test mint setup.
### test_tools_blossom.py
| Test | Prompt | Expected Tool | Assertions |
|---|---|---|---|
| `blossom_list` | "List your blossom blobs" | `blossom_list` | Tool called, success (even if empty) |
Note: Full blossom testing requires a configured blossom server. Phase 1 tests only the list operation.
### test_timeouts.py
| Test | Action | Assertions |
|---|---|---|
| `response_within_timeout` | Send simple prompt, measure time | Response received within 60s |
| `status_responds_fast` | `GET /api/status`, measure time | Response within 2s |
| `context_responds_fast` | `GET /api/context/current`, measure time | Response within 5s |
### test_errors.py
| Test | Action | Assertions |
|---|---|---|
| `invalid_json_body` | POST malformed JSON to `/api/prompt/agent` | Returns 400, `success=false` |
| `missing_message_field` | POST `{}` to `/api/prompt/agent` | Returns 400, `success=false` |
| `unknown_endpoint` | GET `/api/nonexistent` | Returns 404 |
| `webhook_nonexistent_dtag` | POST to `/api/trigger/nonexistent-dtag` | Returns 404 |
### test_restart.py
| Test | Action | Assertions |
|---|---|---|
| `clean_restart` | Stop agent, start agent | Agent comes back, `/api/status` works |
| `status_after_restart` | Restart, then `GET /api/status` | Same pubkey, version as before restart |
| `conversation_after_restart` | Restart, then send prompt | Agent responds normally |
---
## Entry Point: run_tests.py
```
Usage:
python tests/run_tests.py [options]
Options:
--config PATH Path to test genesis.jsonc (default: tests/configs/test_genesis.jsonc)
--binary PATH Path to didactyl binary (default: ./didactyl)
--suite NAME Run only this suite (can repeat)
--test NAME Run only this test (can repeat)
--api-port PORT API port (default: 8485)
--timeout SECS Default response timeout (default: 60)
--debug-level N Agent debug level 0-5 (default: 5)
--output-dir PATH Results output directory (default: tests/results/<timestamp>)
--verbose Print each test result as it runs
--no-restart Don't auto-restart agent on crash (fail remaining tests)
```
---
## Test Genesis Config
`tests/configs/test_genesis.jsonc` — an example config the user fills in:
```jsonc
{
// TEST CONFIGURATION — fill in your test identity secrets
"key": {
"nsec": "nsec1REPLACE_WITH_DISPOSABLE_TEST_NSEC"
},
"admin": {
"pubkey": "npub1REPLACE_WITH_TEST_ADMIN_PUBKEY"
},
"dm_protocol": "nip04",
"llm": {
"provider": "openai",
"api_key": "sk-REPLACE_WITH_API_KEY",
"model": "claude-haiku-4.5",
"base_url": "https://api.anthropic.com/v1",
"max_tokens": 512,
"temperature": 0.3
},
"api": {
"enabled": true,
"port": 8485,
"bind_address": "127.0.0.1"
},
"startup_events": [
{
"kind": 0,
"content_fields": {
"name": "Didactyl Test Agent",
"about": "Automated test instance"
},
"tags": []
},
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net"]
]
},
{
"kind": 31124,
"content": "# Test Agent\n\nYou are a test agent. Respond to all requests. Use tools when asked.\n\n{{my_kind0_profile}}\n\nYour npub: {{my_npub}}",
"tags": [
["d", "identity_and_rules"],
["app", "didactyl"],
["scope", "private"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]
}
]
}
```
---
## Execution Flow
```mermaid
flowchart TD
START[run_tests.py] --> PARSE[Parse CLI args]
PARSE --> BUILD[Verify didactyl binary exists]
BUILD --> CONFIG[Load test genesis config]
CONFIG --> MKDIR[Create results output dir]
MKDIR --> LOG_START[Start LogWatcher]
LOG_START --> AGENT_START[Start AgentProcess]
AGENT_START --> HEALTH[Wait for /api/status 200]
HEALTH -->|timeout| FAIL_STARTUP[Report startup failure and exit]
HEALTH -->|success| DISCOVER[Discover test suites]
DISCOVER --> LOOP{Next test?}
LOOP -->|yes| CHECK_ALIVE{Agent alive?}
CHECK_ALIVE -->|no| RESTART_MID[Restart agent]
RESTART_MID --> CHECK_ALIVE
CHECK_ALIVE -->|yes| MARKER[Set log marker]
MARKER --> RUN_TEST[Run test with timeout]
RUN_TEST -->|pass| RECORD_PASS[Record PASS]
RUN_TEST -->|fail| RECORD_FAIL[Record FAIL]
RUN_TEST -->|timeout| RESTART_TIMEOUT[Restart agent]
RESTART_TIMEOUT --> RECORD_TIMEOUT[Record TIMEOUT]
RUN_TEST -->|error| RECORD_ERROR[Record ERROR]
RECORD_PASS & RECORD_FAIL & RECORD_TIMEOUT & RECORD_ERROR --> COLLECT_LOGS[Collect agent errors since marker]
COLLECT_LOGS --> LOOP
LOOP -->|no more| STOP_AGENT[Stop agent]
STOP_AGENT --> STOP_LOG[Stop LogWatcher]
STOP_LOG --> REPORT[Generate report]
REPORT --> EXIT[Exit with code 0 if all pass else 1]
```
---
## Implementation Order
1. **Harness infrastructure** (agent_process, didactyl_client, log_watcher)
2. **Test runner + reporter** (orchestration layer)
3. **test_health.py** (validates the harness itself works)
4. **test_conversation.py** (validates basic agent interaction)
5. **test_errors.py** (validates error handling)
6. **test_timeouts.py** (validates timeout detection)
7. **test_restart.py** (validates process management)
8. **Tool test suites** (one at a time, identity → nostr → skills → system → memory → cashu → blossom)
9. **Entry point + config** (run_tests.py, test_genesis.jsonc)
10. **End-to-end validation** against a running agent
---
## Future: Phase 2 — LLM-Driven Testing
The architecture supports this naturally. In Phase 2:
- Add an `LLMTestAgent` class that wraps an LLM API call
- The LLM receives: tool documentation, test objectives, previous results
- It generates test prompts, evaluates responses, decides next actions
- The `TestCase.run()` method delegates to the LLM agent instead of scripted logic
- The LLM can also analyze agent logs for anomalies
- Potentially: the LLM can generate code patches for bugs it finds
No architectural changes needed — just a new test suite type that uses LLM reasoning instead of scripted assertions.
+668
View 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
+246
View File
@@ -0,0 +1,246 @@
# DM History Context Cleanup, Skill Rename & Startup Events Unification
## Problems
### 1. DM History as JSON in System Prompt
The `{{nostr_dm_history}}` template variable resolves to a raw JSON array string pasted into the system prompt. The LLM receives conversation history as JSON-within-text, wasting tokens and forcing it to parse structured data embedded in prose.
### 2. Misleading Skill Name
The skill `default_admin_dm` is really an identity and rules skill. Rename to `identity_and_rules`.
### 3. `default_skill` Config Section Is Redundant
The `default_skill` section in genesis.jsonc is a special-case config path that converts a skill into a startup event and auto-adopts it. This is unnecessary — skills should just be startup events like everything else. The program should treat all startup events equally and halt if any fail to publish.
## Solution
Three coordinated changes:
### Part A: DM History Text Format
Add a `format` parameter to the `nostr_dm_history` tool. Create a separate `dm_history` skill.
### Part B: Skill Rename
Rename `default_admin_dm``identity_and_rules` everywhere.
### Part C: Eliminate `default_skill` Config Section
Move skills into `startup_events`. Auto-adopt kind 31123/31124 startup events. Make startup event publishing mandatory (halt on failure).
---
## Implementation Steps
### Part A: DM History Text Format
#### A1. Add `format` parameter to `execute_nostr_dm_history()`
**File:** `src/tools/tool_agent.c` — line 265
Add a `format` parameter: `"json"` (default, current behavior) or `"text"`.
When `format` is `"text"`, after the filtering loop (lines 320-360), iterate the filtered array and build plain text:
```
User: Good morning
Assistant: Good morning! ☀️ How can I help you today?
User: What is the capital of England?
Assistant: London! 🇬🇧
```
Existing `include_current=false` default already deduplicates the current live message (line 339).
#### A2. Add `format` to the tool schema
**File:** `src/tools/tools_schema.c` — around line 1493
Add `format` property with description. Update tool description to mention the parameter.
---
### Part B: Skill Rename
#### B1. Update `src/default_events.h`
- `DIDACTYL_DEFAULT_SKILL_D_TAG`: `"default_admin_dm"``"identity_and_rules"`
- `DIDACTYL_DEFAULT_SKILL_TAGS_JSON`: update d-tag and description
- `DIDACTYL_DEFAULT_SKILL_TEMPLATE`: keep as-is (already clean, no `{{nostr_dm_history}}`)
#### B2. Update tool description examples in `src/tools/tools_schema.c`
- Line 854: `skill_get` example
- Line 883: `skill_view` example
- Line 911: `skill_set` example
#### B3. Update `docs/GENESIS.md`
- Line 33 and 124: `default_admin_dm``identity_and_rules`
---
### Part C: Eliminate `default_skill` Config Section
#### C1. Auto-adopt kind 31123/31124 startup events
**File:** `src/config.c`
Replace `config_ensure_default_skill_startup_events()` with a new function `config_ensure_startup_skill_adoption()` that:
1. Scans all startup events for kind 31123 and 31124
2. For each skill found, extracts its `d_tag` from tags
3. Builds the skill address (`kind:pubkey:d_tag`)
4. Ensures a kind 10123 adoption list startup event exists with all skill addresses
5. If no 10123 event exists, creates one
This replaces the single-skill logic with a generic "adopt all startup skills" approach.
#### C2. Remove `default_skill` config parsing
**File:** `src/config.c`
- Remove `parse_default_skill_config()` function (lines 733-816)
- Remove the call at line 1369
- Keep backward compatibility: if `default_skill` section exists in genesis.jsonc, convert it to a startup event during parsing (migration path) — OR just remove support and let users update their genesis.jsonc
#### C3. Remove `default_skill_config_t` from config struct
**File:** `src/config.h`
- Remove `default_skill_config_t` typedef (lines 67-72)
- Remove `default_skill` field from `didactyl_config_t` (line 134)
#### C4. Update `src/main.c`
- Line 1060: Replace `config_ensure_default_skill_startup_events()` call with `config_ensure_startup_skill_adoption()`
#### C5. Make startup event publishing mandatory
**File:** `src/nostr_handler.c`
In the startup publish flow (around line 3150), after all relays have been tried:
- Check that each startup event was published to at least one relay
- If any event failed on all relays, log an error and return failure
- The caller in `main.c` should halt on this failure
#### C6. Update setup wizard
**File:** `src/setup_wizard.c`
- `configure_default_skill_for_agent()` (line 498): Instead of populating `cfg->default_skill`, append the identity skill as a startup event directly
- Add the `dm_history` skill as a second startup event
- The auto-adoption logic (C1) handles the adoption list
#### C7. Update `default_events.h`
Add the `dm_history` skill template:
```c
#define DIDACTYL_DEFAULT_DM_HISTORY_SKILL_D_TAG "dm_history"
static const char* DIDACTYL_DEFAULT_DM_HISTORY_SKILL_TEMPLATE =
"## Recent Conversation\n\n"
"{{nostr_dm_history({\"format\":\"text\",\"limit\":12})}}";
```
---
### Part D: Genesis Config Updates
#### D1. Update `genesis.jsonc.example`
Remove `default_skill` section. Add skills as startup events:
```jsonc
"startup_events": [
{
"kind": 0,
"content_fields": {
"name": "Didactyl",
"about": "I am a Didactyl agent living on Nostr"
},
"tags": []
},
{
"kind": 3,
"content": "",
"tags": [["p", "ADMIN_HEX_PUBKEY"]]
},
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net"]
]
},
{
"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": [
["d", "identity_and_rules"],
["app", "didactyl"],
["scope", "private"],
["description", "Agent identity and behavioral rules"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]
},
{
"kind": 31124,
"content": "## Recent Conversation\n\n{{nostr_dm_history({\"format\":\"text\",\"limit\":12})}}",
"tags": [
["d", "dm_history"],
["app", "didactyl"],
["scope", "private"],
["description", "DM conversation history for context continuity"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]
}
]
```
#### D2. Update live `genesis.jsonc`
Same changes as D1 but for the actual deployment config.
---
## Result
### Before
```json
[
{"role": "system", "content": "You are {...}\n\nYour npub: npub1...\n\n## Rules\n...\n[{\"role\":\"user\",\"content\":\"Good morning\",...}]"},
{"role": "user", "content": "And Fiji?"}
]
```
### After
```json
[
{"role": "system", "content": "# Didactyl Agent\n\nYou are {...}\n\nYour npub: npub1...\n\n## Rules\n...\n\n---\n\n## Recent Conversation\n\nUser: Good morning\nAssistant: Good morning! ☀️ How can I help you today?\nUser: What is the capital of England?\nAssistant: London! 🇬🇧\nUser: How about France?\nAssistant: Paris! 🇫🇷"},
{"role": "user", "content": "And Fiji?"}
]
```
---
## Files Changed
| File | Change |
|------|--------|
| `src/tools/tool_agent.c` | Add `format` parameter to `execute_nostr_dm_history()` |
| `src/tools/tools_schema.c` | Add `format` to schema; update example d-tags |
| `src/default_events.h` | Rename d-tag; add `dm_history` skill template |
| `src/config.h` | Remove `default_skill_config_t` and field |
| `src/config.c` | Remove `parse_default_skill_config()`; replace `config_ensure_default_skill_startup_events()` with `config_ensure_startup_skill_adoption()` |
| `src/main.c` | Update call to new adoption function |
| `src/nostr_handler.c` | Make startup event publishing mandatory (halt on total failure) |
| `src/setup_wizard.c` | Put skills in startup_events; add dm_history |
| `genesis.jsonc.example` | Remove `default_skill`; add skills as startup events |
| `genesis.jsonc` | Same as example |
| `docs/GENESIS.md` | Update references |
## Migration Note
For backward compatibility, consider keeping `parse_default_skill_config()` as a migration shim that converts `default_skill` into a startup event with a deprecation warning. This way existing genesis.jsonc files with `default_skill` still work but users are prompted to update.
+295
View File
@@ -0,0 +1,295 @@
# NIP-51 Kind 10000 Encrypted Block List
## Problem
Corrupt/unwanted events (e.g., malformed kind:7375 cashu token events) persist on relays even after NIP-09 kind:5 deletion requests. Relays accept the deletion event but continue serving the originals. The agent needs a way to permanently ignore specific events, pubkeys, and hashtags.
## Solution
Implement a **kind 10000 mute/block list** per NIP-51, with all entries **encrypted by default** (NIP-44 encrypt-to-self in the `content` field). Provide an in-memory cache that filters events at the `nostr_handler` layer so blocked items are dropped system-wide.
## Event Format (NIP-51 Kind 10000)
```json
{
"kind": 10000,
"tags": [],
"content": "<NIP-44 encrypted to self: JSON array of tag tuples>"
}
```
When decrypted, `content` yields:
```json
[
["p", "<blocked-pubkey-hex>"],
["e", "<blocked-event-id-hex>"],
["t", "spam"]
]
```
- Kind 10000 is **replaceable** (10000-19999 range) — each publish replaces the previous
- Public entries go in `tags`, private entries go in encrypted `content`
- **Default: all entries are private/encrypted**
## Data Flow Analysis
Events enter the didactyl system through two paths:
### Path 1: Subscriptions (streaming)
```
nostr_relay_pool_subscribe() → on_event callback → various handlers
```
Used by: DM subscriptions, admin context, agent context, self-skills, custom subscriptions
### Path 2: Synchronous queries
```
nostr_relay_pool_query_sync() → nostr_handler_query_json() → caller parses JSON array
```
Used by: cashu_wallet_load_from_relays(), tool queries, startup checks, skill loading
### Where to Filter
There is **no single upstream chokepoint** that catches both paths. The two best integration points are:
1. **`nostr_handler_query_json()`** — wraps all sync queries. Add filtering in the event loop at line 2862 before adding events to the result array.
2. **Managed subscription dispatch** — the internal function at line 606 that activates subscriptions. Wrap each subscription's `on_event` callback with a filtering wrapper.
Both are in `nostr_handler.c` and can call the same `nostr_block_list_is_event_filtered()` function.
```mermaid
graph TD
subgraph "nostr_core_lib - Pool Layer"
POOL[nostr_relay_pool]
end
subgraph "nostr_handler.c - FILTERING LAYER"
QJ[nostr_handler_query_json<br/>line 2842]
SWF[nostr_handler_subscribe_with_filter<br/>line 2880]
MS[managed subscription dispatch<br/>line 606]
QJ --> |"filter here: skip blocked events<br/>before adding to result array"| QJ_OUT[Return filtered JSON]
MS --> |"wrap on_event: check block list<br/>before dispatching to callback"| CB[Original on_event callback]
SWF --> |"wrap on_event: check block list"| SWF_CB[Original on_event callback]
end
subgraph "nostr_block_list module"
BL[nostr_block_list_is_event_filtered]
BL --> PS[Blocked pubkeys set]
BL --> ES[Blocked event IDs set]
BL --> TS[Blocked hashtags set]
end
POOL --> QJ
POOL --> MS
POOL --> SWF
QJ --> BL
MS --> BL
SWF --> BL
```
## Module Design
### New Files
#### `src/nostr_block_list.h`
```c
#ifndef DIDACTYL_NOSTR_BLOCK_LIST_H
#define DIDACTYL_NOSTR_BLOCK_LIST_H
#include "config.h"
#include "cjson/cJSON.h"
// Lifecycle
int nostr_block_list_init(didactyl_config_t* cfg);
int nostr_block_list_load_from_relays(void);
void nostr_block_list_cleanup(void);
// Query — O(1) lookups, thread-safe
int nostr_block_list_is_pubkey_blocked(const char* pubkey_hex);
int nostr_block_list_is_event_blocked(const char* event_id_hex);
int nostr_block_list_is_hashtag_blocked(const char* hashtag);
// Generic filter — checks event's pubkey, id, and t-tags
// Returns 1 if event should be blocked, 0 if allowed
int nostr_block_list_is_event_filtered(cJSON* event);
// Edit — modifies private (encrypted) entries by default
int nostr_block_list_add(const char* tag_key, const char* tag_value, int is_public);
int nostr_block_list_remove(const char* tag_key, const char* tag_value);
// Refresh in-memory cache from relays
int nostr_block_list_refresh(void);
// Introspection — returns JSON with all blocked items
char* nostr_block_list_json(void);
#endif
```
#### `src/nostr_block_list.c`
Internal state:
```c
typedef struct {
didactyl_config_t* cfg;
int initialized;
int loaded;
pthread_mutex_t mutex;
// Public entries (from tags)
char** pub_pubkeys; int pub_pubkey_count;
char** pub_event_ids; int pub_event_id_count;
char** pub_hashtags; int pub_hashtag_count;
// Private entries (from decrypted content)
char** priv_pubkeys; int priv_pubkey_count;
char** priv_event_ids; int priv_event_id_count;
char** priv_hashtags; int priv_hashtag_count;
} block_list_state_t;
```
Key implementation details:
- `load_from_relays()`: Query kind:10000 authored by agent pubkey, parse public tags, NIP-44 decrypt content for private tags
- `is_event_filtered(event)`: Extract `id`, `pubkey`, and `t` tags from event JSON, check against both public and private sets
- `add()/remove()`: Fetch existing kind:10000, decrypt content, modify private tag list, re-encrypt, republish
- All lookups are O(n) linear scan initially (simple); can upgrade to hash set if list grows large
### New Tool Files
#### `src/tools/tool_nostr_block.c`
Two tool functions:
**`execute_nostr_block_list()`** — View current block list
```json
// Input
{ "type": "all" } // or "p", "e", "t"
// Output
{
"success": true,
"blocked_pubkeys": ["abc123..."],
"blocked_events": ["def456..."],
"blocked_hashtags": ["spam"],
"total_entries": 3
}
```
**`execute_nostr_block_edit()`** — Add/remove block entries
```json
// Input
{
"action": "add",
"items": [["e", "<event-id>"], ["p", "<pubkey>"]],
"public": false
}
// Output
{
"success": true,
"action": "add",
"items_affected": 2,
"event_id": "<new-kind-10000-event-id>"
}
```
## Integration Points
### 1. `nostr_handler_query_json()` — Sync query filtering
At line 2862 in `nostr_handler.c`, before adding each event to the result array:
```c
for (int i = 0; i < event_count; i++) {
if (!events[i]) continue;
// NEW: block list filter
if (nostr_block_list_is_event_filtered(events[i])) {
cJSON_Delete(events[i]);
continue;
}
cJSON* dup = cJSON_Duplicate(events[i], 1);
if (dup) cJSON_AddItemToArray(arr, dup);
cJSON_Delete(events[i]);
}
```
This catches: cashu wallet loading, tool queries, skill loading, startup checks.
### 2. Subscription event dispatch — Streaming filter
For managed subscriptions (line ~606) and `nostr_handler_subscribe_with_filter()` (line 2880), wrap the `on_event` callback:
```c
// Wrapper that checks block list before dispatching
static void filtered_on_event(cJSON* event, const char* relay_url, void* user_data) {
if (nostr_block_list_is_event_filtered(event)) return;
// dispatch to original callback
...
}
```
This catches: DMs, admin context events, agent context events, self-skill events, custom subscriptions.
### 3. Startup sequence in `main.c`
```c
nostr_handler_init(cfg);
nostr_block_list_init(cfg);
nostr_block_list_load_from_relays(); // must load before cashu wallet
cashu_wallet_init(cfg);
```
### 4. After block list edits
When `execute_nostr_block_edit()` publishes an updated kind:10000, call `nostr_block_list_refresh()` to reload the in-memory cache.
## Encryption Details
### Encrypt to self (for private entries)
```c
// Encrypt: sender=agent privkey, recipient=agent pubkey
nostr_nip44_encrypt(
cfg->keys.private_key, // sender private key (32 bytes)
cfg->keys.public_key, // recipient public key (agent's own, 32 bytes)
private_tags_json_string, // plaintext
output_buffer,
output_buffer_size
);
// Decrypt: recipient=agent privkey, sender=agent pubkey
nostr_nip44_decrypt(
cfg->keys.private_key, // recipient private key
cfg->keys.public_key, // sender public key (agent's own)
encrypted_content, // ciphertext from event content
output_buffer,
output_buffer_size
);
```
## Implementation Checklist
- [ ] Create `src/nostr_block_list.h` with API declarations
- [ ] Create `src/nostr_block_list.c` with:
- [ ] State struct and lifecycle (init/cleanup)
- [ ] `load_from_relays()` — fetch kind:10000, parse public tags, NIP-44 decrypt private tags
- [ ] `is_pubkey_blocked()`, `is_event_blocked()`, `is_hashtag_blocked()` — lookup functions
- [ ] `is_event_filtered()` — generic event check combining all three
- [ ] `add()` / `remove()` — fetch existing, decrypt, modify, re-encrypt, republish
- [ ] `refresh()` — reload from relays
- [ ] `nostr_block_list_json()` — introspection
- [ ] Create `src/tools/tool_nostr_block.c` with:
- [ ] `execute_nostr_block_list()` — view tool
- [ ] `execute_nostr_block_edit()` — edit tool
- [ ] Register tools in `tools_schema.c` and `tools_dispatch.c`
- [ ] Add declarations to `tools_internal.h`
- [ ] Integrate filtering in `nostr_handler.c`:
- [ ] Filter in `nostr_handler_query_json()` event loop
- [ ] Wrap subscription `on_event` callbacks with block list check
- [ ] Add `nostr_block_list_init()` and `nostr_block_list_load_from_relays()` to startup in `main.c`
- [ ] Update `Makefile` to compile new source files
- [ ] Test: add corrupt cashu token event IDs to block list, verify wallet skips them on reload
+224
View File
@@ -0,0 +1,224 @@
# Strict Startup Validation — Eliminate Fake Event Seeding
## Problem
The agent currently seeds a **synthetic event** into the self-skill cache during startup
(`seed_default_skill_into_cache()` in `src/nostr_handler.c:1299`). This fake event has:
- `id` = all zeros
- `created_at` = `time(NULL)` (current startup time)
Because the fake event is newer than the real relay event, the upsert rejects the real
event. This causes `skill_get` to return an all-zeros ID and prevents the cache from
ever holding the authentic relay event.
Additionally, the adopted skills cache in `src/agent.c` has a startup fallback path
that uses `g_cfg->startup_events` when the adoption list is empty. This masks real
failures and creates inconsistency between what the agent thinks it has and what
actually exists on relays.
## Principle
> If the agent cannot retrieve the events it needs to run, startup should fail with
> a clear diagnostic. No fake events. No fallbacks. If it is not there, fail.
## Two Startup Modes
### Genesis Startup (first run)
1. Connect to relays
2. Publish startup events (profile, contacts, relay list, default skill, adoption list)
3. **Wait for publish confirmation** from at least one relay per event
4. Log at INFO level: which events were published to which relays
5. Subscribe to self-skill cache
6. Wait for EOSE — validate published events are now in cache
7. If validation fails: abort with diagnostic
### Subsequent Startup (existing agent)
1. Connect to relays
2. Subscribe to self-skill cache
3. Wait for EOSE
4. Log at INFO level: which events were received from which relays
5. Validate required events exist in cache
6. If validation fails: abort with diagnostic
## Current Startup Flow (steps 1418)
```
Step 14: Subscribe self-skill cache (sends REQ for kinds 31123/31124/10123)
→ Does NOT wait for EOSE
→ Calls seed_default_skill_into_cache() with fake event
Step 15: Subscribe DMs (agent starts accepting messages)
→ Startup DM sent BEFORE this step
Step 16: Subscribe wallet events
Step 17: Initialize cashu wallet
Step 18: READY — enter main poll loop
```
## Proposed New Flow
```
Step 14: Subscribe self-skill cache (sends REQ for kinds 31123/31124/10123)
Step 14a: Wait for self-skill EOSE (15s timeout)
Step 14b: Validate required events exist in cache
→ Log received events at INFO level
→ If no skill events: FAIL startup with diagnostic
Step 15: Subscribe DMs
Step 16: Subscribe wallet events
Step 17: Initialize cashu wallet
Step 18: READY — send startup DM AFTER all validation, enter main loop
```
## Changes
### 1. Remove `seed_default_skill_into_cache()` entirely
**File:** `src/nostr_handler.c`
- Delete the entire function (lines 12991341)
- Remove its call at line 3165 in `nostr_handler_reconcile_startup_events()`
- Remove its forward declaration at line 451
### 2. Remove startup fallback in `refresh_adopted_skills_cache_if_needed()`
**File:** `src/agent.c`
Delete the entire fallback block (lines ~15711619) that uses
`g_cfg->startup_events` when the adoption list is empty. If the adoption
list is empty, the cache stays empty — no synthetic population.
### 3. Add synchronous EOSE wait for self-skill subscription
**File:** `src/nostr_handler.c`
Add a new exported function:
```c
int nostr_handler_wait_for_self_skill_eose(int timeout_ms);
```
Implementation:
- Use a static `volatile int g_self_skill_eose_received = 0` flag
- Set it to 1 in `on_self_skill_eose()`
- The wait function polls `nostr_relay_pool_poll()` in a loop until the
flag is set or the timeout expires
- Returns 0 on success, -1 on timeout
### 4. Add post-EOSE validation with logging
**File:** `src/nostr_handler.c`
Add a new exported function:
```c
int nostr_handler_validate_self_skill_cache(int* out_skill_count, int* out_adoption_count);
```
This function:
- Counts skill events (kind 31123 + 31124) in cache
- Counts adoption events (kind 10123) in cache
- Logs at INFO level each cached event: kind, d_tag, id, created_at
- Returns 0 if at least one skill event exists
- Returns -1 if no skill events found
### 5. Update startup sequence in `main.c`
**File:** `src/main.c`
After step 14 subscribe, add EOSE wait and validation:
```c
// Wait for self-skill EOSE
int eose_timeout_ms = 15000;
if (nostr_handler_wait_for_self_skill_eose(eose_timeout_ms) != 0) {
startup_step_fail(14, "Subscribe self-skill cache",
"self-skill events not received within timeout; "
"check relay connectivity and that skill events exist");
// cleanup and return 1
}
// Validate
int skill_count = 0, adoption_count = 0;
if (nostr_handler_validate_self_skill_cache(&skill_count, &adoption_count) != 0) {
startup_step_fail(14, "Subscribe self-skill cache",
"no skill events found after EOSE; "
"run genesis to publish default skill events");
// cleanup and return 1
}
char detail[128];
snprintf(detail, sizeof(detail),
"skills=%d adoptions=%d", skill_count, adoption_count);
startup_step_ok(14, "Subscribe self-skill cache", detail);
```
### 6. Move startup DM to after all validation
**File:** `src/main.c`
Move the startup DM send from its current position (between steps 14 and 15)
to just before step 18 READY. The agent should only announce itself as online
after all required events have been validated and all subscriptions are active.
### 7. Genesis publish confirmation logging
**File:** `src/nostr_handler.c` / `src/main.c`
For genesis startup, the existing publish path already logs at INFO level:
```
kind 31124 event published to wss://relay.damus.io (async)
```
Enhance this to also log a summary after all startup events are published:
```
[INFO] startup publish summary: 5 events published to 3/4 relays
[INFO] kind=0 (profile) → relay.damus.io, relay.primal.net, nos.lol
[INFO] kind=31124 (default_admin_dm) → relay.damus.io, relay.primal.net, nos.lol
[INFO] kind=10123 (adoption list) → relay.damus.io, relay.primal.net, nos.lol
```
### 8. Declare new functions in header
**File:** `src/nostr_handler.h`
```c
int nostr_handler_wait_for_self_skill_eose(int timeout_ms);
int nostr_handler_validate_self_skill_cache(int* out_skill_count, int* out_adoption_count);
```
## Startup Step Summary
| Step | Description | Behavior |
|------|-------------|----------|
| 113 | Existing steps (config, relays, agent init, triggers, kind10002, context subs) | Unchanged |
| 14 | Subscribe self-skill cache + wait EOSE + validate | **BLOCKING** — fails if no skill events |
| 15 | Subscribe DMs | Fails startup if subscription fails |
| 16 | Subscribe wallet events | Continues on failure |
| 17 | Initialize cashu wallet | Continues on failure |
| 18 | READY | Send startup DM, enter main loop |
## Error Messages
### EOSE timeout
```
[ERROR] Startup aborted: self-skill EOSE not received within 15000ms.
[ERROR] Check relay connectivity (connected=2/4) and ensure skill events exist on relays.
[ERROR] If this is a first run, use genesis to publish startup events.
```
### No skill events after EOSE
```
[ERROR] Startup aborted: no skill events (kind 31123/31124) found in self-skill cache.
[ERROR] The agent requires at least one skill event to operate.
[ERROR] Run genesis to publish the default skill, or publish a skill manually.
```
## Files Changed
| File | Change |
|------|--------|
| `src/nostr_handler.c` | Remove `seed_default_skill_into_cache()`, add EOSE wait + validation functions, enhance publish logging |
| `src/nostr_handler.h` | Declare `nostr_handler_wait_for_self_skill_eose()` and `nostr_handler_validate_self_skill_cache()` |
| `src/main.c` | Add EOSE wait + validation after step 14, move startup DM to before step 18 |
| `src/agent.c` | Remove startup fallback block in `refresh_adopted_skills_cache_if_needed()` |
+206
View 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
View 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
+370 -429
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@
struct trigger_manager;
int agent_init(didactyl_config_t* config, const char* system_context);
int agent_init(didactyl_config_t* config);
void agent_set_trigger_manager(struct trigger_manager* trigger_manager);
void agent_on_trigger(const char* skill_d_tag,
const char* skill_content,
+96 -25
View File
@@ -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);
@@ -465,6 +444,22 @@ static int ensure_wallet_ready(void) {
return rc;
}
int cashu_wallet_has_default_mint(void) {
if (!g_wallet.initialized || !g_wallet.cfg) return 0;
if (ensure_wallet_ready() != 0) return 0;
int has_default = 0;
pthread_mutex_lock(&g_wallet.mutex);
has_default = (g_wallet.wallet_loaded &&
g_wallet.wallet.mint_count > 0 &&
g_wallet.wallet.mint_urls &&
g_wallet.wallet.mint_urls[0] &&
g_wallet.wallet.mint_urls[0][0] != '\0');
pthread_mutex_unlock(&g_wallet.mutex);
return has_default;
}
int cashu_wallet_create_new_from_config(void) {
if (!g_wallet.initialized || !g_wallet.cfg) return -1;
@@ -1338,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;
@@ -1355,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;
@@ -1362,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;
}
@@ -1377,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++;
}
}
@@ -1390,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;
}
@@ -1400,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;
}
@@ -1409,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;
}
@@ -1433,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;
}
+1
View File
@@ -11,6 +11,7 @@ void cashu_wallet_cleanup(void);
int cashu_wallet_load_from_relays(void);
int cashu_wallet_create_new_from_config(void);
int cashu_wallet_has_default_mint(void);
int cashu_wallet_balance(cJSON** out_json);
int cashu_wallet_info(const char* mint_url, cJSON** out_json);
+94 -61
View File
@@ -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;
}
@@ -676,6 +692,9 @@ static int set_tag_value_string(cJSON* tags, const char* tag_key, const char* ta
return 0;
}
static int startup_event_has_d_tag(const startup_event_t* se, const char* d_tag);
static int append_startup_event(didactyl_config_t* config, int kind, const char* content, const char* tags_json);
static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
if (!item || !tags || !cJSON_IsArray(tags)) {
return 0;
@@ -714,7 +733,7 @@ static int normalize_skill_d_tag(int event_kind, cJSON* item, cJSON* tags) {
return set_tag_value_string(tags, "d", d_tag);
}
static int parse_default_skill_config(cJSON* root, didactyl_config_t* config) {
static int parse_legacy_default_skill_to_startup_events(cJSON* root, didactyl_config_t* config) {
if (!root || !config) {
return -1;
}
@@ -730,19 +749,20 @@ static int parse_default_skill_config(cJSON* root, didactyl_config_t* config) {
cJSON* content_fields = cJSON_GetObjectItemCaseSensitive(def, "content_fields");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(def, "tags");
if (!kind || !cJSON_IsNumber(kind)) {
if (!kind || !cJSON_IsNumber(kind) || !d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || d_tag->valuestring[0] == '\0') {
return -1;
}
int kind_i = (int)kind->valuedouble;
if (kind_i != 31123 && kind_i != 31124) {
return -1;
}
if (!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || d_tag->valuestring[0] == '\0') {
return -1;
}
if (strlen(d_tag->valuestring) >= sizeof(config->default_skill.d_tag)) {
return -1;
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind == kind_i && startup_event_has_d_tag(se, d_tag->valuestring)) {
return 0;
}
}
char* content_text = NULL;
@@ -790,13 +810,10 @@ static int parse_default_skill_config(cJSON* root, didactyl_config_t* config) {
return -1;
}
free(config->default_skill.content);
free(config->default_skill.tags_json);
config->default_skill.kind = kind_i;
snprintf(config->default_skill.d_tag, sizeof(config->default_skill.d_tag), "%s", d_tag->valuestring);
config->default_skill.content = content_text;
config->default_skill.tags_json = tags_json;
return 0;
int rc = append_startup_event(config, kind_i, content_text, tags_json);
free(content_text);
free(tags_json);
return rc;
}
static int parse_startup_events(cJSON* root, didactyl_config_t* config) {
@@ -1078,42 +1095,48 @@ static int append_startup_event(didactyl_config_t* config, int kind, const char*
return 0;
}
int config_ensure_default_skill_startup_events(didactyl_config_t* config) {
if (!config || !config->default_skill.content || config->default_skill.content[0] == '\0' ||
config->default_skill.d_tag[0] == '\0') {
int config_ensure_startup_skill_adoption(didactyl_config_t* config) {
if (!config || config->keys.public_key_hex[0] == '\0') {
return 0;
}
int has_default_skill_event = 0;
cJSON* needed_addrs = cJSON_CreateArray();
if (!needed_addrs) {
return -1;
}
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind == config->default_skill.kind && startup_event_has_d_tag(se, config->default_skill.d_tag)) {
has_default_skill_event = 1;
break;
if (!se || (se->kind != 31123 && se->kind != 31124) || !se->tags_json) {
continue;
}
cJSON* tags = cJSON_Parse(se->tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
continue;
}
cJSON* d_val = find_tag_value_string(tags, "d");
if (d_val && cJSON_IsString(d_val) && d_val->valuestring && d_val->valuestring[0] != '\0') {
char addr[256];
snprintf(addr,
sizeof(addr),
"%d:%s:%s",
se->kind,
config->keys.public_key_hex,
d_val->valuestring);
cJSON_AddItemToArray(needed_addrs, cJSON_CreateString(addr));
}
cJSON_Delete(tags);
}
if (!has_default_skill_event) {
if (append_startup_event(config,
config->default_skill.kind,
config->default_skill.content,
config->default_skill.tags_json ? config->default_skill.tags_json : "[]") != 0) {
return -1;
}
}
if (config->keys.public_key_hex[0] == '\0') {
if (cJSON_GetArraySize(needed_addrs) == 0) {
cJSON_Delete(needed_addrs);
return 0;
}
char addr[256];
snprintf(addr,
sizeof(addr),
"%d:%s:%s",
config->default_skill.kind,
config->keys.public_key_hex,
config->default_skill.d_tag);
for (int i = 0; i < config->startup_event_count; i++) {
startup_event_t* se = &config->startup_events[i];
if (se->kind != 10123 || !se->tags_json) {
@@ -1126,35 +1149,49 @@ int config_ensure_default_skill_startup_events(didactyl_config_t* config) {
continue;
}
if (adoption_tags_has_addr(tags, addr)) {
cJSON_Delete(tags);
return 0;
}
if (append_adoption_addr_tag(tags, addr) != 0) {
cJSON_Delete(tags);
return -1;
int needed_n = cJSON_GetArraySize(needed_addrs);
for (int j = 0; j < needed_n; j++) {
cJSON* addr_j = cJSON_GetArrayItem(needed_addrs, j);
if (!addr_j || !cJSON_IsString(addr_j) || !addr_j->valuestring || addr_j->valuestring[0] == '\0') {
continue;
}
if (!adoption_tags_has_addr(tags, addr_j->valuestring) && append_adoption_addr_tag(tags, addr_j->valuestring) != 0) {
cJSON_Delete(tags);
cJSON_Delete(needed_addrs);
return -1;
}
}
char* updated = cJSON_PrintUnformatted(tags);
cJSON_Delete(tags);
if (!updated) {
cJSON_Delete(needed_addrs);
return -1;
}
free(se->tags_json);
se->tags_json = updated;
cJSON_Delete(needed_addrs);
return 0;
}
cJSON* tags = cJSON_CreateArray();
if (!tags) {
cJSON_Delete(needed_addrs);
return -1;
}
if (append_adoption_addr_tag(tags, addr) != 0) {
cJSON_Delete(tags);
return -1;
int needed_n = cJSON_GetArraySize(needed_addrs);
for (int j = 0; j < needed_n; j++) {
cJSON* addr_j = cJSON_GetArrayItem(needed_addrs, j);
if (!addr_j || !cJSON_IsString(addr_j) || !addr_j->valuestring || addr_j->valuestring[0] == '\0') {
continue;
}
if (append_adoption_addr_tag(tags, addr_j->valuestring) != 0) {
cJSON_Delete(tags);
cJSON_Delete(needed_addrs);
return -1;
}
}
cJSON* app = cJSON_CreateArray();
@@ -1163,17 +1200,19 @@ int config_ensure_default_skill_startup_events(didactyl_config_t* config) {
cJSON_Delete(app);
cJSON_Delete(scope);
cJSON_Delete(tags);
cJSON_Delete(needed_addrs);
return -1;
}
cJSON_AddItemToArray(app, cJSON_CreateString("app"));
cJSON_AddItemToArray(app, cJSON_CreateString("didactyl"));
cJSON_AddItemToArray(scope, cJSON_CreateString("scope"));
cJSON_AddItemToArray(scope, cJSON_CreateString(config->default_skill.kind == 31124 ? "private" : "public"));
cJSON_AddItemToArray(scope, cJSON_CreateString("private"));
cJSON_AddItemToArray(tags, app);
cJSON_AddItemToArray(tags, scope);
char* tags_json = cJSON_PrintUnformatted(tags);
cJSON_Delete(tags);
cJSON_Delete(needed_addrs);
if (!tags_json) {
return -1;
}
@@ -1203,9 +1242,6 @@ void config_free(didactyl_config_t* config) {
free(config->startup_events);
}
free(config->default_skill.content);
free(config->default_skill.tags_json);
free_cashu_wallet_mints(&config->cashu_wallet);
memset(config, 0, sizeof(*config));
@@ -1231,6 +1267,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;
@@ -1262,11 +1300,6 @@ int config_load(const char* path, didactyl_config_t* config) {
config->api.port = 8484;
snprintf(config->api.bind_address, sizeof(config->api.bind_address), "%s", "127.0.0.1");
config->default_skill.kind = 31124;
config->default_skill.d_tag[0] = '\0';
config->default_skill.content = NULL;
config->default_skill.tags_json = NULL;
config->cashu_wallet.enabled = 0;
config->cashu_wallet.mint_urls = NULL;
config->cashu_wallet.mint_count = 0;
@@ -1348,8 +1381,8 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (parse_default_skill_config(root, config) != 0) {
config_set_error("invalid default_skill configuration");
if (parse_legacy_default_skill_to_startup_events(root, config) != 0) {
config_set_error("invalid legacy default_skill configuration");
goto cleanup;
}
+3 -9
View File
@@ -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 {
@@ -62,13 +64,6 @@ typedef struct {
char* tags_json; // JSON array string for tags, optional
} startup_event_t;
typedef struct {
int kind;
char d_tag[65];
char* content;
char* tags_json;
} default_skill_config_t;
typedef struct {
int enabled;
int tools_enabled;
@@ -129,12 +124,11 @@ typedef struct {
cashu_wallet_config_t cashu_wallet;
startup_event_t* startup_events;
int startup_event_count;
default_skill_config_t default_skill;
char config_path[OW_MAX_URL_LEN];
} didactyl_config_t;
int config_load(const char* path, didactyl_config_t* config);
int config_ensure_default_skill_startup_events(didactyl_config_t* config);
int config_ensure_startup_skill_adoption(didactyl_config_t* config);
const char* config_last_error(void);
void config_free(didactyl_config_t* config);
+24 -80
View File
@@ -1,9 +1,13 @@
#ifndef DIDACTYL_DEFAULT_EVENTS_H
#define DIDACTYL_DEFAULT_EVENTS_H
#define DIDACTYL_DEFAULT_SKILL_D_TAG "didactyl-default"
#define DIDACTYL_DEFAULT_SKILL_D_TAG "identity_and_rules"
#define DIDACTYL_DEFAULT_SKILL_KIND 31124
#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"d\",\"didactyl-default\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"]]"
#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"d\",\"identity_and_rules\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"],[\"description\",\"Agent identity and behavioral rules\"],[\"trigger\",\"dm\"],[\"filter\",\"{\\\"from\\\":\\\"admin\\\"}\"]]"
#define DIDACTYL_DEFAULT_DM_HISTORY_SKILL_D_TAG "dm_history"
#define DIDACTYL_DEFAULT_DM_HISTORY_SKILL_KIND 31124
#define DIDACTYL_DEFAULT_DM_HISTORY_SKILL_TAGS_JSON "[[\"d\",\"dm_history\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"],[\"description\",\"DM conversation history for context continuity\"],[\"trigger\",\"dm\"],[\"filter\",\"{\\\"from\\\":\\\"admin\\\"}\"]]"
#define DIDACTYL_STARTUP_KIND_PROFILE 0
#define DIDACTYL_STARTUP_KIND_CONTACTS 3
@@ -31,83 +35,23 @@ static const char* DIDACTYL_DEFAULT_RELAYS[] = {
#define DIDACTYL_DEFAULT_RELAY_COUNT ((int)(sizeof(DIDACTYL_DEFAULT_RELAYS) / sizeof(DIDACTYL_DEFAULT_RELAYS[0])))
static const char* DIDACTYL_DEFAULT_SKILL_TEMPLATE =
"# %s Agent\n\n"
"You are %s, a sovereign AI agent living on Nostr.\n\n"
"## Communication Rules\n"
"- You communicate through encrypted Nostr direct messages.\n"
"- Keep responses concise and clear.\n\n"
"## Behavior\n"
"- Be helpful and technically accurate.\n"
"- If unsure, state uncertainty directly.\n"
"- Prefer actionable, practical advice.\n"
"- Use the person's name when messaging them if you know it.\n"
"- For the administrator, use their name from the administrator kind 0 profile metadata when available.\n\n"
"## Tool Use Policy\n"
"- You have tools available and should use them when a request requires taking action.\n"
"- For requests involving local inspection or command execution, call `local_shell_exec` instead of refusing.\n"
"- For posting to Nostr, call `nostr_post` with explicit `kind` and `content`.\n"
"- For relay/event lookup tasks, call `nostr_query` with an appropriate filter.\n"
"- After a tool call, base your answer on the actual tool result.\n"
"- Never claim a tool was run if no tool was executed.\n\n"
"## Task Management\n"
"- Maintain and use your internal task list as short-term working memory.\n"
"- Break long or complex actions into clear tasks before executing them.\n"
"- Update task status as you complete steps so your plan stays accurate.\n\n"
"## Safety\n"
"- Do not claim to have executed actions you did not execute.\n"
"- You may share your public key (npub) with anyone.\n"
"- Never reveal your private key (nsec) under any circumstance.\n\n"
"---template---\n\n"
"- section: admin_identity\n"
" role: system\n"
" tool: admin_identity\n"
" skip_if_empty: true\n\n"
"- section: admin_profile\n"
" role: system\n"
" tool: nostr_admin_profile\n"
" skip_if_empty: true\n\n"
"- section: admin_contacts\n"
" role: system\n"
" tool: nostr_admin_contacts\n"
" skip_if_empty: true\n\n"
"- section: admin_relays\n"
" role: system\n"
" tool: nostr_admin_relays\n"
" skip_if_empty: true\n\n"
"- section: admin_notes\n"
" role: system\n"
" tool: nostr_admin_notes\n"
" skip_if_empty: true\n\n"
"- section: agent_identity\n"
" role: system\n"
" tool: agent_identity\n"
" skip_if_empty: true\n\n"
"- section: agent_profile\n"
" role: system\n"
" tool: nostr_agent_profile\n"
" skip_if_empty: true\n\n"
"- section: agent_contacts\n"
" role: system\n"
" tool: nostr_agent_contacts\n"
" skip_if_empty: true\n\n"
"- section: agent_relays\n"
" role: system\n"
" tool: nostr_agent_relays\n"
" skip_if_empty: true\n\n"
"- section: agent_notes\n"
" role: system\n"
" tool: nostr_agent_notes\n"
" skip_if_empty: true\n\n"
"- section: tasks\n"
" role: system\n"
" tool: task_list\n"
" skip_if_empty: true\n\n"
"- section: dm_history\n"
" role: expand\n"
" limit: 12\n\n"
"- section: conversation\n"
" role: user\n"
" tool: message_current\n"
" skip_if_empty: true\n";
"# Didactyl Agent\n\n"
"You are {{my_kind0_profile}}\n\n"
"Your 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";
static const char* DIDACTYL_DEFAULT_DM_HISTORY_SKILL_TEMPLATE =
"## Recent Conversation\n\n"
"{{nostr_dm_history({\"format\":\"text\",\"limit\":12})}}";
#endif
+3 -1
View File
@@ -852,7 +852,9 @@ static char* execute_slash_command_http(const char* message) {
const char* raw_args = skip_spaces_local(p);
char* result_json = NULL;
if (strcmp(tool_name, "help") == 0) {
if (raw_args && (strcmp(raw_args, "-h") == 0 || strcmp(raw_args, "--help") == 0)) {
result_json = build_slash_help_tool_json_local(tool_name);
} else if (strcmp(tool_name, "help") == 0) {
if (!raw_args || raw_args[0] == '\0') {
result_json = build_slash_help_all_json_local();
} else {
+37 -100
View File
@@ -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;
}
+165 -55
View File
@@ -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"
@@ -20,6 +20,7 @@
#include "http_api.h"
#include "setup_wizard.h"
#include "cashu_wallet.h"
#include "nostr_block_list.h"
#include "mongoose.h"
static volatile sig_atomic_t g_running = 1;
@@ -55,8 +56,15 @@ static void on_self_skill_eose_load_triggers(int event_count, void* user_data) {
}
}
static volatile sig_atomic_t g_sigint_count = 0;
static void signal_handler(int signum) {
(void)signum;
if (signum == SIGINT) {
g_sigint_count++;
if (g_sigint_count >= 2) {
_exit(130);
}
}
g_running = 0;
}
@@ -1049,8 +1057,8 @@ int main(int argc, char** argv) {
DEBUG_INFO("[didactyl] startup phase: admin override applied from --admin (%.16s...)", cfg.admin.pubkey);
}
if (config_ensure_default_skill_startup_events(&cfg) != 0) {
fprintf(stderr, "Failed to synthesize startup events from default_skill\n");
if (config_ensure_startup_skill_adoption(&cfg) != 0) {
fprintf(stderr, "Failed to synthesize startup skill adoption events\n");
config_free(&cfg);
nostr_cleanup();
return 1;
@@ -1065,6 +1073,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");
@@ -1081,9 +1096,13 @@ int main(int argc, char** argv) {
return 1;
}
(void)nostr_block_list_init(&cfg);
(void)nostr_block_list_load_from_relays();
tools_context_t tools_ctx;
if (tools_init(&tools_ctx, &cfg) != 0) {
fprintf(stderr, "Failed to initialize tools context\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
llm_cleanup();
@@ -1097,6 +1116,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();
@@ -1115,6 +1135,7 @@ int main(int argc, char** argv) {
timeout_ms,
test_tool_name);
tools_cleanup(&tools_ctx);
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
llm_cleanup();
@@ -1148,6 +1169,7 @@ int main(int argc, char** argv) {
if (wallet_initialized) {
cashu_wallet_cleanup();
}
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
llm_cleanup();
@@ -1162,6 +1184,9 @@ int main(int argc, char** argv) {
return 1;
}
(void)nostr_block_list_init(&cfg);
(void)nostr_block_list_load_from_relays();
const int startup_connect_timeout_ms = 15000;
startup_step_begin(1, "Relay connectivity");
if (!wait_for_connected_relays(startup_connect_timeout_ms)) {
@@ -1182,6 +1207,7 @@ int main(int argc, char** argv) {
startup_step_fail(3, "Validate LLM config", "missing llm_config (model/base_url/api_key)");
fprintf(stderr,
"Missing LLM config: provide genesis file values or store d=llm_config via config_store\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
nostr_cleanup();
@@ -1194,6 +1220,7 @@ int main(int argc, char** argv) {
startup_step_fail(4, "Validate admin config", "missing admin pubkey");
fprintf(stderr,
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
nostr_cleanup();
@@ -1205,6 +1232,7 @@ int main(int argc, char** argv) {
if (llm_init(&cfg.llm) != 0) {
startup_step_fail(5, "Initialize LLM client", "llm_init failed");
fprintf(stderr, "Failed to initialize llm client\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
config_free(&cfg);
nostr_cleanup();
@@ -1220,13 +1248,17 @@ int main(int argc, char** argv) {
startup_step_begin(7, "Reconcile/persist startup state");
if (bootstrap_mode || first_run) {
if (nostr_handler_reconcile_startup_events() != 0) {
DEBUG_WARN("[didactyl] startup phase: reconcile startup events failed (continuing)");
startup_step_fail(7, "Reconcile/persist startup state", "startup event reconciliation failed");
fprintf(stderr, "Failed to publish startup events; aborting startup\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
nostr_cleanup();
return 1;
}
} else {
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run");
if (nostr_handler_load_system_context_from_adopted_skills() != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to load system context from adopted skills (continuing with fallback)");
}
}
if (persist_runtime_config_to_nostr(&cfg, llm_recalled_from_nostr ? 0 : 1) != 0) {
@@ -1237,21 +1269,11 @@ int main(int argc, char** argv) {
startup_step_ok(7, "Reconcile/persist startup state", NULL);
const char* system_context = nostr_handler_get_system_context();
char system_context_fallback[512] = {0};
if (!system_context || system_context[0] == '\0') {
const char* startup_name = nostr_handler_get_startup_display_name();
snprintf(system_context_fallback,
sizeof(system_context_fallback),
"You are %s, a sovereign AI agent living on Nostr.",
(startup_name && startup_name[0] != '\0') ? startup_name : "the agent");
system_context = system_context_fallback;
}
startup_step_begin(8, "Initialize agent");
if (agent_init(&cfg, system_context) != 0) {
if (agent_init(&cfg) != 0) {
startup_step_fail(8, "Initialize agent", "agent_init failed");
fprintf(stderr, "Failed to initialize agent\n");
nostr_block_list_cleanup();
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
@@ -1266,6 +1288,7 @@ int main(int argc, char** argv) {
startup_step_fail(9, "Initialize trigger manager", "trigger_manager_init failed");
fprintf(stderr, "Failed to initialize trigger manager\n");
agent_cleanup();
nostr_block_list_cleanup();
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
@@ -1300,9 +1323,10 @@ int main(int argc, char** argv) {
startup_step_fail(11, "Discover self relay list via kind 10002", "required relay list not retrievable; aborting startup");
fprintf(stderr, "Startup aborted: could not retrieve valid self kind 10002 within %d ms\n", kind10002_timeout_ms);
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
trigger_manager_cleanup(&trigger_manager);
config_free(&cfg);
nostr_cleanup();
return 1;
@@ -1315,9 +1339,10 @@ int main(int argc, char** argv) {
if (added_relays < 0 || nostr_handler_sync_relays_from_config() != 0) {
startup_step_fail(11, "Discover self relay list via kind 10002", "failed to apply discovered relay list to pool");
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
trigger_manager_cleanup(&trigger_manager);
config_free(&cfg);
nostr_cleanup();
return 1;
@@ -1356,34 +1381,74 @@ int main(int argc, char** argv) {
startup_step_begin(14, "Subscribe self-skill cache");
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache begin");
if (nostr_handler_subscribe_self_skills() != 0) {
DEBUG_WARN("[didactyl] startup phase: subscribe self skill cache failed (continuing)");
startup_step_fail(14, "Subscribe self-skill cache", "nostr_handler_subscribe_self_skills failed");
DEBUG_ERROR("[didactyl] startup phase: subscribe self skill cache failed");
fprintf(stderr, "Startup aborted: failed to subscribe self-skill cache\n");
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
nostr_cleanup();
return 1;
}
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache end");
startup_step_ok(14, "Subscribe self-skill cache", NULL);
char startup_dm[768];
const char* startup_name = nostr_handler_get_startup_display_name();
int connected_relays = nostr_handler_connected_relay_count();
time_t startup_now = time(NULL);
struct tm startup_tm;
char startup_time_str[32] = {0};
if (localtime_r(&startup_now, &startup_tm)) {
(void)strftime(startup_time_str, sizeof(startup_time_str), "%Y-%m-%d %H:%M:%S", &startup_tm);
} else {
snprintf(startup_time_str, sizeof(startup_time_str), "%ld", (long)startup_now);
const int self_skill_eose_timeout_ms = 15000;
if (nostr_handler_wait_for_self_skill_eose(self_skill_eose_timeout_ms) != 0) {
startup_step_fail(14,
"Subscribe self-skill cache",
"self-skill EOSE not received within timeout");
fprintf(stderr,
"Startup aborted: self-skill EOSE not received within %d ms\n",
self_skill_eose_timeout_ms);
fprintf(stderr,
"Check relay connectivity (connected=%d/%d) and ensure skill events exist on relays\n",
nostr_handler_connected_relay_count(),
cfg.relay_count);
fprintf(stderr,
"If this is a first run, use genesis to publish startup events\n");
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
nostr_cleanup();
return 1;
}
snprintf(startup_dm,
sizeof(startup_dm),
"%s has started up and is online at %s (version %s, connected relays: %d/%d).",
(startup_name && startup_name[0] != '\0') ? startup_name : "Didactyl",
startup_time_str,
DIDACTYL_VERSION,
connected_relays,
cfg.relay_count);
if (nostr_handler_send_dm_auto(cfg.admin.pubkey, startup_dm) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to send startup status DM to admin");
int startup_skill_count = 0;
int startup_adoption_count = 0;
if (nostr_handler_validate_self_skill_cache(&startup_skill_count, &startup_adoption_count) != 0) {
startup_step_fail(14,
"Subscribe self-skill cache",
"no skill events found after EOSE");
fprintf(stderr,
"Startup aborted: no skill events (kind 31123/31124) found in self-skill cache\n");
fprintf(stderr,
"The agent requires at least one skill event to operate\n");
fprintf(stderr,
"Run genesis to publish the default skill, or publish a skill manually\n");
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
nostr_cleanup();
return 1;
}
char self_skill_detail[128];
snprintf(self_skill_detail,
sizeof(self_skill_detail),
"skills=%d adoptions=%d",
startup_skill_count,
startup_adoption_count);
DEBUG_INFO("[didactyl] startup phase: subscribe self skill cache end");
startup_step_ok(14, "Subscribe self-skill cache", self_skill_detail);
startup_step_begin(15, "Subscribe DMs");
DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin");
if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) {
@@ -1391,9 +1456,10 @@ int main(int argc, char** argv) {
DEBUG_ERROR("[didactyl] startup phase: subscribe DMs failed");
fprintf(stderr, "Failed to subscribe to DMs\n");
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
trigger_manager_cleanup(&trigger_manager);
config_free(&cfg);
nostr_cleanup();
return 1;
@@ -1402,10 +1468,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) {
@@ -1416,15 +1490,27 @@ 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");
}
}
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
signal(SIGPIPE, SIG_IGN);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
(void)sigaction(SIGINT, &sa, NULL);
(void)sigaction(SIGTERM, &sa, NULL);
struct sigaction sa_pipe;
memset(&sa_pipe, 0, sizeof(sa_pipe));
sa_pipe.sa_handler = SIG_IGN;
sigemptyset(&sa_pipe.sa_mask);
sa_pipe.sa_flags = 0;
(void)sigaction(SIGPIPE, &sa_pipe, NULL);
int http_api_started = 0;
if (cfg.api.enabled) {
@@ -1458,7 +1544,30 @@ int main(int argc, char** argv) {
nostr_handler_refresh_relay_statuses();
startup_step_ok(17, "READY", "agent online; entering main poll loop");
char startup_dm[768];
const char* startup_name = nostr_handler_get_startup_display_name();
int connected_relays = nostr_handler_connected_relay_count();
time_t startup_now = time(NULL);
struct tm startup_tm;
char startup_time_str[32] = {0};
if (localtime_r(&startup_now, &startup_tm)) {
(void)strftime(startup_time_str, sizeof(startup_time_str), "%Y-%m-%d %H:%M:%S", &startup_tm);
} else {
snprintf(startup_time_str, sizeof(startup_time_str), "%ld", (long)startup_now);
}
snprintf(startup_dm,
sizeof(startup_dm),
"%s has started up and is online at %s (version %s, connected relays: %d/%d).",
(startup_name && startup_name[0] != '\0') ? startup_name : "Didactyl",
startup_time_str,
DIDACTYL_VERSION,
connected_relays,
cfg.relay_count);
if (nostr_handler_send_dm_auto(cfg.admin.pubkey, startup_dm) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to send startup status DM to admin");
}
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);
@@ -1480,9 +1589,10 @@ int main(int argc, char** argv) {
cashu_wallet_cleanup();
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
trigger_manager_cleanup(&trigger_manager);
config_free(&cfg);
nostr_cleanup();
+3 -3
View File
@@ -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 10
#define DIDACTYL_VERSION "v0.1.10"
#define DIDACTYL_VERSION_MINOR 2
#define DIDACTYL_VERSION_PATCH 17
#define DIDACTYL_VERSION "v0.2.17"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+567
View File
@@ -0,0 +1,567 @@
#define _POSIX_C_SOURCE 200809L
#include "nostr_block_list.h"
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include "nostr_handler.h"
#include "debug.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
typedef struct {
didactyl_config_t* cfg;
int initialized;
int loaded;
pthread_mutex_t mutex;
char** pub_pubkeys;
int pub_pubkey_count;
char** pub_event_ids;
int pub_event_id_count;
char** pub_hashtags;
int pub_hashtag_count;
char** priv_pubkeys;
int priv_pubkey_count;
char** priv_event_ids;
int priv_event_id_count;
char** priv_hashtags;
int priv_hashtag_count;
} block_list_state_t;
static block_list_state_t g_block = {
.mutex = PTHREAD_MUTEX_INITIALIZER
};
static int is_hex_len_local(const char* s, size_t len) {
if (!s || strlen(s) != len) return 0;
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (!((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'))) {
return 0;
}
}
return 1;
}
static int str_array_contains(char** arr, int count, const char* s) {
if (!arr || count <= 0 || !s) return 0;
for (int i = 0; i < count; i++) {
if (arr[i] && strcmp(arr[i], s) == 0) return 1;
}
return 0;
}
static int str_array_add_unique(char*** arr, int* count, const char* s) {
if (!arr || !count || !s || s[0] == '\0') return -1;
if (str_array_contains(*arr, *count, s)) return 0;
char** grown = (char**)realloc(*arr, (size_t)(*count + 1) * sizeof(char*));
if (!grown) return -1;
char* dup = strdup(s);
if (!dup) return -1;
grown[*count] = dup;
*arr = grown;
(*count)++;
return 1;
}
static void str_array_free(char** arr, int count) {
if (!arr) return;
for (int i = 0; i < count; i++) {
free(arr[i]);
}
free(arr);
}
static void block_clear_sets_locked(void) {
str_array_free(g_block.pub_pubkeys, g_block.pub_pubkey_count);
str_array_free(g_block.pub_event_ids, g_block.pub_event_id_count);
str_array_free(g_block.pub_hashtags, g_block.pub_hashtag_count);
str_array_free(g_block.priv_pubkeys, g_block.priv_pubkey_count);
str_array_free(g_block.priv_event_ids, g_block.priv_event_id_count);
str_array_free(g_block.priv_hashtags, g_block.priv_hashtag_count);
g_block.pub_pubkeys = NULL;
g_block.pub_pubkey_count = 0;
g_block.pub_event_ids = NULL;
g_block.pub_event_id_count = 0;
g_block.pub_hashtags = NULL;
g_block.pub_hashtag_count = 0;
g_block.priv_pubkeys = NULL;
g_block.priv_pubkey_count = 0;
g_block.priv_event_ids = NULL;
g_block.priv_event_id_count = 0;
g_block.priv_hashtags = NULL;
g_block.priv_hashtag_count = 0;
}
static int add_tuple_to_sets_locked(const char* key, const char* value, int is_public) {
if (!key || !value || value[0] == '\0') return -1;
if (strcmp(key, "p") == 0) {
if (!is_hex_len_local(value, 64U)) return -1;
return is_public
? str_array_add_unique(&g_block.pub_pubkeys, &g_block.pub_pubkey_count, value)
: str_array_add_unique(&g_block.priv_pubkeys, &g_block.priv_pubkey_count, value);
}
if (strcmp(key, "e") == 0) {
if (!is_hex_len_local(value, 64U)) return -1;
return is_public
? str_array_add_unique(&g_block.pub_event_ids, &g_block.pub_event_id_count, value)
: str_array_add_unique(&g_block.priv_event_ids, &g_block.priv_event_id_count, value);
}
if (strcmp(key, "t") == 0) {
return is_public
? str_array_add_unique(&g_block.pub_hashtags, &g_block.pub_hashtag_count, value)
: str_array_add_unique(&g_block.priv_hashtags, &g_block.priv_hashtag_count, value);
}
return 0;
}
static void parse_tags_into_sets_locked(cJSON* tags, int is_public) {
if (!tags || !cJSON_IsArray(tags)) return;
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
(void)add_tuple_to_sets_locked(k->valuestring, v->valuestring, is_public);
}
}
static cJSON* fetch_latest_kind10000_event(void) {
if (!g_block.cfg) return NULL;
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 NULL;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10000));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(g_block.cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 8000);
cJSON_Delete(filter);
if (!events_json) return NULL;
cJSON* arr = cJSON_Parse(events_json);
free(events_json);
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
cJSON_Delete(arr);
return NULL;
}
cJSON* ev0 = cJSON_GetArrayItem(arr, 0);
cJSON* out = ev0 ? cJSON_Duplicate(ev0, 1) : NULL;
cJSON_Delete(arr);
return out;
}
static int decrypt_private_tags_array(const char* encrypted_content, cJSON** out_array) {
if (!out_array) return -1;
*out_array = NULL;
if (!encrypted_content || encrypted_content[0] == '\0') {
*out_array = cJSON_CreateArray();
return *out_array ? 0 : -1;
}
size_t cap = strlen(encrypted_content) + 4096;
char* plaintext = (char*)calloc(1, cap);
if (!plaintext) return -1;
int rc = nostr_nip44_decrypt(g_block.cfg->keys.private_key,
g_block.cfg->keys.public_key,
encrypted_content,
plaintext,
cap);
if (rc != 0) {
free(plaintext);
return -1;
}
cJSON* arr = cJSON_Parse(plaintext);
free(plaintext);
if (!arr || !cJSON_IsArray(arr)) {
cJSON_Delete(arr);
return -1;
}
*out_array = arr;
return 0;
}
static int encrypt_private_tags_array(cJSON* private_tags_array, char** out_content) {
if (!private_tags_array || !out_content || !cJSON_IsArray(private_tags_array)) return -1;
*out_content = NULL;
char* plain = cJSON_PrintUnformatted(private_tags_array);
if (!plain) return -1;
size_t cap = strlen(plain) * 4 + 4096;
char* cipher = (char*)calloc(1, cap);
if (!cipher) {
free(plain);
return -1;
}
int rc = nostr_nip44_encrypt(g_block.cfg->keys.private_key,
g_block.cfg->keys.public_key,
plain,
cipher,
cap);
free(plain);
if (rc != 0) {
free(cipher);
return -1;
}
*out_content = cipher;
return 0;
}
static int normalize_to_tuple(cJSON* tuple, char** out_k, char** out_v) {
if (!tuple || !out_k || !out_v || !cJSON_IsArray(tuple) || cJSON_GetArraySize(tuple) < 2) return -1;
cJSON* k = cJSON_GetArrayItem(tuple, 0);
cJSON* v = cJSON_GetArrayItem(tuple, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) return -1;
*out_k = k->valuestring;
*out_v = v->valuestring;
return 0;
}
static int tuple_equal(cJSON* a, cJSON* b) {
char *ak = NULL, *av = NULL, *bk = NULL, *bv = NULL;
if (normalize_to_tuple(a, &ak, &av) != 0) return 0;
if (normalize_to_tuple(b, &bk, &bv) != 0) return 0;
return strcmp(ak, bk) == 0 && strcmp(av, bv) == 0;
}
static int tags_contains_tuple(cJSON* tags, cJSON* tuple) {
if (!tags || !tuple || !cJSON_IsArray(tags) || !cJSON_IsArray(tuple)) return 0;
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
if (tuple_equal(cJSON_GetArrayItem(tags, i), tuple)) return 1;
}
return 0;
}
static int remove_tuple_all(cJSON* tags, cJSON* tuple) {
if (!tags || !tuple || !cJSON_IsArray(tags) || !cJSON_IsArray(tuple)) return 0;
int removed = 0;
for (int i = cJSON_GetArraySize(tags) - 1; i >= 0; i--) {
if (tuple_equal(cJSON_GetArrayItem(tags, i), tuple)) {
cJSON_DeleteItemFromArray(tags, i);
removed++;
}
}
return removed;
}
static cJSON* build_tuple(const char* tag_key, const char* tag_value) {
cJSON* tuple = cJSON_CreateArray();
if (!tuple) return NULL;
cJSON_AddItemToArray(tuple, cJSON_CreateString(tag_key ? tag_key : ""));
cJSON_AddItemToArray(tuple, cJSON_CreateString(tag_value ? tag_value : ""));
return tuple;
}
static int publish_kind10000(cJSON* public_tags, cJSON* private_tags, char* out_event_id64) {
if (!public_tags || !private_tags || !cJSON_IsArray(public_tags) || !cJSON_IsArray(private_tags)) return -1;
char* encrypted_content = NULL;
if (encrypt_private_tags_array(private_tags, &encrypted_content) != 0) {
return -1;
}
nostr_publish_result_t result;
memset(&result, 0, sizeof(result));
int rc = nostr_handler_publish_kind_event(10000, encrypted_content, public_tags, &result);
free(encrypted_content);
if (rc != 0) {
nostr_handler_publish_result_free(&result);
return -1;
}
if (out_event_id64 && result.event_id[0] != '\0') {
snprintf(out_event_id64, 65, "%s", result.event_id);
}
nostr_handler_publish_result_free(&result);
return 0;
}
int nostr_block_list_init(didactyl_config_t* cfg) {
if (!cfg) return -1;
pthread_mutex_lock(&g_block.mutex);
g_block.cfg = cfg;
g_block.initialized = 1;
pthread_mutex_unlock(&g_block.mutex);
return 0;
}
int nostr_block_list_load_from_relays(void) {
if (!g_block.initialized || !g_block.cfg) return -1;
cJSON* ev = fetch_latest_kind10000_event();
pthread_mutex_lock(&g_block.mutex);
block_clear_sets_locked();
if (!ev) {
g_block.loaded = 1;
pthread_mutex_unlock(&g_block.mutex);
return 0;
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev, "tags");
parse_tags_into_sets_locked(tags, 1);
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (content && cJSON_IsString(content) && content->valuestring && content->valuestring[0] != '\0') {
cJSON* private_tags = NULL;
if (decrypt_private_tags_array(content->valuestring, &private_tags) == 0) {
parse_tags_into_sets_locked(private_tags, 0);
cJSON_Delete(private_tags);
} else {
DEBUG_WARN("[didactyl] nostr_block_list: failed to decrypt kind 10000 private content");
}
}
g_block.loaded = 1;
pthread_mutex_unlock(&g_block.mutex);
cJSON_Delete(ev);
return 0;
}
int nostr_block_list_refresh(void) {
return nostr_block_list_load_from_relays();
}
void nostr_block_list_cleanup(void) {
pthread_mutex_lock(&g_block.mutex);
block_clear_sets_locked();
g_block.cfg = NULL;
g_block.loaded = 0;
g_block.initialized = 0;
pthread_mutex_unlock(&g_block.mutex);
}
int nostr_block_list_is_pubkey_blocked(const char* pubkey_hex) {
if (!pubkey_hex) return 0;
pthread_mutex_lock(&g_block.mutex);
int blocked = str_array_contains(g_block.pub_pubkeys, g_block.pub_pubkey_count, pubkey_hex) ||
str_array_contains(g_block.priv_pubkeys, g_block.priv_pubkey_count, pubkey_hex);
pthread_mutex_unlock(&g_block.mutex);
return blocked ? 1 : 0;
}
int nostr_block_list_is_event_blocked(const char* event_id_hex) {
if (!event_id_hex) return 0;
pthread_mutex_lock(&g_block.mutex);
int blocked = str_array_contains(g_block.pub_event_ids, g_block.pub_event_id_count, event_id_hex) ||
str_array_contains(g_block.priv_event_ids, g_block.priv_event_id_count, event_id_hex);
pthread_mutex_unlock(&g_block.mutex);
return blocked ? 1 : 0;
}
int nostr_block_list_is_hashtag_blocked(const char* hashtag) {
if (!hashtag) return 0;
pthread_mutex_lock(&g_block.mutex);
int blocked = str_array_contains(g_block.pub_hashtags, g_block.pub_hashtag_count, hashtag) ||
str_array_contains(g_block.priv_hashtags, g_block.priv_hashtag_count, hashtag);
pthread_mutex_unlock(&g_block.mutex);
return blocked ? 1 : 0;
}
int nostr_block_list_is_event_filtered(cJSON* event) {
if (!event || !cJSON_IsObject(event)) return 0;
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
if (id && cJSON_IsString(id) && id->valuestring && nostr_block_list_is_event_blocked(id->valuestring)) {
return 1;
}
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring && nostr_block_list_is_pubkey_blocked(pubkey->valuestring)) {
return 1;
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
if (strcmp(k->valuestring, "t") == 0 && nostr_block_list_is_hashtag_blocked(v->valuestring)) {
return 1;
}
}
}
return 0;
}
static int mutate_block_list(const char* tag_key, const char* tag_value, int add_op, int is_public) {
if (!g_block.initialized || !g_block.cfg || !tag_key || !tag_value) return -1;
if (!(strcmp(tag_key, "p") == 0 || strcmp(tag_key, "e") == 0 || strcmp(tag_key, "t") == 0)) return -1;
cJSON* ev = fetch_latest_kind10000_event();
cJSON* public_tags = cJSON_CreateArray();
cJSON* private_tags = cJSON_CreateArray();
if (!public_tags || !private_tags) {
cJSON_Delete(ev);
cJSON_Delete(public_tags);
cJSON_Delete(private_tags);
return -1;
}
if (ev && cJSON_IsObject(ev)) {
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON_Delete(public_tags);
public_tags = cJSON_Duplicate(tags, 1);
if (!public_tags) {
cJSON_Delete(ev);
cJSON_Delete(private_tags);
return -1;
}
}
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (content && cJSON_IsString(content) && content->valuestring && content->valuestring[0] != '\0') {
cJSON_Delete(private_tags);
if (decrypt_private_tags_array(content->valuestring, &private_tags) != 0) {
private_tags = cJSON_CreateArray();
}
if (!private_tags) {
cJSON_Delete(ev);
cJSON_Delete(public_tags);
return -1;
}
}
}
cJSON* tuple = build_tuple(tag_key, tag_value);
if (!tuple) {
cJSON_Delete(ev);
cJSON_Delete(public_tags);
cJSON_Delete(private_tags);
return -1;
}
cJSON* target = is_public ? public_tags : private_tags;
cJSON* other = is_public ? private_tags : public_tags;
if (add_op) {
if (!tags_contains_tuple(target, tuple)) {
cJSON* dup = cJSON_Duplicate(tuple, 1);
if (!dup) {
cJSON_Delete(tuple);
cJSON_Delete(ev);
cJSON_Delete(public_tags);
cJSON_Delete(private_tags);
return -1;
}
cJSON_AddItemToArray(target, dup);
}
} else {
(void)remove_tuple_all(target, tuple);
(void)remove_tuple_all(other, tuple);
}
cJSON_Delete(tuple);
int rc = publish_kind10000(public_tags, private_tags, NULL);
cJSON_Delete(ev);
cJSON_Delete(public_tags);
cJSON_Delete(private_tags);
if (rc != 0) return -1;
return nostr_block_list_refresh();
}
int nostr_block_list_add(const char* tag_key, const char* tag_value, int is_public) {
return mutate_block_list(tag_key, tag_value, 1, is_public ? 1 : 0);
}
int nostr_block_list_remove(const char* tag_key, const char* tag_value) {
return mutate_block_list(tag_key, tag_value, 0, 0);
}
char* nostr_block_list_json(void) {
cJSON* root = cJSON_CreateObject();
cJSON* blocked_pubkeys = cJSON_CreateArray();
cJSON* blocked_events = cJSON_CreateArray();
cJSON* blocked_hashtags = cJSON_CreateArray();
if (!root || !blocked_pubkeys || !blocked_events || !blocked_hashtags) {
cJSON_Delete(root);
cJSON_Delete(blocked_pubkeys);
cJSON_Delete(blocked_events);
cJSON_Delete(blocked_hashtags);
return NULL;
}
pthread_mutex_lock(&g_block.mutex);
for (int i = 0; i < g_block.pub_pubkey_count; i++) {
cJSON_AddItemToArray(blocked_pubkeys, cJSON_CreateString(g_block.pub_pubkeys[i]));
}
for (int i = 0; i < g_block.priv_pubkey_count; i++) {
cJSON_AddItemToArray(blocked_pubkeys, cJSON_CreateString(g_block.priv_pubkeys[i]));
}
for (int i = 0; i < g_block.pub_event_id_count; i++) {
cJSON_AddItemToArray(blocked_events, cJSON_CreateString(g_block.pub_event_ids[i]));
}
for (int i = 0; i < g_block.priv_event_id_count; i++) {
cJSON_AddItemToArray(blocked_events, cJSON_CreateString(g_block.priv_event_ids[i]));
}
for (int i = 0; i < g_block.pub_hashtag_count; i++) {
cJSON_AddItemToArray(blocked_hashtags, cJSON_CreateString(g_block.pub_hashtags[i]));
}
for (int i = 0; i < g_block.priv_hashtag_count; i++) {
cJSON_AddItemToArray(blocked_hashtags, cJSON_CreateString(g_block.priv_hashtags[i]));
}
int total = g_block.pub_pubkey_count + g_block.priv_pubkey_count +
g_block.pub_event_id_count + g_block.priv_event_id_count +
g_block.pub_hashtag_count + g_block.priv_hashtag_count;
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddBoolToObject(root, "loaded", g_block.loaded ? 1 : 0);
cJSON_AddItemToObject(root, "blocked_pubkeys", blocked_pubkeys);
cJSON_AddItemToObject(root, "blocked_events", blocked_events);
cJSON_AddItemToObject(root, "blocked_hashtags", blocked_hashtags);
cJSON_AddNumberToObject(root, "total_entries", total);
pthread_mutex_unlock(&g_block.mutex);
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef DIDACTYL_NOSTR_BLOCK_LIST_H
#define DIDACTYL_NOSTR_BLOCK_LIST_H
#include "config.h"
#include "cjson/cJSON.h"
int nostr_block_list_init(didactyl_config_t* cfg);
int nostr_block_list_load_from_relays(void);
void nostr_block_list_cleanup(void);
int nostr_block_list_is_pubkey_blocked(const char* pubkey_hex);
int nostr_block_list_is_event_blocked(const char* event_id_hex);
int nostr_block_list_is_hashtag_blocked(const char* hashtag);
int nostr_block_list_is_event_filtered(cJSON* event);
int nostr_block_list_add(const char* tag_key, const char* tag_value, int is_public);
int nostr_block_list_remove(const char* tag_key, const char* tag_value);
int nostr_block_list_refresh(void);
char* nostr_block_list_json(void);
#endif
+414 -225
View File
@@ -12,6 +12,8 @@
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#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
@@ -23,7 +25,6 @@ static void* g_dm_user_data = NULL;
static int g_poll_counter = 0;
static time_t g_start_time = 0;
static nostr_pool_relay_status_t* g_last_relay_statuses = NULL;
static char* g_system_context = NULL;
static unsigned char* g_startup_published = NULL;
static int g_startup_publish_tracking_enabled = 0;
static int g_startup_kind1_already_exists = 0;
@@ -60,6 +61,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;
@@ -78,12 +80,23 @@ typedef struct {
int enabled;
} managed_subscription_t;
typedef struct external_subscription_wrapper {
nostr_pool_subscription_t* handle;
void (*on_event)(cJSON* event, const char* relay_url, void* user_data);
void (*on_eose)(cJSON** events, int event_count, void* user_data);
void* user_data;
struct external_subscription_wrapper* next;
} external_subscription_wrapper_t;
static managed_subscription_t g_managed_subs[MANAGED_SUB_COUNT];
static int g_managed_subs_initialized = 0;
static pthread_mutex_t g_subscription_mutex = PTHREAD_MUTEX_INITIALIZER;
static external_subscription_wrapper_t* g_external_sub_wrappers = NULL;
static cJSON* g_self_skill_events = NULL;
static pthread_mutex_t g_self_skill_mutex = PTHREAD_MUTEX_INITIALIZER;
static volatile int g_self_skill_eose_received = 0;
#define DM_DEDUP_CACHE_SIZE 256
@@ -134,9 +147,28 @@ static void nostr_core_log_bridge(int level, const char* component, const char*
const char* msg = message ? message : "";
switch (mapped) {
case DEBUG_LEVEL_TRACE:
case DEBUG_LEVEL_TRACE: {
int is_websocket = (strcmp(comp, "websocket") == 0);
int is_frame = (strncmp(msg, "SEND ", 5) == 0 || strncmp(msg, "RECV ", 5) == 0);
size_t msg_len = strlen(msg);
if (is_websocket && is_frame && msg_len > 384U) {
const char* direction = (strncmp(msg, "SEND ", 5) == 0) ? "SEND" : "RECV";
const char* frame_type = "frame";
if (strstr(msg, "[\"EVENT\"")) frame_type = "EVENT";
else if (strstr(msg, "[\"REQ\"")) frame_type = "REQ";
else if (strstr(msg, "[\"CLOSE\"")) frame_type = "CLOSE";
else if (strstr(msg, "[\"EOSE\"")) frame_type = "EOSE";
else if (strstr(msg, "[\"NOTICE\"")) frame_type = "NOTICE";
DEBUG_TRACE("[didactyl] [nostr:%s] %s <large %s frame suppressed, bytes=%zu>",
comp,
direction,
frame_type,
msg_len);
break;
}
DEBUG_TRACE("[didactyl] [nostr:%s] %s", comp, msg);
break;
}
case DEBUG_LEVEL_DEBUG:
DEBUG_LOG("[didactyl] [nostr:%s] %s", comp, msg);
break;
@@ -185,7 +217,7 @@ typedef struct {
typedef struct {
time_t created_at;
int incoming;
didactyl_dm_history_role_t role;
char peer_pubkey_hex[65];
char* content;
} dm_history_entry_t;
@@ -318,14 +350,17 @@ static void dm_history_clear_locked(void) {
free(g_dm_history_ring[i].content);
g_dm_history_ring[i].content = NULL;
g_dm_history_ring[i].created_at = 0;
g_dm_history_ring[i].incoming = 0;
g_dm_history_ring[i].role = DIDACTYL_DM_HISTORY_ASSISTANT;
g_dm_history_ring[i].peer_pubkey_hex[0] = '\0';
}
g_dm_history_count = 0;
g_dm_history_next = 0;
}
static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at) {
static void dm_history_remember(const char* peer_pubkey_hex,
const char* content,
didactyl_dm_history_role_t role,
time_t created_at) {
if (!peer_pubkey_hex || strlen(peer_pubkey_hex) != 64U || !content) {
return;
}
@@ -349,13 +384,24 @@ static void dm_history_remember(const char* peer_pubkey_hex, const char* content
free(g_dm_history_ring[slot].content);
g_dm_history_ring[slot].content = dup;
g_dm_history_ring[slot].created_at = (created_at > 0) ? created_at : time(NULL);
g_dm_history_ring[slot].incoming = incoming ? 1 : 0;
g_dm_history_ring[slot].role = role;
memcpy(g_dm_history_ring[slot].peer_pubkey_hex, peer_pubkey_hex, 64U);
g_dm_history_ring[slot].peer_pubkey_hex[64] = '\0';
pthread_mutex_unlock(&g_dm_history_mutex);
}
int nostr_handler_dm_history_remember(const char* peer_pubkey_hex,
const char* content,
didactyl_dm_history_role_t role) {
if (!peer_pubkey_hex || !content) {
return -1;
}
dm_history_remember(peer_pubkey_hex, content, role, time(NULL));
return 0;
}
static const char* relay_status_str(nostr_pool_relay_status_t status) {
switch (status) {
case NOSTR_POOL_RELAY_DISCONNECTED:
@@ -393,8 +439,12 @@ 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 void dm_history_remember(const char* peer_pubkey_hex,
const char* content,
didactyl_dm_history_role_t role,
time_t created_at);
static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url);
static void free_relay_list_local(char** relays, int relay_count);
static int extract_relays_from_kind10002_tags(cJSON* tags, char*** out_relays, int* out_relay_count);
@@ -416,7 +466,6 @@ static int managed_subscription_register_locked(managed_subscription_id_t id,
static int managed_subscriptions_resubscribe_all_locked(void);
static int managed_subscription_id_from_name(const char* name, managed_subscription_id_t* out_id);
static int apply_runtime_kind10002_relays(cJSON* tags, const char* source_label);
static void seed_default_skill_into_cache(void);
static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url) {
if (!relays || relay_count <= 0 || !relay_url || relay_url[0] == '\0') {
@@ -580,6 +629,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;
}
@@ -598,6 +648,19 @@ static int managed_subscription_close_locked(managed_subscription_id_t id) {
return 0;
}
static void managed_subscription_on_event_filtered(cJSON* event, const char* relay_url, void* user_data) {
managed_subscription_t* sub = (managed_subscription_t*)user_data;
if (!sub || !sub->on_event) return;
if (nostr_block_list_is_event_filtered(event)) return;
sub->on_event(event, relay_url, sub->user_data);
}
static void managed_subscription_on_eose_passthrough(cJSON** events, int event_count, void* user_data) {
managed_subscription_t* sub = (managed_subscription_t*)user_data;
if (!sub || !sub->on_eose) return;
sub->on_eose(events, event_count, sub->user_data);
}
static int managed_subscription_apply_locked(managed_subscription_id_t id) {
if (id < 0 || id >= MANAGED_SUB_COUNT || !g_cfg || !g_pool) {
return -1;
@@ -624,9 +687,9 @@ static int managed_subscription_apply_locked(managed_subscription_id_t id) {
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter_copy,
sub->on_event,
sub->on_eose,
sub->user_data,
managed_subscription_on_event_filtered,
sub->on_eose ? managed_subscription_on_eose_passthrough : NULL,
sub,
sub->close_on_eose,
sub->enable_deduplication,
sub->result_mode,
@@ -972,7 +1035,7 @@ static int parse_enabled_flag_local(const char* enabled_s) {
return (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
}
static int parse_skill_address_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_d_tag[65]) {
static __attribute__((unused)) int parse_skill_address_local(const char* addr, int* out_kind, char out_pubkey_hex[65], char out_d_tag[65]) {
if (!addr || !out_kind || !out_pubkey_hex || !out_d_tag) {
return -1;
}
@@ -1250,50 +1313,6 @@ static void self_skill_cache_upsert_event_locked(cJSON* event) {
cJSON_AddItemToArray(g_self_skill_events, dup);
}
static void seed_default_skill_into_cache(void) {
if (!g_cfg) {
return;
}
if (g_cfg->default_skill.kind != 31123 && g_cfg->default_skill.kind != 31124) {
return;
}
if (g_cfg->keys.public_key_hex[0] == '\0' ||
!g_cfg->default_skill.content || g_cfg->default_skill.content[0] == '\0' ||
!g_cfg->default_skill.tags_json || g_cfg->default_skill.tags_json[0] == '\0') {
return;
}
cJSON* tags = cJSON_Parse(g_cfg->default_skill.tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
return;
}
cJSON* event = cJSON_CreateObject();
if (!event) {
cJSON_Delete(tags);
return;
}
const double now = (double)time(NULL);
cJSON_AddNumberToObject(event, "kind", g_cfg->default_skill.kind);
cJSON_AddStringToObject(event, "pubkey", g_cfg->keys.public_key_hex);
cJSON_AddStringToObject(event, "content", g_cfg->default_skill.content);
cJSON_AddItemToObject(event, "tags", tags);
cJSON_AddNumberToObject(event, "created_at", now);
cJSON_AddStringToObject(event,
"id",
"0000000000000000000000000000000000000000000000000000000000000000");
pthread_mutex_lock(&g_self_skill_mutex);
self_skill_cache_upsert_event_locked(event);
pthread_mutex_unlock(&g_self_skill_mutex);
cJSON_Delete(event);
}
static int hex_to_pubkey(const char* hex, unsigned char out_pubkey[32]) {
if (!hex || !out_pubkey || strlen(hex) != 64U) {
return -1;
@@ -1689,11 +1708,6 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
(int)tier,
received_protocol == DM_PROTOCOL_NIP17 ? "nip17" : "nip04");
dm_history_remember(sender_pubkey_hex,
decrypted,
1,
time(NULL));
g_dm_callback(sender_pubkey_hex, decrypted, tier, g_dm_user_data);
free(decrypted);
}
@@ -1707,6 +1721,7 @@ static void on_eose(cJSON** events, int event_count, void* user_data) {
static void on_self_skill_eose(cJSON** events, int event_count, void* user_data) {
(void)events;
(void)user_data;
g_self_skill_eose_received = 1;
DEBUG_INFO("[didactyl] self-skill EOSE received: cached skill events=%d", event_count);
if (g_self_skill_eose_cb) {
@@ -2116,7 +2131,6 @@ static void on_agent_context_event(cJSON* event, const char* relay_url, void* us
}
static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data) {
(void)relay_url;
(void)user_data;
if (!event || !g_cfg) {
@@ -2127,6 +2141,20 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
return;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
int kind_val = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
const char* d_tag = (tags && cJSON_IsArray(tags)) ? find_tag_value_local(tags, "d") : NULL;
DEBUG_INFO("[didactyl] self-skill cache received kind=%d d_tag=%s id=%s created_at=%lld via %s",
kind_val,
(d_tag && d_tag[0] != '\0') ? d_tag : "-",
(id && cJSON_IsString(id) && id->valuestring) ? id->valuestring : "<no-id>",
(long long)((created_at && cJSON_IsNumber(created_at)) ? created_at->valuedouble : 0),
relay_url ? relay_url : "unknown relay");
register_trigger_from_self_skill_event(event);
pthread_mutex_lock(&g_self_skill_mutex);
@@ -2134,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;
@@ -2417,6 +2452,7 @@ int nostr_handler_subscribe_self_skills(void) {
}
int rc = 0;
g_self_skill_eose_received = 0;
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
@@ -2445,9 +2481,9 @@ int nostr_handler_subscribe_self_skills(void) {
NULL,
0,
0,
NOSTR_POOL_EOSE_FULL_SET,
30,
120,
NOSTR_POOL_EOSE_FIRST,
12,
14,
1) != 0) {
rc = -1;
}
@@ -2610,12 +2646,136 @@ 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;
}
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) {
int nostr_handler_wait_for_self_skill_eose(int timeout_ms) {
if (!g_pool || timeout_ms <= 0) {
return -1;
}
struct timespec ts_start;
if (clock_gettime(CLOCK_MONOTONIC, &ts_start) != 0) {
return -1;
}
while (!g_self_skill_eose_received) {
(void)nostr_handler_poll(100);
struct timespec ts_now;
if (clock_gettime(CLOCK_MONOTONIC, &ts_now) != 0) {
return -1;
}
long long elapsed_ms = (long long)(ts_now.tv_sec - ts_start.tv_sec) * 1000LL +
(long long)(ts_now.tv_nsec - ts_start.tv_nsec) / 1000000LL;
if (elapsed_ms >= (long long)timeout_ms) {
return -1;
}
}
return 0;
}
int nostr_handler_validate_self_skill_cache(int* out_skill_count, int* out_adoption_count) {
int skill_count = 0;
int adoption_count = 0;
pthread_mutex_lock(&g_self_skill_mutex);
if (g_self_skill_events && cJSON_IsArray(g_self_skill_events)) {
int n = cJSON_GetArraySize(g_self_skill_events);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(g_self_skill_events, i);
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
cJSON* tags = ev ? cJSON_GetObjectItemCaseSensitive(ev, "tags") : NULL;
cJSON* id = ev ? cJSON_GetObjectItemCaseSensitive(ev, "id") : NULL;
cJSON* created_at = ev ? cJSON_GetObjectItemCaseSensitive(ev, "created_at") : NULL;
if (!kind || !cJSON_IsNumber(kind)) {
continue;
}
int k = (int)kind->valuedouble;
if (k == 31123 || k == 31124) {
skill_count++;
} else if (k == 10123) {
adoption_count++;
}
const char* d_tag = (tags && cJSON_IsArray(tags)) ? find_tag_value_local(tags, "d") : NULL;
DEBUG_INFO("[didactyl] self-skill cache event: kind=%d d_tag=%s id=%s created_at=%lld",
k,
(d_tag && d_tag[0] != '\0') ? d_tag : "-",
(id && cJSON_IsString(id) && id->valuestring) ? id->valuestring : "<no-id>",
(long long)((created_at && cJSON_IsNumber(created_at)) ? created_at->valuedouble : 0));
}
}
pthread_mutex_unlock(&g_self_skill_mutex);
if (out_skill_count) {
*out_skill_count = skill_count;
}
if (out_adoption_count) {
*out_adoption_count = adoption_count;
}
return skill_count > 0 ? 0 : -1;
}
int nostr_handler_send_dm_with_role(const char* recipient_pubkey_hex,
const char* message,
didactyl_dm_history_role_t role) {
if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message) {
return -1;
}
@@ -2697,7 +2857,7 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
if (sent > 0) {
dm_history_remember(recipient_pubkey_hex,
message,
0,
role,
time(NULL));
}
@@ -2706,26 +2866,34 @@ int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message)
return sent > 0 ? 0 : -1;
}
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message) {
int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message) {
return nostr_handler_send_dm_with_role(recipient_pubkey_hex,
message,
DIDACTYL_DM_HISTORY_ASSISTANT);
}
int nostr_handler_send_dm_auto_with_role(const char* recipient_pubkey_hex,
const char* message,
didactyl_dm_history_role_t role) {
if (!recipient_pubkey_hex || !message) {
return -1;
}
if (!g_cfg) {
return nostr_handler_send_dm(recipient_pubkey_hex, message);
return nostr_handler_send_dm_with_role(recipient_pubkey_hex, message, role);
}
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP04) {
return nostr_handler_send_dm(recipient_pubkey_hex, message);
return nostr_handler_send_dm_with_role(recipient_pubkey_hex, message, role);
}
if (g_cfg->dm_protocol == DM_PROTOCOL_NIP17) {
return nostr_handler_send_dm_nip17(recipient_pubkey_hex, message, NULL);
return nostr_handler_send_dm_nip17_with_role(recipient_pubkey_hex, message, NULL, role);
}
dm_protocol_t target = sender_protocol_lookup(recipient_pubkey_hex);
if (target == DM_PROTOCOL_NIP17) {
int rc17 = nostr_handler_send_dm_nip17(recipient_pubkey_hex, message, NULL);
int rc17 = nostr_handler_send_dm_nip17_with_role(recipient_pubkey_hex, message, NULL, role);
if (rc17 == 0) {
return 0;
}
@@ -2733,7 +2901,13 @@ int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* mes
recipient_pubkey_hex);
}
return nostr_handler_send_dm(recipient_pubkey_hex, message);
return nostr_handler_send_dm_with_role(recipient_pubkey_hex, message, role);
}
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message) {
return nostr_handler_send_dm_auto_with_role(recipient_pubkey_hex,
message,
DIDACTYL_DM_HISTORY_ASSISTANT);
}
static int publish_kind_event_to_relays(int kind,
@@ -2863,6 +3037,10 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
if (!events[i]) {
continue;
}
if (nostr_block_list_is_event_filtered(events[i])) {
cJSON_Delete(events[i]);
continue;
}
cJSON* dup = cJSON_Duplicate(events[i], 1);
if (dup) {
cJSON_AddItemToArray(arr, dup);
@@ -2877,6 +3055,45 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms) {
return out;
}
static void external_subscription_on_event_filtered(cJSON* event, const char* relay_url, void* user_data) {
external_subscription_wrapper_t* w = (external_subscription_wrapper_t*)user_data;
if (!w || !w->on_event) return;
if (nostr_block_list_is_event_filtered(event)) return;
w->on_event(event, relay_url, w->user_data);
}
static void external_subscription_on_eose_passthrough(cJSON** events, int event_count, void* user_data) {
external_subscription_wrapper_t* w = (external_subscription_wrapper_t*)user_data;
if (!w || !w->on_eose) return;
w->on_eose(events, event_count, w->user_data);
}
static void external_subscription_wrapper_track(external_subscription_wrapper_t* w) {
if (!w) return;
pthread_mutex_lock(&g_subscription_mutex);
w->next = g_external_sub_wrappers;
g_external_sub_wrappers = w;
pthread_mutex_unlock(&g_subscription_mutex);
}
static void external_subscription_wrapper_untrack_and_free(nostr_pool_subscription_t* handle) {
if (!handle) return;
pthread_mutex_lock(&g_subscription_mutex);
external_subscription_wrapper_t* prev = NULL;
external_subscription_wrapper_t* cur = g_external_sub_wrappers;
while (cur) {
if (cur->handle == handle) {
if (prev) prev->next = cur->next;
else g_external_sub_wrappers = cur->next;
free(cur);
break;
}
prev = cur;
cur = cur->next;
}
pthread_mutex_unlock(&g_subscription_mutex);
}
nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
cJSON* filter,
void (*on_event)(cJSON* event, const char* relay_url, void* user_data),
@@ -2891,26 +3108,43 @@ nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
return NULL;
}
return nostr_relay_pool_subscribe(
external_subscription_wrapper_t* w = (external_subscription_wrapper_t*)calloc(1, sizeof(*w));
if (!w) return NULL;
w->on_event = on_event;
w->on_eose = on_eose;
w->user_data = user_data;
nostr_pool_subscription_t* sub = nostr_relay_pool_subscribe(
g_pool,
(const char**)g_cfg->relays,
g_cfg->relay_count,
filter,
on_event,
on_eose,
user_data,
external_subscription_on_event_filtered,
on_eose ? external_subscription_on_eose_passthrough : NULL,
w,
close_on_eose,
enable_deduplication,
result_mode,
relay_timeout_seconds,
eose_timeout_seconds);
if (!sub) {
free(w);
return NULL;
}
w->handle = sub;
external_subscription_wrapper_track(w);
return sub;
}
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription) {
if (!subscription) {
return 0;
}
return nostr_pool_subscription_close(subscription);
int rc = nostr_pool_subscription_close(subscription);
external_subscription_wrapper_untrack_and_free(subscription);
return rc;
}
static void publish_pending_startup_events_for_relay_index(int relay_index, const char* reason) {
@@ -2970,14 +3204,24 @@ static void publish_pending_startup_events_for_relay_index(int relay_index, cons
}
}
static const char* startup_kind_label(int kind) {
switch (kind) {
case 0: return "profile";
case 1: return "note";
case 3: return "contacts";
case 10002: return "relay_list";
case 10123: return "adoption_list";
case 31123: return "private_skill";
case 31124: return "skill";
default: return "event";
}
}
int nostr_handler_reconcile_startup_events(void) {
if (!g_cfg || !g_pool) {
return -1;
}
free(g_system_context);
g_system_context = NULL;
free(g_startup_published);
g_startup_published = NULL;
g_startup_publish_tracking_enabled = 0;
@@ -2991,159 +3235,80 @@ int nostr_handler_reconcile_startup_events(void) {
g_startup_publish_tracking_enabled = 1;
}
if (g_cfg->default_skill.content && g_cfg->default_skill.content[0] != '\0') {
g_system_context = strdup(g_cfg->default_skill.content);
}
load_startup_display_name();
g_startup_kind1_already_exists = startup_self_kind1_exists();
if (g_startup_kind1_already_exists) {
DEBUG_INFO("[didactyl] startup phase: existing self kind-1 note found; skipping startup kind-1 publish");
}
if (!g_system_context) {
char default_ctx[512];
snprintf(default_ctx,
sizeof(default_ctx),
"You are %s, a sovereign AI agent living on Nostr.",
g_startup_display_name[0] != '\0' ? g_startup_display_name : "the agent");
g_system_context = strdup(default_ctx);
}
for (int relay_index = 0; relay_index < g_cfg->relay_count; relay_index++) {
publish_pending_startup_events_for_relay_index(relay_index, "startup_reconcile");
}
seed_default_skill_into_cache();
if (g_startup_publish_tracking_enabled && g_startup_published) {
int events_published = 0;
int relays_used = 0;
int missing_any_event = 0;
for (int r = 0; r < g_cfg->relay_count; r++) {
int used = 0;
for (int i = 0; i < g_cfg->startup_event_count; i++) {
size_t slot = (size_t)i * (size_t)g_cfg->relay_count + (size_t)r;
if (g_startup_published[slot]) {
used = 1;
break;
}
}
if (used) {
relays_used++;
}
}
for (int i = 0; i < g_cfg->startup_event_count; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
char relays_line[512];
relays_line[0] = '\0';
int relay_hits = 0;
for (int r = 0; r < g_cfg->relay_count; r++) {
size_t slot = (size_t)i * (size_t)g_cfg->relay_count + (size_t)r;
if (!g_startup_published[slot]) {
continue;
}
relay_hits++;
if (relays_line[0] != '\0') {
strncat(relays_line, ", ", sizeof(relays_line) - strlen(relays_line) - 1U);
}
strncat(relays_line, g_cfg->relays[r], sizeof(relays_line) - strlen(relays_line) - 1U);
}
if (relay_hits > 0) {
events_published++;
DEBUG_INFO("[didactyl] startup publish detail: kind=%d (%s) -> %s",
se->kind,
startup_kind_label(se->kind),
relays_line[0] ? relays_line : "<none>");
} else {
missing_any_event = 1;
DEBUG_ERROR("[didactyl] startup publish missing: kind=%d (%s) was not published to any relay",
se->kind,
startup_kind_label(se->kind));
}
}
DEBUG_INFO("[didactyl] startup publish summary: %d/%d event(s) published to at least one relay; relays used=%d/%d",
events_published,
g_cfg->startup_event_count,
relays_used,
g_cfg->relay_count);
if (missing_any_event) {
DEBUG_ERROR("[didactyl] startup reconcile failed: one or more startup events were not published to any relay");
return -1;
}
}
return 0;
}
int nostr_handler_load_system_context_from_adopted_skills(void) {
if (!g_cfg || !g_pool || g_cfg->keys.public_key_hex[0] == '\0') {
return -1;
}
cJSON* adoption_filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!adoption_filter || !kinds || !authors) {
cJSON_Delete(adoption_filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10123));
cJSON_AddItemToObject(adoption_filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(adoption_filter, "authors", authors);
cJSON_AddNumberToObject(adoption_filter, "limit", 1);
char* adoption_events_json = nostr_handler_query_json(adoption_filter, 3000);
cJSON_Delete(adoption_filter);
if (!adoption_events_json) {
return -1;
}
cJSON* adoption_events = cJSON_Parse(adoption_events_json);
free(adoption_events_json);
if (!adoption_events || !cJSON_IsArray(adoption_events) || cJSON_GetArraySize(adoption_events) <= 0) {
cJSON_Delete(adoption_events);
return -1;
}
cJSON* adoption = cJSON_GetArrayItem(adoption_events, 0);
cJSON* tags = adoption ? cJSON_GetObjectItemCaseSensitive(adoption, "tags") : NULL;
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(adoption_events);
return -1;
}
const char* first_addr = NULL;
int tag_n = cJSON_GetArraySize(tags);
for (int i = 0; i < tag_n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) {
continue;
}
if (strcmp(k->valuestring, "a") == 0 && v->valuestring[0] != '\0') {
first_addr = v->valuestring;
break;
}
}
if (!first_addr) {
cJSON_Delete(adoption_events);
return -1;
}
int skill_kind = 0;
char skill_pubkey_hex[65] = {0};
char skill_d_tag[65] = {0};
if (parse_skill_address_local(first_addr, &skill_kind, skill_pubkey_hex, skill_d_tag) != 0) {
cJSON_Delete(adoption_events);
return -1;
}
cJSON* skill_filter = cJSON_CreateObject();
cJSON* skill_kinds = cJSON_CreateArray();
cJSON* skill_authors = cJSON_CreateArray();
cJSON* d_tags = cJSON_CreateArray();
if (!skill_filter || !skill_kinds || !skill_authors || !d_tags) {
cJSON_Delete(adoption_events);
cJSON_Delete(skill_filter);
cJSON_Delete(skill_kinds);
cJSON_Delete(skill_authors);
cJSON_Delete(d_tags);
return -1;
}
cJSON_AddItemToArray(skill_kinds, cJSON_CreateNumber(skill_kind));
cJSON_AddItemToObject(skill_filter, "kinds", skill_kinds);
cJSON_AddItemToArray(skill_authors, cJSON_CreateString(skill_pubkey_hex));
cJSON_AddItemToObject(skill_filter, "authors", skill_authors);
cJSON_AddItemToArray(d_tags, cJSON_CreateString(skill_d_tag));
cJSON_AddItemToObject(skill_filter, "#d", d_tags);
cJSON_AddNumberToObject(skill_filter, "limit", 1);
char* skill_events_json = nostr_handler_query_json(skill_filter, 3000);
cJSON_Delete(skill_filter);
cJSON_Delete(adoption_events);
if (!skill_events_json) {
return -1;
}
cJSON* skill_events = cJSON_Parse(skill_events_json);
free(skill_events_json);
if (!skill_events || !cJSON_IsArray(skill_events) || cJSON_GetArraySize(skill_events) <= 0) {
cJSON_Delete(skill_events);
return -1;
}
cJSON* skill_event = cJSON_GetArrayItem(skill_events, 0);
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
if (!content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(skill_events);
return -1;
}
free(g_system_context);
g_system_context = strdup(content->valuestring);
cJSON_Delete(skill_events);
return g_system_context ? 0 : -1;
}
const char* nostr_handler_get_system_context(void) {
return g_system_context;
}
char* nostr_handler_get_self_events_by_kind_json(int kind) {
cJSON* out = cJSON_CreateArray();
if (!out) {
@@ -3557,7 +3722,10 @@ int nostr_handler_subscription_set_json(const char* name, cJSON* filter, int ena
return rc;
}
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject) {
int nostr_handler_send_dm_nip17_with_role(const char* recipient_pubkey_hex,
const char* message,
const char* subject,
didactyl_dm_history_role_t role) {
if (!g_cfg || !g_pool || !recipient_pubkey_hex || !message || recipient_pubkey_hex[0] == '\0' || message[0] == '\0') {
return -1;
}
@@ -3647,12 +3815,19 @@ int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* me
dm_history_remember(recipient_pubkey_hex,
message,
0,
role,
time(NULL));
return 0;
}
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject) {
return nostr_handler_send_dm_nip17_with_role(recipient_pubkey_hex,
message,
subject,
DIDACTYL_DM_HISTORY_ASSISTANT);
}
char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit) {
if (!peer_pubkey_hex || strlen(peer_pubkey_hex) != 64U) {
return strdup("[]");
@@ -3706,7 +3881,15 @@ char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit)
if (!item) {
continue;
}
cJSON_AddStringToObject(item, "role", entry->incoming ? "user" : "assistant");
const char* role_s = "assistant";
if (entry->role == DIDACTYL_DM_HISTORY_USER) {
role_s = "user";
} else if (entry->role == DIDACTYL_DM_HISTORY_TOOL_REQUEST) {
role_s = "tool_request";
} else if (entry->role == DIDACTYL_DM_HISTORY_TOOL_RESPONSE) {
role_s = "tool_response";
}
cJSON_AddStringToObject(item, "role", role_s);
cJSON_AddStringToObject(item, "content", entry->content);
cJSON_AddNumberToObject(item, "created_at", (double)entry->created_at);
cJSON_AddItemToArray(arr, item);
@@ -4038,6 +4221,14 @@ void nostr_handler_cleanup(void) {
g_managed_subs[i].on_eose = NULL;
g_managed_subs[i].user_data = NULL;
}
external_subscription_wrapper_t* ext = g_external_sub_wrappers;
while (ext) {
external_subscription_wrapper_t* next = ext->next;
free(ext);
ext = next;
}
g_external_sub_wrappers = NULL;
pthread_mutex_unlock(&g_subscription_mutex);
if (g_pool) {
@@ -4058,8 +4249,6 @@ void nostr_handler_cleanup(void) {
g_cfg = NULL;
g_dm_callback = NULL;
g_dm_user_data = NULL;
free(g_system_context);
g_system_context = NULL;
memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids));
g_seen_dm_count = 0;
g_seen_dm_next = 0;
+23 -2
View File
@@ -13,6 +13,13 @@ typedef enum {
DIDACTYL_SENDER_STRANGER = 3
} didactyl_sender_tier_t;
typedef enum {
DIDACTYL_DM_HISTORY_USER = 1,
DIDACTYL_DM_HISTORY_ASSISTANT = 2,
DIDACTYL_DM_HISTORY_TOOL_REQUEST = 3,
DIDACTYL_DM_HISTORY_TOOL_RESPONSE = 4
} didactyl_dm_history_role_t;
typedef void (*dm_callback_t)(const char* sender_pubkey_hex,
const char* message,
didactyl_sender_tier_t tier,
@@ -40,8 +47,15 @@ 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_with_role(const char* recipient_pubkey_hex,
const char* message,
didactyl_dm_history_role_t role);
int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message);
int nostr_handler_send_dm_auto_with_role(const char* recipient_pubkey_hex,
const char* message,
didactyl_dm_history_role_t role);
int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result);
void nostr_handler_publish_result_free(nostr_publish_result_t* result);
char* nostr_handler_query_json(cJSON* filter, int timeout_ms);
@@ -58,10 +72,10 @@ nostr_pool_subscription_t* nostr_handler_subscribe_with_filter(
int nostr_handler_close_subscription(nostr_pool_subscription_t* subscription);
int nostr_handler_poll(int timeout_ms);
int nostr_handler_reconcile_startup_events(void);
int nostr_handler_load_system_context_from_adopted_skills(void);
int nostr_handler_wait_for_self_skill_eose(int timeout_ms);
int nostr_handler_validate_self_skill_cache(int* out_skill_count, int* out_adoption_count);
void nostr_handler_refresh_relay_statuses(void);
int nostr_handler_sync_relays_from_config(void);
const char* nostr_handler_get_system_context(void);
char* nostr_handler_get_self_skill_events_json(void);
int nostr_handler_is_event_in_self_cache(const char* event_id_hex);
const char* nostr_handler_get_startup_display_name(void);
@@ -80,6 +94,13 @@ char* nostr_handler_relay_info_json(const char* relay_url);
char* nostr_handler_subscription_status_json(void);
int nostr_handler_subscription_set_json(const char* name, cJSON* filter, int enabled, int enabled_set);
int nostr_handler_send_dm_nip17(const char* recipient_pubkey_hex, const char* message, const char* subject);
int nostr_handler_send_dm_nip17_with_role(const char* recipient_pubkey_hex,
const char* message,
const char* subject,
didactyl_dm_history_role_t role);
int nostr_handler_dm_history_remember(const char* peer_pubkey_hex,
const char* content,
didactyl_dm_history_role_t role);
char* nostr_handler_get_dm_history_json(const char* peer_pubkey_hex, int limit);
void nostr_handler_cleanup(void);
+68 -515
View File
@@ -1,18 +1,16 @@
#define _POSIX_C_SOURCE 200809L
#include "prompt_template.h"
#include "cjson/cJSON.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char* dup_range(const char* s, size_t n) {
char* out = (char*)malloc(n + 1U);
if (!out) return NULL;
if (n > 0) {
memcpy(out, s, n);
}
if (n > 0) memcpy(out, s, n);
out[n] = '\0';
return out;
}
@@ -32,52 +30,6 @@ static void rtrim_inplace(char* s) {
}
}
static int starts_with(const char* s, const char* prefix) {
if (!s || !prefix) return 0;
size_t n = strlen(prefix);
return strncmp(s, prefix, n) == 0;
}
static int add_line(char*** lines, int* count, int* cap, char* line) {
if (!lines || !count || !cap) return -1;
if (*count >= *cap) {
int next = (*cap == 0) ? 64 : (*cap * 2);
char** grown = (char**)realloc(*lines, (size_t)next * sizeof(char*));
if (!grown) return -1;
*lines = grown;
*cap = next;
}
(*lines)[*count] = line;
(*count)++;
return 0;
}
static int split_lines_inplace(char* s, char*** out_lines, int* out_count) {
if (!s || !out_lines || !out_count) return -1;
char** lines = NULL;
int count = 0;
int cap = 0;
char* p = s;
while (*p) {
char* start = p;
while (*p && *p != '\n') p++;
if (*p == '\n') {
*p = '\0';
p++;
}
if (add_line(&lines, &count, &cap, start) != 0) {
free(lines);
return -1;
}
}
*out_lines = lines;
*out_count = count;
return 0;
}
static int append_text(char** buf, size_t* cap, size_t* used, const char* s) {
if (!buf || !cap || !used || !s) return -1;
size_t n = strlen(s);
@@ -105,10 +57,11 @@ 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;
}
static char* resolve_inline_variables_local(const char* tpl, tools_context_t* tools_ctx) {
char* prompt_template_resolve_inline_variables(const char* tpl, tools_context_t* tools_ctx) {
if (!tpl) return strdup("");
size_t cap = strlen(tpl) + 64U;
@@ -161,17 +114,62 @@ static char* resolve_inline_variables_local(const char* tpl, tools_context_t* to
const char* replacement = "";
char* replacement_owned = NULL;
char* call_name_owned = NULL;
char* call_args_owned = NULL;
const char* lookup_name = var_trim;
const char* call_args_json = "{}";
if (strcmp(var_trim, "message") == 0) {
size_t var_len = strlen(var_trim);
if (var_len > 0 && var_trim[var_len - 1] == ')') {
char* open_paren = strchr(var_trim, '(');
if (open_paren && open_paren < (var_trim + var_len - 1)) {
call_name_owned = dup_range(var_trim, (size_t)(open_paren - var_trim));
if (!call_name_owned) {
free(var);
free(out);
return NULL;
}
char* call_name_trim = ltrim_inplace(call_name_owned);
rtrim_inplace(call_name_trim);
char* raw_args = dup_range(open_paren + 1,
(size_t)((var_trim + var_len - 1) - (open_paren + 1)));
if (!raw_args) {
free(call_name_owned);
free(var);
free(out);
return NULL;
}
char* raw_args_trim = ltrim_inplace(raw_args);
rtrim_inplace(raw_args_trim);
if (call_name_trim[0] != '\0') {
lookup_name = call_name_trim;
if (raw_args_trim[0] != '\0') {
cJSON* parsed_args = cJSON_Parse(raw_args_trim);
if (parsed_args) {
call_args_owned = strdup(raw_args_trim);
cJSON_Delete(parsed_args);
}
}
}
free(raw_args);
}
}
if (call_args_owned) {
call_args_json = call_args_owned;
}
if (strcmp(lookup_name, "message") == 0) {
replacement = (tools_ctx && tools_ctx->template_current_user_message)
? tools_ctx->template_current_user_message
: "";
} else if (strcmp(var_trim, "dm_history") == 0) {
replacement = "";
} else if (tools_ctx) {
const char* tool_name = map_variable_tool_name(var_trim);
const char* tool_name = map_variable_tool_name(lookup_name);
if (tool_name && tool_name[0] != '\0') {
char* tool_result = tools_execute(tools_ctx, tool_name, "{}");
char* tool_result = tools_execute(tools_ctx, tool_name, call_args_json);
if (tool_result) {
cJSON* root = cJSON_Parse(tool_result);
if (root && cJSON_IsObject(root)) {
@@ -189,16 +187,30 @@ static char* resolve_inline_variables_local(const char* tpl, tools_context_t* to
free(tool_result);
}
}
if (!replacement_owned && tools_ctx->template_skill_lookup && lookup_name[0] != '\0') {
const char* skill_content = tools_ctx->template_skill_lookup(
tools_ctx->template_skill_lookup_user_data,
lookup_name);
if (skill_content && skill_content[0] != '\0') {
replacement_owned = strdup(skill_content);
}
}
replacement = replacement_owned ? replacement_owned : "";
}
if (append_text(&out, &cap, &used, replacement) != 0) {
free(call_args_owned);
free(call_name_owned);
free(replacement_owned);
free(var);
free(out);
return NULL;
}
free(call_args_owned);
free(call_name_owned);
free(replacement_owned);
free(var);
p = close + 2;
@@ -206,462 +218,3 @@ static char* resolve_inline_variables_local(const char* tpl, tools_context_t* to
return out;
}
static void init_section_defaults(prompt_template_section_t* sec) {
if (!sec) return;
memset(sec->name, 0, sizeof(sec->name));
memset(sec->role, 0, sizeof(sec->role));
snprintf(sec->role, sizeof(sec->role), "system");
sec->content_template = NULL;
sec->tool_name = NULL;
sec->tool_args = NULL;
sec->result_field = NULL;
sec->limit = 0;
sec->skip_if_empty = 0;
sec->provider_name = NULL;
sec->provider_content_template = NULL;
}
static int parse_int_or_zero(const char* s) {
if (!s) return 0;
while (*s && isspace((unsigned char)*s)) s++;
return atoi(s);
}
int prompt_template_parse(const char* skill_content, prompt_template_t* out_template) {
if (!skill_content || !out_template) {
return -1;
}
memset(out_template, 0, sizeof(*out_template));
const char* marker = strstr(skill_content, PROMPT_TEMPLATE_MARKER);
if (!marker) {
return -1;
}
size_t personality_len = (size_t)(marker - skill_content);
out_template->personality = dup_range(skill_content, personality_len);
if (!out_template->personality) {
return -1;
}
rtrim_inplace(out_template->personality);
const char* after = marker + strlen(PROMPT_TEMPLATE_MARKER);
while (*after == '\r' || *after == '\n') after++;
char* tpl = strdup(after);
if (!tpl) {
prompt_template_free(out_template);
return -1;
}
char** lines = NULL;
int line_count = 0;
if (split_lines_inplace(tpl, &lines, &line_count) != 0) {
free(tpl);
prompt_template_free(out_template);
return -1;
}
int current = -1;
int i = 0;
while (i < line_count) {
char* raw = lines[i];
rtrim_inplace(raw);
char* line = ltrim_inplace(raw);
if (*line == '\0') {
i++;
continue;
}
if (starts_with(line, "- section:")) {
if (out_template->section_count >= PROMPT_TEMPLATE_MAX_SECTIONS) {
break;
}
current = out_template->section_count;
init_section_defaults(&out_template->sections[current]);
out_template->section_count++;
char* name = line + strlen("- section:");
name = ltrim_inplace(name);
rtrim_inplace(name);
snprintf(out_template->sections[current].name,
sizeof(out_template->sections[current].name),
"%s",
name);
i++;
continue;
}
if (current < 0) {
i++;
continue;
}
if (starts_with(line, "role:")) {
char* role = line + strlen("role:");
role = ltrim_inplace(role);
rtrim_inplace(role);
snprintf(out_template->sections[current].role,
sizeof(out_template->sections[current].role),
"%s",
(*role) ? role : "system");
i++;
continue;
}
if (starts_with(line, "limit:")) {
char* lim = line + strlen("limit:");
out_template->sections[current].limit = parse_int_or_zero(lim);
i++;
continue;
}
if (starts_with(line, "skip_if_empty:")) {
char* flag = line + strlen("skip_if_empty:");
flag = ltrim_inplace(flag);
rtrim_inplace(flag);
out_template->sections[current].skip_if_empty =
(strcmp(flag, "true") == 0 || strcmp(flag, "1") == 0) ? 1 : 0;
i++;
continue;
}
if (starts_with(line, "tool:")) {
char* val = line + strlen("tool:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].tool_name);
out_template->sections[current].tool_name = strdup(val);
i++;
continue;
}
if (starts_with(line, "args:")) {
char* val = line + strlen("args:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].tool_args);
out_template->sections[current].tool_args = strdup((*val) ? val : "{}");
i++;
continue;
}
if (starts_with(line, "result_field:")) {
char* val = line + strlen("result_field:");
val = ltrim_inplace(val);
rtrim_inplace(val);
free(out_template->sections[current].result_field);
out_template->sections[current].result_field = strdup(val);
i++;
continue;
}
if (starts_with(line, "content:")) {
char* val = line + strlen("content:");
val = ltrim_inplace(val);
free(out_template->sections[current].content_template);
out_template->sections[current].content_template = NULL;
if (strcmp(val, "|") == 0) {
char* acc = strdup("");
size_t cap = acc ? 1U : 0U;
size_t used = 0U;
if (!acc) {
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
while (i < line_count) {
char* next_raw = lines[i];
char* next_ltrim = ltrim_inplace(next_raw);
if (starts_with(next_ltrim, "- section:") ||
starts_with(next_ltrim, "role:") ||
starts_with(next_ltrim, "limit:") ||
starts_with(next_ltrim, "skip_if_empty:") ||
starts_with(next_ltrim, "tool:") ||
starts_with(next_ltrim, "args:") ||
starts_with(next_ltrim, "result_field:") ||
starts_with(next_ltrim, "content:") ||
starts_with(next_ltrim, "provider:")) {
break;
}
char* piece = next_raw;
if (strncmp(piece, " ", 4) == 0) piece += 4;
else if (strncmp(piece, " ", 2) == 0) piece += 2;
rtrim_inplace(piece);
if (append_text(&acc, &cap, &used, piece) != 0 ||
append_text(&acc, &cap, &used, "\n") != 0) {
free(acc);
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
}
rtrim_inplace(acc);
out_template->sections[current].content_template = acc;
continue;
}
rtrim_inplace(val);
out_template->sections[current].content_template = strdup(val);
i++;
continue;
}
if (starts_with(line, "provider:")) {
i++;
if (i >= line_count) continue;
char* provider_line = ltrim_inplace(lines[i]);
rtrim_inplace(provider_line);
char* colon = strchr(provider_line, ':');
if (!colon) {
continue;
}
*colon = '\0';
char* provider_name = ltrim_inplace(provider_line);
rtrim_inplace(provider_name);
char* provider_val = ltrim_inplace(colon + 1);
rtrim_inplace(provider_val);
free(out_template->sections[current].provider_name);
out_template->sections[current].provider_name = strdup(provider_name);
free(out_template->sections[current].provider_content_template);
out_template->sections[current].provider_content_template = NULL;
if (strcmp(provider_val, "|") == 0) {
char* acc = strdup("");
size_t cap = acc ? 1U : 0U;
size_t used = 0U;
if (!acc) {
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
while (i < line_count) {
char* next = ltrim_inplace(lines[i]);
if (starts_with(next, "- section:") ||
starts_with(next, "role:") ||
starts_with(next, "limit:") ||
starts_with(next, "skip_if_empty:") ||
starts_with(next, "tool:") ||
starts_with(next, "args:") ||
starts_with(next, "result_field:") ||
starts_with(next, "content:") ||
starts_with(next, "provider:")) {
break;
}
char* piece = lines[i];
if (strncmp(piece, " ", 4) == 0) piece += 4;
else if (strncmp(piece, " ", 2) == 0) piece += 2;
rtrim_inplace(piece);
if (append_text(&acc, &cap, &used, piece) != 0 ||
append_text(&acc, &cap, &used, "\n") != 0) {
free(acc);
free(lines);
free(tpl);
prompt_template_free(out_template);
return -1;
}
i++;
}
rtrim_inplace(acc);
out_template->sections[current].provider_content_template = acc;
continue;
}
out_template->sections[current].provider_content_template = strdup(provider_val);
i++;
continue;
}
i++;
}
free(lines);
free(tpl);
return 0;
}
static int append_message_object(cJSON* messages, const char* role, const char* content) {
if (!messages || !role) return -1;
cJSON* msg = cJSON_CreateObject();
if (!msg) return -1;
cJSON_AddStringToObject(msg, "role", role);
cJSON_AddStringToObject(msg, "content", content ? content : "");
cJSON_AddItemToArray(messages, msg);
return 0;
}
static char* extract_tool_result_content(const prompt_template_section_t* sec, const char* tool_result_json) {
if (!tool_result_json) return strdup("");
cJSON* root = cJSON_Parse(tool_result_json);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return strdup(tool_result_json);
}
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
if (success && cJSON_IsBool(success) && !cJSON_IsTrue(success)) {
cJSON_Delete(root);
return strdup("");
}
const char* field_name = (sec && sec->result_field && sec->result_field[0] != '\0')
? sec->result_field
: "content";
cJSON* field = cJSON_GetObjectItemCaseSensitive(root, field_name);
if (!field) {
field = cJSON_GetObjectItemCaseSensitive(root, "content");
}
char* out = NULL;
if (field && cJSON_IsString(field) && field->valuestring) {
out = strdup(field->valuestring);
} else if (field) {
out = cJSON_PrintUnformatted(field);
} else {
out = cJSON_PrintUnformatted(root);
}
cJSON_Delete(root);
return out ? out : strdup("");
}
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,
tools_context_t* tools_ctx,
cJSON* dm_history_messages,
int dm_history_default_limit,
prompt_template_emit_hook_fn emit_hook,
void* emit_hook_user_data) {
if (!tmpl) return NULL;
cJSON* out = cJSON_CreateArray();
if (!out) return NULL;
int out_idx = 0;
if (tmpl->personality && tmpl->personality[0] != '\0') {
if (append_message_object(out, "system", tmpl->personality) != 0) {
cJSON_Delete(out);
return NULL;
}
if (emit_hook) emit_hook("system_prompt", out_idx, emit_hook_user_data);
out_idx++;
}
for (int i = 0; i < tmpl->section_count; i++) {
const prompt_template_section_t* sec = &tmpl->sections[i];
const char* role = sec->role[0] ? sec->role : "system";
if (strcmp(role, "expand") == 0) {
if (!dm_history_messages || !cJSON_IsArray(dm_history_messages)) {
continue;
}
int total = cJSON_GetArraySize(dm_history_messages);
int lim = sec->limit > 0 ? sec->limit : dm_history_default_limit;
int start = (lim > 0 && total > lim) ? (total - lim) : 0;
for (int j = start; j < total; j++) {
cJSON* item = cJSON_GetArrayItem(dm_history_messages, j);
if (!item || !cJSON_IsObject(item)) continue;
cJSON* dup = cJSON_Duplicate(item, 1);
if (!dup) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToArray(out, dup);
if (emit_hook) emit_hook(sec->name[0] ? sec->name : "context_part", out_idx, emit_hook_user_data);
out_idx++;
}
continue;
}
char* resolved = NULL;
if (sec->tool_name && sec->tool_name[0] != '\0' && tools_ctx) {
const char* args_json = (sec->tool_args && sec->tool_args[0] != '\0') ? sec->tool_args : "{}";
char* tool_result = tools_execute(tools_ctx, sec->tool_name, args_json);
resolved = extract_tool_result_content(sec, tool_result);
free(tool_result);
} else {
const char* tpl = sec->content_template;
if (provider_name && sec->provider_name && sec->provider_content_template &&
strcmp(provider_name, sec->provider_name) == 0) {
tpl = sec->provider_content_template;
}
resolved = resolve_inline_variables_local(tpl ? tpl : "", tools_ctx);
}
if (!resolved) {
cJSON_Delete(out);
return NULL;
}
if (sec->skip_if_empty) {
char* chk = ltrim_inplace(resolved);
if (chk && *chk == '\0') {
free(resolved);
continue;
}
}
if (append_message_object(out, role, resolved) != 0) {
free(resolved);
cJSON_Delete(out);
return NULL;
}
if (emit_hook) emit_hook(sec->name[0] ? sec->name : "context_part", out_idx, emit_hook_user_data);
out_idx++;
free(resolved);
}
return out;
}
void prompt_template_free(prompt_template_t* tmpl) {
if (!tmpl) return;
free(tmpl->personality);
tmpl->personality = NULL;
for (int i = 0; i < tmpl->section_count; i++) {
free(tmpl->sections[i].content_template);
tmpl->sections[i].content_template = NULL;
free(tmpl->sections[i].tool_name);
tmpl->sections[i].tool_name = NULL;
free(tmpl->sections[i].tool_args);
tmpl->sections[i].tool_args = NULL;
free(tmpl->sections[i].result_field);
tmpl->sections[i].result_field = NULL;
free(tmpl->sections[i].provider_name);
tmpl->sections[i].provider_name = NULL;
free(tmpl->sections[i].provider_content_template);
tmpl->sections[i].provider_content_template = NULL;
tmpl->sections[i].name[0] = '\0';
tmpl->sections[i].role[0] = '\0';
tmpl->sections[i].limit = 0;
tmpl->sections[i].skip_if_empty = 0;
}
tmpl->section_count = 0;
}
+1 -40
View File
@@ -1,47 +1,8 @@
#ifndef DIDACTYL_PROMPT_TEMPLATE_H
#define DIDACTYL_PROMPT_TEMPLATE_H
#include "cjson/cJSON.h"
#include "tools/tools.h"
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
#define PROMPT_TEMPLATE_MAX_ROLE_LEN 16
#define PROMPT_TEMPLATE_MARKER "---template---"
typedef struct {
char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];
char* content_template;
char* tool_name;
char* tool_args;
char* result_field;
int limit;
int skip_if_empty;
char* provider_name;
char* provider_content_template;
} prompt_template_section_t;
typedef struct {
char* personality;
prompt_template_section_t sections[PROMPT_TEMPLATE_MAX_SECTIONS];
int section_count;
} prompt_template_t;
typedef void (*prompt_template_emit_hook_fn)(const char* section_name,
int message_index,
void* user_data);
int prompt_template_parse(const char* skill_content, prompt_template_t* out_template);
cJSON* prompt_template_build_messages(const prompt_template_t* tmpl,
const char* provider_name,
tools_context_t* tools_ctx,
cJSON* dm_history_messages,
int dm_history_default_limit,
prompt_template_emit_hook_fn emit_hook,
void* emit_hook_user_data);
void prompt_template_free(prompt_template_t* tmpl);
char* prompt_template_resolve_inline_variables(const char* tpl, tools_context_t* tools_ctx);
#endif
+60 -24
View File
@@ -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;
@@ -370,17 +372,64 @@ static int build_kind10002_tags_json(const didactyl_config_t* cfg, char** out_ta
return 0;
}
static int tags_json_get_d_tag_local(const char* tags_json, char* out, size_t out_size) {
if (!tags_json || !out || out_size == 0) {
return -1;
}
out[0] = '\0';
cJSON* tags = cJSON_Parse(tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
return -1;
}
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue;
cJSON* k = cJSON_GetArrayItem(tag, 0);
cJSON* v = cJSON_GetArrayItem(tag, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) continue;
if (strcmp(k->valuestring, "d") == 0 && v->valuestring[0] != '\0') {
snprintf(out, out_size, "%s", v->valuestring);
cJSON_Delete(tags);
return 0;
}
}
cJSON_Delete(tags);
return -1;
}
static int upsert_startup_event(didactyl_config_t* cfg, int kind, const char* content, const char* tags_json) {
if (!cfg || !content || !tags_json) {
return -1;
}
int is_skill_kind = (kind == 31123 || kind == 31124);
char incoming_d[128] = {0};
int incoming_has_d = (is_skill_kind && tags_json_get_d_tag_local(tags_json, incoming_d, sizeof(incoming_d)) == 0);
for (int i = 0; i < cfg->startup_event_count; i++) {
startup_event_t* se = &cfg->startup_events[i];
if (se->kind != kind) {
continue;
}
if (is_skill_kind) {
if (!incoming_has_d) {
continue;
}
char existing_d[128] = {0};
if (tags_json_get_d_tag_local(se->tags_json, existing_d, sizeof(existing_d)) != 0) {
continue;
}
if (strcmp(existing_d, incoming_d) != 0) {
continue;
}
}
char* content_dup = strdup(content);
char* tags_dup = strdup(tags_json);
if (!content_dup || !tags_dup) {
@@ -493,35 +542,22 @@ static int apply_default_startup_events(didactyl_config_t* cfg, const char* agen
return 0;
}
static int configure_default_skill_for_agent(didactyl_config_t* cfg, const char* agent_name) {
static int configure_default_skills_for_agent(didactyl_config_t* cfg, const char* agent_name) {
if (!cfg || !agent_name || agent_name[0] == '\0') {
return -1;
}
free(cfg->default_skill.content);
cfg->default_skill.content = NULL;
free(cfg->default_skill.tags_json);
cfg->default_skill.tags_json = NULL;
cfg->default_skill.kind = DIDACTYL_DEFAULT_SKILL_KIND;
snprintf(cfg->default_skill.d_tag, sizeof(cfg->default_skill.d_tag), "%s", DIDACTYL_DEFAULT_SKILL_D_TAG);
int needed = snprintf(NULL, 0, DIDACTYL_DEFAULT_SKILL_TEMPLATE, agent_name, agent_name);
if (needed <= 0) {
if (upsert_startup_event(cfg,
DIDACTYL_DEFAULT_SKILL_KIND,
DIDACTYL_DEFAULT_SKILL_TEMPLATE,
DIDACTYL_DEFAULT_SKILL_TAGS_JSON) != 0) {
return -1;
}
cfg->default_skill.content = (char*)malloc((size_t)needed + 1U);
if (!cfg->default_skill.content) {
return -1;
}
snprintf(cfg->default_skill.content, (size_t)needed + 1U, DIDACTYL_DEFAULT_SKILL_TEMPLATE, agent_name, agent_name);
cfg->default_skill.tags_json = strdup(DIDACTYL_DEFAULT_SKILL_TAGS_JSON);
if (!cfg->default_skill.tags_json) {
free(cfg->default_skill.content);
cfg->default_skill.content = NULL;
if (upsert_startup_event(cfg,
DIDACTYL_DEFAULT_DM_HISTORY_SKILL_KIND,
DIDACTYL_DEFAULT_DM_HISTORY_SKILL_TEMPLATE,
DIDACTYL_DEFAULT_DM_HISTORY_SKILL_TAGS_JSON) != 0) {
return -1;
}
@@ -2155,8 +2191,8 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t
if (prompt_llm_config(cfg) != 0) return -1;
}
if (configure_default_skill_for_agent(cfg, agent_name) != 0) {
fprintf(stderr, "%sFailed to prepare default skill event content.%s\n", ANSI_RED, ANSI_RESET);
if (configure_default_skills_for_agent(cfg, agent_name) != 0) {
fprintf(stderr, "%sFailed to prepare default startup skill events.%s\n", ANSI_RED, ANSI_RESET);
return -1;
}
+21 -61
View File
@@ -48,15 +48,23 @@ char* execute_admin_identity(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
char content[768];
snprintf(content,
sizeof(content),
"## Administrator Identity (source: config.admin.pubkey)\n\n"
"This is your administrator! Admin pubkey (hex): %s\n\n"
"%s",
ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown",
tier_text);
cJSON_AddStringToObject(out, "content", content);
cJSON* identity = cJSON_CreateObject();
if (!identity) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddStringToObject(identity,
"admin_pubkey",
ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown");
cJSON_AddStringToObject(identity, "sender_tier", tier_text);
char* identity_json = cJSON_PrintUnformatted(identity);
cJSON_Delete(identity);
if (!identity_json) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddStringToObject(out, "content", identity_json);
free(identity_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -81,19 +89,7 @@ char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind0);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): %s",
profile_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", profile_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -119,19 +115,7 @@ char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json)
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): ") + strlen(contacts_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind3);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): %s",
contacts_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", contacts_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -157,19 +141,7 @@ char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): ") + strlen(relays_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind10002);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): %s",
relays_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", relays_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -195,19 +167,7 @@ char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Administrator Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(notes);
return NULL;
}
snprintf(content,
content_len,
"## Administrator Recent Kind 1 Notes\n\n%s",
notes_text);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", notes_text);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
+206 -59
View File
@@ -31,6 +31,25 @@ static cJSON* parse_args_local(const char* args_json) {
return args;
}
static int append_text_local(char** buf, size_t* cap, size_t* used, const char* s) {
if (!buf || !cap || !used || !s) return -1;
size_t n = strlen(s);
if (*used + n + 1U > *cap) {
size_t next = *cap;
while (*used + n + 1U > next) {
next = (next == 0U) ? 256U : (next * 2U);
}
char* grown = (char*)realloc(*buf, next);
if (!grown) return -1;
*buf = grown;
*cap = next;
}
memcpy(*buf + *used, s, n);
*used += n;
(*buf)[*used] = '\0';
return 0;
}
char* execute_message_current(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
@@ -63,13 +82,23 @@ char* execute_agent_identity(tools_context_t* ctx, const char* args_json) {
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
char content[384];
snprintf(content,
sizeof(content),
"## Agent Identity\n\nYour pubkey (hex): %s\nYour npub: %s",
ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown",
npub);
cJSON_AddStringToObject(out, "content", content);
cJSON* identity = cJSON_CreateObject();
if (!identity) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddStringToObject(identity,
"pubkey",
ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown");
cJSON_AddStringToObject(identity, "npub", npub);
char* identity_json = cJSON_PrintUnformatted(identity);
cJSON_Delete(identity);
if (!identity_json) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddStringToObject(out, "content", identity_json);
free(identity_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -94,19 +123,7 @@ char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Agent Kind 0 Profile (source: nostr kind 0)\n\nAgent kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind0);
return NULL;
}
snprintf(content,
content_len,
"## Agent Kind 0 Profile (source: nostr kind 0)\n\nAgent kind 0 profile content (JSON): %s",
profile_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", profile_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -132,19 +149,7 @@ char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json)
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Agent Kind 3 Contacts (source: nostr kind 3)\n\nAgent contacts (JSON): ") + strlen(contacts_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind3);
return NULL;
}
snprintf(content,
content_len,
"## Agent Kind 3 Contacts (source: nostr kind 3)\n\nAgent contacts (JSON): %s",
contacts_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", contacts_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -170,19 +175,7 @@ char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Agent Kind 10002 Relays (source: nostr kind 10002)\n\nAgent relay list (JSON): ") + strlen(relays_json) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(kind10002);
return NULL;
}
snprintf(content,
content_len,
"## Agent Kind 10002 Relays (source: nostr kind 10002)\n\nAgent relay list (JSON): %s",
relays_json);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", relays_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -208,19 +201,7 @@ char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
size_t content_len = strlen("## Agent Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
char* content = (char*)malloc(content_len);
if (!content) {
cJSON_Delete(out);
free(notes);
return NULL;
}
snprintf(content,
content_len,
"## Agent Recent Kind 1 Notes\n\n%s",
notes_text);
cJSON_AddStringToObject(out, "content", content);
free(content);
cJSON_AddStringToObject(out, "content", notes_text);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -300,6 +281,172 @@ char* execute_trigger_event(tools_context_t* ctx, const char* args_json) {
return json;
}
char* execute_nostr_dm_history(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");
int limit = 12;
cJSON* limit_j = cJSON_GetObjectItemCaseSensitive(args, "limit");
if (limit_j && cJSON_IsNumber(limit_j)) {
limit = (int)limit_j->valuedouble;
}
if (limit < 1) limit = 1;
if (limit > 200) limit = 200;
int include_current = 0;
cJSON* include_current_j = cJSON_GetObjectItemCaseSensitive(args, "include_current");
if (include_current_j && cJSON_IsBool(include_current_j) && cJSON_IsTrue(include_current_j)) {
include_current = 1;
}
int format_text = 0;
cJSON* format_j = cJSON_GetObjectItemCaseSensitive(args, "format");
if (format_j && cJSON_IsString(format_j) && format_j->valuestring) {
if (strcmp(format_j->valuestring, "text") == 0) {
format_text = 1;
}
}
const char* peer_pubkey = ctx->cfg->admin.pubkey;
cJSON* peer_j = cJSON_GetObjectItemCaseSensitive(args, "peer_pubkey");
if (peer_j && cJSON_IsString(peer_j) && peer_j->valuestring && peer_j->valuestring[0] != '\0') {
peer_pubkey = peer_j->valuestring;
}
char* history_json = nostr_handler_get_dm_history_json(peer_pubkey, limit);
cJSON_Delete(args);
if (!history_json) {
return json_error_local("failed to fetch dm history");
}
cJSON* parsed = cJSON_Parse(history_json);
free(history_json);
if (!parsed || !cJSON_IsArray(parsed)) {
cJSON_Delete(parsed);
parsed = cJSON_CreateArray();
if (!parsed) {
return json_error_local("failed to build dm history response");
}
}
cJSON* filtered = cJSON_CreateArray();
if (!filtered) {
cJSON_Delete(parsed);
return json_error_local("failed to build dm history response");
}
const char* current_message = (!include_current && ctx->template_current_user_message)
? ctx->template_current_user_message
: NULL;
const char* last_role = NULL;
const char* last_content = NULL;
int n = cJSON_GetArraySize(parsed);
for (int i = 0; i < n; i++) {
cJSON* item = cJSON_GetArrayItem(parsed, i);
cJSON* role = item ? cJSON_GetObjectItemCaseSensitive(item, "role") : NULL;
cJSON* content = item ? cJSON_GetObjectItemCaseSensitive(item, "content") : NULL;
cJSON* created_at = item ? cJSON_GetObjectItemCaseSensitive(item, "created_at") : NULL;
if (!role || !content || !cJSON_IsString(role) || !cJSON_IsString(content) ||
!role->valuestring || !content->valuestring) {
continue;
}
const char* role_s = role->valuestring;
const char* content_s = content->valuestring;
int role_is_user = (strcmp(role_s, "user") == 0);
if (!role_is_user && strcmp(role_s, "assistant") != 0) {
continue;
}
if (i == n - 1 && role_is_user && current_message && strcmp(content_s, current_message) == 0) {
continue;
}
if (last_role && last_content && strcmp(last_role, role_s) == 0 && strcmp(last_content, content_s) == 0) {
continue;
}
cJSON* out_item = cJSON_CreateObject();
if (!out_item) {
continue;
}
cJSON_AddStringToObject(out_item, "role", role_s);
cJSON_AddStringToObject(out_item, "content", content_s);
if (created_at && cJSON_IsNumber(created_at)) {
cJSON_AddNumberToObject(out_item, "created_at", created_at->valuedouble);
}
cJSON_AddItemToArray(filtered, out_item);
last_role = role_s;
last_content = content_s;
}
cJSON_Delete(parsed);
char* rendered = NULL;
if (format_text) {
size_t cap = 256U;
size_t used = 0U;
rendered = (char*)malloc(cap);
if (!rendered) {
cJSON_Delete(filtered);
return json_error_local("failed to serialize dm history");
}
rendered[0] = '\0';
int m = cJSON_GetArraySize(filtered);
for (int i = 0; i < m; i++) {
cJSON* item = cJSON_GetArrayItem(filtered, i);
cJSON* role = item ? cJSON_GetObjectItemCaseSensitive(item, "role") : NULL;
cJSON* content = item ? cJSON_GetObjectItemCaseSensitive(item, "content") : NULL;
if (!role || !content || !cJSON_IsString(role) || !cJSON_IsString(content) ||
!role->valuestring || !content->valuestring) {
continue;
}
const char* prefix = (strcmp(role->valuestring, "user") == 0) ? "User: " : "Assistant: ";
if (append_text_local(&rendered, &cap, &used, prefix) != 0 ||
append_text_local(&rendered, &cap, &used, content->valuestring) != 0 ||
append_text_local(&rendered, &cap, &used, "\n") != 0) {
free(rendered);
cJSON_Delete(filtered);
return json_error_local("failed to serialize dm history");
}
}
if (used > 0U && rendered[used - 1] == '\n') {
rendered[used - 1] = '\0';
}
} else {
rendered = cJSON_PrintUnformatted(filtered);
if (!rendered) {
cJSON_Delete(filtered);
return json_error_local("failed to serialize dm history");
}
}
cJSON_Delete(filtered);
cJSON* out = cJSON_CreateObject();
if (!out) {
free(rendered);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", rendered);
free(rendered);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
char* execute_agent_version(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
+304
View 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;
}
+8 -8
View File
@@ -73,7 +73,7 @@ char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json) {
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_info requires mint_url when no cashu_wallet.mint_urls are configured");
}
@@ -100,7 +100,7 @@ char* execute_cashu_wallet_mint_quote(tools_context_t* ctx, const char* args_jso
cJSON* unit = cJSON_GetObjectItemCaseSensitive(args, "unit");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
const char* unit_s = (unit && cJSON_IsString(unit) && unit->valuestring) ? unit->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_quote requires mint_url when no cashu_wallet.mint_urls are configured");
}
@@ -125,7 +125,7 @@ char* execute_cashu_wallet_mint_check(tools_context_t* ctx, const char* args_jso
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_check requires mint_url when no cashu_wallet.mint_urls are configured");
}
@@ -160,7 +160,7 @@ char* execute_cashu_wallet_mint_claim(tools_context_t* ctx, const char* args_jso
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_claim requires mint_url when no cashu_wallet.mint_urls are configured");
}
@@ -187,7 +187,7 @@ char* execute_cashu_wallet_melt_quote(tools_context_t* ctx, const char* args_jso
cJSON* unit = cJSON_GetObjectItemCaseSensitive(args, "unit");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
const char* unit_s = (unit && cJSON_IsString(unit) && unit->valuestring) ? unit->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_melt_quote requires mint_url when no cashu_wallet.mint_urls are configured");
}
@@ -212,7 +212,7 @@ char* execute_cashu_wallet_melt_pay(tools_context_t* ctx, const char* args_json)
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_melt_pay requires mint_url when no cashu_wallet.mint_urls are configured");
}
@@ -231,7 +231,7 @@ char* execute_cashu_wallet_check_proofs(tools_context_t* ctx, const char* args_j
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_check_proofs requires mint_url when no cashu_wallet.mint_urls are configured");
}
@@ -277,7 +277,7 @@ char* execute_cashu_wallet_send_token(tools_context_t* ctx, const char* args_jso
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
if ((!mint_url || mint_url[0] == '\0') && !cashu_wallet_has_default_mint()) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_send_token requires mint_url when no cashu_wallet.mint_urls are configured");
}
+48 -129
View File
@@ -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;
}
+148
View File
@@ -0,0 +1,148 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../nostr_block_list.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;
}
char* execute_nostr_block_list(tools_context_t* ctx, const char* args_json) {
(void)ctx;
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* type = cJSON_GetObjectItemCaseSensitive(args, "type");
const char* which = (type && cJSON_IsString(type) && type->valuestring) ? type->valuestring : "all";
char* raw = nostr_block_list_json();
cJSON_Delete(args);
if (!raw) return json_error_local("nostr_block_list unavailable");
cJSON* parsed = cJSON_Parse(raw);
free(raw);
if (!parsed || !cJSON_IsObject(parsed)) {
cJSON_Delete(parsed);
return json_error_local("nostr_block_list returned invalid JSON");
}
if (strcmp(which, "all") != 0 && strcmp(which, "p") != 0 && strcmp(which, "e") != 0 && strcmp(which, "t") != 0) {
cJSON_Delete(parsed);
return json_error_local("type must be one of: all, p, e, t");
}
if (strcmp(which, "all") == 0) {
char* out = cJSON_PrintUnformatted(parsed);
cJSON_Delete(parsed);
return out;
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(parsed);
return NULL;
}
cJSON* success = cJSON_GetObjectItemCaseSensitive(parsed, "success");
cJSON_AddBoolToObject(out, "success", success && cJSON_IsBool(success) ? cJSON_IsTrue(success) : 1);
if (strcmp(which, "p") == 0) {
cJSON* arr = cJSON_Duplicate(cJSON_GetObjectItemCaseSensitive(parsed, "blocked_pubkeys"), 1);
if (!arr) arr = cJSON_CreateArray();
cJSON_AddItemToObject(out, "blocked_pubkeys", arr);
} else if (strcmp(which, "e") == 0) {
cJSON* arr = cJSON_Duplicate(cJSON_GetObjectItemCaseSensitive(parsed, "blocked_events"), 1);
if (!arr) arr = cJSON_CreateArray();
cJSON_AddItemToObject(out, "blocked_events", arr);
} else {
cJSON* arr = cJSON_Duplicate(cJSON_GetObjectItemCaseSensitive(parsed, "blocked_hashtags"), 1);
if (!arr) arr = cJSON_CreateArray();
cJSON_AddItemToObject(out, "blocked_hashtags", arr);
}
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(parsed);
return json;
}
char* execute_nostr_block_edit(tools_context_t* ctx, const char* args_json) {
(void)ctx;
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action");
cJSON* items = cJSON_GetObjectItemCaseSensitive(args, "items");
cJSON* is_public = cJSON_GetObjectItemCaseSensitive(args, "public");
if (!action || !cJSON_IsString(action) || !action->valuestring ||
!items || !cJSON_IsArray(items)) {
cJSON_Delete(args);
return json_error_local("nostr_block_edit requires action and items");
}
int add_mode = strcmp(action->valuestring, "add") == 0;
int remove_mode = strcmp(action->valuestring, "remove") == 0;
if (!add_mode && !remove_mode) {
cJSON_Delete(args);
return json_error_local("action must be add or remove");
}
int public_mode = (is_public && cJSON_IsBool(is_public) && cJSON_IsTrue(is_public)) ? 1 : 0;
int affected = 0;
int count = cJSON_GetArraySize(items);
for (int i = 0; i < count; i++) {
cJSON* tuple = cJSON_GetArrayItem(items, i);
if (!tuple || !cJSON_IsArray(tuple) || cJSON_GetArraySize(tuple) < 2) {
continue;
}
cJSON* k = cJSON_GetArrayItem(tuple, 0);
cJSON* v = cJSON_GetArrayItem(tuple, 1);
if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v) || !k->valuestring || !v->valuestring) {
continue;
}
int rc = add_mode
? nostr_block_list_add(k->valuestring, v->valuestring, public_mode)
: nostr_block_list_remove(k->valuestring, v->valuestring);
if (rc == 0) {
affected++;
}
}
cJSON_Delete(args);
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", affected > 0 ? 1 : 0);
cJSON_AddStringToObject(out, "action", add_mode ? "add" : "remove");
cJSON_AddBoolToObject(out, "public", public_mode ? 1 : 0);
cJSON_AddNumberToObject(out, "items_affected", affected);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
}
+2
View File
@@ -227,6 +227,7 @@ char* execute_nostr_pubkey(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "pubkey", ctx->cfg->keys.public_key_hex);
cJSON_AddStringToObject(out, "content", ctx->cfg->keys.public_key_hex);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -250,6 +251,7 @@ char* execute_nostr_npub(tools_context_t* ctx, const char* args_json) {
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "npub", npub);
cJSON_AddStringToObject(out, "content", npub);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
+309
View File
@@ -908,6 +908,315 @@ char* execute_skill_list(tools_context_t* ctx, const char* args_json) {
return json;
}
char* execute_skill_get(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* d_tag = cJSON_GetObjectItemCaseSensitive(args, "d");
cJSON* input = cJSON_GetObjectItemCaseSensitive(args, "input");
const char* d_tag_value = NULL;
if (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring && validate_skill_d_tag_local(d_tag->valuestring)) {
d_tag_value = d_tag->valuestring;
} else if (input && cJSON_IsString(input) && input->valuestring && validate_skill_d_tag_local(input->valuestring)) {
d_tag_value = input->valuestring;
}
if (!d_tag_value) {
cJSON_Delete(args);
return json_error_local("skill_get requires valid d tag");
}
char* events_json = nostr_handler_get_self_skill_events_json();
if (!events_json) {
cJSON_Delete(args);
return json_error_local("skill_get cache unavailable");
}
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
cJSON_Delete(args);
cJSON_Delete(events);
return json_error_local("skill_get cache returned invalid JSON");
}
cJSON* target = NULL;
long long best_created_at = -1;
int event_count = cJSON_GetArraySize(events);
for (int i = 0; i < event_count; i++) {
cJSON* ev = cJSON_GetArrayItem(events, i);
if (!ev || !cJSON_IsObject(ev)) continue;
cJSON* kind = cJSON_GetObjectItemCaseSensitive(ev, "kind");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev, "tags");
if (!kind || !cJSON_IsNumber(kind) || !tags || !cJSON_IsArray(tags)) continue;
int kind_val = (int)kind->valuedouble;
if (kind_val != 31123 && kind_val != 31124) continue;
cJSON* d_val = find_tag_value_string_local(tags, "d");
if (!d_val || !cJSON_IsString(d_val) || !d_val->valuestring) continue;
if (strcmp(d_val->valuestring, d_tag_value) != 0) continue;
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
long long created = (created_at && cJSON_IsNumber(created_at)) ? (long long)created_at->valuedouble : 0;
if (!target || created > best_created_at) {
target = ev;
best_created_at = created;
}
}
if (!target) {
cJSON_Delete(args);
cJSON_Delete(events);
return json_error_local("skill_get could not find skill with that d");
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(target, "kind");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(target, "pubkey");
cJSON* id = cJSON_GetObjectItemCaseSensitive(target, "id");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(target, "created_at");
cJSON* content = cJSON_GetObjectItemCaseSensitive(target, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(target, "tags");
if (!kind || !cJSON_IsNumber(kind) || !pubkey || !cJSON_IsString(pubkey) || !pubkey->valuestring ||
!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
cJSON_Delete(args);
cJSON_Delete(events);
return json_error_local("skill_get found malformed skill event");
}
int kind_val = (int)kind->valuedouble;
cJSON* scope = find_tag_value_string_local(tags, "scope");
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(args);
cJSON_Delete(events);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "d_tag", d_tag_value);
cJSON_AddNumberToObject(out, "kind", kind_val);
cJSON_AddStringToObject(out,
"scope",
(scope && cJSON_IsString(scope) && scope->valuestring)
? scope->valuestring
: (kind_val == 31124 ? "private" : "public"));
cJSON_AddStringToObject(out, "owner", skill_owner_label_local(ctx->cfg, pubkey->valuestring));
if (id && cJSON_IsString(id) && id->valuestring) {
cJSON_AddStringToObject(out, "id", id->valuestring);
}
if (created_at && cJSON_IsNumber(created_at)) {
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%lld", (long long)created_at->valuedouble);
cJSON* ts_raw = cJSON_CreateRaw(ts_buf);
if (ts_raw) {
cJSON_AddItemToObject(out, "created_at", ts_raw);
}
}
cJSON_AddStringToObject(out, "content", content->valuestring);
cJSON* tags_dup = cJSON_Duplicate(tags, 1);
if (tags_dup) {
cJSON_AddItemToObject(out, "tags", tags_dup);
}
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(args);
cJSON_Delete(events);
return json;
}
char* execute_skill_view(tools_context_t* ctx, const char* args_json) {
return execute_skill_get(ctx, args_json);
}
char* execute_skill_set(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* skill = cJSON_GetObjectItemCaseSensitive(args, "skill");
cJSON* input = cJSON_GetObjectItemCaseSensitive(args, "input");
cJSON* parsed_input = NULL;
cJSON* src = NULL;
if (skill && cJSON_IsObject(skill)) {
src = skill;
} else if (input && cJSON_IsString(input) && input->valuestring && input->valuestring[0] != '\0') {
parsed_input = cJSON_Parse(input->valuestring);
if (parsed_input && cJSON_IsObject(parsed_input)) {
src = parsed_input;
}
} else if (cJSON_IsObject(args)) {
src = args;
}
if (!src || !cJSON_IsObject(src)) {
cJSON_Delete(parsed_input);
cJSON_Delete(args);
return json_error_local("skill_set requires a skill object");
}
cJSON* d = cJSON_GetObjectItemCaseSensitive(src, "d");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(src, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(src, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(src, "tags");
cJSON* scope = cJSON_GetObjectItemCaseSensitive(src, "scope");
if (!d || !cJSON_IsString(d) || !d->valuestring || !validate_skill_d_tag_local(d->valuestring)) {
cJSON_Delete(parsed_input);
cJSON_Delete(args);
return json_error_local("skill_set requires valid d");
}
if (!content || !cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(parsed_input);
cJSON_Delete(args);
return json_error_local("skill_set requires string content");
}
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(parsed_input);
cJSON_Delete(args);
return json_error_local("skill_set requires tags array");
}
int out_kind = 0;
if (kind && cJSON_IsNumber(kind)) {
out_kind = (int)kind->valuedouble;
} else if (scope && cJSON_IsString(scope) && scope->valuestring) {
out_kind = (strcmp(scope->valuestring, "private") == 0) ? 31124 : 31123;
} else {
cJSON* scope_tag = find_tag_value_string_local(tags, "scope");
out_kind = (scope_tag && cJSON_IsString(scope_tag) && scope_tag->valuestring && strcmp(scope_tag->valuestring, "private") == 0)
? 31124
: 31123;
}
if (out_kind != 31123 && out_kind != 31124) {
cJSON_Delete(parsed_input);
cJSON_Delete(args);
return json_error_local("skill_set kind must be 31123 or 31124");
}
cJSON* tags_out = cJSON_Duplicate(tags, 1);
if (!tags_out || !cJSON_IsArray(tags_out)) {
cJSON_Delete(tags_out);
cJSON_Delete(parsed_input);
cJSON_Delete(args);
return json_error_local("skill_set failed to duplicate tags");
}
if (upsert_string_tag_local(tags_out, "d", d->valuestring) != 0 ||
upsert_string_tag_local(tags_out, "app", "didactyl") != 0 ||
upsert_string_tag_local(tags_out, "scope", out_kind == 31124 ? "private" : "public") != 0) {
cJSON_Delete(tags_out);
cJSON_Delete(parsed_input);
cJSON_Delete(args);
return json_error_local("skill_set failed to normalize required tags");
}
nostr_publish_result_t publish_result;
memset(&publish_result, 0, sizeof(publish_result));
int rc = nostr_handler_publish_kind_event(out_kind, content->valuestring, tags_out, &publish_result);
int trigger_registered = 0;
if (rc == 0 && ctx->trigger_manager) {
cJSON* trigger = find_tag_value_string_local(tags_out, "trigger");
cJSON* filter = find_tag_value_string_local(tags_out, "filter");
cJSON* enabled = find_tag_value_string_local(tags_out, "enabled");
cJSON* llm = find_tag_value_string_local(tags_out, "llm");
cJSON* tools = find_tag_value_string_local(tags_out, "tools");
cJSON* max_tokens = find_tag_value_string_local(tags_out, "max_tokens");
cJSON* temperature = find_tag_value_string_local(tags_out, "temperature");
cJSON* seed = find_tag_value_string_local(tags_out, "seed");
int enabled_i = !(enabled && cJSON_IsString(enabled) && enabled->valuestring && strcmp(enabled->valuestring, "false") == 0);
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;
if (trigger_s && filter_s && enabled_i) {
int max_tokens_set = (max_tokens && cJSON_IsString(max_tokens) && max_tokens->valuestring && max_tokens->valuestring[0] != '\0') ? 1 : 0;
int max_tokens_v = max_tokens_set ? atoi(max_tokens->valuestring) : 0;
int temperature_set = (temperature && cJSON_IsString(temperature) && temperature->valuestring && temperature->valuestring[0] != '\0') ? 1 : 0;
double temperature_v = temperature_set ? atof(temperature->valuestring) : 0.0;
int seed_set = (seed && cJSON_IsString(seed) && seed->valuestring && seed->valuestring[0] != '\0') ? 1 : 0;
int seed_v = seed_set ? atoi(seed->valuestring) : 0;
if (trigger_manager_update(ctx->trigger_manager,
d->valuestring,
content->valuestring,
filter_s,
TRIGGER_ACTION_LLM,
trigger_s,
enabled_i,
(llm && cJSON_IsString(llm) && llm->valuestring) ? llm->valuestring : NULL,
(tools && cJSON_IsString(tools) && tools->valuestring) ? tools->valuestring : NULL,
max_tokens_set,
max_tokens_v,
temperature_set,
temperature_v,
seed_set,
seed_v) == 0 ||
trigger_manager_add(ctx->trigger_manager,
d->valuestring,
content->valuestring,
filter_s,
TRIGGER_ACTION_LLM,
trigger_s,
enabled_i,
(llm && cJSON_IsString(llm) && llm->valuestring) ? llm->valuestring : NULL,
(tools && cJSON_IsString(tools) && tools->valuestring) ? tools->valuestring : NULL,
max_tokens_set,
max_tokens_v,
temperature_set,
temperature_v,
seed_set,
seed_v) == 0) {
trigger_registered = 1;
}
} else {
(void)trigger_manager_remove(ctx->trigger_manager, d->valuestring);
}
}
cJSON_Delete(tags_out);
if (rc != 0) {
cJSON_Delete(parsed_input);
cJSON_Delete(args);
nostr_handler_publish_result_free(&publish_result);
return json_error_local("skill_set failed to publish skill event");
}
cJSON* out = cJSON_CreateObject();
if (!out) {
cJSON_Delete(parsed_input);
cJSON_Delete(args);
nostr_handler_publish_result_free(&publish_result);
return NULL;
}
cJSON_AddBoolToObject(out, "success", publish_result.success ? 1 : 0);
cJSON_AddStringToObject(out, "d_tag", d->valuestring);
cJSON_AddNumberToObject(out, "kind", out_kind);
cJSON_AddStringToObject(out, "scope", out_kind == 31124 ? "private" : "public");
cJSON_AddStringToObject(out, "skill_event_id", publish_result.event_id);
cJSON_AddBoolToObject(out, "trigger_registered", trigger_registered ? 1 : 0);
if (publish_result.naddr_uri[0] != '\0') {
cJSON_AddStringToObject(out, "naddr_uri", publish_result.naddr_uri);
}
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
cJSON_Delete(parsed_input);
cJSON_Delete(args);
nostr_handler_publish_result_free(&publish_result);
return json;
}
char* execute_skill_adopt(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
+2
View File
@@ -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);
+35
View File
@@ -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;
}
@@ -84,6 +86,12 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "nostr_list_manage") == 0) {
return execute_nostr_list_manage(ctx, args_json);
}
if (strcmp(tool_name, "nostr_block_list") == 0) {
return execute_nostr_block_list(ctx, args_json);
}
if (strcmp(tool_name, "nostr_block_edit") == 0) {
return execute_nostr_block_edit(ctx, args_json);
}
if (strcmp(tool_name, "nostr_query") == 0) {
return execute_nostr_query(args_json);
}
@@ -123,6 +131,15 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "skill_list") == 0) {
return execute_skill_list(ctx, args_json);
}
if (strcmp(tool_name, "skill_get") == 0) {
return execute_skill_get(ctx, args_json);
}
if (strcmp(tool_name, "skill_view") == 0) {
return execute_skill_get(ctx, args_json);
}
if (strcmp(tool_name, "skill_set") == 0) {
return execute_skill_set(ctx, args_json);
}
if (strcmp(tool_name, "skill_adopt") == 0) {
return execute_skill_adopt(ctx, args_json);
}
@@ -232,6 +249,9 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "trigger_event") == 0) {
return execute_trigger_event(ctx, args_json);
}
if (strcmp(tool_name, "nostr_dm_history") == 0) {
return execute_nostr_dm_history(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_balance") == 0) {
return execute_cashu_wallet_balance(ctx, args_json);
}
@@ -268,6 +288,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");
}
+12
View File
@@ -39,6 +39,8 @@ char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json);
char* execute_nostr_dm_send_nip17(const char* args_json);
char* execute_nostr_delete(const char* args_json);
char* execute_nostr_list_manage(tools_context_t* ctx, const char* args_json);
char* execute_nostr_block_list(tools_context_t* ctx, const char* args_json);
char* execute_nostr_block_edit(tools_context_t* ctx, const char* args_json);
char* execute_admin_identity(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json);
char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json);
@@ -55,6 +57,9 @@ char* execute_nostr_post_readme(tools_context_t* ctx, const char* args_json);
char* execute_nostr_file_md_to_longform_post(tools_context_t* ctx, const char* args_json);
char* execute_skill_create(tools_context_t* ctx, const char* args_json);
char* execute_skill_list(tools_context_t* ctx, const char* args_json);
char* execute_skill_get(tools_context_t* ctx, const char* args_json);
char* execute_skill_view(tools_context_t* ctx, const char* args_json);
char* execute_skill_set(tools_context_t* ctx, const char* args_json);
char* execute_skill_adopt(tools_context_t* ctx, const char* args_json);
char* execute_skill_remove(tools_context_t* ctx, const char* args_json);
char* execute_skill_edit(tools_context_t* ctx, const char* args_json);
@@ -68,6 +73,7 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json);
char* execute_config_recall(tools_context_t* ctx, const char* args_json);
char* execute_adopted_skills(tools_context_t* ctx, const char* args_json);
char* execute_trigger_event(tools_context_t* ctx, const char* args_json);
char* execute_nostr_dm_history(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json);
@@ -82,6 +88,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);
+343
View File
@@ -25,9 +25,11 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_kind = cJSON_CreateObject();
cJSON_AddStringToObject(p_kind, "type", "integer");
cJSON_AddStringToObject(p_kind, "description", "Nostr event kind number (for example 1 for note, 7 for reaction, 30023 for longform)");
cJSON_AddItemToObject(t1_props, "kind", p_kind);
cJSON* p_content = cJSON_CreateObject();
cJSON_AddStringToObject(p_content, "type", "string");
cJSON_AddStringToObject(p_content, "description", "Event content/body text to publish");
cJSON_AddItemToObject(t1_props, "content", p_content);
cJSON* p_tags = cJSON_CreateObject();
cJSON_AddStringToObject(p_tags, "type", "array");
@@ -61,9 +63,11 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_filter = cJSON_CreateObject();
cJSON_AddStringToObject(p_filter, "type", "object");
cJSON_AddStringToObject(p_filter, "description", "Nostr filter object (authors, kinds, ids, since, until, limit, and tag filters)");
cJSON_AddItemToObject(t2_props, "filter", p_filter);
cJSON* p_timeout = cJSON_CreateObject();
cJSON_AddStringToObject(p_timeout, "type", "integer");
cJSON_AddStringToObject(p_timeout, "description", "Optional query timeout in milliseconds");
cJSON_AddItemToObject(t2_props, "timeout_ms", p_timeout);
cJSON_AddItemToArray(t2_required, cJSON_CreateString("filter"));
@@ -86,6 +90,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_cmd = cJSON_CreateObject();
cJSON_AddStringToObject(p_cmd, "type", "string");
cJSON_AddStringToObject(p_cmd, "description", "Shell command to execute in the configured working directory");
cJSON_AddItemToObject(t3_props, "command", p_cmd);
cJSON_AddItemToArray(t3_required, cJSON_CreateString("command"));
@@ -108,9 +113,11 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_fr_path = cJSON_CreateObject();
cJSON_AddStringToObject(p_fr_path, "type", "string");
cJSON_AddStringToObject(p_fr_path, "description", "Path to file to read (relative to configured working directory)");
cJSON_AddItemToObject(t4_props, "path", p_fr_path);
cJSON* p_fr_max = cJSON_CreateObject();
cJSON_AddStringToObject(p_fr_max, "type", "integer");
cJSON_AddStringToObject(p_fr_max, "description", "Optional maximum bytes to return");
cJSON_AddItemToObject(t4_props, "max_bytes", p_fr_max);
cJSON_AddItemToArray(t4_required, cJSON_CreateString("path"));
@@ -133,12 +140,15 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_fw_path = cJSON_CreateObject();
cJSON_AddStringToObject(p_fw_path, "type", "string");
cJSON_AddStringToObject(p_fw_path, "description", "Path to file to write (relative to configured working directory)");
cJSON_AddItemToObject(t5_props, "path", p_fw_path);
cJSON* p_fw_content = cJSON_CreateObject();
cJSON_AddStringToObject(p_fw_content, "type", "string");
cJSON_AddStringToObject(p_fw_content, "description", "Text content to write");
cJSON_AddItemToObject(t5_props, "content", p_fw_content);
cJSON* p_fw_append = cJSON_CreateObject();
cJSON_AddStringToObject(p_fw_append, "type", "boolean");
cJSON_AddStringToObject(p_fw_append, "description", "When true, append to file instead of overwriting");
cJSON_AddItemToObject(t5_props, "append", p_fw_append);
cJSON_AddItemToArray(t5_required, cJSON_CreateString("path"));
cJSON_AddItemToArray(t5_required, cJSON_CreateString("content"));
@@ -179,6 +189,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_del_ids = cJSON_CreateObject();
cJSON_AddStringToObject(p_del_ids, "type", "array");
cJSON_AddStringToObject(p_del_ids, "description", "Event IDs to request deletion for");
cJSON* p_del_ids_items = cJSON_CreateObject();
cJSON_AddStringToObject(p_del_ids_items, "type", "string");
cJSON_AddItemToObject(p_del_ids, "items", p_del_ids_items);
@@ -186,6 +197,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_del_kinds = cJSON_CreateObject();
cJSON_AddStringToObject(p_del_kinds, "type", "array");
cJSON_AddStringToObject(p_del_kinds, "description", "Optional kinds corresponding to event_ids entries");
cJSON* p_del_kinds_items = cJSON_CreateObject();
cJSON_AddStringToObject(p_del_kinds_items, "type", "integer");
cJSON_AddItemToObject(p_del_kinds, "items", p_del_kinds_items);
@@ -193,6 +205,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_del_reason = cJSON_CreateObject();
cJSON_AddStringToObject(p_del_reason, "type", "string");
cJSON_AddStringToObject(p_del_reason, "description", "Optional human-readable reason");
cJSON_AddItemToObject(t7_props, "reason", p_del_reason);
cJSON_AddItemToArray(t7_required, cJSON_CreateString("event_ids"));
@@ -216,18 +229,22 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_react_event_id = cJSON_CreateObject();
cJSON_AddStringToObject(p_react_event_id, "type", "string");
cJSON_AddStringToObject(p_react_event_id, "description", "Target event ID to react to");
cJSON_AddItemToObject(t8_props, "event_id", p_react_event_id);
cJSON* p_react_event_pubkey = cJSON_CreateObject();
cJSON_AddStringToObject(p_react_event_pubkey, "type", "string");
cJSON_AddStringToObject(p_react_event_pubkey, "description", "Author pubkey of target event");
cJSON_AddItemToObject(t8_props, "event_pubkey", p_react_event_pubkey);
cJSON* p_react_event_kind = cJSON_CreateObject();
cJSON_AddStringToObject(p_react_event_kind, "type", "integer");
cJSON_AddStringToObject(p_react_event_kind, "description", "Optional kind of target event");
cJSON_AddItemToObject(t8_props, "event_kind", p_react_event_kind);
cJSON* p_react_reaction = cJSON_CreateObject();
cJSON_AddStringToObject(p_react_reaction, "type", "string");
cJSON_AddStringToObject(p_react_reaction, "description", "Reaction content such as +, -, ❤️, or emoji");
cJSON_AddItemToObject(t8_props, "reaction", p_react_reaction);
cJSON_AddItemToArray(t8_required, cJSON_CreateString("event_id"));
@@ -252,6 +269,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_profile_pubkey = cJSON_CreateObject();
cJSON_AddStringToObject(p_profile_pubkey, "type", "string");
cJSON_AddStringToObject(p_profile_pubkey, "description", "Hex pubkey of the profile to fetch");
cJSON_AddItemToObject(t9_props, "pubkey", p_profile_pubkey);
cJSON_AddItemToArray(t9_required, cJSON_CreateString("pubkey"));
@@ -290,9 +308,11 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_nip05_identifier = cJSON_CreateObject();
cJSON_AddStringToObject(p_nip05_identifier, "type", "string");
cJSON_AddStringToObject(p_nip05_identifier, "description", "NIP-05 identifier in the form user@domain");
cJSON_AddItemToObject(t11_props, "identifier", p_nip05_identifier);
cJSON* p_nip05_pubkey = cJSON_CreateObject();
cJSON_AddStringToObject(p_nip05_pubkey, "type", "string");
cJSON_AddStringToObject(p_nip05_pubkey, "description", "Optional expected pubkey to verify against lookup result");
cJSON_AddItemToObject(t11_props, "pubkey", p_nip05_pubkey);
cJSON_AddItemToArray(t11_required, cJSON_CreateString("identifier"));
@@ -315,21 +335,26 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_encode_type = cJSON_CreateObject();
cJSON_AddStringToObject(p_encode_type, "type", "string");
cJSON_AddStringToObject(p_encode_type, "description", "Encoding target: npub, note, nprofile, nevent, or naddr");
cJSON_AddItemToObject(t12_props, "type", p_encode_type);
cJSON* p_encode_hex = cJSON_CreateObject();
cJSON_AddStringToObject(p_encode_hex, "type", "string");
cJSON_AddStringToObject(p_encode_hex, "description", "Hex pubkey or event id to encode");
cJSON_AddItemToObject(t12_props, "hex", p_encode_hex);
cJSON* p_encode_relays = cJSON_CreateObject();
cJSON_AddStringToObject(p_encode_relays, "type", "array");
cJSON_AddStringToObject(p_encode_relays, "description", "Optional relay URLs included in nprofile/nevent/naddr encodings");
cJSON* p_encode_relays_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_encode_relays_item, "type", "string");
cJSON_AddItemToObject(p_encode_relays, "items", p_encode_relays_item);
cJSON_AddItemToObject(t12_props, "relays", p_encode_relays);
cJSON* p_encode_kind = cJSON_CreateObject();
cJSON_AddStringToObject(p_encode_kind, "type", "integer");
cJSON_AddStringToObject(p_encode_kind, "description", "Required for naddr: replaceable event kind");
cJSON_AddItemToObject(t12_props, "kind", p_encode_kind);
cJSON* p_encode_identifier = cJSON_CreateObject();
cJSON_AddStringToObject(p_encode_identifier, "type", "string");
cJSON_AddStringToObject(p_encode_identifier, "description", "Required for naddr: replaceable event identifier (d tag)");
cJSON_AddItemToObject(t12_props, "identifier", p_encode_identifier);
cJSON_AddItemToArray(t12_required, cJSON_CreateString("type"));
@@ -354,6 +379,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_decode_uri = cJSON_CreateObject();
cJSON_AddStringToObject(p_decode_uri, "type", "string");
cJSON_AddStringToObject(p_decode_uri, "description", "nostr: URI or bech32 value to decode");
cJSON_AddItemToObject(t13_props, "uri", p_decode_uri);
cJSON_AddItemToArray(t13_required, cJSON_CreateString("uri"));
@@ -376,9 +402,11 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_dm_recipient = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm_recipient, "type", "string");
cJSON_AddStringToObject(p_dm_recipient, "description", "Recipient pubkey in hex");
cJSON_AddItemToObject(t14_props, "recipient_pubkey", p_dm_recipient);
cJSON* p_dm_message = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm_message, "type", "string");
cJSON_AddStringToObject(p_dm_message, "description", "Plaintext message to encrypt and send");
cJSON_AddItemToObject(t14_props, "message", p_dm_message);
cJSON_AddItemToArray(t14_required, cJSON_CreateString("recipient_pubkey"));
@@ -403,6 +431,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_relay_info_url = cJSON_CreateObject();
cJSON_AddStringToObject(p_relay_info_url, "type", "string");
cJSON_AddStringToObject(p_relay_info_url, "description", "Relay websocket URL (ws:// or wss://)");
cJSON_AddItemToObject(t15_props, "relay_url", p_relay_info_url);
cJSON_AddItemToArray(t15_required, cJSON_CreateString("relay_url"));
@@ -473,9 +502,11 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_encrypt_recipient = cJSON_CreateObject();
cJSON_AddStringToObject(p_encrypt_recipient, "type", "string");
cJSON_AddStringToObject(p_encrypt_recipient, "description", "Recipient pubkey in hex");
cJSON_AddItemToObject(t16_props, "recipient_pubkey", p_encrypt_recipient);
cJSON* p_encrypt_plaintext = cJSON_CreateObject();
cJSON_AddStringToObject(p_encrypt_plaintext, "type", "string");
cJSON_AddStringToObject(p_encrypt_plaintext, "description", "Plaintext to encrypt with NIP-44");
cJSON_AddItemToObject(t16_props, "plaintext", p_encrypt_plaintext);
cJSON_AddItemToArray(t16_required, cJSON_CreateString("recipient_pubkey"));
@@ -500,9 +531,11 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_decrypt_sender = cJSON_CreateObject();
cJSON_AddStringToObject(p_decrypt_sender, "type", "string");
cJSON_AddStringToObject(p_decrypt_sender, "description", "Sender pubkey in hex");
cJSON_AddItemToObject(t17_props, "sender_pubkey", p_decrypt_sender);
cJSON* p_decrypt_ciphertext = cJSON_CreateObject();
cJSON_AddStringToObject(p_decrypt_ciphertext, "type", "string");
cJSON_AddStringToObject(p_decrypt_ciphertext, "description", "NIP-44 ciphertext payload to decrypt");
cJSON_AddItemToObject(t17_props, "ciphertext", p_decrypt_ciphertext);
cJSON_AddItemToArray(t17_required, cJSON_CreateString("sender_pubkey"));
@@ -527,12 +560,15 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_dm17_recipient = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm17_recipient, "type", "string");
cJSON_AddStringToObject(p_dm17_recipient, "description", "Recipient pubkey in hex");
cJSON_AddItemToObject(t18_props, "recipient_pubkey", p_dm17_recipient);
cJSON* p_dm17_message = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm17_message, "type", "string");
cJSON_AddStringToObject(p_dm17_message, "description", "Message body to send in gift-wrap DM");
cJSON_AddItemToObject(t18_props, "message", p_dm17_message);
cJSON* p_dm17_subject = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm17_subject, "type", "string");
cJSON_AddStringToObject(p_dm17_subject, "description", "Optional subject label for the message");
cJSON_AddItemToObject(t18_props, "subject", p_dm17_subject);
cJSON_AddItemToArray(t18_required, cJSON_CreateString("recipient_pubkey"));
@@ -557,12 +593,15 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_list_kind = cJSON_CreateObject();
cJSON_AddStringToObject(p_list_kind, "type", "integer");
cJSON_AddStringToObject(p_list_kind, "description", "Replaceable list kind to edit (for example 10000 mute, 10001 pin, 10002 relay list)");
cJSON_AddItemToObject(t19_props, "list_kind", p_list_kind);
cJSON* p_list_action = cJSON_CreateObject();
cJSON_AddStringToObject(p_list_action, "type", "string");
cJSON_AddStringToObject(p_list_action, "description", "Operation to perform: add or remove");
cJSON_AddItemToObject(t19_props, "action", p_list_action);
cJSON* p_list_items = cJSON_CreateObject();
cJSON_AddStringToObject(p_list_items, "type", "array");
cJSON_AddStringToObject(p_list_items, "description", "Tag tuples to add/remove, e.g. [[\"p\",\"<pubkey>\"],[\"e\",\"<event_id>\"]]");
cJSON* p_list_items_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_list_items_item, "type", "array");
cJSON* p_list_items_item_item = cJSON_CreateObject();
@@ -579,6 +618,67 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t19, "function", t19_fn);
cJSON_AddItemToArray(tools, t19);
cJSON* t19b = cJSON_CreateObject();
cJSON* t19b_fn = cJSON_CreateObject();
cJSON* t19b_params = cJSON_CreateObject();
cJSON* t19b_props = cJSON_CreateObject();
cJSON_AddStringToObject(t19b, "type", "function");
cJSON_AddStringToObject(t19b_fn, "name", "nostr_block_list");
cJSON_AddStringToObject(t19b_fn, "description", "View blocked pubkeys, event IDs, or hashtags from the local encrypted kind-10000 block list cache");
cJSON_AddStringToObject(t19b_params, "type", "object");
cJSON_AddItemToObject(t19b_params, "properties", t19b_props);
cJSON* p_block_list_type = cJSON_CreateObject();
cJSON_AddStringToObject(p_block_list_type, "type", "string");
cJSON_AddStringToObject(p_block_list_type, "description", "Optional filter: all, p, e, or t (default all)");
cJSON_AddItemToObject(t19b_props, "type", p_block_list_type);
cJSON_AddItemToObject(t19b_fn, "parameters", t19b_params);
cJSON_AddItemToObject(t19b, "function", t19b_fn);
cJSON_AddItemToArray(tools, t19b);
cJSON* t19c = cJSON_CreateObject();
cJSON* t19c_fn = cJSON_CreateObject();
cJSON* t19c_params = cJSON_CreateObject();
cJSON* t19c_props = cJSON_CreateObject();
cJSON* t19c_required = cJSON_CreateArray();
cJSON_AddStringToObject(t19c, "type", "function");
cJSON_AddStringToObject(t19c_fn, "name", "nostr_block_edit");
cJSON_AddStringToObject(t19c_fn, "description", "Add or remove blocked tuples in kind-10000 block list; entries are private (encrypted) by default");
cJSON_AddStringToObject(t19c_params, "type", "object");
cJSON_AddItemToObject(t19c_params, "properties", t19c_props);
cJSON_AddItemToObject(t19c_params, "required", t19c_required);
cJSON* p_block_action = cJSON_CreateObject();
cJSON_AddStringToObject(p_block_action, "type", "string");
cJSON_AddStringToObject(p_block_action, "description", "add or remove");
cJSON_AddItemToObject(t19c_props, "action", p_block_action);
cJSON* p_block_items = cJSON_CreateObject();
cJSON_AddStringToObject(p_block_items, "type", "array");
cJSON* p_block_items_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_block_items_item, "type", "array");
cJSON* p_block_items_item_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_block_items_item_item, "type", "string");
cJSON_AddItemToObject(p_block_items_item, "items", p_block_items_item_item);
cJSON_AddItemToObject(p_block_items, "items", p_block_items_item);
cJSON_AddStringToObject(p_block_items, "description", "Tag tuples like [[\"e\",\"<event_id>\"],[\"p\",\"<pubkey>\"],[\"t\",\"spam\"]]");
cJSON_AddItemToObject(t19c_props, "items", p_block_items);
cJSON* p_block_public = cJSON_CreateObject();
cJSON_AddStringToObject(p_block_public, "type", "boolean");
cJSON_AddStringToObject(p_block_public, "description", "Optional, default false; when true add to public tags instead of encrypted private content");
cJSON_AddItemToObject(t19c_props, "public", p_block_public);
cJSON_AddItemToArray(t19c_required, cJSON_CreateString("action"));
cJSON_AddItemToArray(t19c_required, cJSON_CreateString("items"));
cJSON_AddItemToObject(t19c_fn, "parameters", t19c_params);
cJSON_AddItemToObject(t19c, "function", t19c_fn);
cJSON_AddItemToArray(tools, t19c);
cJSON* t20 = cJSON_CreateObject();
cJSON* t20_fn = cJSON_CreateObject();
cJSON* t20_params = cJSON_CreateObject();
@@ -609,14 +709,17 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_http_url = cJSON_CreateObject();
cJSON_AddStringToObject(p_http_url, "type", "string");
cJSON_AddStringToObject(p_http_url, "description", "HTTP(S) URL to request");
cJSON_AddItemToObject(t21_props, "url", p_http_url);
cJSON* p_http_method = cJSON_CreateObject();
cJSON_AddStringToObject(p_http_method, "type", "string");
cJSON_AddStringToObject(p_http_method, "description", "Optional method such as GET, POST, PUT, PATCH, or DELETE (default GET)");
cJSON_AddItemToObject(t21_props, "method", p_http_method);
cJSON* p_http_headers = cJSON_CreateObject();
cJSON_AddStringToObject(p_http_headers, "type", "array");
cJSON_AddStringToObject(p_http_headers, "description", "Optional headers as [\"Name: value\", ...]");
cJSON* p_http_headers_item = cJSON_CreateObject();
cJSON_AddStringToObject(p_http_headers_item, "type", "string");
cJSON_AddItemToObject(p_http_headers, "items", p_http_headers_item);
@@ -624,14 +727,17 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON* p_http_body = cJSON_CreateObject();
cJSON_AddStringToObject(p_http_body, "type", "string");
cJSON_AddStringToObject(p_http_body, "description", "Optional request body for non-GET methods");
cJSON_AddItemToObject(t21_props, "body", p_http_body);
cJSON* p_http_timeout = cJSON_CreateObject();
cJSON_AddStringToObject(p_http_timeout, "type", "integer");
cJSON_AddStringToObject(p_http_timeout, "description", "Optional timeout in seconds");
cJSON_AddItemToObject(t21_props, "timeout_seconds", p_http_timeout);
cJSON* p_http_max_bytes = cJSON_CreateObject();
cJSON_AddStringToObject(p_http_max_bytes, "type", "integer");
cJSON_AddStringToObject(p_http_max_bytes, "description", "Optional maximum response bytes to capture");
cJSON_AddItemToObject(t21_props, "max_bytes", p_http_max_bytes);
cJSON_AddItemToArray(t21_required, cJSON_CreateString("url"));
@@ -737,6 +843,89 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t23, "function", t23_fn);
cJSON_AddItemToArray(tools, t23);
cJSON* t24a = cJSON_CreateObject();
cJSON* t24a_fn = cJSON_CreateObject();
cJSON* t24a_params = cJSON_CreateObject();
cJSON* t24a_props = cJSON_CreateObject();
cJSON* t24a_required = cJSON_CreateArray();
cJSON_AddStringToObject(t24a, "type", "function");
cJSON_AddStringToObject(t24a_fn, "name", "skill_get");
cJSON_AddStringToObject(t24a_fn, "description", "Get full skill JSON by d tag from local cache; JSON: {\"d\":\"identity_and_rules\"} (slash shorthand also works: /skill_get identity_and_rules)");
cJSON_AddStringToObject(t24a_params, "type", "object");
cJSON_AddItemToObject(t24a_params, "properties", t24a_props);
cJSON_AddItemToObject(t24a_params, "required", t24a_required);
cJSON* p_skill_get_d = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_get_d, "type", "string");
cJSON_AddStringToObject(p_skill_get_d, "description", "Skill d tag (preferred JSON key)");
cJSON_AddItemToObject(t24a_props, "d", p_skill_get_d);
cJSON* p_skill_get_input = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_get_input, "type", "string");
cJSON_AddStringToObject(p_skill_get_input, "description", "Slash shorthand fallback key when args are passed as plain text");
cJSON_AddItemToObject(t24a_props, "input", p_skill_get_input);
cJSON_AddItemToArray(t24a_required, cJSON_CreateString("d"));
cJSON_AddItemToObject(t24a_fn, "parameters", t24a_params);
cJSON_AddItemToObject(t24a, "function", t24a_fn);
cJSON_AddItemToArray(tools, t24a);
cJSON* t24b = cJSON_CreateObject();
cJSON* t24b_fn = cJSON_CreateObject();
cJSON* t24b_params = cJSON_CreateObject();
cJSON* t24b_props = cJSON_CreateObject();
cJSON* t24b_required = cJSON_CreateArray();
cJSON_AddStringToObject(t24b, "type", "function");
cJSON_AddStringToObject(t24b_fn, "name", "skill_view");
cJSON_AddStringToObject(t24b_fn, "description", "Compatibility alias for skill_get; JSON: {\"d\":\"identity_and_rules\"}");
cJSON_AddStringToObject(t24b_params, "type", "object");
cJSON_AddItemToObject(t24b_params, "properties", t24b_props);
cJSON_AddItemToObject(t24b_params, "required", t24b_required);
cJSON* p_skill_view_d = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_view_d, "type", "string");
cJSON_AddItemToObject(t24b_props, "d", p_skill_view_d);
cJSON* p_skill_view_input = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_view_input, "type", "string");
cJSON_AddItemToObject(t24b_props, "input", p_skill_view_input);
cJSON_AddItemToArray(t24b_required, cJSON_CreateString("d"));
cJSON_AddItemToObject(t24b_fn, "parameters", t24b_params);
cJSON_AddItemToObject(t24b, "function", t24b_fn);
cJSON_AddItemToArray(tools, t24b);
cJSON* t24c = cJSON_CreateObject();
cJSON* t24c_fn = cJSON_CreateObject();
cJSON* t24c_params = cJSON_CreateObject();
cJSON* t24c_props = cJSON_CreateObject();
cJSON_AddStringToObject(t24c, "type", "function");
cJSON_AddStringToObject(t24c_fn, "name", "skill_set");
cJSON_AddStringToObject(t24c_fn,
"description",
"Set/publish a full skill JSON payload and refresh runtime cache; JSON: {\"skill\":{\"d\":\"identity_and_rules\",\"kind\":31124,\"scope\":\"private\",\"content\":\"...\",\"tags\":[[\"d\",\"identity_and_rules\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"]]}}");
cJSON_AddStringToObject(t24c_params, "type", "object");
cJSON_AddItemToObject(t24c_params, "properties", t24c_props);
cJSON* p_skill_set_skill = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_set_skill, "type", "object");
cJSON_AddStringToObject(p_skill_set_skill, "description", "Full skill object from skill_get output content");
cJSON_AddItemToObject(t24c_props, "skill", p_skill_set_skill);
cJSON* p_skill_set_input = cJSON_CreateObject();
cJSON_AddStringToObject(p_skill_set_input, "type", "string");
cJSON_AddStringToObject(p_skill_set_input, "description", "Raw JSON string for slash shorthand or manual paste");
cJSON_AddItemToObject(t24c_props, "input", p_skill_set_input);
cJSON_AddItemToObject(t24c_fn, "parameters", t24c_params);
cJSON_AddItemToObject(t24c, "function", t24c_fn);
cJSON_AddItemToArray(tools, t24c);
cJSON* t24 = cJSON_CreateObject();
cJSON* t24_fn = cJSON_CreateObject();
cJSON* t24_params = cJSON_CreateObject();
@@ -1289,6 +1478,45 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t42, "function", t42_fn);
cJSON_AddItemToArray(tools, t42);
cJSON* t43 = cJSON_CreateObject();
cJSON* t43_fn = cJSON_CreateObject();
cJSON* t43_params = cJSON_CreateObject();
cJSON* t43_props = cJSON_CreateObject();
cJSON_AddStringToObject(t43, "type", "function");
cJSON_AddStringToObject(t43_fn, "name", "nostr_dm_history");
cJSON_AddStringToObject(t43_fn,
"description",
"Fetch recent DM history for template/context usage; JSON: {\"limit\":12,\"include_current\":false,\"format\":\"json\"} or with explicit peer {\"peer_pubkey\":\"<hex64>\",\"limit\":20,\"format\":\"text\"}; template usage: {{nostr_dm_history({\"limit\":12,\"format\":\"text\"})}}");
cJSON_AddStringToObject(t43_params, "type", "object");
cJSON_AddItemToObject(t43_params, "properties", t43_props);
cJSON* p_dm_history_limit = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm_history_limit, "type", "integer");
cJSON_AddStringToObject(p_dm_history_limit, "description", "Max message turns to return (1-200, default 12)");
cJSON_AddItemToObject(t43_props, "limit", p_dm_history_limit);
cJSON* p_dm_history_include_current = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm_history_include_current, "type", "boolean");
cJSON_AddStringToObject(p_dm_history_include_current, "description", "When true, keep a trailing user message that matches the current live message");
cJSON_AddItemToObject(t43_props, "include_current", p_dm_history_include_current);
cJSON* p_dm_history_peer = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm_history_peer, "type", "string");
cJSON_AddStringToObject(p_dm_history_peer, "description", "Optional peer pubkey hex (defaults to configured admin pubkey)");
cJSON_AddItemToObject(t43_props, "peer_pubkey", p_dm_history_peer);
cJSON* p_dm_history_format = cJSON_CreateObject();
cJSON_AddStringToObject(p_dm_history_format, "type", "string");
cJSON_AddStringToObject(p_dm_history_format,
"description",
"Output format: 'json' (default) or 'text' (User:/Assistant: lines)");
cJSON_AddItemToObject(t43_props, "format", p_dm_history_format);
cJSON_AddItemToObject(t43_fn, "parameters", t43_params);
cJSON_AddItemToObject(t43, "function", t43_fn);
cJSON_AddItemToArray(tools, t43);
cJSON* t44 = cJSON_CreateObject();
cJSON* t44_fn = cJSON_CreateObject();
cJSON* t44_params = cJSON_CreateObject();
@@ -1785,6 +2013,121 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t61, "function", t61_fn);
cJSON_AddItemToArray(tools, t61);
cJSON* t62 = cJSON_CreateObject();
cJSON* t62_fn = cJSON_CreateObject();
cJSON* t62_params = cJSON_CreateObject();
cJSON* t62_props = cJSON_CreateObject();
cJSON* t62_required = cJSON_CreateArray();
cJSON_AddStringToObject(t62, "type", "function");
cJSON_AddStringToObject(t62_fn, "name", "cashu_wallet_mints_get");
cJSON_AddStringToObject(t62_fn, "description", "Get currently configured wallet and public mints");
cJSON_AddStringToObject(t62_params, "type", "object");
cJSON_AddItemToObject(t62_params, "properties", t62_props);
cJSON_AddItemToObject(t62_params, "required", t62_required);
cJSON_AddItemToObject(t62_fn, "parameters", t62_params);
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;
+66 -146
View File
@@ -490,7 +490,7 @@ static cJSON* find_tag_value_string(cJSON* tags, const char* key) {
return NULL;
}
static int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_d_tag[65]) {
static __attribute__((unused)) int parse_address_tag(const char* addr, int* out_kind, char out_pubkey[65], char out_d_tag[65]) {
if (!addr || !out_kind || !out_pubkey || !out_d_tag) {
return -1;
}
@@ -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) {
+5 -5
View File
@@ -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);
Binary file not shown.
BIN
View File
Binary file not shown.
+79
View 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;
}
+53
View File
@@ -0,0 +1,53 @@
{
// TEST CONFIGURATION
// Use disposable keys/accounts only.
"key": {
"nsec": "nsec1REPLACE_WITH_DISPOSABLE_TEST_NSEC"
},
"admin": {
"pubkey": "npub1REPLACE_WITH_TEST_ADMIN_PUBKEY"
},
"dm_protocol": "nip04",
"llm": {
"provider": "openai",
"api_key": "sk-REPLACE_WITH_API_KEY",
"model": "claude-haiku-4.5",
"base_url": "https://api.anthropic.com/v1",
"max_tokens": 512,
"temperature": 0.3
},
"api": {
"enabled": true,
"port": 8485,
"bind_address": "127.0.0.1"
},
"startup_events": [
{
"kind": 0,
"content_fields": {
"name": "Didactyl Test Agent",
"about": "Automated test instance"
},
"tags": []
},
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net"]
]
},
{
"kind": 31124,
"content": "# Test Agent\n\nYou are a test agent. Respond to requests and use tools when needed.",
"tags": [
["d", "identity_and_rules"],
["app", "didactyl"],
["scope", "private"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"]
]
}
]
}
+26
View File
@@ -0,0 +1,26 @@
"""Didactyl automated test harness package."""
from .agent_process import AgentProcess
from .didactyl_client import (
APIError,
DidactylClient,
DidactylConnectionError,
DidactylTimeoutError,
)
from .log_watcher import LogWatcher
from .reporter import Reporter
from .test_runner import SkipTest, TestCase, TestResult, TestRunner
__all__ = [
"AgentProcess",
"APIError",
"DidactylClient",
"DidactylConnectionError",
"DidactylTimeoutError",
"LogWatcher",
"Reporter",
"SkipTest",
"TestCase",
"TestResult",
"TestRunner",
]
Binary file not shown.
Binary file not shown.
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import os
import signal
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from .didactyl_client import DidactylClient
@dataclass
class AgentProcess:
binary_path: str
config_path: str
api_port: int = 8485
api_bind: str = "127.0.0.1"
debug_level: int = 5
log_file: Optional[str] = None
base_url: Optional[str] = None
def __post_init__(self) -> None:
self.binary_path = str(Path(self.binary_path))
self.config_path = str(Path(self.config_path))
self.log_file = self.log_file or "tests/results/agent_debug.log"
scheme = "https"
self.base_url = self.base_url or f"{scheme}://{self.api_bind}:{self.api_port}"
self.process: Optional[subprocess.Popen[str]] = None
def _command(self) -> list[str]:
return [
self.binary_path,
"--config",
self.config_path,
"--debug",
str(self.debug_level),
"--api-port",
str(self.api_port),
"--api-bind",
self.api_bind,
]
def start(self, timeout: float = 30.0) -> bool:
if self.is_alive():
return True
env = os.environ.copy()
env["DIDACTYL_LOG_FILE"] = str(self.log_file)
Path(self.log_file).parent.mkdir(parents=True, exist_ok=True)
self.process = subprocess.Popen(
self._command(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
client = DidactylClient(base_url=self.base_url, timeout=2.0, verify_tls=False)
deadline = time.time() + timeout
while time.time() < deadline:
if self.process and self.process.poll() is not None:
return False
try:
data = client.status()
if data.get("success"):
return True
except Exception:
pass
time.sleep(0.5)
return False
def stop(self, timeout: float = 10.0) -> bool:
if not self.process:
return True
if self.process.poll() is not None:
return True
try:
self.process.send_signal(signal.SIGTERM)
self.process.wait(timeout=timeout)
return True
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=5)
return False
def restart(self, timeout: float = 30.0) -> bool:
self.stop()
return self.start(timeout=timeout)
def is_alive(self) -> bool:
return self.process is not None and self.process.poll() is None
def pid(self) -> Optional[int]:
return None if not self.process else self.process.pid
def return_code(self) -> Optional[int]:
return None if not self.process else self.process.poll()
def read_pipes(self) -> tuple[str, str]:
if not self.process:
return "", ""
out = ""
err = ""
if self.process.stdout:
out = self.process.stdout.read() or ""
if self.process.stderr:
err = self.process.stderr.read() or ""
return out, err
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import json
import ssl
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Optional
class DidactylTimeoutError(TimeoutError):
pass
class DidactylConnectionError(ConnectionError):
pass
@dataclass
class APIError(Exception):
status_code: int
body: str
def __str__(self) -> str:
return f"HTTP {self.status_code}: {self.body}"
class DidactylClient:
def __init__(self, base_url: str = "https://127.0.0.1:8485", timeout: float = 60.0, verify_tls: bool = False) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.verify_tls = verify_tls
self.ssl_context = None
if self.base_url.startswith("https://") and not verify_tls:
self.ssl_context = ssl._create_unverified_context()
def _request(self, method: str, path: str, payload: Optional[dict[str, Any]] = None, raw_body: Optional[bytes] = None) -> dict[str, Any]:
url = f"{self.base_url}{path}"
body = raw_body
if payload is not None:
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url=url, method=method.upper(), data=body)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ssl_context) as resp:
status = resp.getcode()
text = resp.read().decode("utf-8", errors="replace")
data = json.loads(text) if text.strip() else {}
if status < 200 or status >= 300:
raise APIError(status, text)
return data
except urllib.error.HTTPError as e:
text = e.read().decode("utf-8", errors="replace")
raise APIError(e.code, text) from e
except urllib.error.URLError as e:
reason = str(getattr(e, "reason", e))
if "timed out" in reason.lower():
raise DidactylTimeoutError(reason) from e
raise DidactylConnectionError(reason) from e
except TimeoutError as e:
raise DidactylTimeoutError(str(e)) from e
def status(self) -> dict[str, Any]:
return self._request("GET", "/api/status")
def context_current(self) -> dict[str, Any]:
return self._request("GET", "/api/context/current")
def context_parts(self) -> dict[str, Any]:
return self._request("GET", "/api/context/parts")
def prompt(self, message: str, max_turns: int = 4, model: Optional[str] = None) -> dict[str, Any]:
payload: dict[str, Any] = {"message": message, "max_turns": max_turns}
if model:
payload["model"] = model
return self._request("POST", "/api/prompt/agent", payload=payload)
def prompt_raw(self, messages: list[dict[str, str]], max_turns: int = 4, model: Optional[str] = None) -> dict[str, Any]:
payload: dict[str, Any] = {"messages": messages, "max_turns": max_turns}
if model:
payload["model"] = model
return self._request("POST", "/api/prompt/run", payload=payload)
def prompt_simple(self, system: str, user: str, model: Optional[str] = None) -> dict[str, Any]:
payload: dict[str, Any] = {"system": system, "user": user}
if model:
payload["model"] = model
return self._request("POST", "/api/prompt/run-simple", payload=payload)
def fire_webhook(self, d_tag: str, payload: Optional[dict[str, Any]] = None) -> dict[str, Any]:
encoded = urllib.parse.quote(d_tag, safe="")
return self._request("POST", f"/api/trigger/{encoded}", payload=payload or {})
def raw_post(self, path: str, body: bytes) -> tuple[int, str]:
url = f"{self.base_url}{path}"
req = urllib.request.Request(url=url, method="POST", data=body)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ssl_context) as resp:
return resp.getcode(), resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="replace")
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import re
import threading
import time
from pathlib import Path
from typing import Optional
class LogWatcher:
def __init__(self, log_path: str, poll_interval: float = 0.1) -> None:
self.log_path = Path(log_path)
self.poll_interval = poll_interval
self._lines: list[str] = []
self._markers: dict[str, int] = {}
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self.log_path.touch(exist_ok=True)
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2)
def _run(self) -> None:
f = self.log_path.open("r", encoding="utf-8", errors="replace")
f.seek(0, 2)
try:
while not self._stop.is_set():
pos = f.tell()
line = f.readline()
if not line:
if self.log_path.exists() and self.log_path.stat().st_size < pos:
f.close()
f = self.log_path.open("r", encoding="utf-8", errors="replace")
time.sleep(self.poll_interval)
continue
with self._lock:
self._lines.append(line.rstrip("\n"))
finally:
f.close()
def set_marker(self, name: str) -> None:
with self._lock:
self._markers[name] = len(self._lines)
def get_lines_since(self, marker: str) -> list[str]:
with self._lock:
idx = self._markers.get(marker, 0)
return list(self._lines[idx:])
def get_all_lines(self) -> list[str]:
with self._lock:
return list(self._lines)
def search(self, pattern: str, since_marker: Optional[str] = None) -> list[str]:
regex = re.compile(pattern)
lines = self.get_all_lines() if since_marker is None else self.get_lines_since(since_marker)
return [line for line in lines if regex.search(line)]
def error_lines(self, since_marker: Optional[str] = None) -> list[str]:
lines = self.get_all_lines() if since_marker is None else self.get_lines_since(since_marker)
return [line for line in lines if "[ERROR]" in line]
def warning_lines(self, since_marker: Optional[str] = None) -> list[str]:
lines = self.get_all_lines() if since_marker is None else self.get_lines_since(since_marker)
return [line for line in lines if "[WARN" in line]
def has_errors(self, since_marker: Optional[str] = None) -> bool:
return len(self.error_lines(since_marker)) > 0
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
from collections import Counter
from dataclasses import asdict
from pathlib import Path
from typing import Any
from .test_runner import TestResult
class Reporter:
def __init__(self, results: list[TestResult], run_meta: dict[str, Any], output_dir: str) -> None:
self.results = results
self.run_meta = run_meta
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
def summary_counts(self) -> Counter:
return Counter(r.status for r in self.results)
def print_summary(self) -> None:
c = self.summary_counts()
total = len(self.results)
print("\n== Didactyl Test Results ==")
print(f"Run: {self.run_meta.get('run_timestamp', 'unknown')}")
print(f"Agent base URL: {self.run_meta.get('base_url', 'unknown')}")
print("")
for k in ["pass", "fail", "error", "timeout", "skip"]:
print(f"{k.capitalize():<8}: {c.get(k, 0)}")
print(f"Total : {total}")
def print_details(self) -> None:
bad = [r for r in self.results if r.status in {"fail", "error", "timeout"}]
if not bad:
return
print("\n-- Non-passing tests --")
for r in bad:
print(f"[{r.status.upper()}] {r.suite}::{r.name}")
print(f" {r.message}")
if r.agent_errors:
print(f" Agent errors: {len(r.agent_errors)}")
def write_json(self, path: str = "results.json") -> Path:
p = self.output_dir / path
payload = {
"run_meta": self.run_meta,
"results": [asdict(r) for r in self.results],
"summary": dict(self.summary_counts()),
}
p.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return p
def write_text(self, path: str = "results.txt") -> Path:
p = self.output_dir / path
c = self.summary_counts()
lines = [
"== Didactyl Test Results ==",
f"Run: {self.run_meta.get('run_timestamp', 'unknown')}",
f"Agent base URL: {self.run_meta.get('base_url', 'unknown')}",
"",
f"Pass: {c.get('pass', 0)}",
f"Fail: {c.get('fail', 0)}",
f"Error: {c.get('error', 0)}",
f"Timeout: {c.get('timeout', 0)}",
f"Skip: {c.get('skip', 0)}",
f"Total: {len(self.results)}",
"",
]
for r in self.results:
lines.append(f"[{r.status.upper()}] {r.suite}::{r.name} ({r.duration_seconds:.2f}s)")
lines.append(f" {r.message}")
if r.agent_errors:
lines.append(f" Agent errors: {len(r.agent_errors)}")
lines.append("")
p.write_text("\n".join(lines), encoding="utf-8")
return p
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import importlib
import pkgutil
import time
from dataclasses import dataclass, field
from types import ModuleType
from typing import Any, Callable
from .agent_process import AgentProcess
from .didactyl_client import DidactylClient, DidactylTimeoutError
from .log_watcher import LogWatcher
class SkipTest(Exception):
pass
TestFn = Callable[["TestContext"], tuple[bool, str, dict[str, Any]]]
@dataclass
class TestCase:
suite: str
name: str
description: str
fn: TestFn
requires_restart: bool = False
@dataclass
class TestResult:
suite: str
name: str
status: str
message: str
duration_seconds: float
agent_errors: list[str] = field(default_factory=list)
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class TestContext:
client: DidactylClient
agent: AgentProcess
log: LogWatcher
args: Any
class TestRunner:
def __init__(self, agent: AgentProcess, client: DidactylClient, log: LogWatcher, args: Any) -> None:
self.agent = agent
self.client = client
self.log = log
self.args = args
def discover_suites(self, package: str = "tests.suites") -> list[TestCase]:
mod = importlib.import_module(package)
tests: list[TestCase] = []
for info in pkgutil.iter_modules(mod.__path__):
if not info.name.startswith("test_"):
continue
if self.args.suite and info.name not in self.args.suite:
continue
module = importlib.import_module(f"{package}.{info.name}")
tests.extend(self._tests_from_module(module))
return tests
def _tests_from_module(self, module: ModuleType) -> list[TestCase]:
if not hasattr(module, "get_tests"):
return []
suite_tests = module.get_tests()
out: list[TestCase] = []
for t in suite_tests:
if self.args.test and t.name not in self.args.test:
continue
out.append(t)
return out
def run_all(self, tests: list[TestCase]) -> list[TestResult]:
results: list[TestResult] = []
ctx = TestContext(client=self.client, agent=self.agent, log=self.log, args=self.args)
for tc in tests:
marker = f"{tc.suite}.{tc.name}.{int(time.time() * 1000)}"
if tc.requires_restart:
self.agent.restart(timeout=30)
if not self.agent.is_alive():
ok = self.agent.restart(timeout=30)
if not ok:
results.append(
TestResult(
suite=tc.suite,
name=tc.name,
status="error",
message="Agent not alive and restart failed",
duration_seconds=0.0,
)
)
continue
self.log.set_marker(marker)
start = time.monotonic()
try:
passed, message, details = tc.fn(ctx)
status = "pass" if passed else "fail"
except SkipTest as e:
status = "skip"
message = str(e)
details = {}
except DidactylTimeoutError as e:
status = "timeout"
message = str(e)
details = {}
if not getattr(self.args, "no_restart", False):
self.agent.restart(timeout=30)
except Exception as e:
status = "error"
message = repr(e)
details = {}
if not self.agent.is_alive() and not getattr(self.args, "no_restart", False):
self.agent.restart(timeout=30)
duration = time.monotonic() - start
agent_errors = self.log.error_lines(marker)
results.append(
TestResult(
suite=tc.suite,
name=tc.name,
status=status,
message=message,
duration_seconds=duration,
agent_errors=agent_errors,
details=details,
)
)
if getattr(self.args, "verbose", False):
print(f"[{status.upper()}] {tc.suite}::{tc.name} - {message}")
return results
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tests.harness.agent_process import AgentProcess
from tests.harness.didactyl_client import DidactylClient
from tests.harness.log_watcher import LogWatcher
from tests.harness.reporter import Reporter
from tests.harness.test_runner import TestRunner
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Didactyl automated test harness")
parser.add_argument("--config", default="tests/configs/test_genesis.jsonc")
parser.add_argument("--binary", default="./didactyl")
parser.add_argument("--suite", action="append", help="Run only this suite (repeatable)")
parser.add_argument("--test", action="append", help="Run only this test name (repeatable)")
parser.add_argument("--api-port", type=int, default=8485)
parser.add_argument("--api-bind", default="127.0.0.1")
parser.add_argument("--timeout", type=float, default=60.0)
parser.add_argument("--debug-level", type=int, default=5)
parser.add_argument("--output-dir", default=None)
parser.add_argument("--verbose", action="store_true")
parser.add_argument("--no-restart", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
run_ts = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
output_dir = args.output_dir or f"tests/results/{run_ts}"
Path(output_dir).mkdir(parents=True, exist_ok=True)
binary = Path(args.binary)
if not binary.exists():
print(f"didactyl binary not found: {binary}")
return 2
config = Path(args.config)
if not config.exists():
print(f"config not found: {config}")
return 2
log_file = str(Path(output_dir) / "agent_debug.log")
base_url = f"https://{args.api_bind}:{args.api_port}"
agent = AgentProcess(
binary_path=str(binary),
config_path=str(config),
api_port=args.api_port,
api_bind=args.api_bind,
debug_level=args.debug_level,
log_file=log_file,
base_url=base_url,
)
client = DidactylClient(base_url=base_url, timeout=args.timeout, verify_tls=False)
log = LogWatcher(log_file)
log.start()
started = agent.start(timeout=30)
if not started:
print("Failed to start Didactyl agent")
log.stop()
return 1
try:
runner = TestRunner(agent=agent, client=client, log=log, args=args)
tests = runner.discover_suites("tests.suites")
if not tests:
print("No tests discovered")
return 3
results = runner.run_all(tests)
run_meta = {
"run_timestamp": dt.datetime.now(dt.timezone.utc).isoformat(),
"base_url": base_url,
"config": str(config),
"binary": str(binary),
"test_count": len(results),
}
reporter = Reporter(results=results, run_meta=run_meta, output_dir=output_dir)
reporter.print_summary()
reporter.print_details()
json_path = reporter.write_json("results.json")
txt_path = reporter.write_text("results.txt")
print(f"\nWrote: {json_path}")
print(f"Wrote: {txt_path}")
bad = [r for r in results if r.status in {"fail", "error", "timeout"}]
return 1 if bad else 0
finally:
agent.stop(timeout=10)
log.stop()
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Test suite modules for Didactyl harness."""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Any
from tests.harness.test_runner import TestCase, TestContext
def assert_success(resp: dict[str, Any]) -> None:
assert isinstance(resp, dict), "response must be dict"
assert resp.get("success") is True, f"expected success=true, got: {resp}"
def turns_tool_names(resp: dict[str, Any]) -> list[str]:
names: list[str] = []
for turn in resp.get("turns", []) or []:
for tc in turn.get("tool_calls", []) or []:
name = tc.get("name")
if isinstance(name, str):
names.append(name)
return names
def simple_prompt_test(suite: str, name: str, description: str, prompt: str, expected_tool: str | None = None) -> TestCase:
def _run(ctx: TestContext):
resp = ctx.client.prompt(prompt, max_turns=6)
assert_success(resp)
final_response = str(resp.get("final_response", "")).strip()
assert final_response, "final_response is empty"
tools = turns_tool_names(resp)
if expected_tool:
assert expected_tool in tools, f"expected tool {expected_tool}, got {tools}"
return True, "ok", {"tool_calls": tools, "final_response": final_response[:200]}
return TestCase(suite=suite, name=name, description=description, fn=_run)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from tests.harness.test_runner import TestCase
from .common import assert_success
SUITE = "test_conversation"
def _simple_greeting(ctx):
resp = ctx.client.prompt("Hello, what is your name?", max_turns=4)
assert_success(resp)
text = str(resp.get("final_response", "")).strip()
assert text, "empty final_response"
return True, "greeting response ok", {"response": text[:200]}
def _self_description(ctx):
resp = ctx.client.prompt("What are you? Describe yourself briefly.", max_turns=4)
assert_success(resp)
text = str(resp.get("final_response", "")).lower()
assert text, "empty final_response"
assert any(token in text for token in ["didactyl", "agent", "nostr"]), "response missing expected identity terms"
return True, "self description ok", {"response": text[:200]}
def _empty_message(ctx):
resp = ctx.client.prompt("", max_turns=2)
assert isinstance(resp, dict), "response should be JSON object"
return True, "empty message handled", {"success": resp.get("success")}
def _long_message(ctx):
long_msg = "A" * 10000
resp = ctx.client.prompt(long_msg, max_turns=4)
assert isinstance(resp, dict), "response should be JSON object"
return True, "long message handled", {"success": resp.get("success")}
def get_tests():
return [
TestCase(SUITE, "simple_greeting", "Simple hello prompt", _simple_greeting),
TestCase(SUITE, "agent_responds_about_itself", "Agent self description", _self_description),
TestCase(SUITE, "empty_message_handling", "Empty message behavior", _empty_message),
TestCase(SUITE, "very_long_message", "Very long input behavior", _long_message),
]
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from tests.harness.test_runner import TestCase
SUITE = "test_errors"
def _invalid_json(ctx):
code, body = ctx.client.raw_post("/api/prompt/agent", b"{not json")
assert code == 400, f"expected 400, got {code} body={body}"
return True, "invalid json rejected", {"status": code}
def _missing_message(ctx):
code, body = ctx.client.raw_post("/api/prompt/agent", b"{}")
assert code == 400, f"expected 400, got {code} body={body}"
return True, "missing message rejected", {"status": code}
def _unknown_endpoint(ctx):
code, _body = ctx.client.raw_post("/api/nonexistent", b"{}")
assert code == 404, f"expected 404, got {code}"
return True, "unknown endpoint 404", {"status": code}
def _webhook_nonexistent(ctx):
code, _body = ctx.client.raw_post("/api/trigger/nonexistent-dtag", b"{}")
assert code == 404, f"expected 404, got {code}"
return True, "nonexistent d_tag 404", {"status": code}
def get_tests():
return [
TestCase(SUITE, "invalid_json_body", "Malformed JSON rejected", _invalid_json),
TestCase(SUITE, "missing_message_field", "Missing message field rejected", _missing_message),
TestCase(SUITE, "unknown_endpoint", "Unknown endpoint returns 404", _unknown_endpoint),
TestCase(SUITE, "webhook_nonexistent_dtag", "Unknown webhook dtag returns 404", _webhook_nonexistent),
]
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from tests.harness.test_runner import TestCase
from .common import assert_success
SUITE = "test_health"
def _status_returns_200(ctx):
resp = ctx.client.status()
assert_success(resp)
return True, "status success", {"keys": list(resp.keys())}
def _status_has_fields(ctx):
resp = ctx.client.status()
for key in ["name", "version", "pubkey", "relay_count"]:
assert key in resp, f"missing field: {key}"
return True, "required fields present", {}
def _context_current(ctx):
resp = ctx.client.context_current()
assert_success(resp)
msgs = resp.get("messages", [])
assert isinstance(msgs, list), "messages must be a list"
assert resp.get("total_chars", 0) >= 0, "missing/invalid total_chars"
return True, "context current ok", {"message_count": len(msgs)}
def _context_parts(ctx):
resp = ctx.client.context_parts()
assert_success(resp)
parts = resp.get("parts", [])
names = [p.get("name") for p in parts if isinstance(p, dict)]
assert "system_prompt" in names, "system_prompt part missing"
return True, "context parts ok", {"parts": names}
def get_tests():
return [
TestCase(SUITE, "status_returns_200", "GET /api/status success", _status_returns_200),
TestCase(SUITE, "status_has_fields", "GET /api/status has required fields", _status_has_fields),
TestCase(SUITE, "context_current_returns_messages", "GET /api/context/current has messages", _context_current),
TestCase(SUITE, "context_parts_has_system_prompt", "GET /api/context/parts has system_prompt", _context_parts),
]
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from tests.harness.test_runner import TestCase
SUITE = "test_restart"
def _clean_restart(ctx):
ok = ctx.agent.restart(timeout=30)
assert ok, "restart failed"
resp = ctx.client.status()
assert resp.get("success") is True
return True, "restart succeeded", {}
def _status_after_restart(ctx):
before = ctx.client.status()
assert before.get("success") is True
ok = ctx.agent.restart(timeout=30)
assert ok, "restart failed"
after = ctx.client.status()
assert after.get("success") is True
assert before.get("pubkey") == after.get("pubkey"), "pubkey changed after restart"
return True, "status stable after restart", {"pubkey": after.get("pubkey")}
def _conversation_after_restart(ctx):
ok = ctx.agent.restart(timeout=30)
assert ok, "restart failed"
resp = ctx.client.prompt("After restart, say hello.", max_turns=3)
assert resp.get("success") is True
assert str(resp.get("final_response", "")).strip(), "empty final response"
return True, "conversation works post-restart", {}
def get_tests():
return [
TestCase(SUITE, "clean_restart", "Stop/start restart", _clean_restart, requires_restart=True),
TestCase(SUITE, "status_after_restart", "Status stable after restart", _status_after_restart),
TestCase(SUITE, "conversation_after_restart", "Prompt after restart", _conversation_after_restart),
]
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import time
from tests.harness.test_runner import TestCase
SUITE = "test_timeouts"
def _response_within_timeout(ctx):
start = time.monotonic()
resp = ctx.client.prompt("Say hello in one sentence.", max_turns=2)
elapsed = time.monotonic() - start
assert isinstance(resp, dict)
assert elapsed <= float(ctx.args.timeout), f"response exceeded timeout {ctx.args.timeout}s"
return True, "response within timeout", {"elapsed": elapsed}
def _status_fast(ctx):
start = time.monotonic()
resp = ctx.client.status()
elapsed = time.monotonic() - start
assert resp.get("success") is True
assert elapsed <= 2.0, f"status too slow: {elapsed}s"
return True, "status fast", {"elapsed": elapsed}
def _context_fast(ctx):
start = time.monotonic()
resp = ctx.client.context_current()
elapsed = time.monotonic() - start
assert resp.get("success") is True
assert elapsed <= 5.0, f"context too slow: {elapsed}s"
return True, "context fast", {"elapsed": elapsed}
def get_tests():
return [
TestCase(SUITE, "response_within_timeout", "Prompt response within timeout", _response_within_timeout),
TestCase(SUITE, "status_responds_fast", "Status endpoint latency", _status_fast),
TestCase(SUITE, "context_responds_fast", "Context endpoint latency", _context_fast),
]
+9
View File
@@ -0,0 +1,9 @@
from .common import simple_prompt_test
SUITE = "test_tools_blossom"
def get_tests():
return [
simple_prompt_test(SUITE, "blossom_list", "Blossom list", "List your blossom blobs", "blossom_list"),
]
+9
View File
@@ -0,0 +1,9 @@
from .common import simple_prompt_test
SUITE = "test_tools_cashu"
def get_tests():
return [
simple_prompt_test(SUITE, "wallet_balance", "Wallet balance", "Check your cashu wallet balance", "cashu_wallet_balance"),
]
+13
View File
@@ -0,0 +1,13 @@
from .common import simple_prompt_test
SUITE = "test_tools_identity"
def get_tests():
return [
simple_prompt_test(SUITE, "get_pubkey", "Fetch hex pubkey", "What is your public key in hex?", "nostr_pubkey"),
simple_prompt_test(SUITE, "get_npub", "Fetch npub", "What is your npub?", "nostr_npub"),
simple_prompt_test(SUITE, "agent_identity", "Agent identity tool", "Tell me about your identity", "agent_identity"),
simple_prompt_test(SUITE, "agent_version", "Agent version tool", "What version are you?", "agent_version"),
simple_prompt_test(SUITE, "admin_identity", "Admin identity tool", "Who is your administrator?", "admin_identity"),
]
+23
View File
@@ -0,0 +1,23 @@
from .common import simple_prompt_test
SUITE = "test_tools_memory"
def get_tests():
return [
simple_prompt_test(SUITE, "task_list", "Task list", "Show me your current task list", "task_list"),
simple_prompt_test(
SUITE,
"task_manage_add_remove",
"Task add/remove",
"Add a task test harness probe task then remove it",
"task_manage",
),
simple_prompt_test(
SUITE,
"memory_save_recall",
"Save and recall memory",
"Save test harness probe to memory, then recall your memory",
"memory_save",
),
]
+15
View File
@@ -0,0 +1,15 @@
from .common import simple_prompt_test
SUITE = "test_tools_nostr"
def get_tests():
return [
simple_prompt_test(SUITE, "nostr_post_kind1", "Post kind1 note", "Post a test note saying Automated test post", "nostr_post"),
simple_prompt_test(SUITE, "nostr_query_recent", "Query recent notes", "Query the 3 most recent kind 1 notes from any author", "nostr_query"),
simple_prompt_test(SUITE, "nostr_my_events", "List my events", "List your recent events", "nostr_my_events"),
simple_prompt_test(SUITE, "nostr_relay_status", "Relay status", "What is the status of your relay connections?", "nostr_relay_status"),
simple_prompt_test(SUITE, "nostr_dm_send", "Send DM", "Send a test DM to yourself", "nostr_dm_send"),
simple_prompt_test(SUITE, "nostr_encode_npub", "Encode npub", "Encode your pubkey as an npub", "nostr_encode"),
simple_prompt_test(SUITE, "nostr_profile_get", "Profile lookup", "Look up your own Nostr profile", "nostr_profile_get"),
]

Some files were not shown because too many files have changed in this diff Show More