# 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.
