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;