v0.0.78 - Seed default skill into self-skill cache during startup reconcile

This commit is contained in:
Your Name
2026-03-18 09:46:08 -04:00
parent 326acb63d1
commit 42fc14a5b3
4 changed files with 142 additions and 4 deletions

View File

@@ -55,11 +55,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.0.77
## Current Status — v0.0.78
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.77Wizard now verifies systemd service is active after install, reports admin inbox expectation, and exits to avoid duplicate foreground boot
> Last release update: v0.0.78Seed default skill into self-skill cache during startup reconcile
- 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

@@ -0,0 +1,91 @@
# Fix: Default Skill Missing from skill_list Cache
## Problem
When a new didactyl agent is created via the setup wizard, the default skill (`didactyl-default`, kind 31124) does not appear in the `skill_list` output. The agent IS using the default skill as its system context, but the skill cache (`g_self_skill_events`) does not contain it.
## Evidence
- Default skill event IS published to relays (confirmed by `nostr_my_events`)
- Default skill event shows `in_current_cache: false`
- `skill_list` returns `count: 1` with only `infrastructure-monitor`
- The kind 10123 adoption list only contains `infrastructure-monitor`
## Root Cause Analysis
The startup sequence in `main.c` is:
```
1. nostr_handler_init() — resets g_self_skill_events to []
2. wait_for_connected_relays() — waits for relay connections
3. reconcile_startup_events() — publishes default skill to relays
-> publish_kind_event_to_relays() — inserts into cache IF sent > 0
4. ... relay list expansion ...
5. subscribe_self_skills() — subscribes to relays for kinds 31123/31124/10123
```
The default skill SHOULD be inserted into the cache at step 3 via `publish_kind_event_to_relays` -> `self_skill_cache_upsert_event_locked`. However, the cache insert at line 2750 is gated by `if (sent > 0)`.
There are two failure modes:
### Failure Mode 1: Async publish timing
The startup publish creates a NEW event per relay via `publish_pending_startup_events_for_relay_index`. Each call to `publish_kind_event_to_relays` creates a fresh event with `nostr_create_and_sign_event`. If the relay is not connected at that moment, `sent = 0` and the cache insert is skipped.
### Failure Mode 2: Subscription race condition
Even if the cache insert succeeds at step 3, the self-skill subscription at step 5 queries relays. For a brand new agent, the default skill was JUST published asynchronously. If the relay hasn't indexed it by the time the subscription query runs, the subscription returns EOSE without the default skill. The subscription stays open but the default skill was published BEFORE the subscription started, so it won't arrive as a live event.
The cache entry from step 3 should persist, but there may be an edge case where the relay pool reconnection handler at line 792 triggers another publish cycle, creating a new event that replaces the cache entry, and if that publish fails (sent=0), the replacement doesn't happen but the old entry is still there.
### Most Likely Cause
The most likely cause is that `publish_kind_event_to_relays` returns `sent = 0` for the default skill during the initial reconcile. This can happen if:
- The relay connection drops momentarily between `wait_for_connected_relays` and the actual publish
- The relay pool's async publish fails silently
## Proposed Fix
### Approach: Seed the cache directly from config during reconcile
In `nostr_handler_reconcile_startup_events()`, after publishing startup events, explicitly seed the self-skill cache with a synthetic event built from `g_cfg->default_skill`. This ensures the default skill is ALWAYS in the cache regardless of relay publish success.
### Implementation
In `nostr_handler.c`, add a new static function `seed_default_skill_into_cache()` that:
1. Checks if `g_cfg->default_skill.content` is non-empty
2. Builds a cJSON event object with:
- `kind`: `g_cfg->default_skill.kind` (31124)
- `pubkey`: `g_cfg->keys.public_key_hex`
- `content`: `g_cfg->default_skill.content`
- `tags`: parsed from `g_cfg->default_skill.tags_json`
- `created_at`: `time(NULL)` (or 0 as a floor value)
- `id`: a placeholder hex string (e.g., all zeros) — will be replaced when the real event arrives from relay
3. Calls `self_skill_cache_upsert_event_locked()` with this synthetic event
Call this function at the END of `nostr_handler_reconcile_startup_events()`, AFTER the publish loop. This way:
- If the publish succeeded, the cache already has the real event (with real ID/sig). The synthetic event would have `created_at` <= the real one, so the upsert would be a no-op.
- If the publish failed, the cache gets the synthetic event as a fallback.
- When the subscription later returns the real event from relays, it replaces the synthetic one (since it has a real ID and potentially newer timestamp).
### Alternative Approach: Remove the `sent > 0` gate
Change `publish_kind_event_to_relays` to always insert skill events (kinds 31123/31124) into the cache, regardless of whether the relay publish succeeded. This is simpler but changes the semantics — the cache would contain events that may not be on any relay.
### Recommended: Approach 1 (seed from config)
This is safer because:
- It doesn't change the publish function's behavior
- It explicitly handles the default skill case
- The synthetic event gets replaced by the real one when it arrives from relays
- It works even if the publish completely fails
### Files to Modify
1. `src/nostr_handler.c`:
- Add `seed_default_skill_into_cache()` static function
- Call it at the end of `nostr_handler_reconcile_startup_events()`
### Edge Cases
- If the default skill is later updated via `skill_update`, the relay version will have a newer `created_at` and will replace the synthetic entry
- If the agent has no default skill configured, the seed function is a no-op
- The synthetic event has a placeholder ID, so `in_current_cache` checks by event ID won't match it — but `skill_list` iterates all events and doesn't check by ID

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 77
#define DIDACTYL_VERSION "v0.0.77"
#define DIDACTYL_VERSION_PATCH 78
#define DIDACTYL_VERSION "v0.0.78"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -416,6 +416,7 @@ 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') {
@@ -1249,6 +1250,50 @@ 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;
@@ -2969,6 +3014,8 @@ int nostr_handler_reconcile_startup_events(void) {
publish_pending_startup_events_for_relay_index(relay_index, "startup_reconcile");
}
seed_default_skill_into_cache();
return 0;
}