# Settings System — Kind 30078

This document is the definitive specification for how user settings and application-specific data are stored, encrypted, synced, and consumed across all projects in this ecosystem (client web pages, Didactyl agent, and future apps).

## Table of Contents

1. [Overview](#1-overview)
2. [NIP-78 Context](#2-nip-78-context)
3. [User-Centric Layout](#3-user-centric-layout)
4. [Centralized User Settings Event](#4-centralized-user-settings-event)
5. [Worker Lifecycle](#5-worker-lifecycle)
6. [Page-Level API](#6-page-level-api)
7. [Standalone Kind 30078 Events](#7-standalone-kind-30078-events)
8. [Cross-Project Alignment: Didactyl](#8-cross-project-alignment-didactyl)
9. [Encryption Standard](#9-encryption-standard)
10. [Adding a New Settings Namespace](#10-adding-a-new-settings-namespace)
11. [Audit Findings and Migration Plan](#11-audit-findings-and-migration-plan)
12. [Key Source Files](#12-key-source-files)

---

## 1. Overview

The app uses **NIP-78 Application-specific Data** (`kind:30078`) for two distinct purposes:

1. **Centralized User Settings** — A single addressable event (`d:user-settings`) containing a namespaced JSON object. This is the user's portable preference file, designed to work across apps.
2. **Standalone Page Data** — Individual addressable events with page-specific `d` tags for large or specialized data stores.

Both are **parameterized replaceable events**: for a given `pubkey + kind + d-tag`, only the latest event is retained by relays.

```mermaid
graph TD
    subgraph Centralized - d:user-settings
        W[SharedWorker ndk-worker.js]
        W -->|hydrate on init| C[IndexedDB cache]
        W -->|fetch + decrypt| R[Relays]
        W -->|NIP-44 encrypt + publish| R
        P1[Page A] -->|getUserSettings| W
        P2[Page B] -->|patchUserSettings| W
        P3[Page C] -->|onUserSettings| W
        D[Didactyl Agent] -->|reads llm from user prefs| R
    end

    subgraph Standalone - per-page d-tags
        S1[todo.html] -->|d:todo| R2[Relays]
        S2[cal.html] -->|d:calorie_foods / d:calorie_diary| R2
        S3[links.html] -->|d:links| R2
        S4[ai.html] -->|d:convo-id with t:client-ai-chat-v1| R2
        S5[keep-alive.html] -->|d:relay-list-N with t:relay-list| R2
        S6[post.html] -->|d:viewed| R2
    end
```

---

## 2. NIP-78 Context

[NIP-78](../reference_repos/nips/78.md) specifies kind `30078` as an addressable event with a `d` tag containing "some reference to the app name and context — or any other arbitrary string." Content and tags can be anything.

**NIP-78 is intentionally a blank canvas.** No NIP defines:
- A standard schema for user preferences
- A convention for d-tag naming
- A cross-app settings format

This means our `d:user-settings` convention is ours to define. If it proves useful across the Nostr ecosystem, it could eventually become a NIP proposal for standardized user preferences.

**Related NIPs:**
- **NIP-44** — The required encryption standard for settings content (NIP-04 is deprecated)
- **NIP-51** — Defines structured lists (kinds 10000-30007) but not app settings
- **NIP-37** — Defines draft wraps (kind 31234) with separate relay lists for private content

---

## 3. User-Centric Layout

From the user's npub perspective, their kind 30078 events should be organized into three categories:

### Category 1: Cross-App User Preferences (`d:user-settings`)

Settings that any compatible app should respect. These follow the user across devices and apps:

| Namespace | Purpose | Example consumers |
|-----------|---------|-------------------|
| `global_llm` | LLM provider, model, API key, multi-provider config | AI pages, skills-edit, Didactyl agent |
| `global_zaps` | Default zap amount, comment, preferred method, mint allowlist | Cashu page, post interactions, any zap button |
| `global_ui` | Theme, language, accessibility preferences | All pages |
| `global_relays` | Relay preferences | All pages |
| `global_experimental` | Feature flags | All pages |

The `global_` prefix distinguishes cross-app settings from page-specific ones at a glance.

### Category 2: Page-Specific Settings (also in `d:user-settings`)

Layout and UI state that only matters to specific pages within client:

| Namespace | Purpose | Owning page |
|-----------|---------|-------------|
| `feed` | Video autoplay | `feed.html` |
| `post` | Viewed scroll behavior | `post.html` |
| `notifications` | Filters, readAt timestamp | `notifications.html` |
| `blobs` | Grid columns, rows per page | `blobs.html` |
| `vjPage` | Streaming sites, columns, draft | `vj.html` |
| `strudel` | Strudel music coding settings | strudel pages |

No prefix — these are clearly page-local by their names.

### Category 3: Standalone Data Events (separate d-tags)

Large data stores that would bloat the centralized settings event:

| d-tag | Purpose | Why standalone |
|-------|---------|---------------|
| `todo` | Todo list items | Can grow large |
| `calorie_foods` / `calorie_diary` | Calorie tracking data | Separate data domains |
| `links` | Bookmarks (Netscape HTML) | Large, compressed |
| `viewed` | Read/unread tracking per follow | Updates frequently |
| `{conversation-id}` | AI chat conversations | Many events, large |
| `relay-list-{N}` | Relay registry chunks | Intentionally public |
| `show-playlist:{show}:{ts}` | VJ episode playlists | Intentionally public |

---

## 4. Centralized User Settings Event

### Nostr Event Shape

```json
{
  "kind": 30078,
  "tags": [["d", "user-settings"]],
  "content": "<NIP-44 encrypted JSON>",
  "created_at": 1708646400
}
```

- **`d` tag**: Always `"user-settings"`
- **Encryption**: NIP-44 self-encrypt (sender = recipient = user pubkey)
- **Replaceability**: Addressable — publishing a new one replaces the previous on relays

### Target Schema (v2)

```json
{
  "v": 2,
  "updatedAt": 1708646400,

  "global_llm": {
    "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"]
  },

  "global_zaps": {
    "defaultAmountSats": 21,
    "defaultComment": "",
    "preferredMethod": "auto",
    "receiveMintAllowlist": []
  },

  "global_ui": {},
  "global_relays": {},
  "global_experimental": {},

  "feed": { "videoAutoplay": false },
  "post": { "viewed": { "scrollToMark": false } },
  "notifications": { "filters": {}, "readAt": 0 },
  "blobs": { "gridColumns": 4, "rowsPerPage": 10 },
  "vjPage": {
    "streamingSites": [],
    "selectedSiteName": "",
    "streamDraft": {},
    "autoAnnounce": {},
    "columns": {}
  },
  "strudel": {}
}
```

### Current Schema (v1) — Supported

The current implementation uses these namespace names without the `global_` prefix:

| Current key | Target key | Status |
|-------------|------------|--------|
| `ui` | `global_ui` | Rename pending |
| `relays` | `global_relays` | Rename pending |
| `experimental` | `global_experimental` | Rename pending |
| `zaps` | `global_zaps` | Rename pending |
| `ai` | `global_llm` | Rename + schema alignment pending |
| `post` | `post` | No change |
| `feed` | `feed` | No change |
| `notifications` | `notifications` | No change |
| `blobs` | `blobs` | No change |
| `vjPage` | `vjPage` | No change |
| `strudel` | `strudel` | No change |

### Default Settings

Defined in [`getDefaultUserSettings()`](../www/ndk-worker.js:324):

```js
{
    v: 1,           // SETTINGS_SCHEMA_VERSION
    updatedAt: 0,
    ui: {},
    post: { viewed: { scrollToMark: false } },
    strudel: {},
    relays: {},
    experimental: {}
}
```

Feature namespaces not in the defaults (e.g. `feed`, `notifications`, `zaps`, `blobs`, `ai`, `vjPage`) are created on first patch. The [`deepMerge()`](../www/ndk-worker.js:344) function ensures new keys are added without destroying existing ones.

### Normalization

[`normalizeUserSettings()`](../www/ndk-worker.js:359) deep-merges any input with the defaults, then forces:
- `v` to the current `SETTINGS_SCHEMA_VERSION`
- `updatedAt` to a numeric value

---

## 5. Worker Lifecycle

### Hydration (startup)

Called by [`hydrateUserSettingsForPubkey()`](../www/ndk-worker.js:3992) during [`handleInit()`](../www/ndk-worker.js:4271):

```
1. Read from IndexedDB cache (ndk-shared-settings DB, kv store)
2. Normalize and broadcast to all connected tabs
3. Fetch kind 30078 d:user-settings from relays
4. NIP-44 decrypt the content
5. Compare updatedAt timestamps — relay wins if >= local
6. Write winner to IndexedDB cache
7. Broadcast final settings to all tabs
```

### Publishing

Called by [`publishUserSettingsNow()`](../www/ndk-worker.js:4018):

```
1. Set updatedAt to current unix timestamp
2. Normalize the settings object
3. JSON.stringify the normalized object
4. NIP-44 encrypt (self-encrypt: sender = recipient = user pubkey)
5. Create NDKEvent with kind:30078, tags:[['d','user-settings']]
6. Sign and publish to connected relays
```

### Debounced Publish

[`scheduleUserSettingsPublish()`](../www/ndk-worker.js:4055) debounces rapid patches with a 250-350ms delay so multiple quick UI changes result in a single relay publish.

---

## 6. Page-Level API

All functions are exported from [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) and communicate with the SharedWorker via `postMessage`.

### `getUserSettings()` -> `Promise<Object>`

[Source](../www/js/init-ndk.mjs:956). Returns the current merged settings object. Times out after 7 seconds.

### `patchUserSettings(patch, options?)` -> `Promise<Object>`

[Source](../www/js/init-ndk.mjs:985). Deep-merges `patch` into current settings, writes to cache, broadcasts to all tabs, and (unless `options.publish === false`) schedules a debounced relay publish.

**Convention**: Each page patches only its own namespace:

```js
await patchUserSettings({
  myFeature: { someSetting: true }
});
```

### `onUserSettings(callback)` -> `unsubscribe function`

[Source](../www/js/init-ndk.mjs:1015). Subscribes to live settings updates via the `ndkUserSettings` custom DOM event. Returns an unsubscribe function.

### Standard Page Pattern

```js
// 1. Import
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';

// 2. Initial read
let pageSettings = {};
try {
  pageSettings = await getUserSettings();
} catch (error) {
  pageSettings = {};
}

// 3. Subscribe to live updates
const unsubscribe = onUserSettings((settings) => {
  pageSettings = settings || {};
  applySettings(pageSettings);
});

// 4. Patch your namespace
await patchUserSettings({ myFeature: { key: value } });
```

---

## 7. Standalone Kind 30078 Events

These are **not** part of the centralized user-settings object. Each page manages its own `d`-tagged event independently.

| d-tag | Page | Encryption | Content Format | Description |
|-------|------|------------|----------------|-------------|
| `todo` | [`todo.html`](../www/todo.html:831) | NIP-04 (legacy) | `{ rows: [todo items] }` | User todo list |
| `calorie_foods` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [food items] }` | Calorie food database |
| `calorie_diary` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [diary entries] }` | Daily food diary |
| `links` | [`links.html`](../www/links.html:497) | NIP-04 + LZW (legacy) | Netscape bookmark HTML | Saved bookmarks |
| `viewed` | [`post.html`](../www/post.html) | NIP-44 | `{ v:1, lastGlobalView, follows: {} }` | Read/unread tracking |
| `relay-list-{N}` | [`keep-alive.html`](../www/keep-alive.html:1162) | Plaintext | JSON relay metadata chunks | Relay registry |
| `{conversation-id}` | [`ai.html`](../www/ai.html:788) | NIP-44 | `{ id, title, messages, ... }` | AI chat conversations |
| `show-playlist:{show}:{ts}` | [`vj-stream.mjs`](../www/js/vj-stream.mjs:5) | Plaintext | Episode playlist with track tags | VJ episode playlists |

### Why Standalone?

- **Size**: Todo lists, bookmarks, and conversations can be large
- **Update frequency**: Conversations and playlists update frequently and independently
- **Different encryption**: Some use NIP-04 (legacy), some NIP-44, some are plaintext
- **Different audiences**: Relay registry and playlists are intentionally public

---

## 8. Cross-Project Alignment: Didactyl

### Didactyl's Kind 30078 Usage

The Didactyl agent (a C binary with its own Nostr identity) uses kind 30078 with NIP-44 self-encrypted payloads:

| d-tag | Source | Content |
|-------|--------|---------|
| `user-settings` | [`main.c`](/home/user/lt/didactyl/src/main.c) | `{ v, updatedAt, global_llm, didactyl }` |
| `tasks` | [`tool_task.c:14`](/home/user/lt/didactyl/src/tools/tool_task.c:14) | Agent task memory |
| `memory` | [`tool_memory.c:14`](/home/user/lt/didactyl/src/tools/tool_memory.c:14) | Agent long-term memory |
| Any d-tag | [`tool_config.c:128`](/home/user/lt/didactyl/src/tools/tool_config.c:128) | Generic config_store/config_recall |

### Didactyl `user-settings` Shape

Didactyl now stores runtime LLM + agent metadata in a single `d:user-settings` event under the **agent's own pubkey**:

```json
{
  "v": 2,
  "updatedAt": 1712345678,
  "global_llm": {
    "provider": "ppq",
    "api_key": "sk-...",
    "model": "claude-opus-4.6",
    "base_url": "https://api.ppq.ai",
    "max_tokens": 200000,
    "temperature": 0.7
  },
  "didactyl": {
    "admin_pubkey": "npub1...",
    "dm_protocol": "nip04"
  }
}
```

`model_set` performs read-modify-write of this event by patching `global_llm` while preserving other namespaces.

### Cross-Project Reading

Didactyl does **not** read admin/user web `user-settings` for runtime startup. It only reads/writes:

```
kind:30078, authors:[agent_pubkey], #d:[user-settings]
```

If a web page wants to inspect agent runtime settings, it should query the agent pubkey's `d:user-settings` event and read:

- `global_llm` for model/provider/api settings
- `didactyl` for agent-specific runtime metadata

---

## 9. Encryption Standard

| Context | Method | Rationale |
|---------|--------|-----------|
| Centralized user-settings | NIP-44 self-encrypt | Modern standard; worker handles via messageSigner |
| `viewed` (post read state) | NIP-44 self-encrypt | Privacy — relays cannot see read state |
| AI conversations | NIP-44 self-encrypt | Contains private chat history |
| Didactyl configs | NIP-44 self-encrypt | Contains API keys |
| `todo` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `calorie_foods` / `calorie_diary` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `links` | **NIP-04 + LZW (legacy — migrate)** | Predates NIP-44 |
| `relay-list-{N}` | Plaintext | Intentionally public |
| VJ episode playlists | Plaintext | Intentionally public |

**NIP-04 is deprecated.** All legacy pages should migrate to NIP-44.

---

## 10. Adding a New Settings Namespace

### Step 1: Choose centralized vs standalone

- Small config that benefits from cross-tab sync -> centralized namespace
- Large data, high-frequency updates, or different encryption needs -> standalone d-tag
- Cross-app portable preference -> centralized with `global_` prefix

### Step 2: For centralized (recommended for most page settings)

```js
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';

// Read on init
const settings = await getUserSettings();
const myConfig = settings?.myNewFeature || {};

// Subscribe to live updates
onUserSettings((s) => {
  const updated = s?.myNewFeature || {};
  applyMyConfig(updated);
});

// Save changes
await patchUserSettings({
  myNewFeature: { option1: true, option2: 'value' }
});
```

### Step 3: For standalone

```js
import { subscribe, publishEvent } from './js/init-ndk.mjs';

// Read
subscribe(
  { kinds: [30078], authors: [pubkey], '#d': ['my-feature-data'] },
  { closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);

// Write (use NIP-44 encryption via worker messageSigner)
await publishEvent({
  kind: 30078,
  tags: [['d', 'my-feature-data']],
  content: encryptedContent,
  created_at: Math.floor(Date.now() / 1000)
});
```

### Step 4: Document

Add the new namespace to the appropriate table in this file.

---

## 11. Audit Findings and Migration Plan

### Issue 1: Duplicate AI Config Storage — RESOLVED

**Previous problem**: AI provider config was stored in two places:
- `skills-edit.html` used standalone user-pubkey `d:llm_config`
- Other AI pages used centralized user settings (`settings.ai` / `global_llm`) via `patchUserSettings`

**Resolution implemented**:
- `skills-edit.html` now reads from centralized user settings `global_llm` (with `ai` fallback for v1 compatibility)
- `skills-edit.html` now writes LLM updates via `patchUserSettings({ global_llm: ... })`
- Standalone user-pubkey `d:llm_config` is deprecated
- Agent-pubkey standalone `d:llm_config` remains the Didactyl runtime config format

### Issue 2: NIP-04 Legacy Encryption — HIGH PRIORITY

**Problem**: Three pages use deprecated NIP-04 encryption:
- [`todo.html`](../www/todo.html:822) — `window.nostr.nip04.encrypt()`
- [`cal.html`](../www/cal.html:972) — `window.nostr.nip04.encrypt()`
- [`links.html`](../www/links.html:501) — `window.nostr.nip04.encrypt()` + LZW

**Migration**: Switch to NIP-44. Add one-time migration: read NIP-04, re-encrypt with NIP-44, republish.

### Issue 3: Direct `window.nostr` Calls — MEDIUM PRIORITY

**Problem**: Standalone pages call `window.nostr.nip04.encrypt/decrypt` directly, bypassing the worker's `messageSigner` infrastructure. This breaks with remote signers/bunkers.

**Migration**: Route encryption through the worker's `messageSigner` for consistency.

### Issue 4: Inconsistent Filter Syntax — LOW PRIORITY

**Problem**: [`cal.html`](../www/cal.html:997) passes filter as an array `[{ kinds: [30078], ... }]` instead of a plain object.

**Migration**: Normalize to object form.

### Issue 5: Missing `cacheUsage: 'CACHE_FIRST'` — LOW PRIORITY

**Problem**: [`todo.html`](../www/todo.html:863), [`cal.html`](../www/cal.html:998), [`links.html`](../www/links.html:434) subscribe without `cacheUsage: 'CACHE_FIRST'`.

**Migration**: Add `cacheUsage: 'CACHE_FIRST'` for faster load times.

### Issue 6: Merge `calorie_foods` + `calorie_diary` — LOW PRIORITY

**Problem**: [`cal.html`](../www/cal.html:1103) publishes two separate events that are always loaded and saved together.

**Migration**: Combine into single `d:calorie` event: `{ foods: {...}, diary: {...} }`.

### Issue 7: Rename to `global_` Prefix — DEFERRED

**Problem**: Current cross-app namespaces (`ai`, `zaps`, `ui`, `relays`, `experimental`) lack the `global_` prefix.

**Migration**: Bump schema to v2. In `normalizeUserSettings()`, detect v1 and migrate: copy `ai` -> `global_llm`, `zaps` -> `global_zaps`, etc. Support reading both during transition.

### Migration Priority Order

1. Merge `skills-edit.html` `d:llm_config` into centralized settings (Issue 1)
2. Migrate `todo.html`, `cal.html`, `links.html` from NIP-04 to NIP-44 (Issue 2)
3. Route standalone encryption through worker messageSigner (Issue 3)
4. Fix `cal.html` filter syntax + add CACHE_FIRST everywhere (Issues 4, 5)
5. Merge calorie events (Issue 6)
6. Rename to `global_` prefix with v2 schema migration (Issue 7)

---

## 12. Key Source Files

| File | Role |
|------|------|
| [`www/ndk-worker.js`](../www/ndk-worker.js:311) | Settings state, hydration, publish, cache, normalize |
| [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs:956) | Page-facing API: getUserSettings, patchUserSettings, onUserSettings |
| [`reference_repos/nips/78.md`](../reference_repos/nips/78.md) | NIP-78 specification for kind 30078 |
| [`reference_repos/nips/44.md`](../reference_repos/nips/44.md) | NIP-44 encryption specification |
| `/home/user/lt/didactyl/src/main.c` | Didactyl agent llm_config and agent_config publish/recall |
| `/home/user/lt/didactyl/src/tools/tool_config.c` | Didactyl generic config_store/config_recall tool |
| `/home/user/lt/didactyl/src/tools/tool_model.c` | Didactyl model_set tool (persists to d:llm_config) |
