v0.2.25 - Fix private skill interoperability: NIP-44 encode/decode for kind 31124 with runtime tag normalization

This commit is contained in:
Didactyl User
2026-04-05 20:43:32 -04:00
parent 1241943c52
commit 6ad668b693
13 changed files with 2904 additions and 254 deletions

1528
.skills-edit-check.mjs Normal file

File diff suppressed because it is too large Load Diff

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. 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.24 ## Current Status — v0.2.25
**Active build — this project is barely working. Experiment at your own risk.** **Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.24Improve skills editor trigger filters with per-trigger helper widgets and presets > Last release update: v0.2.25Fix private skill interoperability: NIP-44 encode/decode for kind 31124 with runtime tag normalization
- Connects to configured relays with auto-reconnect and relay state transition logging - Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected - Publishes configured startup events per relay as each relay becomes connected

View File

@@ -20,8 +20,8 @@ Minimum practical sections:
- `key.nsec` (or runtime `--nsec` / `DIDACTYL_NSEC`) - `key.nsec` (or runtime `--nsec` / `DIDACTYL_NSEC`)
- `admin.pubkey` - `admin.pubkey`
- `llm`
- `startup_events` (must include kind `10002` relay tags) - `startup_events` (must include kind `10002` relay tags)
- `encrypted_events` with a `kind:30078` / `d_tag:user-settings` payload containing `global_llm` and `didactyl`
Typical optional sections: Typical optional sections:
@@ -96,17 +96,39 @@ Security behavior:
## Encrypted Config Events ## Encrypted Config Events
Didactyl exposes config persistence tools for encrypted self-config on Nostr: Didactyl uses encrypted runtime config in two ways:
- `config_store` — publish encrypted kind `30078` config by `d_tag` 1. At bootstrap time via `genesis.jsonc` `encrypted_events` entries
- `config_recall` — query+decrypt kind `30078` config by `d_tag` 2. At runtime via encrypted kind `30078` self-events (`config_store` / `config_recall`)
Recommended tags: `encrypted_events` format:
- `d=llm_config` - `kind` (currently `30078`)
- `d=agent_config` - `d_tag` (required: `user-settings`)
- `content` (JSON string payload to encrypt and publish)
These are encrypted to self with NIP-44. `d=user-settings` payload shape:
```json
{
"v": 2,
"updatedAt": 0,
"global_llm": {
"provider": "openai",
"api_key": "sk-...",
"model": "gpt-4o-mini",
"base_url": "https://api.openai.com/v1",
"max_tokens": 512,
"temperature": 0.7
},
"didactyl": {
"admin_pubkey": "npub1...",
"dm_protocol": "nip04"
}
}
```
These values are encrypted to self with NIP-44 before publish.
--- ---
@@ -116,6 +138,8 @@ These are encrypted to self with NIP-44.
That relay list is used as the initial network attachment for querying existing state and publishing startup events. That relay list is used as the initial network attachment for querying existing state and publishing startup events.
`encrypted_events` are a separate section and are not part of relay-list derivation.
--- ---
## Migration Notes (v0.2.0) ## Migration Notes (v0.2.0)

View File

@@ -18,16 +18,15 @@
// Supported values: "nip04", "nip17", or "both". // Supported values: "nip04", "nip17", or "both".
"dm_protocol": "nip04", "dm_protocol": "nip04",
// ─── LLM Provider ────────────────────────────────────────────────── // ─── Encrypted Startup Config Events ───────────────────────────────
// OpenAI-compatible endpoint settings. // These are published as NIP-44 encrypted kind 30078 self-events on first run.
"llm": { "encrypted_events": [
"provider": "openai", {
"api_key": "sk-REPLACE_WITH_API_KEY", "kind": 30078,
"model": "gpt-4o-mini", "d_tag": "user-settings",
"base_url": "https://api.openai.com/v1", "content": "{\"v\":2,\"updatedAt\":0,\"global_llm\":{\"provider\":\"openai\",\"api_key\":\"sk-REPLACE_WITH_API_KEY\",\"model\":\"gpt-4o-mini\",\"base_url\":\"https://api.openai.com/v1\",\"max_tokens\":512,\"temperature\":0.7},\"didactyl\":{\"admin_pubkey\":\"npub1REPLACE_WITH_ADMIN_PUBKEY\",\"dm_protocol\":\"nip04\"}}"
"max_tokens": 512, }
"temperature": 0.7 ],
},
// ─── HTTP Admin API ──────────────────────────────────────────────── // ─── HTTP Admin API ────────────────────────────────────────────────
"api": { "api": {
@@ -60,7 +59,6 @@
["r", "wss://relay.damus.io"], ["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net"] ["r", "wss://relay.primal.net"]
] ]
}
}, },
{ {
"kind": 31124, "kind": 31124,

View File

@@ -0,0 +1,285 @@
# Global LLM Config Alignment — Plan
Align LLM configuration storage across Didactyl (C agent) and client-ndk (web pages) so both projects read and write the same canonical schema, as specified in [SETTINGS.md](../../client-ndk/docs/SETTINGS.md).
---
## Problem Statement
LLM provider config is currently stored in two incompatible ways:
| Project | Storage | d-tag | Namespace | Schema |
|---------|---------|-------|-----------|--------|
| Didactyl agent | `kind 30078` under agent pubkey | `llm_config` | flat root | `{ provider, api_key, model, base_url, max_tokens, temperature }` |
| client-ndk pages | `kind 30078` under user pubkey | `user-settings` | `settings.ai` | `{ provider, api_key, model, base_url, max_tokens, temperature, providers[], favorites[] }` |
| skills-edit.html | `kind 30078` under user pubkey | `llm_config` | flat root | Same as Didactyl — standalone event |
This creates three issues:
1. **skills-edit.html** writes a standalone `d:llm_config` event under the user pubkey, duplicating the centralized `settings.ai` data
2. **Didactyl** cannot read the admin user's LLM preferences from their `d:user-settings` event
3. **Namespace mismatch** — the centralized settings use `ai` (v1) / `global_llm` (v2 target) while Didactyl uses flat fields at root level
---
## Target State
Per [SETTINGS.md §3](../../client-ndk/docs/SETTINGS.md) and [§8](../../client-ndk/docs/SETTINGS.md):
### Canonical Schema — `global_llm` namespace
```json
{
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7,
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
}
],
"favorites": ["claude-opus-4.6"]
}
```
### Storage locations after alignment
| Actor | Event | d-tag | Where LLM lives | Notes |
|-------|-------|-------|------------------|-------|
| User pubkey | `kind 30078` | `user-settings` | `global_llm` namespace | Canonical source for user LLM prefs |
| Agent pubkey | `kind 30078` | `llm_config` | flat root | Agent runtime config — flat fields are a subset of `global_llm` |
| User pubkey | `kind 30078` | `llm_config` | **DEPRECATED** | skills-edit.html stops writing this |
### Cross-project reading
```mermaid
graph LR
subgraph User Pubkey Events
US[d:user-settings<br/>global_llm namespace]
end
subgraph Agent Pubkey Events
AL[d:llm_config<br/>flat fields]
end
subgraph Consumers
SE[skills-edit.html]
AI[ai.html / skills-tv.html]
DA[Didactyl Agent]
end
SE -->|getUserSettings - global_llm| US
AI -->|getUserSettings - global_llm| US
DA -->|recall own d:llm_config| AL
DA -->|optionally read admin global_llm| US
```
---
## Detailed Changes
### Phase 1: genesis.jsonc alignment
**File**: [`genesis.jsonc`](../../didactyl/genesis.jsonc)
Current `"llm"` section already uses compatible flat field names. The only issue is `provider` contains a URL (`"https://api.ppq.ai"`) instead of a short name (`"ppq"`).
**Change**: Update `provider` to be a short name. The `base_url` field already carries the URL.
```jsonc
"llm": {
"provider": "ppq", // was "https://api.ppq.ai"
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7
}
```
Also update [`genesis.jsonc.example`](../../didactyl/genesis.jsonc.example) to match.
**No C struct changes needed**`llm_config_t.provider` is `char[32]` which holds short names fine.
### Phase 2: Didactyl C-side — persist writes global_llm-compatible fields
**File**: [`src/tools/tool_model.c`](../../didactyl/src/tools/tool_model.c:33) — `persist_llm_config_nostr()`
Already writes: `provider`, `api_key`, `model`, `base_url`, `max_tokens`, `temperature` — these are the exact flat fields in `global_llm`. **No change needed** to the persist path.
**File**: [`src/main.c`](../../didactyl/src/main.c:844) — `persist_runtime_config_to_nostr()`
Same flat fields. **No change needed**.
### Phase 3: Didactyl C-side — recall from admin `d:user-settings`
**File**: [`src/main.c`](../../didactyl/src/main.c:882) — `recover_missing_runtime_config_from_nostr()`
Currently only queries the agent's own pubkey for `d:llm_config`. Add a fallback path:
1. After failing to find own `d:llm_config`, query admin pubkey for `d:user-settings`
2. Decrypt with NIP-44 using admin pubkey as sender
3. Parse JSON, extract `global_llm` object (fall back to `ai` for v1 compat)
4. Pass the extracted object to `apply_recalled_llm_config()`
**File**: [`src/main.c`](../../didactyl/src/main.c:729) — `apply_recalled_llm_config()`
Already reads flat fields from a JSON object. If we pass it the `global_llm` sub-object, it works as-is. It ignores unknown fields like `providers` and `favorites`. **No change needed**.
**New function**: `fetch_admin_user_settings_llm()` in `main.c`:
```c
static int fetch_admin_user_settings_llm(didactyl_config_t* cfg, char** out_plaintext) {
// 1. Query kind:30078, authors:[admin_pubkey], #d:["user-settings"]
// 2. NIP-44 decrypt content (admin encrypted to self — agent cannot decrypt)
// WAIT: Agent cannot decrypt admin's self-encrypted content!
// This path only works if admin explicitly shares config with agent.
// Alternative: Agent reads its OWN d:user-settings if one exists.
// 3. Parse JSON, extract global_llm or ai sub-object
// 4. Serialize sub-object to *out_plaintext
}
```
**Important constraint**: The admin's `d:user-settings` is NIP-44 self-encrypted (admin encrypts to admin). The agent cannot decrypt it because it does not have the admin's private key.
**Revised approach**: Instead of reading the admin's settings directly, the agent should:
1. **Primary**: Read its own `d:llm_config` (current behavior — works)
2. **Secondary**: If the admin wants to push LLM config to the agent, they use the `model_set` tool via DM, which calls [`persist_llm_config_nostr()`](../../didactyl/src/tools/tool_model.c:33) to write `d:llm_config` under the agent pubkey
3. **New path**: The web UI (skills-edit or a future agent-config page) can write `d:llm_config` under the **agent's** pubkey by publishing a NIP-44 encrypted event addressed to the agent. The agent can then decrypt this on recall.
Actually, re-reading [SETTINGS.md §8](../../client-ndk/docs/SETTINGS.md):
> When Didactyl wants to know the user's LLM preferences:
> `kind:30078, authors:[admin_pubkey], #d:[user-settings]`
> Parse the `global_llm` field.
This implies the agent CAN read it. But the content is NIP-44 self-encrypted by the admin. The agent would need the admin to encrypt a copy for the agent, OR the admin publishes their settings unencrypted (unlikely for API keys), OR there is a shared-secret mechanism.
**Resolution**: The practical cross-project path is:
- The **web UI** reads the user's own `global_llm` from `d:user-settings` (it can decrypt its own data)
- The **web UI** can optionally push config to the agent's `d:llm_config` event (encrypt to agent pubkey)
- The **agent** reads its own `d:llm_config` as today
- If the agent needs admin LLM config at startup, the genesis.jsonc provides it, and the setup wizard persists it to `d:llm_config`
So Phase 3 simplifies to: **no C-side recall changes needed for reading admin settings**. The alignment is about ensuring the JSON field names are compatible so the web UI can bridge the two.
### Phase 4: skills-edit.html — migrate to centralized global_llm
**File**: [`../client-ndk/www/skills-edit.html`](../../client-ndk/www/skills-edit.html:1076)
Current behavior:
- [`fetchNostrLlmConfig()`](../../client-ndk/www/skills-edit.html:1076) subscribes to `kind:30078, #d:['llm_config']` under user pubkey
- Decrypts and parses the standalone event
- Merges into `aiConfig`
New behavior:
- Remove `fetchNostrLlmConfig()` entirely
- In [`initializeLlmHelperFromSettings()`](../../client-ndk/www/skills-edit.html:1195), read from `pageSettings.global_llm` (with fallback to `pageSettings.ai` for v1 compat)
- The `pageSettings` object is already populated by `getUserSettings()` and kept live by `onUserSettings()`
- When the user changes provider/model via the LLM helper, patch back via `patchUserSettings({ global_llm: { ... } })`
**Specific changes**:
1. Remove `fetchNostrLlmConfig()` function (~120 lines)
2. Remove `llmConfigLoadNonce` variable
3. Update `initializeLlmHelperFromSettings()`:
```js
async function initializeLlmHelperFromSettings() {
aiConfig = loadAiConfigLocal(aiConfig || getDefaultAiConfig(), AI_STORAGE_KEY);
// Read from centralized settings (global_llm with ai fallback)
const nostrLlm = pageSettings?.global_llm || pageSettings?.ai;
if (nostrLlm && typeof nostrLlm === 'object') {
aiConfig = mergeAiConfigFromSettings(aiConfig, nostrLlm);
}
aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY);
renderLlmProviderOptions();
renderLlmModelOptions();
syncLlmHelperFromSkillInput();
fetchLlmModels();
}
```
4. Update `selectLlmProvider()` and `toggleCurrentModelFavorite()` to also patch centralized settings:
```js
// After saving to local storage, also persist to centralized settings
await patchUserSettings({
global_llm: {
provider: aiConfig.provider,
api_key: aiConfig.api_key,
model: aiConfig.model,
base_url: aiConfig.base_url,
max_tokens: aiConfig.max_tokens,
temperature: aiConfig.temperature,
providers: aiConfig.providers,
favorites: aiConfig.favorites
}
});
```
5. Add `patchUserSettings` to the imports from `init-ndk.mjs`
### Phase 5: ai-ui.mjs — support global_llm namespace
**File**: [`../client-ndk/www/js/ai-ui.mjs`](../../client-ndk/www/js/ai-ui.mjs:72)
The [`mergeAiConfigFromSettings()`](../../client-ndk/www/js/ai-ui.mjs:72) function already accepts any object with the right flat fields. It works with both `settings.ai` and `settings.global_llm` — the caller just passes the right sub-object.
**No changes needed to ai-ui.mjs itself.** The callers (skills-edit.html and other pages) just need to read from `global_llm` instead of `ai`.
### Phase 6: Update SETTINGS.md
Mark Issue 1 as resolved. Update the audit findings section to reflect that skills-edit.html now uses centralized `global_llm`.
---
## Files Modified
| File | Project | Change |
|------|---------|--------|
| `genesis.jsonc` | didactyl | Fix `provider` to short name |
| `genesis.jsonc.example` | didactyl | Fix `provider` to short name |
| `../client-ndk/www/skills-edit.html` | client-ndk | Remove `fetchNostrLlmConfig`, read from `pageSettings.global_llm`, add `patchUserSettings` import and calls |
| `../client-ndk/docs/SETTINGS.md` | client-ndk | Mark Issue 1 resolved |
---
## What Does NOT Change
| Component | Why |
|-----------|-----|
| `llm_config_t` C struct | Field names already match `global_llm` flat fields |
| `persist_llm_config_nostr()` in tool_model.c | Already writes compatible flat fields |
| `apply_recalled_llm_config()` in main.c | Already reads compatible flat fields, ignores extras |
| `persist_runtime_config_to_nostr()` in main.c | Already writes compatible flat fields |
| Agent's own `d:llm_config` event | Stays as agent runtime config under agent pubkey |
| `ai-ui.mjs` | `mergeAiConfigFromSettings()` already handles the right shape |
| `ndk-worker.js` | Settings infrastructure unchanged — just a new namespace key |
---
## Migration Safety
- **v1 → v2 compat**: skills-edit.html reads `global_llm || ai` so it works with both old and new settings
- **Local storage fallback**: `loadAiConfigLocal()` still provides defaults if no Nostr settings exist
- **No breaking change for agent**: Agent continues reading its own `d:llm_config` — the flat field names are already compatible
- **Standalone `d:llm_config` under user pubkey**: Becomes orphaned but harmless. Can be cleaned up later.
---
## Encryption Constraint
The agent **cannot** decrypt the admin's `d:user-settings` because it is NIP-44 self-encrypted by the admin. Cross-project LLM config sharing works through:
1. **Genesis file** — admin provides initial LLM config
2. **Setup wizard** — persists to agent's own `d:llm_config`
3. **model_set tool** — admin sends DM to update agent LLM config at runtime
4. **Future**: Web UI could publish a `d:llm_config` event encrypted to the agent's pubkey
This is the correct architecture — the agent should not need to read the admin's private settings directly.

View File

@@ -0,0 +1,283 @@
# Multi-Model Skill Pipelines
## Overview
Didactyl's skill system already supports executing a series of tasks where each task uses a different LLM model — even from different providers. This document describes what works today, what the limitations are, and what improvements would unlock more powerful multi-step workflows.
---
## What Works Today
### Per-Skill LLM Override
Every skill can declare its own `llm` tag with a provider/model spec and fallback chain:
```
["llm", "anthropic/claude-sonnet-4-20250514, cheap"]
["llm", "openai/gpt-4o-mini"]
["llm", "best"]
```
When a triggered skill fires, the runtime applies the skill's execution parameters (model, temperature, max_tokens) before calling the LLM, then restores the agent defaults afterward. This happens in `apply_trigger_runtime_to_llm_config()` in `src/trigger_manager.c`.
The `llm` tag supports the `provider/model` format. If the tag contains a slash, the runtime parses the provider name and model name separately and overrides both in the LLM config for that execution.
### Chain Triggers Connect Skills Sequentially
The `chain` trigger type fires when another skill completes execution. The `filter` field specifies the source skill's `d` tag:
```json
{
"trigger": "chain",
"filter": "source-skill-d-tag"
}
```
After a triggered skill completes, `trigger_manager_fire_chains()` in `src/trigger_manager.c` looks for all adopted skills with `trigger=chain` whose `filter` matches the completed skill's d-tag, and fires them.
### Combined: Multi-Model Pipeline
By combining per-skill LLM overrides with chain triggers, you can build a pipeline where each step uses a different model:
```
DM arrives
├─ Skill: triage
│ llm: openai/gpt-4o-mini (fast/cheap)
│ trigger: dm
│ → Classifies the request
├─ chain fires ──→ Skill: deep-analysis
│ llm: anthropic/claude-sonnet-4-20250514 (powerful)
│ trigger: chain, filter: triage
│ → Performs thorough analysis
└─ chain fires ──→ Skill: summarize
llm: openai/gpt-4o-mini (cheap)
trigger: chain, filter: deep-analysis
→ Summarizes and DMs admin
```
Each skill gets its own model, temperature, and max_tokens applied independently.
### Execution Parameter Resolution Per Step
For each triggered skill execution:
1. Start with agent/app defaults
2. Apply the skill's `llm` tag (parsed as `provider/model` if slash present)
3. Apply the skill's `temperature` tag if present
4. Apply the skill's `max_tokens` tag if present
5. Execute with those effective settings
6. Restore defaults after the run
---
## Concrete Example
### Skill 1: triage (cheap fast model)
```json
{
"kind": 31123,
"content": "## Triage\n\nClassify the incoming message:\n- If it needs deep research, use the memory_save tool to store the classification and key details.\n- If trivial, respond directly.\n\n{{message}}",
"tags": [
["d", "triage"],
["description", "Fast triage of incoming messages"],
["trigger", "dm"],
["filter", "{\"from\":\"admin\"}"],
["llm", "openai/gpt-4o-mini, cheap"],
["temperature", "0"],
["max_tokens", "200"]
]
}
```
### Skill 2: deep-analysis (powerful model, chains from triage)
```json
{
"kind": 31123,
"content": "## Deep Analysis\n\n{{identity}}\n\nRecall the triage classification from memory. Perform thorough analysis using available tools. Save your findings to memory for the next step.\n\nOriginal request context:\n{{message}}",
"tags": [
["d", "deep-analysis"],
["description", "Thorough analysis with powerful model"],
["trigger", "chain"],
["filter", "triage"],
["llm", "anthropic/claude-sonnet-4-20250514, best"],
["max_tokens", "2000"],
["requires_tool", "memory_recall"],
["requires_tool", "memory_save"],
["requires_tool", "nostr_query"],
["requires_skill", "identity"]
]
}
```
### Skill 3: summarize (cheap model, chains from deep-analysis)
```json
{
"kind": 31123,
"content": "## Summarize\n\nRecall the analysis findings from memory. Write a concise summary and DM it to admin.",
"tags": [
["d", "summarize"],
["description", "Summarize analysis and notify admin"],
["trigger", "chain"],
["filter", "deep-analysis"],
["llm", "openai/gpt-4o-mini, cheap"],
["max_tokens", "500"],
["requires_tool", "memory_recall"],
["requires_tool", "nostr_dm_send"]
]
}
```
---
## Current Capabilities
| Capability | Status | Notes |
|---|---|---|
| Different model per skill | ✅ Works | Via `llm` tag on each skill |
| Different provider per skill | ✅ Works | `provider/model` format in `llm` tag |
| Sequential multi-step pipelines | ✅ Works | Via `chain` trigger type |
| Per-step temperature | ✅ Works | Via `temperature` tag |
| Per-step max_tokens | ✅ Works | Via `max_tokens` tag |
| Fallback chains per skill | ✅ Works | `provider/model, provider/model, cheap` |
| LLM config restore after each step | ✅ Works | Runtime saves/restores global config |
---
## Current Limitations
### 1. No Direct Data Passing Between Chain Steps
**Problem:** Chain triggers fire with the *original* triggering event, not the output of the previous skill. Skill B doesn't automatically receive Skill A's output.
**Current workaround:** Use `memory_save` at the end of each step and `memory_recall` at the start of the next. This works but is fragile — memory is a shared scratchpad, not a structured pipeline bus.
**Potential improvement:** Extend the chain trigger event to include the previous skill's final LLM response text. In `trigger_manager_fire_chains()`, the chain event could carry a `"previous_output"` field that the next skill accesses via `{{triggering_event}}`.
### 2. No Conditional Branching
**Problem:** All chain skills matching a source d-tag fire unconditionally. You can't say "if triage classifies as X, run skill A; if Y, run skill B."
**Current workaround:** The chained skill can check the triggering event or memory and decide to do nothing if the condition doesn't match. But it still fires and consumes an LLM call.
**Potential improvement:** Add an optional `chain_condition` tag that the runtime evaluates before firing. Could be a simple JSON match against the previous output, or a keyword presence check.
### 3. No Parallel Fan-Out
**Problem:** Multiple chain skills matching the same source fire sequentially, not in parallel.
**Current workaround:** This is fine for most use cases. True parallelism would require thread-safe LLM config management.
### 4. Chain Depth Limit of 5
**Problem:** `s_chain_depth` in `trigger_manager_fire_chains()` caps at 5 levels to prevent runaway chains.
**Current workaround:** 5 steps is usually sufficient. For longer pipelines, the last step could use a tool to trigger a new chain externally.
**Potential improvement:** Make the depth limit configurable via genesis config.
### 5. Provider Credentials Are Global
**Problem:** The runtime has one set of API keys per provider. If Skill A uses `anthropic/claude-sonnet-4-20250514` and Skill B uses `openai/gpt-4o`, both providers must be configured in the agent's LLM config. There's no per-skill credential storage.
**Current workaround:** Configure all needed providers in the agent's genesis config or via `model_set` tool. The runtime already supports provider switching via the `provider` field in `llm_config_t`.
**Potential improvement:** None needed for most cases — agents typically have a small number of providers configured globally.
---
## Architecture: How It Works in Code
### Trigger Execution Flow
```
trigger_manager fires skill
├─ Save current llm_config (old_cfg)
├─ apply_trigger_runtime_to_llm_config(trigger, &next_cfg)
│ ├─ Parse llm tag: "anthropic/claude-sonnet-4-20250514"
│ │ ├─ Set cfg->provider = "anthropic"
│ │ └─ Set cfg->model = "claude-sonnet-4-20250514"
│ ├─ Apply max_tokens if present
│ └─ Apply temperature if present
├─ llm_set_config(&next_cfg)
├─ Execute skill (agent_on_trigger)
│ ├─ Build context from triggered skills
│ ├─ Call llm_chat_with_tools_messages()
│ └─ Tool loop until completion
├─ Restore llm_set_config(&old_cfg)
└─ trigger_manager_fire_chains(source_d_tag)
├─ Find chain skills where filter == source_d_tag
├─ For each matching chain skill:
│ ├─ Save config again
│ ├─ Apply chain skill's llm override
│ ├─ Execute chain skill
│ ├─ Restore config
│ └─ Recursively fire chains (depth < 5)
└─ Done
```
### Key Source Files
| File | Role |
|---|---|
| `src/trigger_manager.c` | Trigger matching, chain firing, LLM config override/restore |
| `src/agent.c` | `agent_on_trigger()` — builds context and runs LLM loop |
| `src/llm.c` | `llm_chat_with_tools_messages()` — actual LLM API call |
| `docs/SKILLS.md` | Skill spec including `llm` tag format and chain triggers |
---
## Future Enhancements (Not Yet Implemented)
### Priority 1: Chain Output Forwarding
Pass the previous skill's output to the next chain step via the triggering event:
```c
// In trigger_manager_fire_chains():
cJSON_AddStringToObject(event, "previous_output", last_response_text);
```
The chained skill would access this via `{{triggering_event}}` in its template, seeing:
```json
{
"type": "chain",
"source_d_tag": "triage",
"previous_output": "Classification: needs deep analysis. Key topics: ..."
}
```
### Priority 2: Conditional Chain Firing
Add an optional `chain_condition` tag:
```json
["chain_condition", "{\"previous_output_contains\":\"needs deep analysis\"}"]
```
The runtime would check this before firing the chain skill.
### Priority 3: Configurable Chain Depth
```json
// In genesis.jsonc:
"trigger_chain_max_depth": 10
```
---
## Summary
Multi-model skill pipelines work today using per-skill `llm` tags and `chain` triggers. The main gap is data flow between steps (currently requires memory_save/recall workaround). The system is designed for this use case — each skill execution gets its own model config applied and restored — it just needs better inter-step communication to be truly seamless.

View File

@@ -496,6 +496,162 @@ static int parse_dm_protocol_config(cJSON* root, didactyl_config_t* config) {
return 0; return 0;
} }
static int parse_llm_payload_object(cJSON* llm_obj, didactyl_config_t* config, int require_all_fields) {
if (!llm_obj || !cJSON_IsObject(llm_obj) || !config) {
return -1;
}
if (copy_json_string(llm_obj, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
return -1;
}
if (copy_json_string(llm_obj, "api_key", config->llm.api_key, sizeof(config->llm.api_key), require_all_fields) != 0) {
return -1;
}
if (copy_json_string(llm_obj, "model", config->llm.model, sizeof(config->llm.model), require_all_fields) != 0) {
return -1;
}
if (copy_json_string(llm_obj, "base_url", config->llm.base_url, sizeof(config->llm.base_url), require_all_fields) != 0) {
return -1;
}
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(llm_obj, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(llm_obj, "temperature");
if (max_tokens && cJSON_IsNumber(max_tokens)) {
config->llm.max_tokens = (int)max_tokens->valuedouble;
} else if (config->llm.max_tokens <= 0) {
config->llm.max_tokens = 512;
}
if (temperature && cJSON_IsNumber(temperature)) {
config->llm.temperature = temperature->valuedouble;
} else if (config->llm.temperature <= 0.0) {
config->llm.temperature = 0.7;
}
if (config->llm.provider[0] == '\0') {
snprintf(config->llm.provider, sizeof(config->llm.provider), "%s", "openai");
}
return 0;
}
static int parse_agent_payload_object(cJSON* agent_obj, didactyl_config_t* config) {
if (!agent_obj || !cJSON_IsObject(agent_obj) || !config) {
return -1;
}
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(agent_obj, "admin_pubkey");
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
char decoded[65] = {0};
if (decode_pubkey_hex_or_npub(admin_pubkey->valuestring, decoded) != 0) {
return -1;
}
snprintf(config->admin.pubkey, sizeof(config->admin.pubkey), "%s", decoded);
}
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(agent_obj, "dm_protocol");
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') {
if (strcmp(dm_protocol->valuestring, "nip04") == 0) {
config->dm_protocol = DM_PROTOCOL_NIP04;
} else if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
config->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
config->dm_protocol = DM_PROTOCOL_BOTH;
} else {
return -1;
}
}
return 0;
}
static int parse_user_settings_payload_object(cJSON* user_settings_obj, didactyl_config_t* config) {
if (!user_settings_obj || !cJSON_IsObject(user_settings_obj) || !config) {
return -1;
}
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(user_settings_obj, "global_llm");
if (!global_llm || !cJSON_IsObject(global_llm) ||
parse_llm_payload_object(global_llm, config, 1) != 0) {
return -1;
}
cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(user_settings_obj, "didactyl");
if (!didactyl || !cJSON_IsObject(didactyl) ||
parse_agent_payload_object(didactyl, config) != 0) {
return -1;
}
return 0;
}
static int parse_encrypted_events(cJSON* root, didactyl_config_t* config) {
cJSON* arr = cJSON_GetObjectItemCaseSensitive(root, "encrypted_events");
if (!arr) {
return 0;
}
if (!cJSON_IsArray(arr)) {
return -1;
}
int count = cJSON_GetArraySize(arr);
if (count <= 0) {
return 0;
}
config->encrypted_events = (encrypted_event_t*)calloc((size_t)count, sizeof(encrypted_event_t));
if (!config->encrypted_events) {
return -1;
}
config->encrypted_event_count = count;
int user_settings_seen = 0;
for (int i = 0; i < count; i++) {
cJSON* item = cJSON_GetArrayItem(arr, i);
if (!item || !cJSON_IsObject(item)) {
return -1;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(item, "kind");
cJSON* d_tag = cJSON_GetObjectItemCaseSensitive(item, "d_tag");
cJSON* content = cJSON_GetObjectItemCaseSensitive(item, "content");
if (!kind || !cJSON_IsNumber(kind) ||
!d_tag || !cJSON_IsString(d_tag) || !d_tag->valuestring || d_tag->valuestring[0] == '\0' ||
!content || !cJSON_IsString(content) || !content->valuestring) {
return -1;
}
encrypted_event_t* ee = &config->encrypted_events[i];
ee->kind = (int)kind->valuedouble;
ee->d_tag = strdup(d_tag->valuestring);
ee->content = strdup(content->valuestring);
if (!ee->d_tag || !ee->content) {
return -1;
}
if (ee->kind == 30078 && strcmp(ee->d_tag, "user-settings") == 0 && ee->content[0] != '\0') {
cJSON* user_settings_payload = cJSON_Parse(ee->content);
if (!user_settings_payload || !cJSON_IsObject(user_settings_payload) ||
parse_user_settings_payload_object(user_settings_payload, config) != 0) {
cJSON_Delete(user_settings_payload);
return -1;
}
cJSON_Delete(user_settings_payload);
user_settings_seen = 1;
}
}
if (!user_settings_seen) {
return -1;
}
return 0;
}
static int parse_api_config(cJSON* root, didactyl_config_t* config) { static int parse_api_config(cJSON* root, didactyl_config_t* config) {
cJSON* api = cJSON_GetObjectItemCaseSensitive(root, "api"); cJSON* api = cJSON_GetObjectItemCaseSensitive(root, "api");
if (!api || !cJSON_IsObject(api)) { if (!api || !cJSON_IsObject(api)) {
@@ -1244,6 +1400,14 @@ void config_free(didactyl_config_t* config) {
free_cashu_wallet_mints(&config->cashu_wallet); free_cashu_wallet_mints(&config->cashu_wallet);
if (config->encrypted_events) {
for (int i = 0; i < config->encrypted_event_count; i++) {
free(config->encrypted_events[i].d_tag);
free(config->encrypted_events[i].content);
}
free(config->encrypted_events);
}
memset(config, 0, sizeof(*config)); memset(config, 0, sizeof(*config));
} }
@@ -1351,9 +1515,8 @@ int config_load(const char* path, didactyl_config_t* config) {
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin"); cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm"); cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
if (!admin || !cJSON_IsObject(admin) || if (!admin || !cJSON_IsObject(admin)) {
!llm || !cJSON_IsObject(llm)) { config_set_error("config must include object section: admin");
config_set_error("config must include object sections: admin, llm");
goto cleanup; goto cleanup;
} }
@@ -1381,6 +1544,11 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup; goto cleanup;
} }
if (parse_encrypted_events(root, config) != 0) {
config_set_error("invalid encrypted_events configuration");
goto cleanup;
}
if (parse_legacy_default_skill_to_startup_events(root, config) != 0) { if (parse_legacy_default_skill_to_startup_events(root, config) != 0) {
config_set_error("invalid legacy default_skill configuration"); config_set_error("invalid legacy default_skill configuration");
goto cleanup; goto cleanup;
@@ -1391,32 +1559,16 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup; goto cleanup;
} }
if (copy_json_string(llm, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) { if (llm) {
config_set_error("llm.provider must be a string if provided"); if (!cJSON_IsObject(llm)) {
goto cleanup; config_set_error("llm must be an object when provided");
goto cleanup;
}
if (parse_llm_payload_object(llm, config, 1) != 0) {
config_set_error("llm configuration is invalid (required: api_key, model, base_url)");
goto cleanup;
}
} }
if (config->llm.provider[0] == '\0') {
strcpy(config->llm.provider, "openai");
}
if (copy_json_string(llm, "api_key", config->llm.api_key, sizeof(config->llm.api_key), 1) != 0) {
config_set_error("llm.api_key is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "model", config->llm.model, sizeof(config->llm.model), 1) != 0) {
config_set_error("llm.model is required and must be a string");
goto cleanup;
}
if (copy_json_string(llm, "base_url", config->llm.base_url, sizeof(config->llm.base_url), 1) != 0) {
config_set_error("llm.base_url is required and must be a string");
goto cleanup;
}
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(llm, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(llm, "temperature");
config->llm.max_tokens = (max_tokens && cJSON_IsNumber(max_tokens)) ? (int)max_tokens->valuedouble : 512;
config->llm.temperature = (temperature && cJSON_IsNumber(temperature)) ? temperature->valuedouble : 0.7;
if (parse_dm_protocol_config(root, config) != 0) { if (parse_dm_protocol_config(root, config) != 0) {
config_set_error("invalid dm_protocol configuration (expected 'nip04', 'nip17', or 'both')"); config_set_error("invalid dm_protocol configuration (expected 'nip04', 'nip17', or 'both')");

View File

@@ -64,6 +64,12 @@ typedef struct {
char* tags_json; // JSON array string for tags, optional char* tags_json; // JSON array string for tags, optional
} startup_event_t; } startup_event_t;
typedef struct {
int kind;
char* d_tag;
char* content;
} encrypted_event_t;
typedef struct { typedef struct {
int enabled; int enabled;
int tools_enabled; int tools_enabled;
@@ -124,6 +130,8 @@ typedef struct {
cashu_wallet_config_t cashu_wallet; cashu_wallet_config_t cashu_wallet;
startup_event_t* startup_events; startup_event_t* startup_events;
int startup_event_count; int startup_event_count;
encrypted_event_t* encrypted_events;
int encrypted_event_count;
char config_path[OW_MAX_URL_LEN]; char config_path[OW_MAX_URL_LEN];
} didactyl_config_t; } didactyl_config_t;

View File

@@ -726,7 +726,7 @@ static int fetch_self_config_plaintext(const didactyl_config_t* cfg, const char*
return 0; return 0;
} }
static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plaintext) { static int apply_recalled_user_settings(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1; if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext); cJSON* root = cJSON_Parse(plaintext);
@@ -735,16 +735,28 @@ static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plainte
return -1; return -1;
} }
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider"); cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key"); cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(root, "didactyl");
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model"); if (!global_llm || !cJSON_IsObject(global_llm) || !didactyl || !cJSON_IsObject(didactyl)) {
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url"); cJSON_Delete(root);
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens"); return -1;
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature"); }
cJSON* provider = cJSON_GetObjectItemCaseSensitive(global_llm, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(global_llm, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(global_llm, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(global_llm, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(global_llm, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(global_llm, "temperature");
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(didactyl, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(didactyl, "dm_protocol");
if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' || if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' ||
!model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' || !model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' ||
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0') { !base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0' ||
!admin_pubkey || !cJSON_IsString(admin_pubkey) || !admin_pubkey->valuestring || admin_pubkey->valuestring[0] == '\0' ||
!dm_protocol || !cJSON_IsString(dm_protocol) || !dm_protocol->valuestring || dm_protocol->valuestring[0] == '\0') {
cJSON_Delete(root); cJSON_Delete(root);
return -1; return -1;
} }
@@ -765,32 +777,16 @@ static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plainte
cfg->llm.temperature = temperature->valuedouble; cfg->llm.temperature = temperature->valuedouble;
} }
cJSON_Delete(root); {
return 0;
}
static int apply_recalled_agent_config(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(root, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
char decoded[65] = {0}; char decoded[65] = {0};
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) { if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) != 0) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded); cJSON_Delete(root);
return -1;
} }
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
} }
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') { dm_protocol_from_string(dm_protocol->valuestring, cfg);
dm_protocol_from_string(dm_protocol->valuestring, cfg);
}
cJSON_Delete(root); cJSON_Delete(root);
return 0; return 0;
@@ -844,39 +840,45 @@ static int publish_encrypted_self_config(const didactyl_config_t* cfg, const cha
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int persist_llm_config) { static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int persist_llm_config) {
if (!cfg) return -1; if (!cfg) return -1;
int llm_rc = 0; if (!persist_llm_config) {
if (persist_llm_config) { DEBUG_INFO("[didactyl] startup phase: skipping user-settings publish because runtime config was recalled from Nostr");
cJSON* llm = cJSON_CreateObject(); return 0;
if (!llm) return -1;
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
free(llm_json);
} else {
DEBUG_INFO("[didactyl] startup phase: skipping llm_config publish because it was recalled from Nostr");
} }
cJSON* agent = cJSON_CreateObject(); cJSON* user_settings = cJSON_CreateObject();
if (!agent) return llm_rc; cJSON* global_llm = cJSON_CreateObject();
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey); cJSON* didactyl = cJSON_CreateObject();
cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string(cfg->dm_protocol)); if (!user_settings || !global_llm || !didactyl) {
char* agent_json = cJSON_PrintUnformatted(agent); cJSON_Delete(user_settings);
cJSON_Delete(agent); cJSON_Delete(global_llm);
if (!agent_json) return llm_rc; cJSON_Delete(didactyl);
return -1;
}
int agent_rc = publish_encrypted_self_config(cfg, "agent_config", agent_json); cJSON_AddNumberToObject(user_settings, "v", 2);
free(agent_json); cJSON_AddNumberToObject(user_settings, "updatedAt", (double)time(NULL));
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1; cJSON_AddStringToObject(global_llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(global_llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(global_llm, "model", cfg->llm.model);
cJSON_AddStringToObject(global_llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(global_llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(global_llm, "temperature", cfg->llm.temperature);
cJSON_AddItemToObject(user_settings, "global_llm", global_llm);
cJSON_AddStringToObject(didactyl, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(didactyl, "dm_protocol", dm_protocol_to_string(cfg->dm_protocol));
cJSON_AddItemToObject(user_settings, "didactyl", didactyl);
char* user_settings_json = cJSON_PrintUnformatted(user_settings);
cJSON_Delete(user_settings);
if (!user_settings_json) {
return -1;
}
int rc = publish_encrypted_self_config(cfg, "user-settings", user_settings_json);
free(user_settings_json);
return rc;
} }
static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) { static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
@@ -884,45 +886,22 @@ static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
return 0; return 0;
} }
int llm_recalled_from_nostr = 0; char* settings_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "user-settings", &settings_plaintext) != 0 || !settings_plaintext) {
if (!llm_config_is_complete(&cfg->llm)) { DEBUG_WARN("[didactyl] startup phase: user-settings recall unavailable");
char* llm_plaintext = NULL; free(settings_plaintext);
if (fetch_self_config_plaintext(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) { return 0;
if (apply_recalled_llm_config(cfg, llm_plaintext) == 0) {
llm_recalled_from_nostr = 1;
DEBUG_INFO("[didactyl] startup phase: recovered llm_config from Nostr");
} else {
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled llm_config");
}
} else {
DEBUG_WARN("[didactyl] startup phase: llm_config recall unavailable");
}
free(llm_plaintext);
} }
char* agent_plaintext = NULL; if (apply_recalled_user_settings(cfg, settings_plaintext) != 0) {
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) { DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled user-settings");
didactyl_config_t recalled = *cfg; free(settings_plaintext);
if (apply_recalled_agent_config(&recalled, agent_plaintext) == 0) { return 0;
if (!admin_config_is_complete(cfg)) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", recalled.admin.pubkey);
cfg->dm_protocol = recalled.dm_protocol;
DEBUG_INFO("[didactyl] startup phase: recovered agent_config from Nostr");
} else if (recalled.admin.pubkey[0] != '\0' && strcmp(cfg->admin.pubkey, recalled.admin.pubkey) != 0) {
DEBUG_WARN("[didactyl] startup phase: agent_config admin pubkey differs from loaded config (loaded=%.16s..., nostr=%.16s...); loaded config wins",
cfg->admin.pubkey,
recalled.admin.pubkey);
}
} else {
DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled agent_config");
}
} else {
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
} }
free(agent_plaintext);
return llm_recalled_from_nostr; free(settings_plaintext);
DEBUG_INFO("[didactyl] startup phase: recovered user-settings from Nostr");
return 1;
} }
int main(int argc, char** argv) { int main(int argc, char** argv) {
@@ -1223,13 +1202,13 @@ int main(int argc, char** argv) {
startup_step_begin(2, "Recover runtime config from Nostr"); startup_step_begin(2, "Recover runtime config from Nostr");
int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg); int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg);
startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr ? "llm_config recalled" : NULL); startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr ? "user-settings recalled" : NULL);
startup_step_begin(3, "Validate LLM config"); startup_step_begin(3, "Validate LLM config");
if (!llm_config_is_complete(&cfg.llm)) { if (!llm_config_is_complete(&cfg.llm)) {
startup_step_fail(3, "Validate LLM config", "missing llm_config (model/base_url/api_key)"); startup_step_fail(3, "Validate LLM config", "missing user-settings.global_llm (model/base_url/api_key)");
fprintf(stderr, fprintf(stderr,
"Missing LLM config: provide genesis file values or store d=llm_config via config_store\n"); "Missing LLM config: provide genesis file values or store d=user-settings with global_llm\n");
nostr_block_list_cleanup(); nostr_block_list_cleanup();
nostr_handler_cleanup(); nostr_handler_cleanup();
config_free(&cfg); config_free(&cfg);
@@ -1242,7 +1221,7 @@ int main(int argc, char** argv) {
if (!admin_config_is_complete(&cfg)) { if (!admin_config_is_complete(&cfg)) {
startup_step_fail(4, "Validate admin config", "missing admin pubkey"); startup_step_fail(4, "Validate admin config", "missing admin pubkey");
fprintf(stderr, fprintf(stderr,
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n"); "Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=user-settings with didactyl.admin_pubkey\n");
nostr_block_list_cleanup(); nostr_block_list_cleanup();
nostr_handler_cleanup(); nostr_handler_cleanup();
config_free(&cfg); config_free(&cfg);
@@ -1284,10 +1263,14 @@ int main(int argc, char** argv) {
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run"); DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run");
} }
if (persist_runtime_config_to_nostr(&cfg, llm_recalled_from_nostr ? 0 : 1) != 0) { if (bootstrap_mode || first_run) {
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr"); if (persist_runtime_config_to_nostr(&cfg, llm_recalled_from_nostr ? 0 : 1) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
} else {
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
}
} else { } else {
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr"); DEBUG_INFO("[didactyl] startup phase: skipping encrypted runtime config publish on subsequent run");
} }
startup_step_ok(7, "Reconcile/persist startup state", NULL); startup_step_ok(7, "Reconcile/persist startup state", NULL);

View File

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

View File

@@ -1028,6 +1028,287 @@ static const char* find_tag_value_local(cJSON* tags, const char* key) {
return NULL; return NULL;
} }
static int nip44_encrypt_self_local(const char* plaintext, char** out_ciphertext) {
if (!g_cfg || !out_ciphertext) {
return -1;
}
const char* plain = plaintext ? plaintext : "";
size_t out_cap = (strlen(plain) * 4U) + 1024U;
char* ciphertext = (char*)malloc(out_cap);
if (!ciphertext) {
return -1;
}
int rc = nostr_nip44_encrypt(g_cfg->keys.private_key,
g_cfg->keys.public_key,
plain,
ciphertext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(ciphertext);
return -1;
}
*out_ciphertext = ciphertext;
return 0;
}
static int nip44_decrypt_self_local(const char* ciphertext, char** out_plaintext) {
if (!g_cfg || !ciphertext || !out_plaintext) {
return -1;
}
size_t out_cap = strlen(ciphertext) + 1024U;
char* plaintext = (char*)malloc(out_cap);
if (!plaintext) {
return -1;
}
int rc = nostr_nip44_decrypt(g_cfg->keys.private_key,
g_cfg->keys.public_key,
ciphertext,
plaintext,
out_cap);
if (rc != NOSTR_SUCCESS) {
free(plaintext);
return -1;
}
*out_plaintext = plaintext;
return 0;
}
static cJSON* duplicate_public_d_tags_local(cJSON* tags) {
cJSON* out = cJSON_CreateArray();
if (!out) {
return NULL;
}
if (!tags || !cJSON_IsArray(tags)) {
return out;
}
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* key = cJSON_GetArrayItem(tag, 0);
if (!key || !cJSON_IsString(key) || !key->valuestring || strcmp(key->valuestring, "d") != 0) {
continue;
}
cJSON* dup = cJSON_Duplicate(tag, 1);
if (!dup) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToArray(out, dup);
}
return out;
}
static cJSON* duplicate_private_non_d_tags_local(cJSON* tags) {
cJSON* out = cJSON_CreateArray();
if (!out) {
return NULL;
}
if (!tags || !cJSON_IsArray(tags)) {
return out;
}
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* key = cJSON_GetArrayItem(tag, 0);
if (!key || !cJSON_IsString(key) || !key->valuestring || strcmp(key->valuestring, "d") == 0) {
continue;
}
cJSON* dup = cJSON_Duplicate(tag, 1);
if (!dup) {
cJSON_Delete(out);
return NULL;
}
cJSON_AddItemToArray(out, dup);
}
return out;
}
static int encode_private_skill_event_payload_local(const char* content,
cJSON* tags,
char** out_encrypted_content,
cJSON** out_public_tags) {
if (!out_encrypted_content || !out_public_tags) {
return -1;
}
*out_encrypted_content = NULL;
*out_public_tags = NULL;
cJSON* payload = cJSON_CreateObject();
cJSON* private_tags = duplicate_private_non_d_tags_local(tags);
cJSON* public_tags = duplicate_public_d_tags_local(tags);
if (!payload || !private_tags || !public_tags) {
cJSON_Delete(payload);
cJSON_Delete(private_tags);
cJSON_Delete(public_tags);
return -1;
}
cJSON_AddStringToObject(payload, "content", content ? content : "");
cJSON_AddItemToObject(payload, "private_tags", private_tags);
char* payload_json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (!payload_json) {
cJSON_Delete(public_tags);
return -1;
}
char* encrypted_content = NULL;
int rc = nip44_encrypt_self_local(payload_json, &encrypted_content);
free(payload_json);
if (rc != 0) {
cJSON_Delete(public_tags);
return -1;
}
*out_encrypted_content = encrypted_content;
*out_public_tags = public_tags;
return 0;
}
static int tag_tuple_exists_local(cJSON* tags, const char* key, const char* value) {
if (!tags || !cJSON_IsArray(tags) || !key || !value) {
return 0;
}
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* ex_key = cJSON_GetArrayItem(tag, 0);
cJSON* ex_value = cJSON_GetArrayItem(tag, 1);
if (!ex_key || !ex_value ||
!cJSON_IsString(ex_key) || !ex_key->valuestring ||
!cJSON_IsString(ex_value) || !ex_value->valuestring) {
continue;
}
if (strcmp(ex_key->valuestring, key) == 0 && strcmp(ex_value->valuestring, value) == 0) {
return 1;
}
}
return 0;
}
static int merge_private_tags_into_event_local(cJSON* event, cJSON* private_tags) {
if (!event || !cJSON_IsObject(event) || !private_tags || !cJSON_IsArray(private_tags)) {
return -1;
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
tags = cJSON_CreateArray();
if (!tags) {
return -1;
}
cJSON_AddItemToObject(event, "tags", tags);
}
int n = cJSON_GetArraySize(private_tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(private_tags, i);
if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (!key || !value ||
!cJSON_IsString(key) || !key->valuestring ||
!cJSON_IsString(value) || !value->valuestring) {
continue;
}
if (strcmp(key->valuestring, "d") == 0) {
continue;
}
if (tag_tuple_exists_local(tags, key->valuestring, value->valuestring)) {
continue;
}
cJSON* dup = cJSON_Duplicate(tag, 1);
if (!dup) {
return -1;
}
cJSON_AddItemToArray(tags, dup);
}
return 0;
}
static int normalize_private_skill_event_local(cJSON* event) {
if (!event || !cJSON_IsObject(event)) {
return -1;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!kind || !cJSON_IsNumber(kind) || (int)kind->valuedouble != 31124 ||
!content || !cJSON_IsString(content) || !content->valuestring) {
return 0;
}
char* plaintext = NULL;
if (nip44_decrypt_self_local(content->valuestring, &plaintext) != 0) {
return 0;
}
cJSON* payload = cJSON_Parse(plaintext);
if (!payload || !cJSON_IsObject(payload)) {
cJSON_Delete(payload);
free(plaintext);
return 0;
}
cJSON* payload_content = cJSON_GetObjectItemCaseSensitive(payload, "content");
cJSON* private_tags = cJSON_GetObjectItemCaseSensitive(payload, "private_tags");
if (!payload_content || !cJSON_IsString(payload_content) || !payload_content->valuestring ||
!private_tags || !cJSON_IsArray(private_tags)) {
cJSON_Delete(payload);
free(plaintext);
return 0;
}
cJSON* content_item = cJSON_CreateString(payload_content->valuestring);
if (content_item) {
cJSON_ReplaceItemInObject(event, "content", content_item);
}
(void)merge_private_tags_into_event_local(event, private_tags);
cJSON_Delete(payload);
free(plaintext);
return 0;
}
static int parse_enabled_flag_local(const char* enabled_s) { static int parse_enabled_flag_local(const char* enabled_s) {
if (!enabled_s || enabled_s[0] == '\0') { if (!enabled_s || enabled_s[0] == '\0') {
return 1; return 1;
@@ -2142,10 +2423,14 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_
} }
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind"); cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
int kind_val = (kind && cJSON_IsNumber(kind)) ? (int)kind->valuedouble : -1;
if (kind_val == 31124) {
(void)normalize_private_skill_event_local(event);
}
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id"); cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at"); 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; 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", DEBUG_INFO("[didactyl] self-skill cache received kind=%d d_tag=%s id=%s created_at=%lld via %s",
@@ -2921,19 +3206,36 @@ static int publish_kind_event_to_relays(int kind,
return -1; return -1;
} }
const char* content_to_publish = content;
cJSON* tags_to_publish = tags;
char* encrypted_content = NULL;
cJSON* public_tags = NULL;
if (kind == 31124) {
if (encode_private_skill_event_payload_local(content, tags, &encrypted_content, &public_tags) != 0) {
return -1;
}
content_to_publish = encrypted_content;
tags_to_publish = public_tags;
}
cJSON* tags_copy = NULL; cJSON* tags_copy = NULL;
if (tags) { if (tags_to_publish) {
tags_copy = cJSON_Duplicate(tags, 1); tags_copy = cJSON_Duplicate(tags_to_publish, 1);
if (!tags_copy) { if (!tags_copy) {
free(encrypted_content);
cJSON_Delete(public_tags);
return -1; return -1;
} }
} }
cJSON* event = nostr_create_and_sign_event(kind, content, tags_copy, g_cfg->keys.private_key, time(NULL)); cJSON* event = nostr_create_and_sign_event(kind, content_to_publish, tags_copy, g_cfg->keys.private_key, time(NULL));
if (tags_copy) { if (tags_copy) {
cJSON_Delete(tags_copy); cJSON_Delete(tags_copy);
} }
if (!event) { if (!event) {
free(encrypted_content);
cJSON_Delete(public_tags);
return -1; return -1;
} }
@@ -2966,12 +3268,17 @@ static int publish_kind_event_to_relays(int kind,
sent); sent);
if (sent > 0) { if (sent > 0) {
if (kind == 31124) {
(void)normalize_private_skill_event_local(event);
}
pthread_mutex_lock(&g_self_skill_mutex); pthread_mutex_lock(&g_self_skill_mutex);
self_skill_cache_upsert_event_locked(event); self_skill_cache_upsert_event_locked(event);
pthread_mutex_unlock(&g_self_skill_mutex); pthread_mutex_unlock(&g_self_skill_mutex);
} }
cJSON_Delete(event); cJSON_Delete(event);
free(encrypted_content);
cJSON_Delete(public_tags);
return sent > 0 ? 0 : -1; return sent > 0 ? 0 : -1;
} }

View File

@@ -981,7 +981,7 @@ static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, co
return 0; return 0;
} }
static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char* plaintext) { static int apply_recalled_user_settings_wizard(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1; if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext); cJSON* root = cJSON_Parse(plaintext);
@@ -990,16 +990,28 @@ static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char*
return -1; return -1;
} }
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider"); cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key"); cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(root, "didactyl");
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model"); if (!global_llm || !cJSON_IsObject(global_llm) || !didactyl || !cJSON_IsObject(didactyl)) {
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url"); cJSON_Delete(root);
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens"); return -1;
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature"); }
cJSON* provider = cJSON_GetObjectItemCaseSensitive(global_llm, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(global_llm, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(global_llm, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(global_llm, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(global_llm, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(global_llm, "temperature");
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(didactyl, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(didactyl, "dm_protocol");
if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' || if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' ||
!model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' || !model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' ||
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0') { !base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0' ||
!admin_pubkey || !cJSON_IsString(admin_pubkey) || !admin_pubkey->valuestring || admin_pubkey->valuestring[0] == '\0' ||
!dm_protocol || !cJSON_IsString(dm_protocol) || !dm_protocol->valuestring || dm_protocol->valuestring[0] == '\0') {
cJSON_Delete(root); cJSON_Delete(root);
return -1; return -1;
} }
@@ -1020,37 +1032,21 @@ static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char*
cfg->llm.temperature = temperature->valuedouble; cfg->llm.temperature = temperature->valuedouble;
} }
cJSON_Delete(root); {
return 0;
}
static int apply_recalled_agent_config_wizard(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(root, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
char decoded[65] = {0}; char decoded[65] = {0};
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) { if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) != 0) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded); cJSON_Delete(root);
return -1;
} }
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
} }
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') { if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
if (strcmp(dm_protocol->valuestring, "nip17") == 0) { cfg->dm_protocol = DM_PROTOCOL_NIP17;
cfg->dm_protocol = DM_PROTOCOL_NIP17; } else if (strcmp(dm_protocol->valuestring, "both") == 0) {
} else if (strcmp(dm_protocol->valuestring, "both") == 0) { cfg->dm_protocol = DM_PROTOCOL_BOTH;
cfg->dm_protocol = DM_PROTOCOL_BOTH; } else {
} else { cfg->dm_protocol = DM_PROTOCOL_NIP04;
cfg->dm_protocol = DM_PROTOCOL_NIP04;
}
} }
cJSON_Delete(root); cJSON_Delete(root);
@@ -1077,17 +1073,11 @@ static int recover_full_config_from_nostr(didactyl_config_t* cfg,
(void)query_self_kind0_name(cfg, out_agent_name, out_agent_name_size, out_agent_name_found); (void)query_self_kind0_name(cfg, out_agent_name, out_agent_name_size, out_agent_name_found);
char* llm_plaintext = NULL; char* settings_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) { if (fetch_and_decrypt_self_config_wizard(cfg, "user-settings", &settings_plaintext) == 0 && settings_plaintext) {
(void)apply_recalled_llm_config_wizard(cfg, llm_plaintext); (void)apply_recalled_user_settings_wizard(cfg, settings_plaintext);
} }
free(llm_plaintext); free(settings_plaintext);
char* agent_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
(void)apply_recalled_agent_config_wizard(cfg, agent_plaintext);
}
free(agent_plaintext);
nostr_handler_cleanup(); nostr_handler_cleanup();
return 0; return 0;
@@ -1153,34 +1143,38 @@ static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg,
static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) { static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) {
if (!cfg) return -1; if (!cfg) return -1;
cJSON* llm = cJSON_CreateObject(); cJSON* user_settings = cJSON_CreateObject();
if (!llm) return -1; cJSON* global_llm = cJSON_CreateObject();
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider); cJSON* didactyl = cJSON_CreateObject();
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key); if (!user_settings || !global_llm || !didactyl) {
cJSON_AddStringToObject(llm, "model", cfg->llm.model); cJSON_Delete(user_settings);
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url); cJSON_Delete(global_llm);
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens); cJSON_Delete(didactyl);
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature); return -1;
}
char* llm_json = cJSON_PrintUnformatted(llm); cJSON_AddNumberToObject(user_settings, "v", 2);
cJSON_Delete(llm); cJSON_AddNumberToObject(user_settings, "updatedAt", (double)time(NULL));
if (!llm_json) return -1;
int llm_rc = publish_encrypted_self_config_wizard(cfg, "llm_config", llm_json); cJSON_AddStringToObject(global_llm, "provider", cfg->llm.provider);
free(llm_json); cJSON_AddStringToObject(global_llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(global_llm, "model", cfg->llm.model);
cJSON_AddStringToObject(global_llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(global_llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(global_llm, "temperature", cfg->llm.temperature);
cJSON_AddItemToObject(user_settings, "global_llm", global_llm);
cJSON* agent = cJSON_CreateObject(); cJSON_AddStringToObject(didactyl, "admin_pubkey", cfg->admin.pubkey);
if (!agent) return llm_rc; cJSON_AddStringToObject(didactyl, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol));
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey); cJSON_AddItemToObject(user_settings, "didactyl", didactyl);
cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol));
char* agent_json = cJSON_PrintUnformatted(agent);
cJSON_Delete(agent);
if (!agent_json) return llm_rc;
int agent_rc = publish_encrypted_self_config_wizard(cfg, "agent_config", agent_json); char* settings_json = cJSON_PrintUnformatted(user_settings);
free(agent_json); cJSON_Delete(user_settings);
if (!settings_json) return -1;
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1; int rc = publish_encrypted_self_config_wizard(cfg, "user-settings", settings_json);
free(settings_json);
return rc;
} }
static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg) { static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg) {

View File

@@ -5,6 +5,7 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <time.h>
#include "cjson/cJSON.h" #include "cjson/cJSON.h"
#include "../config.h" #include "../config.h"
@@ -30,7 +31,9 @@ static cJSON* parse_args_local(const char* args_json) {
return args; return args;
} }
static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cfg, char** out_error) { static int persist_global_llm_user_settings_nostr(tools_context_t* ctx,
const llm_config_t* cfg,
char** out_error) {
if (!ctx || !ctx->cfg || !cfg) { if (!ctx || !ctx->cfg || !cfg) {
if (out_error) *out_error = strdup("invalid persist context"); if (out_error) *out_error = strdup("invalid persist context");
return -1; return -1;
@@ -38,28 +41,115 @@ static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cf
if (out_error) *out_error = NULL; if (out_error) *out_error = NULL;
cJSON* args = cJSON_CreateObject(); cJSON* recall_args = cJSON_CreateObject();
cJSON* content = cJSON_CreateObject(); if (!recall_args) {
if (!args || !content) { if (out_error) *out_error = strdup("allocation failure while building config_recall args");
cJSON_Delete(args); return -1;
cJSON_Delete(content); }
if (out_error) *out_error = strdup("allocation failure while building llm_config payload"); cJSON_AddStringToObject(recall_args, "d_tag", "user-settings");
char* recall_args_json = cJSON_PrintUnformatted(recall_args);
cJSON_Delete(recall_args);
if (!recall_args_json) {
if (out_error) *out_error = strdup("failed to serialize config_recall args");
return -1; return -1;
} }
cJSON_AddStringToObject(args, "d_tag", "llm_config"); char* recall_result = execute_config_recall(ctx, recall_args_json);
cJSON_AddStringToObject(content, "provider", cfg->provider); free(recall_args_json);
cJSON_AddStringToObject(content, "api_key", cfg->api_key); if (!recall_result) {
cJSON_AddStringToObject(content, "model", cfg->model); if (out_error) *out_error = strdup("config_recall returned no response");
cJSON_AddStringToObject(content, "base_url", cfg->base_url); return -1;
cJSON_AddNumberToObject(content, "max_tokens", cfg->max_tokens); }
cJSON_AddNumberToObject(content, "temperature", cfg->temperature);
cJSON_AddItemToObject(args, "content", content);
char* store_args_json = cJSON_PrintUnformatted(args); cJSON* recall_root = cJSON_Parse(recall_result);
cJSON_Delete(args); free(recall_result);
if (!recall_root || !cJSON_IsObject(recall_root)) {
cJSON_Delete(recall_root);
if (out_error) *out_error = strdup("config_recall returned invalid JSON");
return -1;
}
cJSON* recall_success = cJSON_GetObjectItemCaseSensitive(recall_root, "success");
if (!recall_success || !cJSON_IsBool(recall_success) || !cJSON_IsTrue(recall_success)) {
cJSON* err = cJSON_GetObjectItemCaseSensitive(recall_root, "error");
if (out_error) {
if (err && cJSON_IsString(err) && err->valuestring) {
*out_error = strdup(err->valuestring);
} else {
*out_error = strdup("config_recall failed");
}
}
cJSON_Delete(recall_root);
return -1;
}
cJSON* found_j = cJSON_GetObjectItemCaseSensitive(recall_root, "found");
int found = (found_j && cJSON_IsBool(found_j) && cJSON_IsTrue(found_j)) ? 1 : 0;
cJSON* content_json = cJSON_GetObjectItemCaseSensitive(recall_root, "content_json");
cJSON* user_settings = NULL;
if (found && content_json && cJSON_IsObject(content_json)) {
user_settings = cJSON_Duplicate(content_json, 1);
}
if (!user_settings) {
user_settings = cJSON_CreateObject();
}
cJSON_Delete(recall_root);
if (!user_settings) {
if (out_error) *out_error = strdup("allocation failure while preparing user-settings payload");
return -1;
}
cJSON_DeleteItemFromObjectCaseSensitive(user_settings, "v");
cJSON_AddNumberToObject(user_settings, "v", 2);
cJSON_DeleteItemFromObjectCaseSensitive(user_settings, "updatedAt");
cJSON_AddNumberToObject(user_settings, "updatedAt", (double)time(NULL));
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(user_settings, "global_llm");
if (global_llm && !cJSON_IsObject(global_llm)) {
cJSON_DeleteItemFromObjectCaseSensitive(user_settings, "global_llm");
global_llm = NULL;
}
if (!global_llm) {
cJSON* new_global_llm = cJSON_CreateObject();
if (!new_global_llm) {
cJSON_Delete(user_settings);
if (out_error) *out_error = strdup("allocation failure while preparing global_llm payload");
return -1;
}
cJSON_AddItemToObject(user_settings, "global_llm", new_global_llm);
global_llm = new_global_llm;
}
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "provider");
cJSON_AddStringToObject(global_llm, "provider", cfg->provider);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "api_key");
cJSON_AddStringToObject(global_llm, "api_key", cfg->api_key);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "model");
cJSON_AddStringToObject(global_llm, "model", cfg->model);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "base_url");
cJSON_AddStringToObject(global_llm, "base_url", cfg->base_url);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "max_tokens");
cJSON_AddNumberToObject(global_llm, "max_tokens", cfg->max_tokens);
cJSON_DeleteItemFromObjectCaseSensitive(global_llm, "temperature");
cJSON_AddNumberToObject(global_llm, "temperature", cfg->temperature);
cJSON* store_args = cJSON_CreateObject();
if (!store_args) {
cJSON_Delete(user_settings);
if (out_error) *out_error = strdup("allocation failure while building config_store args");
return -1;
}
cJSON_AddStringToObject(store_args, "d_tag", "user-settings");
cJSON_AddItemToObject(store_args, "content", user_settings);
char* store_args_json = cJSON_PrintUnformatted(store_args);
cJSON_Delete(store_args);
if (!store_args_json) { if (!store_args_json) {
if (out_error) *out_error = strdup("failed to serialize llm_config payload"); if (out_error) *out_error = strdup("failed to serialize user-settings payload");
return -1; return -1;
} }
@@ -70,17 +160,17 @@ static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cf
return -1; return -1;
} }
cJSON* root = cJSON_Parse(store_result); cJSON* store_root = cJSON_Parse(store_result);
if (!root || !cJSON_IsObject(root)) { free(store_result);
cJSON_Delete(root); if (!store_root || !cJSON_IsObject(store_root)) {
cJSON_Delete(store_root);
if (out_error) *out_error = strdup("config_store returned invalid JSON"); if (out_error) *out_error = strdup("config_store returned invalid JSON");
free(store_result);
return -1; return -1;
} }
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success"); cJSON* store_success = cJSON_GetObjectItemCaseSensitive(store_root, "success");
if (!success || !cJSON_IsBool(success) || !cJSON_IsTrue(success)) { if (!store_success || !cJSON_IsBool(store_success) || !cJSON_IsTrue(store_success)) {
cJSON* err = cJSON_GetObjectItemCaseSensitive(root, "error"); cJSON* err = cJSON_GetObjectItemCaseSensitive(store_root, "error");
if (out_error) { if (out_error) {
if (err && cJSON_IsString(err) && err->valuestring) { if (err && cJSON_IsString(err) && err->valuestring) {
*out_error = strdup(err->valuestring); *out_error = strdup(err->valuestring);
@@ -88,13 +178,11 @@ static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cf
*out_error = strdup("config_store failed"); *out_error = strdup("config_store failed");
} }
} }
cJSON_Delete(root); cJSON_Delete(store_root);
free(store_result);
return -1; return -1;
} }
cJSON_Delete(root); cJSON_Delete(store_root);
free(store_result);
return 0; return 0;
} }
@@ -216,7 +304,7 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) {
ctx->cfg->llm = cfg; ctx->cfg->llm = cfg;
char* persist_error = NULL; char* persist_error = NULL;
int persisted = (persist_llm_config_nostr(ctx, &cfg, &persist_error) == 0) ? 1 : 0; int persisted = (persist_global_llm_user_settings_nostr(ctx, &cfg, &persist_error) == 0) ? 1 : 0;
cJSON* out = cJSON_CreateObject(); cJSON* out = cJSON_CreateObject();
if (!out) return NULL; if (!out) return NULL;