diff --git a/README.md b/README.md
index 74a449f..4abd14a 100644
--- a/README.md
+++ b/README.md
@@ -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.61
+## Current Status — v0.2.62
**Active build — this project is barely working. Experiment at your own risk.**
-> Last release update: v0.2.61 — Skill template uses agent name + admin npub; add skill picker step with toggle menu and custom skill creation
+> Last release update: v0.2.62 — Skill picker cycles X(enabled)→E(edit)→ (disabled); edit mode prompts for new content
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
diff --git a/plans/browser_didactyl.md b/plans/browser_didactyl.md
new file mode 100644
index 0000000..9dd3971
--- /dev/null
+++ b/plans/browser_didactyl.md
@@ -0,0 +1,713 @@
+# Browser Didactyl — A Pure-Browser Agent Runtime
+
+## Vision
+
+Port Didactyl's agent loop to JavaScript as a standalone web page. The browser
+becomes the runtime substrate — no shell, no C binary. You open the page, sign
+in **as the agent** using the agent's Nostr private key (via
+`nostr-login-lite`), and your agent boots: it reads its soul, skills, adoption
+list, and memory from relays, listens for triggers, reasons with an LLM, and
+acts through a browser-safe tool set.
+
+This is **not** `ai.html` (a chat app for conversations) and **not**
+`sovereign_browser` (a full OS browser in C/WebKitGTK). It is Didactyl's agent
+semantics — triggers, two-layer context, skill adoption, tool-call loop —
+running natively in a browser tab.
+
+---
+
+## Why This Works
+
+Didactyl is Nostr-first. Identity, memory, skills, soul, and communication all
+live as Nostr events. The only things tying it to a shell are:
+
+1. **The C binary process** — a browser tab replaces this
+2. **Shell-specific tools** — `local_shell_exec`, `local_file_read`,
+ `local_file_write`, `local_http_fetch` — these are dropped or replaced with
+ browser equivalents
+
+The agent loop itself is portable:
+
+```
+Boot → load config from Nostr → subscribe to triggers →
+ on trigger: build context → call LLM → execute tools → loop →
+ deliver response
+```
+
+A browser can do every step:
+
+| Capability | Browser Implementation |
+|---|---|
+| Nostr relay connection | WebSocket (NDK worker or nostr-tools) |
+| Event signing | NIP-07 `window.nostr` or n_signer |
+| NIP-04/44 encrypt/decrypt | `window.nostr.nip04` / `window.nostr.nip44` |
+| LLM calls | `fetch()` to OpenAI-compatible endpoint |
+| Skill/soul/memory storage | Kind 31123/31124/30078/10123 events |
+| Blossom blob storage | HTTP `fetch()` to Blossom servers |
+| Tool execution | JS tool dispatch (browser-safe subset) |
+
+---
+
+## Architecture
+
+```mermaid
+flowchart TB
+ subgraph BrowserTab
+ UI[Chat UI Page]
+ CORE[Agent Core JS Module]
+ TOOLS[Tool Dispatch JS Module]
+ CTX[Context Builder JS Module]
+ end
+
+ subgraph NostrLayer
+ RELAYS[Nostr Relays via WebSocket]
+ EVENTS[Kind 31123 Skills
Kind 10123 Adoption
Kind 30078 Memory/Config
Kind 4/13 DMs]
+ end
+
+ subgraph External
+ LLM[OpenAI-compatible LLM API]
+ SIGNER[NIP-07 Signer Extension
or n_signer]
+ BLOSSOM[Blossom Servers]
+ end
+
+ UI --> CORE
+ CORE --> CTX
+ CORE --> TOOLS
+ CORE -->|WebSocket| RELAYS
+ RELAYS --> EVENTS
+ CORE -->|fetch| LLM
+ CORE -->|window.nostr| SIGNER
+ TOOLS -->|fetch| BLOSSOM
+ TOOLS -->|publish events| RELAYS
+```
+
+### Boot Sequence
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Page as Browser Didactyl
+ participant NLL as nostr-login-lite
+ participant Signer as window.nostr
+ participant Relays as Nostr Relays
+
+ User->>Page: Open page
+ Page->>NLL: NOSTR_LOGIN_LITE.init methods: local, extension, connect, nsigner
+ NLL->>NLL: Restore persisted auth or show login modal
+ User->>NLL: Enter agent nsec or connect signer
+ NLL->>NLL: Install window.nostr facade
+ Page->>Signer: window.nostr.getPublicKey()
+ Signer-->>Page: agent pubkey hex
+ Page->>Relays: Connect to configured relays
+ Page->>Relays: Subscribe: kind 10002 own pubkey
+ Page->>Relays: Subscribe: kind 10123 own pubkey
+ Page->>Relays: Subscribe: kinds 31123,31124 own pubkey
+ Page->>Relays: Subscribe: kind 30078 own pubkey d=memory
+ Page->>Relays: Subscribe: kind 30078 own pubkey d=llm_config
+ Page->>Relays: Subscribe: kind 4 own pubkey p-tag
+ Relays-->>Page: EOSE + cached events
+ Page->>Page: Build adoption list + skill cache
+ Page->>Page: Load LLM config from 30078
+ Page->>Page: Register trigger subscriptions
+ Page-->>User: Agent ready — listening for DMs and triggers
+```
+
+---
+
+## Browser-Safe Tool Set
+
+The C agent has ~60 tools. The browser agent starts with a focused subset and
+can grow. Tools are grouped by what the browser can actually do.
+
+### Phase 1 — Core Nostr Tools
+
+These are the agent's hands in a browser. All use `window.nostr` for signing and
+the WebSocket relay pool for publishing.
+
+| Tool | C Equivalent | Browser Implementation |
+|---|---|---|
+| `nostr_post` | [`nostr_post`](../src/tools/tool_nostr_post.c:1) | Build event → `window.nostr.signEvent` → publish via relay pool |
+| `nostr_query` | [`nostr_query`](../src/tools/tool_nostr_query.c:1) | Send filter over WebSocket, collect until EOSE |
+| `nostr_dm_send` | [`nostr_dm_send`](../src/tools/tool_nostr_dm.c:1) | NIP-04 encrypt via `window.nostr.nip04.encrypt` → publish kind 4 |
+| `nostr_dm_send_nip17` | [`nostr_dm_send_nip17`](../src/tools/tool_nostr_dm.c:1) | NIP-44 encrypt → gift-wrap kind 13/14 (if signer supports nip44) |
+| `nostr_delete` | [`nostr_delete`](../src/tools/tool_nostr_post.c:1) | Sign kind 5 → publish |
+| `nostr_react` | [`nostr_react`](../src/tools/tool_nostr_post.c:1) | Sign kind 7 → publish |
+| `nostr_profile_get` | [`nostr_profile_get`](../src/tools/tool_nostr_identity.c:1) | Query kind 0 → parse metadata |
+| `nostr_encrypt` | [`nostr_encrypt`](../src/tools/tool_signer_crypto.c:1) | `window.nostr.nip44.encrypt` |
+| `nostr_decrypt` | [`nostr_decrypt`](../src/tools/tool_signer_crypto.c:1) | `window.nostr.nip44.decrypt` |
+
+### Phase 1 — Identity & Context Tools
+
+| Tool | Browser Implementation |
+|---|---|
+| `nostr_pubkey` / `my_pubkey` | Return cached agent pubkey hex |
+| `nostr_npub` / `my_npub` | bech32 encode via nostr-tools |
+| `agent_identity` | Build identity context block |
+| `nostr_relay_status` | Query relay pool connection states |
+| `tool_list` | Return browser tool schemas |
+
+### Phase 1 — Memory & Task Tools
+
+| Tool | C Equivalent | Browser Implementation |
+|---|---|---|
+| `memory_save` | [`memory_save`](../src/tools/tool_memory.c:1) | NIP-44 encrypt → publish kind 30078 d=memory |
+| `memory_recall` | [`memory_recall`](../src/tools/tool_memory.c:1) | Fetch kind 30078 d=memory → decrypt |
+| `task_manage` | [`task_manage`](../src/tools/tool_task.c:1) | Publish/fetch kind 30078 d=tasks |
+| `task_list` | [`task_list`](../src/tools/tool_task.c:1) | Fetch kind 30078 d=tasks → format |
+
+### Phase 1 — Skill Tools
+
+| Tool | Browser Implementation |
+|---|---|
+| `skill_create` | Build kind 31123/31124 → sign → publish |
+| `skill_edit` | Fetch existing → modify → republish |
+| `skill_list` | Query adopted skills from cache |
+| `skill_adopt` | Update kind 10123 → sign → publish |
+| `skill_remove` | Update kind 10123 → sign → publish |
+| `skill_search` | Query kind 31123 by tag/author |
+
+### Phase 1 — Config & Model Tools
+
+| Tool | Browser Implementation |
+|---|---|
+| `model_get` | Return active LLM config from 30078 or localStorage |
+| `model_set` | Publish kind 30078 d=llm_config |
+| `config_store` | Encrypt + publish kind 30078 |
+| `config_recall` | Fetch + decrypt kind 30078 |
+| `agent_version` | Return hardcoded browser version string |
+
+### Phase 2 — Blossom Tools
+
+| Tool | Browser Implementation |
+|---|---|
+| `blossom_upload` | `fetch()` PUT to Blossom server with signed auth |
+| `blossom_download` | `fetch()` GET from Blossom server |
+| `blossom_head` | `fetch()` HEAD |
+| `blossom_list` | `fetch()` GET list endpoint |
+| `blossom_delete` | `fetch()` DELETE with signed auth |
+
+### Phase 2 — Browser-Native Tools
+
+These have no C equivalent — they are browser-only capabilities:
+
+| Tool | Description |
+|---|---|
+| `browser_fetch` | `fetch()` any URL — replaces `local_http_fetch` |
+| `browser_tab_open` | Open a URL in a new tab |
+| `browser_clipboard` | Read/write clipboard |
+| `browser_local_storage` | Read/write localStorage key-value pairs |
+
+### Explicitly Excluded
+
+These tools cannot run in a browser and are intentionally absent:
+
+| Tool | Reason |
+|---|---|
+| `local_shell_exec` | No shell access |
+| `local_file_read` | No filesystem (except via File System Access API, Phase 3) |
+| `local_file_write` | Same |
+| `cashu_wallet_*` | Could be ported later (Cashu is HTTP-based) — Phase 3 |
+
+---
+
+## Context Assembly
+
+The browser agent replicates Didactyl's two-layer context model from
+[`docs/CONTEXT.md`](../docs/CONTEXT.md:1) and [`docs/SKILLS.md`](../docs/SKILLS.md:1).
+
+### Two-Layer Model
+
+```mermaid
+flowchart TD
+ TRIGGER[Trigger fires: DM, cron, subscription]
+ TRIGGER --> WALK[Walk adoption list 10123]
+ WALK --> MATCH{Skill trigger matches?}
+ MATCH -- yes --> L1[Add to Layer 1]
+ MATCH -- no --> SKIP[Skip]
+ L1 --> RESOLVE[Resolve template variables]
+ RESOLVE --> VARTYPE{Variable type?}
+ VARTYPE -- tool name --> TOOLEXEC[Execute tool, insert result as markdown]
+ VARTYPE -- skill d-tag --> SKILLLOOKUP[Insert adopted skill content - Layer 2]
+ VARTYPE -- unknown --> EMPTY[Resolve to empty]
+ TOOLEXEC --> FORMAT
+ SKILLLOOKUP --> FORMAT
+ EMPTY --> FORMAT
+ FORMAT[Format document: add title, bump headings, join with ---]
+ FORMAT --> ROLES[Split into system/user roles]
+ ROLES --> LLM[Send to LLM with tool schemas]
+```
+
+### Template Variable Resolution
+
+The C agent resolves `{{variable}}` placeholders in skill templates. The browser
+agent does the same:
+
+- `{{skill_d_tag}}` → look up adopted skill, insert content (layer 2)
+- `{{tool_name}}` → execute tool, convert JSON result to markdown, insert
+- `{{message}}` → the incoming trigger payload (DM text, etc.)
+- Unknown → empty string
+
+### Heading Bump
+
+Skill content headings are bumped down one level (`#` → `##`, `##` → `###`)
+during assembly, matching [`context_bump_headings()`](../src/context_format.c:1)
+in the C agent.
+
+### Role Split
+
+The assembled markdown is split into system/user roles via the same logic as
+[`context_roles_split()`](../src/context_roles.c:1). The runtime owns role
+construction — skill authors write plain markdown.
+
+---
+
+## Trigger System
+
+The browser agent supports a subset of Didactyl's trigger types:
+
+| Trigger Type | Browser Support | Implementation |
+|---|---|---|
+| `dm` | Yes | Subscribe to kind 4 events with own pubkey as p-tag |
+| `nostr-subscription` | Yes | Register WebSocket subscription with skill's filter |
+| `cron` | Yes | `setInterval` / `setTimeout` with cron expression parser |
+| `chain` | Yes | Internal event — fire when another skill completes |
+| `webhook` | No | No HTTP server in browser (could use a relay-based webhook relay) |
+
+### DM Trigger Flow
+
+```mermaid
+sequenceDiagram
+ participant Admin
+ participant Relays as Nostr Relays
+ participant Page as Browser Didactyl
+ participant LLM as LLM API
+
+ Admin->>Relays: Publish kind 4 DM to agent
+ Relays->>Page: WebSocket event notification
+ Page->>Page: NIP-04 decrypt
+ Page->>Page: Dedup check event ID cache
+ Page->>Page: Classify sender tier admin/wot/stranger
+ Page->>Page: Build context from triggered skills
+ Page->>LLM: Chat completion with tools
+ LLM-->>Page: Response or tool_calls
+ loop tool call loop
+ Page->>Page: Execute tool
+ Page->>LLM: Feed tool result back
+ LLM-->>Page: Next response
+ end
+ Page->>Relays: Publish kind 4 DM reply
+ Relays->>Admin: Deliver reply
+```
+
+### Cron Trigger
+
+Cron expressions are parsed in JS and scheduled via `setTimeout`. The browser
+tab must stay open for cron to fire (a Service Worker could keep it alive in a
+future enhancement).
+
+### Sender Tier Classification
+
+The C agent classifies senders as ADMIN / WoT / STRANGER. The browser agent
+replicates this:
+
+- **ADMIN** — pubkey matches configured admin pubkey
+- **WoT** — pubkey is in admin's kind 3 contact list
+- **STRANGER** — everyone else (configurable: ignore or canned reply)
+
+---
+
+## LLM Integration
+
+### Provider Config
+
+LLM config is read from kind 30078 `d:llm_config` under the agent's own pubkey,
+falling back to the shared `d:user-settings` `global_llm` namespace (matching
+the cross-project settings sync from
+[`sovereign_browser/plans/cross-project-agent-sync.md`](../sovereign_browser/plans/cross-project-agent-sync.md:1)).
+
+The config shape:
+
+```json
+{
+ "provider": "ppq",
+ "api_key": "sk-...",
+ "model": "claude-haiku-4.5",
+ "base_url": "https://api.ppq.ai",
+ "max_tokens": 4096,
+ "temperature": 0.7
+}
+```
+
+### LLM Call
+
+Uses `fetch()` to the OpenAI-compatible `/v1/chat/completions` endpoint. The
+existing [`ai-chat.mjs`](../client/www/js/ai-chat.mjs:1) and
+[`ai-ui.mjs`](../client/www/js/ai-ui.mjs:72) modules provide reusable
+`sendAiChatJson()` and config normalization functions.
+
+### Tool-Call Loop
+
+```javascript
+async function agentLoop(messages, toolsSchema, maxTurns) {
+ for (let turn = 0; turn < maxTurns; turn++) {
+ const response = await callLLM({ messages, tools: toolsSchema });
+ if (response.tool_calls.length === 0) {
+ return response.content; // final answer
+ }
+ messages.push({ role: 'assistant', tool_calls: response.tool_calls });
+ for (const tc of response.tool_calls) {
+ const result = await toolsExecute(tc.name, tc.arguments);
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
+ }
+ }
+ return null; // max turns exhausted
+}
+```
+
+### LLM Fallback Chain
+
+Skill `llm` tags use the CSS font-stack style fallback chain
+(`anthropic/claude-sonnet, openai/gpt-4o-mini, cheap`). The browser agent
+resolves this against its configured providers, matching the C agent's
+[`docs/SKILLS.md`](../docs/SKILLS.md:269) behavior.
+
+---
+
+## Nostr Connectivity
+
+### Relay Pool
+
+Two options:
+
+1. **NDK Worker** — reuse [`ndk-worker.js`](../client/www/ndk-worker.js:1) from
+ the client project. Provides a Web Worker with relay pool, subscription
+ management, event dedup, and NIP-07 signer bridge. This is the fastest path
+ — it is already battle-tested in `ai.html` and other client pages.
+
+2. **nostr-tools** — lighter weight, direct in-page. Simpler but less
+ infrastructure.
+
+**Recommendation: NDK Worker.** It already handles relay connection management,
+reconnection, event dedup, and signer integration. The browser Didactyl page
+would import [`init-ndk.mjs`](../client/www/js/init-ndk.mjs:1) for the auth +
+worker bootstrap, then use the worker's `subscribe` and `publish` message types.
+
+### Subscriptions on Boot
+
+| Subscription | Filter | Purpose |
+|---|---|---|
+| Relay list | kind 10002, own pubkey | Discover agent's configured relays |
+| Adoption list | kind 10123, own pubkey | Load adopted skills |
+| Self skills | kinds 31123, 31124, own pubkey | Load skill definitions |
+| Memory | kind 30078, own pubkey, d=memory | Agent long-term memory |
+| Tasks | kind 30078, own pubkey, d=tasks | Agent task list |
+| LLM config | kind 30078, own pubkey, d=llm_config | Provider/model config |
+| DMs | kind 4, own pubkey as p-tag | Admin command channel |
+| Admin context | kinds 0, 3, 10002, 1, admin pubkey | WoT + admin awareness |
+
+### Event Dedup
+
+The C agent uses an event-ID cache + FNV-1a fingerprint debounce window. The
+browser agent uses a `Set` of seen event IDs with a periodic cleanup
+(evict entries older than N minutes). NDK also has built-in dedup.
+
+---
+
+## Identity & Signing
+
+### nostr-login-lite — Primary Auth Path
+
+The page uses [`nostr-login-lite`](../nostr_login_lite/README.md:1) for
+authentication and signing. This is the same login library used across the
+client project. It provides a NIP-07 compliant `window.nostr` facade that works
+with multiple signing methods:
+
+| Method | How It Works | Use Case |
+|---|---|---|
+| **local** | User enters agent nsec; key is AES-GCM encrypted with a session password and stored in localStorage | Primary — sign in as the agent with its private key |
+| **extension** | Delegates to a real NIP-07 browser extension (Alby, nos2x, Amber) | If the agent key is in an extension |
+| **connect** | NIP-46 remote signer (bunker) | Agent key held by a remote signer service |
+| **nsigner** | USB hardware signer via WebUSB/WebSerial | Agent key on n_signer hardware device |
+| **seedphrase** | BIP-39 seed phrase → derive nsec | Agent key from a 12-word seed |
+| **readonly** | npub only, no signing | Inspection mode — read relay data, no actions |
+
+The key insight: **you sign in as the agent, with the agent's private key.**
+nostr-login-lite installs `window.nostr` with the full NIP-07 surface:
+
+- `window.nostr.getPublicKey()` — returns agent pubkey hex
+- `window.nostr.signEvent(event)` — signs any Nostr event with the agent key
+- `window.nostr.nip04.encrypt(pubkey, plaintext)` — NIP-04 encrypt
+- `window.nostr.nip04.decrypt(pubkey, ciphertext)` — NIP-04 decrypt
+- `window.nostr.nip44.encrypt(pubkey, plaintext)` — NIP-44 encrypt (if supported)
+- `window.nostr.nip44.decrypt(pubkey, ciphertext)` — NIP-44 decrypt (if supported)
+
+All tool implementations that need to sign events, encrypt DMs, or decrypt
+memory route through `window.nostr` — exactly like the C agent routes through
+its signer. The agent's nsec never touches the agent code directly; it stays
+inside nostr-login-lite's encrypted store or the extension/hardware signer.
+
+### Initialization
+
+```javascript
+await window.NOSTR_LOGIN_LITE.init({
+ persistence: true, // Remember login across page reloads
+ isolateSession: false, // Share across tabs (agent is one identity)
+ relays: ['wss://relay.damus.io', 'wss://nos.lol'],
+ methods: {
+ extension: true, // Browser extension if available
+ local: true, // Manual nsec entry — primary for agent
+ readonly: true, // Inspection mode
+ seedphrase: true, // BIP-39 seed
+ connect: true, // NIP-46 remote signer
+ nsigner: true, // USB hardware signer
+ },
+ floatingTab: {
+ enabled: true,
+ appearance: { text: 'Agent Login' }
+ }
+});
+```
+
+After init, nostr-login-lite either restores a persisted session or shows the
+login modal. Once authenticated, `window.nostr` is installed and the boot
+sequence continues.
+
+### Admin Identity
+
+The admin pubkey is stored in the agent's kind 30078 `d:llm_config` or a
+dedicated config event. On boot, the page fetches this to determine the admin
+identity for DM classification.
+
+---
+
+## File Layout
+
+The page lives in the client project's `www/` directory alongside the existing
+pages, reusing the shared CSS, NDK worker, and JS modules.
+
+```
+~/lt/client/www/
+├── didactyl-agent.html # NEW — the browser Didactyl page
+├── css/
+│ └── client.css # existing shared styles
+├── js/
+│ ├── init-ndk.mjs # existing — NDK worker bootstrap + auth
+│ ├── ai-ui.mjs # existing — LLM config + chat completions URL
+│ ├── ai-chat.mjs # existing — sendAiChatJson()
+│ ├── didactyl-agent.mjs # NEW — agent core: boot, trigger dispatch, loop
+│ ├── didactyl-context.mjs # NEW — two-layer context assembly
+│ ├── didactyl-tools.mjs # NEW — browser-safe tool dispatch + schemas
+│ ├── didactyl-skills.mjs # NEW — skill cache, adoption list, trigger matching
+│ ├── didactyl-memory.mjs # NEW — memory/task persistence via kind 30078
+│ └── didactyl-triggers.mjs # NEW — DM, cron, subscription trigger management
+├── ndk-worker.js # existing — relay pool worker
+├── nostr-login-lite/ # existing — login library (vendored or built)
+└── template.html # existing — page template (auth, sidenav, theme)
+```
+
+### Module Responsibilities
+
+| Module | Responsibility |
+|---|---|
+| [`didactyl-agent.mjs`](../client/www/js/didactyl-agent.mjs:1) | Boot sequence, agent loop, message dispatch, UI binding |
+| [`didactyl-context.mjs`](../client/www/js/didactyl-context.mjs:1) | Two-layer context assembly, heading bump, role split, template variable resolution |
+| [`didactyl-tools.mjs`](../client/www/js/didactyl-tools.mjs:1) | Tool schema definitions, tool dispatch, browser-safe tool implementations |
+| [`didactyl-skills.mjs`](../client/www/js/didactyl-skills.mjs:1) | Skill cache, adoption list management, trigger matching |
+| [`didactyl-memory.mjs`](../client/www/js/didactyl-memory.mjs:1) | Memory save/recall, task management via kind 30078 |
+| [`didactyl-triggers.mjs`](../client/www/js/didactyl-triggers.mjs:1) | DM listener, cron scheduler, subscription trigger registration |
+
+---
+
+## UI Design
+
+### Layout
+
+```
++------------------------------------------------------------------+
+| DIDACTYL AGENT [dark/light] |
++------------------------------------------------------------------+
+| Status: Connected to 3 relays · Listening for DMs and triggers |
++------------------------------------------------------------------+
+| | |
+| +------------------------------+ | +---------------------------+|
+| | CONVERSATION / LOG | | | AGENT STATE ||
+| | | | | ||
+| | [admin] Fix the typo in my | | | Pubkey: npub1... ||
+| | latest post | | | Admin: npub1... ||
+| | | | | Relays: 3/3 connected ||
+| | [agent] I found your post | | | Skills: 12 adopted ||
+| | note1abc... | | | Triggers: 3 active ||
+| | The typo is "recieve" | | | ||
+| | → fixing now... | | | --- Trigger Log --- ||
+| | | | | [cron] readme-monitor ||
+| | [tool] nostr_query → 1 event | | | [dm] admin message ||
+| | [tool] nostr_post → published | | | [sub] new follower ||
+| | | | | ||
+| | [admin] Thanks! | | | --- Skills --- ||
+| | | | | [x] personality ||
+| | [agent] Done! | | | [x] chat ||
+| | | | | [x] readme-monitor ||
+| | | | | [ ] spellcheck ||
+| +------------------------------+ | +---------------------------+|
+| | |
+| +------------------------------+ | +---------------------------+|
+| | [Message input...] [Send] | | | [Settings] ||
+| +------------------------------+ | +---------------------------+|
++------------------------------------------------------------------+
+```
+
+### Key UI Elements
+
+- **Conversation log** — DM history with the admin, showing tool calls inline
+- **Agent state panel** — pubkey, admin, relay status, skill count, trigger status
+- **Trigger log** — recent trigger fires with type and skill name
+- **Skills list** — adopted skills with toggle (adopts/removes from 10123)
+- **Message input** — send a DM to the agent directly (for testing)
+- **Settings** — LLM provider config, relay list, admin pubkey
+
+### Reusable Components
+
+The page builds on [`template.html`](../client/www/template.html:1) from the
+client project, inheriting:
+
+- Auth flow (NIP-07 login via `init-ndk.mjs`)
+- Sidenav navigation
+- Theme toggle (dark/light)
+- Relay status indicator
+- Hamburger morphing menu
+
+---
+
+## Implementation Phases
+
+### Phase 1 — Minimum Viable Agent
+
+Goal: A browser page that boots from a Nostr identity, loads its config and
+skills, listens for admin DMs, reasons with an LLM, and replies via DM.
+
+- [ ] Create `didactyl-agent.html` page shell using `template.html` as base
+- [ ] Integrate `nostr-login-lite` for auth: init with local/extension/connect/nsigner/seedphrase methods, sign in as agent with its nsec
+- [ ] Implement `didactyl-agent.mjs` boot sequence: nostr-login-lite auth → `window.nostr.getPublicKey()` → relay connect → subscribe to config/skills/adoption/DMs
+- [ ] Implement `didactyl-skills.mjs`: skill cache, adoption list parsing, trigger matching for `dm` trigger type
+- [ ] Implement `didactyl-context.mjs`: two-layer context assembly, heading bump, role split, `{{message}}` and `{{skill_d_tag}}` variable resolution
+- [ ] Implement `didactyl-tools.mjs`: core Nostr tools (`nostr_post`, `nostr_query`, `nostr_dm_send`, `nostr_profile_get`, `nostr_pubkey`, `nostr_npub`, `tool_list`), OpenAI-format tool schemas
+- [ ] Implement `didactyl-memory.mjs`: `memory_save`, `memory_recall`, `task_manage`, `task_list` via kind 30078
+- [ ] Implement agent loop: build context → call LLM → execute tools → loop → reply as DM
+- [ ] Implement sender tier classification (admin / WoT / stranger)
+- [ ] Wire up conversation log UI + agent state panel
+- [ ] LLM config from kind 30078 `d:llm_config` with localStorage fallback
+
+### Phase 2 — Full Tool Set + Triggers
+
+Goal: Complete the browser-safe tool set and add cron + subscription triggers.
+
+- [ ] Add remaining Nostr tools: `nostr_delete`, `nostr_react`, `nostr_dm_send_nip17`, `nostr_encrypt`, `nostr_decrypt`, `nostr_nip05_lookup`, `nostr_encode`, `nostr_decode`
+- [ ] Add skill tools: `skill_create`, `skill_edit`, `skill_list`, `skill_adopt`, `skill_remove`, `skill_search`
+- [ ] Add config/model tools: `model_get`, `model_set`, `config_store`, `config_recall`, `agent_version`
+- [ ] Add Blossom tools: `blossom_upload`, `blossom_download`, `blossom_head`, `blossom_list`, `blossom_delete`
+- [ ] Add browser-native tools: `browser_fetch`, `browser_tab_open`, `browser_clipboard`, `browser_local_storage`
+- [ ] Implement `didactyl-triggers.mjs`: cron trigger scheduler, nostr-subscription trigger registration, chain trigger
+- [ ] Add trigger log UI
+- [ ] Add skills management UI (adopt/remove, create/edit)
+
+### Phase 3 — Advanced
+
+Goal: Push the boundaries of what a browser agent can do.
+
+- [ ] Cashu wallet tools (NIP-60) — all HTTP-based, feasible in browser
+- [ ] File System Access API — `local_file_read` / `local_file_write` for user-selected directories
+- [ ] Service Worker — keep cron triggers alive when tab is backgrounded
+- [ ] NIP-17 gift-wrap messaging (requires nip44 signer support)
+- [ ] Cross-project settings sync — read/write shared `d:user-settings` kind 30078
+- [ ] Broadcast-debounce admin client — fan-out DMs to multiple agents (browser + C), accept first reply (from [`DECENTRALIZED_DIDACTYL.md`](DECENTRALIZED_DIDACTYL.md:1))
+- [ ] Mobile responsive layout
+- [ ] Offline skill caching via IndexedDB
+
+---
+
+## Compatibility with C Didactyl
+
+### Shared State
+
+The browser agent and C agent share **all state via Nostr events** — no direct
+communication is needed. Both read and write the same event kinds:
+
+| State | Event | Shared |
+|---|---|---|
+| Skills | kind 31123/31124 | Yes — both publish and consume |
+| Adoption list | kind 10123 | Yes |
+| Memory | kind 30078 d=memory | Yes (encrypted, same key needed) |
+| Tasks | kind 30078 d=tasks | Yes |
+| LLM config | kind 30078 d=llm_config | Yes |
+| DMs | kind 4 | Yes |
+| Profile | kind 0 | Yes |
+
+### Same Keys, Different Runtimes
+
+If the browser agent and C agent use the **same nsec**, they share a Nostr
+identity. This has the same problems described in
+[`DECENTRALIZED_DIDACTYL.md`](DECENTRALIZED_DIDACTYL.md:1): both subscribe to
+the same DMs, both process, both reply. The broadcast-debounce model applies.
+
+**Recommended: different keys.** The browser agent is a distinct agent with its
+own identity. The admin talks to whichever agent is appropriate. Skills and
+soul can be shared via adoption lists.
+
+### Skill Portability
+
+Skills are portable by design. A skill published by the C agent works in the
+browser agent and vice versa, as long as:
+
+1. The skill's `tools` tag only references browser-available tools
+2. The skill doesn't depend on shell-specific capabilities
+
+The `llm` fallback chain ensures model portability across runtimes.
+
+---
+
+## Security Considerations
+
+| Concern | Mitigation |
+|---|---|
+| nsec in browser memory | nostr-login-lite encrypts the nsec with AES-GCM and a session password before storing in localStorage. For maximum security, use nsigner (hardware) or connect (NIP-46 remote) methods so the key never enters the browser. |
+| LLM API key exposure | Key is in kind 30078 encrypted event. Browser decrypts to memory. Never logged. |
+| Tool execution safety | Browser sandbox limits damage. No shell, no filesystem (Phase 1/2). |
+| DM sender spoofing | Verify event signatures before processing (NDK does this). Classify sender tier. |
+| Relay trust | Relays are untrusted. Event signatures verified. Encrypted DMs. |
+| CORS for LLM calls | OpenAI-compatible APIs typically support CORS. If not, use a proxy. |
+| Blossom auth | Signed auth events via `window.nostr.signEvent`. |
+
+---
+
+## Reusable Infrastructure
+
+| Asset | Location | What It Provides |
+|---|---|---|
+| NDK Worker | [`ndk-worker.js`](../client/www/ndk-worker.js:1) | Relay pool, subscriptions, event dedup, publish |
+| NDK Init | [`init-ndk.mjs`](../client/www/js/init-ndk.mjs:1) | NIP-07 auth, worker bootstrap, mute list |
+| nostr-login-lite | [`nostr-login-lite`](../nostr_login_lite/README.md:1) | NIP-07 facade: local nsec, extension, NIP-46, nsigner, seedphrase, readonly |
+| LLM Config | [`ai-ui.mjs`](../client/www/js/ai-ui.mjs:1) | Config normalization, chat completions URL, provider merge |
+| LLM Call | [`ai-chat.mjs`](../client/www/js/ai-chat.mjs:1) | `sendAiChatJson()` — fetch-based LLM call |
+| Page Template | [`template.html`](../client/www/template.html:1) | Auth, sidenav, theme toggle, relay status |
+| Shared CSS | [`client.css`](../client/www/css/client.css:1) | Theme variables, layout, components |
+| Skills Demo | [`skills-demo.mjs`](../client/www/js/skills-demo.mjs:1) | Skill discovery, template parsing, execution |
+
+---
+
+## Open Questions
+
+1. **Should the browser agent use the same relay list as the C agent (from kind
+ 10002), or its own?** Same is simpler and more sovereign. Different allows
+ geographic diversity.
+
+2. **NIP-17 gift-wrap support depends on the signer having nip44.** Not all
+ NIP-07 extensions support this. Fallback to NIP-04 or skip NIP-17 tools if
+ unavailable.
+
+3. **Cron triggers die when the tab closes.** Is this acceptable, or do we need
+ a Service Worker? For a true "always-on" agent, the C binary is still the
+ right choice. The browser agent is for interactive/attended use.
+
+4. **Should we support the broadcast-debounce admin client pattern from
+ [`DECENTRALIZED_DIDACTYL.md`](DECENTRALIZED_DIDACTYL.md:1) so the admin can
+ fan-out to both browser and C agents?** This is a Phase 3 enhancement.
diff --git a/src/main.h b/src/main.h
index 24cf874..9b59124 100644
--- a/src/main.h
+++ b/src/main.h
@@ -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 61
-#define DIDACTYL_VERSION "v0.2.61"
+#define DIDACTYL_VERSION_PATCH 62
+#define DIDACTYL_VERSION "v0.2.62"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
diff --git a/src/setup_wizard.c b/src/setup_wizard.c
index 994fca1..da871d9 100644
--- a/src/setup_wizard.c
+++ b/src/setup_wizard.c
@@ -648,7 +648,6 @@ static const skill_descriptor_t DIDACTYL_DEFAULT_SKILLS[] = {
static int prompt_skill_configuration(didactyl_config_t* cfg, const char* agent_name) {
if (!cfg || !agent_name) return -1;
- int skill_enabled[SKILL_PICKER_MAX];
char* skill_d_tags[SKILL_PICKER_MAX];
char* skill_contents[SKILL_PICKER_MAX];
char* skill_tags[SKILL_PICKER_MAX];
@@ -656,7 +655,6 @@ static int prompt_skill_configuration(didactyl_config_t* cfg, const char* agent_
int skill_count = 0;
for (int i = 0; i < DIDACTYL_DEFAULT_SKILL_COUNT && skill_count < SKILL_PICKER_MAX; i++) {
- skill_enabled[skill_count] = DIDACTYL_DEFAULT_SKILLS[i].enabled_by_default;
skill_d_tags[skill_count] = strdup(DIDACTYL_DEFAULT_SKILLS[i].d_tag);
if (strcmp(DIDACTYL_DEFAULT_SKILLS[i].d_tag, "dm_history") == 0) {
skill_contents[skill_count] = strdup(DIDACTYL_DEFAULT_DM_HISTORY_SKILL_TEMPLATE);
@@ -668,13 +666,20 @@ static int prompt_skill_configuration(didactyl_config_t* cfg, const char* agent_
skill_count++;
}
+ /* Skill states: 0 = disabled, 1 = enabled, 2 = edit mode.
+ * Cycling: 0→1 (enabled), 1→2 (edit), 2→0 (disabled). */
+ int skill_state[SKILL_PICKER_MAX];
+ for (int i = 0; i < skill_count; i++) {
+ skill_state[i] = DIDACTYL_DEFAULT_SKILLS[i].enabled_by_default ? 1 : 0;
+ }
+
char input[WIZARD_LINE_MAX];
for (;;) {
render_wizard_page_header("Step 7 of 8", "New Agent Setup -- Skills");
- fprintf(stderr, " Skills (enter a number to toggle, [X] = enabled):\n");
+ fprintf(stderr, " Skills (enter a number to cycle: X=enabled, E=edit, space=disabled):\n");
for (int i = 0; i < skill_count; i++) {
- fprintf(stderr, " %d. [%c] %s\n", i + 1,
- skill_enabled[i] ? 'X' : ' ',
+ char state_char = skill_state[i] == 1 ? 'X' : (skill_state[i] == 2 ? 'E' : ' ');
+ fprintf(stderr, " %d. [%c] %s\n", i + 1, state_char,
skill_d_tags[i] ? skill_d_tags[i] : "");
}
fprintf(stderr, "\n");
@@ -699,7 +704,41 @@ static int prompt_skill_configuration(didactyl_config_t* cfg, const char* agent_
if (is_number) {
int idx = atoi(trimmed) - 1;
if (idx >= 0 && idx < skill_count) {
- skill_enabled[idx] = !skill_enabled[idx];
+ /* Cycle: 0→1 (enabled), 1→2 (edit), 2→0 (disabled) */
+ skill_state[idx]++;
+ if (skill_state[idx] > 2) skill_state[idx] = 0;
+ /* If we entered edit mode, prompt to edit the content. */
+ if (skill_state[idx] == 2) {
+ fprintf(stderr, "\n Editing skill '%s':\n", skill_d_tags[idx] ? skill_d_tags[idx] : "");
+ fprintf(stderr, " Current content:\n%s\n\n", skill_contents[idx] ? skill_contents[idx] : "");
+ fprintf(stderr, " Enter new content (markdown, end with '.' on its own line):\n");
+ char new_content[4096] = {0};
+ size_t content_len = 0;
+ for (;;) {
+ char line[WIZARD_LINE_MAX];
+ if (read_line_prompt(" ", line, sizeof(line)) != 0) {
+ for (int i = 0; i < skill_count; i++) { free(skill_d_tags[i]); free(skill_contents[i]); free(skill_tags[i]); }
+ return -1;
+ }
+ if (strcmp(line, ".") == 0) break;
+ size_t line_len = strlen(line);
+ if (content_len + line_len + 2 > sizeof(new_content)) {
+ fprintf(stderr, "%sContent too long (max %zu bytes).%s\n", ANSI_RED, sizeof(new_content) - 1, ANSI_RESET);
+ break;
+ }
+ if (content_len > 0) new_content[content_len++] = '\n';
+ memcpy(new_content + content_len, line, line_len);
+ content_len += line_len;
+ }
+ if (content_len > 0) {
+ new_content[content_len] = '\0';
+ free(skill_contents[idx]);
+ skill_contents[idx] = strdup(new_content);
+ fprintf(stderr, "%sSkill '%s' updated.%s\n", ANSI_YELLOW, skill_d_tags[idx] ? skill_d_tags[idx] : "", ANSI_RESET);
+ }
+ /* After edit, set back to enabled. */
+ skill_state[idx] = 1;
+ }
} else {
fprintf(stderr, "%sInvalid skill number (1-%d).%s\n", ANSI_RED, skill_count, ANSI_RESET);
}
@@ -722,7 +761,7 @@ static int prompt_skill_configuration(didactyl_config_t* cfg, const char* agent_
cfg->startup_event_count = 0;
for (int i = 0; i < skill_count; i++) {
- if (skill_enabled[i]) {
+ if (skill_state[i] == 1) {
if (upsert_startup_event(cfg, skill_kinds[i],
skill_contents[i],
skill_tags[i]) != 0) {
@@ -794,7 +833,7 @@ static int prompt_skill_configuration(didactyl_config_t* cfg, const char* agent_
d_tag);
skill_tags[skill_count] = strdup(tags_json);
skill_kinds[skill_count] = DIDACTYL_DEFAULT_SKILL_KIND;
- skill_enabled[skill_count] = 1;
+ skill_state[skill_count] = 1;
skill_count++;
fprintf(stderr, "%sCustom skill '%s' added.%s\n", ANSI_YELLOW, d_tag, ANSI_RESET);
continue;