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.
## Current Status — v0.2.24
## Current Status — v0.2.25
**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
- 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`)
- `admin.pubkey`
- `llm`
- `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:
@@ -96,17 +96,39 @@ Security behavior:
## 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`
- `config_recall` — query+decrypt kind `30078` config by `d_tag`
1. At bootstrap time via `genesis.jsonc` `encrypted_events` entries
2. At runtime via encrypted kind `30078` self-events (`config_store` / `config_recall`)
Recommended tags:
`encrypted_events` format:
- `d=llm_config`
- `d=agent_config`
- `kind` (currently `30078`)
- `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.
`encrypted_events` are a separate section and are not part of relay-list derivation.
---
## Migration Notes (v0.2.0)

View File

@@ -18,16 +18,15 @@
// Supported values: "nip04", "nip17", or "both".
"dm_protocol": "nip04",
// ─── LLM Provider ──────────────────────────────────────────────────
// OpenAI-compatible endpoint settings.
"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
},
// ─── Encrypted Startup Config Events ───────────────────────────────
// These are published as NIP-44 encrypted kind 30078 self-events on first run.
"encrypted_events": [
{
"kind": 30078,
"d_tag": "user-settings",
"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\"}}"
}
],
// ─── HTTP Admin API ────────────────────────────────────────────────
"api": {
@@ -60,7 +59,6 @@
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net"]
]
}
},
{
"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;
}
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) {
cJSON* api = cJSON_GetObjectItemCaseSensitive(root, "api");
if (!api || !cJSON_IsObject(api)) {
@@ -1244,6 +1400,14 @@ void config_free(didactyl_config_t* config) {
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));
}
@@ -1351,9 +1515,8 @@ int config_load(const char* path, didactyl_config_t* config) {
cJSON* admin = cJSON_GetObjectItemCaseSensitive(root, "admin");
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
if (!admin || !cJSON_IsObject(admin) ||
!llm || !cJSON_IsObject(llm)) {
config_set_error("config must include object sections: admin, llm");
if (!admin || !cJSON_IsObject(admin)) {
config_set_error("config must include object section: admin");
goto cleanup;
}
@@ -1381,6 +1544,11 @@ int config_load(const char* path, didactyl_config_t* config) {
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) {
config_set_error("invalid legacy default_skill configuration");
goto cleanup;
@@ -1391,32 +1559,16 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (copy_json_string(llm, "provider", config->llm.provider, sizeof(config->llm.provider), 0) != 0) {
config_set_error("llm.provider must be a string if provided");
goto cleanup;
if (llm) {
if (!cJSON_IsObject(llm)) {
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) {
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
} startup_event_t;
typedef struct {
int kind;
char* d_tag;
char* content;
} encrypted_event_t;
typedef struct {
int enabled;
int tools_enabled;
@@ -124,6 +130,8 @@ typedef struct {
cashu_wallet_config_t cashu_wallet;
startup_event_t* startup_events;
int startup_event_count;
encrypted_event_t* encrypted_events;
int encrypted_event_count;
char config_path[OW_MAX_URL_LEN];
} didactyl_config_t;

View File

@@ -726,7 +726,7 @@ static int fetch_self_config_plaintext(const didactyl_config_t* cfg, const char*
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;
cJSON* root = cJSON_Parse(plaintext);
@@ -735,16 +735,28 @@ static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plainte
return -1;
}
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature");
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(root, "didactyl");
if (!global_llm || !cJSON_IsObject(global_llm) || !didactyl || !cJSON_IsObject(didactyl)) {
cJSON_Delete(root);
return -1;
}
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' ||
!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);
return -1;
}
@@ -765,32 +777,16 @@ static int apply_recalled_llm_config(didactyl_config_t* cfg, const char* plainte
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};
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) != 0) {
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);
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) {
if (!cfg) return -1;
int llm_rc = 0;
if (persist_llm_config) {
cJSON* llm = cJSON_CreateObject();
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");
if (!persist_llm_config) {
DEBUG_INFO("[didactyl] startup phase: skipping user-settings publish because runtime config was recalled from Nostr");
return 0;
}
cJSON* agent = cJSON_CreateObject();
if (!agent) return llm_rc;
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string(cfg->dm_protocol));
char* agent_json = cJSON_PrintUnformatted(agent);
cJSON_Delete(agent);
if (!agent_json) return llm_rc;
cJSON* user_settings = cJSON_CreateObject();
cJSON* global_llm = cJSON_CreateObject();
cJSON* didactyl = cJSON_CreateObject();
if (!user_settings || !global_llm || !didactyl) {
cJSON_Delete(user_settings);
cJSON_Delete(global_llm);
cJSON_Delete(didactyl);
return -1;
}
int agent_rc = publish_encrypted_self_config(cfg, "agent_config", agent_json);
free(agent_json);
cJSON_AddNumberToObject(user_settings, "v", 2);
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) {
@@ -884,45 +886,22 @@ static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
return 0;
}
int llm_recalled_from_nostr = 0;
if (!llm_config_is_complete(&cfg->llm)) {
char* llm_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
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* settings_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "user-settings", &settings_plaintext) != 0 || !settings_plaintext) {
DEBUG_WARN("[didactyl] startup phase: user-settings recall unavailable");
free(settings_plaintext);
return 0;
}
char* agent_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
didactyl_config_t recalled = *cfg;
if (apply_recalled_agent_config(&recalled, agent_plaintext) == 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");
if (apply_recalled_user_settings(cfg, settings_plaintext) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled user-settings");
free(settings_plaintext);
return 0;
}
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) {
@@ -1223,13 +1202,13 @@ int main(int argc, char** argv) {
startup_step_begin(2, "Recover runtime config from Nostr");
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");
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,
"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_handler_cleanup();
config_free(&cfg);
@@ -1242,7 +1221,7 @@ int main(int argc, char** argv) {
if (!admin_config_is_complete(&cfg)) {
startup_step_fail(4, "Validate admin config", "missing admin pubkey");
fprintf(stderr,
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n");
"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_handler_cleanup();
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");
}
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");
if (bootstrap_mode || first_run) {
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 {
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);

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 24
#define DIDACTYL_VERSION "v0.2.24"
#define DIDACTYL_VERSION_PATCH 25
#define DIDACTYL_VERSION "v0.2.25"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -1028,6 +1028,287 @@ static const char* find_tag_value_local(cJSON* tags, const char* key) {
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) {
if (!enabled_s || enabled_s[0] == '\0') {
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");
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* 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",
@@ -2921,19 +3206,36 @@ static int publish_kind_event_to_relays(int kind,
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;
if (tags) {
tags_copy = cJSON_Duplicate(tags, 1);
if (tags_to_publish) {
tags_copy = cJSON_Duplicate(tags_to_publish, 1);
if (!tags_copy) {
free(encrypted_content);
cJSON_Delete(public_tags);
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) {
cJSON_Delete(tags_copy);
}
if (!event) {
free(encrypted_content);
cJSON_Delete(public_tags);
return -1;
}
@@ -2966,12 +3268,17 @@ static int publish_kind_event_to_relays(int kind,
sent);
if (sent > 0) {
if (kind == 31124) {
(void)normalize_private_skill_event_local(event);
}
pthread_mutex_lock(&g_self_skill_mutex);
self_skill_cache_upsert_event_locked(event);
pthread_mutex_unlock(&g_self_skill_mutex);
}
cJSON_Delete(event);
free(encrypted_content);
cJSON_Delete(public_tags);
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;
}
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;
cJSON* root = cJSON_Parse(plaintext);
@@ -990,16 +990,28 @@ static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char*
return -1;
}
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature");
cJSON* global_llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
cJSON* didactyl = cJSON_GetObjectItemCaseSensitive(root, "didactyl");
if (!global_llm || !cJSON_IsObject(global_llm) || !didactyl || !cJSON_IsObject(didactyl)) {
cJSON_Delete(root);
return -1;
}
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' ||
!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);
return -1;
}
@@ -1020,37 +1032,21 @@ static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char*
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};
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) != 0) {
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) {
cfg->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
cfg->dm_protocol = DM_PROTOCOL_BOTH;
} else {
cfg->dm_protocol = DM_PROTOCOL_NIP04;
}
if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
cfg->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
cfg->dm_protocol = DM_PROTOCOL_BOTH;
} else {
cfg->dm_protocol = DM_PROTOCOL_NIP04;
}
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);
char* llm_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
(void)apply_recalled_llm_config_wizard(cfg, llm_plaintext);
char* settings_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "user-settings", &settings_plaintext) == 0 && settings_plaintext) {
(void)apply_recalled_user_settings_wizard(cfg, settings_plaintext);
}
free(llm_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);
free(settings_plaintext);
nostr_handler_cleanup();
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) {
if (!cfg) return -1;
cJSON* llm = cJSON_CreateObject();
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);
cJSON* user_settings = cJSON_CreateObject();
cJSON* global_llm = cJSON_CreateObject();
cJSON* didactyl = cJSON_CreateObject();
if (!user_settings || !global_llm || !didactyl) {
cJSON_Delete(user_settings);
cJSON_Delete(global_llm);
cJSON_Delete(didactyl);
return -1;
}
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
cJSON_AddNumberToObject(user_settings, "v", 2);
cJSON_AddNumberToObject(user_settings, "updatedAt", (double)time(NULL));
int llm_rc = publish_encrypted_self_config_wizard(cfg, "llm_config", llm_json);
free(llm_json);
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* agent = cJSON_CreateObject();
if (!agent) return llm_rc;
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey);
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;
cJSON_AddStringToObject(didactyl, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(didactyl, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol));
cJSON_AddItemToObject(user_settings, "didactyl", didactyl);
int agent_rc = publish_encrypted_self_config_wizard(cfg, "agent_config", agent_json);
free(agent_json);
char* settings_json = cJSON_PrintUnformatted(user_settings);
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) {

View File

@@ -5,6 +5,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "cjson/cJSON.h"
#include "../config.h"
@@ -30,7 +31,9 @@ static cJSON* parse_args_local(const char* args_json) {
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 (out_error) *out_error = strdup("invalid persist context");
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;
cJSON* args = cJSON_CreateObject();
cJSON* content = cJSON_CreateObject();
if (!args || !content) {
cJSON_Delete(args);
cJSON_Delete(content);
if (out_error) *out_error = strdup("allocation failure while building llm_config payload");
cJSON* recall_args = cJSON_CreateObject();
if (!recall_args) {
if (out_error) *out_error = strdup("allocation failure while building config_recall args");
return -1;
}
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;
}
cJSON_AddStringToObject(args, "d_tag", "llm_config");
cJSON_AddStringToObject(content, "provider", cfg->provider);
cJSON_AddStringToObject(content, "api_key", cfg->api_key);
cJSON_AddStringToObject(content, "model", cfg->model);
cJSON_AddStringToObject(content, "base_url", cfg->base_url);
cJSON_AddNumberToObject(content, "max_tokens", cfg->max_tokens);
cJSON_AddNumberToObject(content, "temperature", cfg->temperature);
cJSON_AddItemToObject(args, "content", content);
char* recall_result = execute_config_recall(ctx, recall_args_json);
free(recall_args_json);
if (!recall_result) {
if (out_error) *out_error = strdup("config_recall returned no response");
return -1;
}
char* store_args_json = cJSON_PrintUnformatted(args);
cJSON_Delete(args);
cJSON* recall_root = cJSON_Parse(recall_result);
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 (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;
}
@@ -70,17 +160,17 @@ static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cf
return -1;
}
cJSON* root = cJSON_Parse(store_result);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
cJSON* store_root = cJSON_Parse(store_result);
free(store_result);
if (!store_root || !cJSON_IsObject(store_root)) {
cJSON_Delete(store_root);
if (out_error) *out_error = strdup("config_store returned invalid JSON");
free(store_result);
return -1;
}
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
if (!success || !cJSON_IsBool(success) || !cJSON_IsTrue(success)) {
cJSON* err = cJSON_GetObjectItemCaseSensitive(root, "error");
cJSON* store_success = cJSON_GetObjectItemCaseSensitive(store_root, "success");
if (!store_success || !cJSON_IsBool(store_success) || !cJSON_IsTrue(store_success)) {
cJSON* err = cJSON_GetObjectItemCaseSensitive(store_root, "error");
if (out_error) {
if (err && cJSON_IsString(err) && 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");
}
}
cJSON_Delete(root);
free(store_result);
cJSON_Delete(store_root);
return -1;
}
cJSON_Delete(root);
free(store_result);
cJSON_Delete(store_root);
return 0;
}
@@ -216,7 +304,7 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) {
ctx->cfg->llm = cfg;
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();
if (!out) return NULL;