v0.2.62 - Skill picker cycles X(enabled)→E(edit)→ (disabled); edit mode prompts for new content
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<br/>Kind 10123 Adoption<br/>Kind 30078 Memory/Config<br/>Kind 4/13 DMs]
|
||||
end
|
||||
|
||||
subgraph External
|
||||
LLM[OpenAI-compatible LLM API]
|
||||
SIGNER[NIP-07 Signer Extension<br/>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<string>` 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.
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+47
-8
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user