Compare commits

...

5 Commits

17 changed files with 1154 additions and 295 deletions

View File

@@ -54,11 +54,11 @@ 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.2.8
## Current Status — v0.2.13
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.8Clean DM context duplication, suppress oversized websocket frame traces, and return raw content/tags in skill_get
> Last release update: v0.2.13Unify startup skills, rename identity skill, add dm_history text format, and enforce startup publish success
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected

View File

@@ -1,14 +1,56 @@
```text
Context Log - not seen by model
timestamp=2026-03-22 17:56:17
Context Log - seen by model
timestamp=2026-03-23 07:00:39
phase=llm_chat_with_tools_messages
sender=1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139
model=claude-opus-4.6
context_bytes=1318
approx_tokens=329
context_bytes=2065
approx_tokens=516
```
[{"role":"system","content":"You are {\n \"name\": \"Didactyl Agent\",\n \"display_name\": \"Didactyl Agent\",\n \"about\": \"A sovereign AI agent on Nostr.\\n\\nMission: Help maintain and answer questions about the Didactyl project.\\n\\nhttps://git.laantungir.net/laantungir/didactyl\\n\\nNot your keys, not your Bitcoin.\\nNot your keys, not your Agent.\\nNot your keys, not your Robot.\",\n \"picture\": \"https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png\",\n \"banner\": \"https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg\"\n}\n\nYour npub: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn\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\n\nIncoming DM message:\nhello"},{"role":"user","content":"hello","_ts":1774216577}]
[{"role":"system","content":"You are {\n \"name\": \"Didactyl Agent\",\n \"display_name\": \"Didactyl Agent\",\n \"about\": \"A sovereign AI agent on Nostr.\\n\\nMission: Help maintain and answer questions about the Didactyl project.\\n\\nhttps://git.laantungir.net/laantungir/didactyl\\n\\nNot your keys, not your Bitcoin.\\nNot your keys, not your Agent.\\nNot your keys, not your Robot.\",\n \"picture\": \"https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png\",\n \"banner\": \"https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg\"\n}\n\nYour npub: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn\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 [{\"role\":\"assistant\",\"content\":\"Didactyl has started up and is online at 2026-03-23 06:58:49 (version v0.2.12, connected relays: 5/5).\",\"created_at\":1774263529},{\"role\":\"user\",\"content\":\"Good morning\",\"created_at\":1774263548},{\"role\":\"assistant\",\"content\":\"Good morning! ☀️ How can I help you today?\",\"created_at\":1774263551},{\"role\":\"user\",\"content\":\"What is the capital of England?\",\"created_at\":1774263616},{\"role\":\"assistant\",\"content\":\"London! 🇬🇧\",\"created_at\":1774263619},{\"role\":\"user\",\"content\":\"How about France?\",\"created_at\":1774263628},{\"role\":\"assistant\",\"content\":\"Paris! 🇫🇷\",\"created_at\":1774263632},{\"role\":\"user\",\"content\":\"And Fiji?\",\"created_at\":1774263639}]"},{"role":"user","content":"And Fiji?","_ts":1774263639}]
---
```text
Context Log - seen by model
timestamp=2026-03-23 07:00:28
phase=llm_chat_with_tools_messages
sender=1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139
model=claude-opus-4.6
context_bytes=1918
approx_tokens=479
```
[{"role":"system","content":"You are {\n \"name\": \"Didactyl Agent\",\n \"display_name\": \"Didactyl Agent\",\n \"about\": \"A sovereign AI agent on Nostr.\\n\\nMission: Help maintain and answer questions about the Didactyl project.\\n\\nhttps://git.laantungir.net/laantungir/didactyl\\n\\nNot your keys, not your Bitcoin.\\nNot your keys, not your Agent.\\nNot your keys, not your Robot.\",\n \"picture\": \"https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png\",\n \"banner\": \"https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg\"\n}\n\nYour npub: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn\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 [{\"role\":\"assistant\",\"content\":\"Didactyl has started up and is online at 2026-03-23 06:58:49 (version v0.2.12, connected relays: 5/5).\",\"created_at\":1774263529},{\"role\":\"user\",\"content\":\"Good morning\",\"created_at\":1774263548},{\"role\":\"assistant\",\"content\":\"Good morning! ☀️ How can I help you today?\",\"created_at\":1774263551},{\"role\":\"user\",\"content\":\"What is the capital of England?\",\"created_at\":1774263616},{\"role\":\"assistant\",\"content\":\"London! 🇬🇧\",\"created_at\":1774263619},{\"role\":\"user\",\"content\":\"How about France?\",\"created_at\":1774263628}]"},{"role":"user","content":"How about France?","_ts":1774263628}]
---
```text
Context Log - seen by model
timestamp=2026-03-23 07:00:16
phase=llm_chat_with_tools_messages
sender=1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139
model=claude-opus-4.6
context_bytes=1768
approx_tokens=442
```
[{"role":"system","content":"You are {\n \"name\": \"Didactyl Agent\",\n \"display_name\": \"Didactyl Agent\",\n \"about\": \"A sovereign AI agent on Nostr.\\n\\nMission: Help maintain and answer questions about the Didactyl project.\\n\\nhttps://git.laantungir.net/laantungir/didactyl\\n\\nNot your keys, not your Bitcoin.\\nNot your keys, not your Agent.\\nNot your keys, not your Robot.\",\n \"picture\": \"https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png\",\n \"banner\": \"https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg\"\n}\n\nYour npub: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn\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 [{\"role\":\"assistant\",\"content\":\"Didactyl has started up and is online at 2026-03-23 06:58:49 (version v0.2.12, connected relays: 5/5).\",\"created_at\":1774263529},{\"role\":\"user\",\"content\":\"Good morning\",\"created_at\":1774263548},{\"role\":\"assistant\",\"content\":\"Good morning! ☀️ How can I help you today?\",\"created_at\":1774263551},{\"role\":\"user\",\"content\":\"What is the capital of England?\",\"created_at\":1774263616}]"},{"role":"user","content":"What is the capital of England?","_ts":1774263616}]
---
```text
Context Log - seen by model
timestamp=2026-03-23 06:59:08
phase=llm_chat_with_tools_messages
sender=1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139
model=claude-opus-4.6
context_bytes=1541
approx_tokens=385
```
[{"role":"system","content":"You are {\n \"name\": \"Didactyl Agent\",\n \"display_name\": \"Didactyl Agent\",\n \"about\": \"A sovereign AI agent on Nostr.\\n\\nMission: Help maintain and answer questions about the Didactyl project.\\n\\nhttps://git.laantungir.net/laantungir/didactyl\\n\\nNot your keys, not your Bitcoin.\\nNot your keys, not your Agent.\\nNot your keys, not your Robot.\",\n \"picture\": \"https://laantungir.github.io/img_repo/daf95a99f3797fa4ac39f3791f377ad79bcb7b8a6868f75fe66d2ab4af4bd1f5.png\",\n \"banner\": \"https://laantungir.github.io/img_repo/d21c4060632ab3d9d37a6062eeecbdbfbd67f03cb20dbd64838b1ba0d9cd8922.jpg\"\n}\n\nYour npub: npub12237stmmxapc2ta7spx0e06dkdzgz9vg0z2jglqqru44peus4juqg598qn\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 [{\"role\":\"assistant\",\"content\":\"Didactyl has started up and is online at 2026-03-23 06:58:49 (version v0.2.12, connected relays: 5/5).\",\"created_at\":1774263529},{\"role\":\"user\",\"content\":\"Good morning\",\"created_at\":1774263548}]"},{"role":"user","content":"Good morning","_ts":1774263548}]
---

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 trigger behavior, 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` (published as private kind `31124`, default `d=default_admin_dm`)
- Startup skills in `startup_events` (typically private kind `31124`, e.g. `d=identity_and_rules` and `d=dm_history`)
---
@@ -121,10 +121,11 @@ That relay list is used as the initial network attachment for querying existing
## Migration Notes (v0.2.0)
- Legacy `config.jsonc` and template-DSL context files have been removed from the active startup model.
- Default DM handling now relies on `default_skill` with `d_tag: default_admin_dm` and trigger tags:
- `["trigger", "dm"]`
- `["filter", "{\"from\":\"admin\"}"]`
- Default skill content is plain markdown with inline variables such as `{{my_kind0_profile}}` and `{{my_npub}}`.
- 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})}}`.
---

View File

@@ -61,20 +61,30 @@
["r", "wss://relay.primal.net"]
]
}
],
// ─── Default Skill ────────────────────────────────────────────────
// Keep this generic and non-sensitive for repository examples.
"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\"}"]
]
}
},
{
"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\"}"]
]
}
]
}

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.

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()` |

View File

@@ -778,7 +778,9 @@ static int handle_slash_command(const char* sender_pubkey_hex, const char* messa
size_t tool_len = (size_t)(p - (message + 1));
if (tool_len == 0 || tool_len >= 128U) {
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, "{\"success\":false,\"error\":\"invalid slash command\"}");
(void)nostr_handler_send_dm_auto_with_role(sender_pubkey_hex,
"{\"success\":false,\"error\":\"invalid slash command\"}",
DIDACTYL_DM_HISTORY_TOOL_RESPONSE);
return 1;
}
@@ -835,7 +837,9 @@ static int handle_slash_command(const char* sender_pubkey_hex, const char* messa
free(log_payload);
}
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, dm_payload ? dm_payload : "Command executed with no output.");
(void)nostr_handler_send_dm_auto_with_role(sender_pubkey_hex,
dm_payload ? dm_payload : "Command executed with no output.",
DIDACTYL_DM_HISTORY_TOOL_RESPONSE);
free(markdown_result);
free(result_json);
return 1;
@@ -945,7 +949,11 @@ static __attribute__((unused)) void template_emit_hook(const char* section_name,
}
static void append_context_log(const char* sender_pubkey_hex, const char* phase, const char* context_payload) {
if (phase && strcmp(phase, "direct_tool_exec") == 0) {
if (!phase) {
return;
}
if (strcmp(phase, "llm_chat_with_tools_messages") != 0 &&
strcmp(phase, "llm_trigger_with_tools_messages") != 0) {
return;
}
@@ -970,7 +978,7 @@ static void append_context_log(const char* sender_pubkey_hex, const char* phase,
int entry_len = snprintf(NULL,
0,
"```text\n"
"Context Log - not seen by model\n"
"Context Log - seen by model\n"
"timestamp=%s\n"
"phase=%s\n"
"sender=%s\n"
@@ -998,7 +1006,7 @@ static void append_context_log(const char* sender_pubkey_hex, const char* phase,
snprintf(entry,
(size_t)entry_len + 1U,
"```text\n"
"Context Log - not seen by model\n"
"Context Log - seen by model\n"
"timestamp=%s\n"
"phase=%s\n"
"sender=%s\n"
@@ -1542,56 +1550,6 @@ static int refresh_adopted_skills_cache_if_needed(void) {
cJSON_Delete(adoption_events);
if (tmp_count == 0 && g_cfg->startup_event_count > 0 && g_cfg->startup_events) {
for (int i = 0; i < g_cfg->startup_event_count && tmp_count < AGENT_ADOPTED_SKILLS_MAX; i++) {
startup_event_t* se = &g_cfg->startup_events[i];
if (!se || (se->kind != 31123 && se->kind != 31124) || !se->content || se->content[0] == '\0') {
continue;
}
char d_tag_val[65] = {0};
if (se->tags_json) {
cJSON* tags = cJSON_Parse(se->tags_json);
cJSON* d_tag = tags ? find_tag_value_string_local(tags, "d") : NULL;
if (d_tag && cJSON_IsString(d_tag) && d_tag->valuestring && d_tag->valuestring[0] != '\0') {
snprintf(d_tag_val, sizeof(d_tag_val), "%s", d_tag->valuestring);
}
cJSON_Delete(tags);
}
if (d_tag_val[0] == '\0') {
snprintf(d_tag_val, sizeof(d_tag_val), "startup-skill-%d", i + 1);
}
tmp[tmp_count].kind = se->kind;
snprintf(tmp[tmp_count].author_pubkey_hex,
sizeof(tmp[tmp_count].author_pubkey_hex),
"%s",
g_cfg->keys.public_key_hex[0] != '\0'
? g_cfg->keys.public_key_hex
: "unknown");
snprintf(tmp[tmp_count].d_tag, sizeof(tmp[tmp_count].d_tag), "%s", d_tag_val);
tmp[tmp_count].content = strdup(se->content);
tmp[tmp_count].has_trigger = 0;
tmp[tmp_count].trigger_type = TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
tmp[tmp_count].filter_json[0] = '\0';
if (se->tags_json && se->tags_json[0] != '\0') {
cJSON* tags = cJSON_Parse(se->tags_json);
cJSON* trigger_tag = tags ? find_tag_value_string_local(tags, "trigger") : NULL;
cJSON* filter_tag = tags ? find_tag_value_string_local(tags, "filter") : NULL;
if (trigger_tag && cJSON_IsString(trigger_tag) && trigger_tag->valuestring && trigger_tag->valuestring[0] != '\0' &&
filter_tag && cJSON_IsString(filter_tag) && filter_tag->valuestring && filter_tag->valuestring[0] != '\0') {
tmp[tmp_count].has_trigger = 1;
tmp[tmp_count].trigger_type = trigger_type_from_string(trigger_tag->valuestring);
snprintf(tmp[tmp_count].filter_json, sizeof(tmp[tmp_count].filter_json), "%s", filter_tag->valuestring);
}
cJSON_Delete(tags);
}
if (tmp[tmp_count].content) {
tmp_count++;
}
}
}
pthread_mutex_lock(&g_adopted_skills_mutex);
clear_adopted_skills_cache_locked();
for (int i = 0; i < tmp_count; i++) {
@@ -2085,12 +2043,16 @@ void agent_on_message(const char* sender_pubkey_hex,
}
int allow_tools = (tier == DIDACTYL_SENDER_ADMIN) && g_cfg->tools.enabled && g_cfg->security.admin.tools_enabled;
int inbound_role = (message[0] == '/') ? DIDACTYL_DM_HISTORY_TOOL_REQUEST : DIDACTYL_DM_HISTORY_USER;
(void)nostr_handler_dm_history_remember(sender_pubkey_hex, message, (didactyl_dm_history_role_t)inbound_role);
if (message[0] == '/') {
if (!allow_tools) {
const char* denied = "{\"success\":false,\"error\":\"slash commands are disabled for this sender tier\"}";
append_context_log(sender_pubkey_hex, "direct_tool_exec", denied);
(void)nostr_handler_send_dm_auto(sender_pubkey_hex, denied);
(void)nostr_handler_send_dm_auto_with_role(sender_pubkey_hex,
denied,
DIDACTYL_DM_HISTORY_TOOL_RESPONSE);
return;
}
if (handle_slash_command(sender_pubkey_hex, message)) {

View File

@@ -692,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;
@@ -730,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;
}
@@ -746,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;
@@ -806,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) {
@@ -1094,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) {
@@ -1142,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();
@@ -1179,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;
}
@@ -1219,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));
@@ -1280,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;
@@ -1366,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;
}

View File

@@ -64,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;
@@ -131,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);

View File

@@ -1,9 +1,13 @@
#ifndef DIDACTYL_DEFAULT_EVENTS_H
#define DIDACTYL_DEFAULT_EVENTS_H
#define DIDACTYL_DEFAULT_SKILL_D_TAG "default_admin_dm"
#define DIDACTYL_DEFAULT_SKILL_D_TAG "identity_and_rules"
#define DIDACTYL_DEFAULT_SKILL_KIND 31124
#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"d\",\"default_admin_dm\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"],[\"description\",\"Default admin DM handler\"],[\"trigger\",\"dm\"],[\"filter\",\"{\\\"from\\\":\\\"admin\\\"}\"]]"
#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
@@ -46,4 +50,8 @@ static const char* DIDACTYL_DEFAULT_SKILL_TEMPLATE =
"- 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

View File

@@ -1057,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;
@@ -1248,7 +1248,14 @@ 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");
@@ -1317,9 +1324,9 @@ int main(int argc, char** argv) {
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;
@@ -1333,9 +1340,9 @@ int main(int argc, char** argv) {
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;
@@ -1374,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) {
@@ -1410,9 +1457,9 @@ int main(int argc, char** argv) {
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;
@@ -1497,6 +1544,29 @@ int main(int argc, char** argv) {
nostr_handler_refresh_relay_statuses();
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);
@@ -1520,9 +1590,9 @@ 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();

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 2
#define DIDACTYL_VERSION_PATCH 8
#define DIDACTYL_VERSION "v0.2.8"
#define DIDACTYL_VERSION_PATCH 13
#define DIDACTYL_VERSION "v0.2.13"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -96,6 +96,7 @@ 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
@@ -216,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;
@@ -349,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;
}
@@ -380,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:
@@ -426,7 +441,10 @@ 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);
@@ -448,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') {
@@ -1296,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;
@@ -1735,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);
}
@@ -1753,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) {
@@ -2162,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) {
@@ -2173,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);
@@ -2470,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();
@@ -2498,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;
}
@@ -2719,7 +2702,80 @@ void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callb
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;
}
@@ -2801,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));
}
@@ -2810,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;
}
@@ -2837,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,
@@ -3134,6 +3204,19 @@ 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;
@@ -3162,7 +3245,66 @@ int nostr_handler_reconcile_startup_events(void) {
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;
}
@@ -3580,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;
}
@@ -3670,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("[]");
@@ -3729,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);

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,
@@ -42,7 +49,13 @@ void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callb
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);
@@ -59,6 +72,8 @@ 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_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);
char* nostr_handler_get_self_skill_events_json(void);
@@ -79,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);

View File

@@ -372,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) {
@@ -495,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, "%s", DIDACTYL_DEFAULT_SKILL_TEMPLATE);
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, "%s", DIDACTYL_DEFAULT_SKILL_TEMPLATE);
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;
}
@@ -2157,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;
}

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");
@@ -282,6 +301,14 @@ char* execute_nostr_dm_history(tools_context_t* ctx, const char* args_json) {
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') {
@@ -361,20 +388,59 @@ char* execute_nostr_dm_history(tools_context_t* ctx, const char* args_json) {
cJSON_Delete(parsed);
char* filtered_json = cJSON_PrintUnformatted(filtered);
cJSON_Delete(filtered);
if (!filtered_json) {
return json_error_local("failed to serialize dm history");
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(filtered_json);
free(rendered);
return NULL;
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", filtered_json);
free(filtered_json);
cJSON_AddStringToObject(out, "content", rendered);
free(rendered);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);

View File

@@ -851,7 +851,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
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\":\"default_admin_dm\"} (slash shorthand also works: /skill_get default_admin_dm)");
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);
@@ -880,7 +880,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddStringToObject(t24b, "type", "function");
cJSON_AddStringToObject(t24b_fn, "name", "skill_view");
cJSON_AddStringToObject(t24b_fn, "description", "Compatibility alias for skill_get; JSON: {\"d\":\"default_admin_dm\"}");
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);
@@ -908,7 +908,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
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\":\"default_admin_dm\",\"kind\":31124,\"scope\":\"private\",\"content\":\"...\",\"tags\":[[\"d\",\"default_admin_dm\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"]]}}");
"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);
@@ -1487,7 +1487,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
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} or with explicit peer {\"peer_pubkey\":\"<hex64>\",\"limit\":20}; template usage: {{nostr_dm_history({\"limit\":12})}}");
"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);
@@ -1506,6 +1506,13 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
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);