From 40889a91cf1cb1e62804123769745d244324afc0 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 14 Sep 2026 10:17:19 -0700 Subject: [PATCH] Update skills --- .agents/skills/welshman-app/SKILL.md | 597 +++++++++++++++-------- .agents/skills/welshman-content/SKILL.md | 18 +- .agents/skills/welshman-editor/SKILL.md | 27 +- .agents/skills/welshman-feeds/SKILL.md | 38 +- .agents/skills/welshman-lib/SKILL.md | 9 +- .agents/skills/welshman-net/SKILL.md | 262 +++++----- .agents/skills/welshman-signer/SKILL.md | 50 +- .agents/skills/welshman-store/SKILL.md | 94 ++-- .agents/skills/welshman-util/SKILL.md | 277 +++++++++-- .agents/skills/welshman/SKILL.md | 143 +++--- skills-lock.json | 26 +- 11 files changed, 1024 insertions(+), 517 deletions(-) diff --git a/.agents/skills/welshman-app/SKILL.md b/.agents/skills/welshman-app/SKILL.md index c2d33f18..ed0af0a3 100644 --- a/.agents/skills/welshman-app/SKILL.md +++ b/.agents/skills/welshman-app/SKILL.md @@ -1,279 +1,460 @@ --- name: welshman-app -description: "Use this skill when working with @welshman/app: the App instance and its plugins, sessions and login, publishing via Commands and Thunks, app policies, WoT, feeds, sync, or relay selection at the app layer." +description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing with thunks, outbox-model requests, routing, web of trust, feeds, and search." --- -# welshman/app — The App Instance and its Plugins +# welshman/app — Instance-Based Nostr App -`@welshman/app` composes `net`, `store`, `domain`, `signer`, and `feeds` into an application -framework built around a single `App` object. +## Overview + +`@welshman/app` is the high-level app layer of welshman. It ties `util`, `net`, `store`, `domain`, `signer`, and `feeds` together behind a single **`App`** instance. Everything — the event repository, connection pool, the signed-in user, and all features — hangs off that instance. There are **no module-level globals**: you create an app and reach everything through `app.use(...)`. ## Installation ```bash -npm i @welshman/app +npm install @welshman/app +# or +pnpm add @welshman/app +yarn add @welshman/app ``` -## The App +Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade/core`. + +## Core mental model + +1. **An app is an `App` instance.** It owns per-identity state (`repository`, `pool`, `tracker`, `wrapManager`), a `config`, and at most one `User`. Two apps never share data. +2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app. +3. **`Projection` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths. +4. **Reads are reactive and lazy-loading.** `app.use(Profiles).one(pubkey)` returns a store that fetches over the network (outbox model) and updates as events arrive. +5. **Writes are optimistic.** Publishing goes through *thunks*: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo. + +## Creating an app ```typescript -import {App, createApp, User} from "@welshman/app" +import {createApp} from "@welshman/app" +// Batteries-included: installs default policies (event ingestion, relay stats, +// gift-wrap unwrapping, NIP-42 auth-unless-blocked). const app = createApp({ - user: await User.fromSigner(signer), // omit for a signed-out app + user, // optional User config: { - dufflepudUrl: "https://dufflepud.example.com", - getDefaultRelays: () => ["wss://relay.example.com"], - getIndexerRelays: () => ["wss://indexer.example.com"], - getSearchRelays: () => ["wss://search.example.com"], + dufflepudUrl: "https://dufflepud.example", // optional: batches NIP-05/zapper lookups + getDefaultRelays: () => [...], + getIndexerRelays: () => [...], // discovery relays for profiles/relay lists + getSearchRelays: () => [...], // NIP-50 search relays }, - getAdapter, // optional: custom net adapters (tests, mocks) - policies, // optional: overrides the defaults }) + +// Bare app with NO side effects (tests, or custom policies): +import {App} from "@welshman/app" +const bare = new App() + +// Always tear down when discarding an app (e.g. switching identities): +app.cleanup() ``` -An `App` owns everything scoped to one identity: +`AppOptions` is `{user?, config?, getAdapter?, policies?}`, `AppConfig` is the `config` field above, and `AppPolicy` is `(app: IApp) => Unsubscriber`. -| Property | What it is | -|---|---| -| `app.user` | the signed-in `User`, or `undefined` | -| `app.config` | the `AppConfig` above | -| `app.repository` | this identity's event store | -| `app.tracker` | which relays each event was seen on | -| `app.pool` | socket pool | -| `app.wrapManager` | NIP-59 gift wrap bookkeeping | -| `app.netContext` | `{pool, repository, getAdapter}` for the net layer | -| `app.use(Plugin)` | resolve a per-app plugin singleton | -| `app.cleanup()` | run policy teardown and clear pool/tracker/repository | +`IApp` (what plugins/policies depend on): `{user?, config, use, onCleanup, netContext, pool, tracker, repository, wrapManager}`. A plugin registers teardown with `app.onCleanup(unsubscriber)`; `app.cleanup()` runs them in reverse, then clears the pool, tracker, repository and wrap manager. -`createApp` is `new App` plus `defaultAppPolicies`. Use `new App({policies: [...]})` for a bare app. +## User & sessions -**An app is scoped to one identity.** To log in, build a *new* app and `cleanup()` the old one — -never attach a user to an existing app. That's what keeps one account's data out of another's -repository. - -## Plugins - -`app.use(Ctor)` constructs the plugin on first use and memoizes it per app, so calling it inline -is cheap and idiomatic: +A `User` is `{pubkey, signer}`. A `Session` is a serializable `{method, data}` descriptor you persist; session handlers turn it back into a signer. ```typescript -app.use(Profiles).load(pubkey) -app.use(RelayLists).writeUrls(pubkey).get() +import {createApp, User, toSession, nip07} from "@welshman/app" +import {getNip07} from "@welshman/signer" + +// Build a User from a live signer... +const user = await User.fromSigner(getNip07()) + +// ...or from a persisted session +const session = toSession(nip07, {}) // serializable, store this +localStorage.setItem("session", JSON.stringify(session)) +const restored = await User.fromSession(JSON.parse(localStorage.getItem("session")!)) // User | undefined + +const app = createApp({user: restored}) + +// Gate user-only actions (throws if no user): +const u = User.require(app) +await u.sign(stampedEvent) +await u.nip44EncryptToSelf(payload) // encrypt to self (private list entries) ``` -### Plugin base classes +Built-in session handlers (auto-registered): `nip01` `{secret}`, `nip07` `{}`, `nip46` `{clientSecret, signerPubkey, relays}`, `nip55` `{pubkey, signer}`, `pomade` `{clientOptions, email}`. Register custom ones with `defineSessionHandler` + `registerSessionHandler`. -| Base | Shape | -|---|---| -| `MapPlugin` | a plain keyed map of non-event data (relay stats, NIP-11 info) | -| `LoadableMapPlugin` | a `MapPlugin` that knows how to `fetch(key)` from the network | -| `DerivedPlugin` | a keyed collection **derived from the repository** — the repository is the source of truth, never a duplicated map | -| `RelayScopedDerivedPlugin` | keyed by `getKey(item, url)` per relay, for data that only means something relative to a relay | -| `RelaySignedDerivedPlugin` | the same, but only accepts events authored by the relay's NIP-11 `self` pubkey (NIP-29 room state, relay membership/roles) | +`nip55` additionally needs the Capacitor plugin passed to `@welshman/signer` once at startup, or building its signer throws `"Nip55 is not enabled"`: -Derived plugins expose: +```ts +import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin" +import {setNip55Plugin} from "@welshman/signer" -- `index` — `Projection>` -- `all` — `Projection` -- `one(key)` — a store for a single key, loading it on first subscribe -- `get(key)` — synchronous snapshot -- `load(key)` / `forceLoad(key)` — network fetch (cached / uncached) -- `project(key, read)` — a `Projection` derived from one key +setNip55Plugin(NostrSignerPlugin) +``` -A **`Projection` is `{get(): T, $: Readable}`** — bind `.$` in markup, call `.get()` in -callbacks and hot paths. Build new ones with `projection(store)` or `projectFrom(source, read)`. +## Data plugins (reactive collections) -### Available plugins +All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`. -**Core:** `Network`, `Router`, `Domain`, `Thunks`, `Sync`, `Logger`, `Plaintext` +Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Thunk` — it builds the event but does not publish it. Call `.publish()` (or `.publishAsRelay(url)`) on the result to actually send it. See [Commands](#commands-deferred-publishing) below. -**Relays:** `Relays` (NIP-11), `RelayStats`, `RelayManagement` (NIP-86), `RelayLists`, -`BlockedRelayLists`, `SearchRelayLists`, `MessagingRelayLists`, `BlossomServerLists` - -**People:** `Profiles`, `FollowLists`, `MuteLists`, `Handles`, `Zappers`, `Wot`, `Topics` - -**Content:** `Reactions`, `Deletes`, `Pins`, `Pinboards`, `Feeds`, `FeedLists`, `Wraps` - -**NIP-29 / membership:** `Rooms`, `RoomLists`, `RoomPinLists`, `RelayMemberLists`, `RelayRoles` - -## Sessions and login - -A `Session` is `{method, ...data}`, serializable so you can persist it. Handlers convert one into -a signer: `nip01`, `nip07`, `nip46`, `nip55`, `pomade`, plus `registerSessionHandler` for your own. +| Plugin | Data | Notable accessors | +|---|---|---| +| `Profiles` | kind-0 profiles | `display(pk)`, `update(fn)` → `Command`; `profileSearch` | +| `FollowLists` | kind-3 follows | `follow(pk, hint?, petname?)`, `unfollow(pk)`, `update(fn)` → `Command` | +| `MuteLists` | kind-10000 mutes (private = encrypted) | `mutePublicly(tag)`, `mutePrivately(tag)`, `unmute(v)`, `setMutes(...)` → `Command` | +| `PinLists` | kind-10001 pins | `pin(tag)`, `unpin(value)` → `Command` | +| `RelayLists` | NIP-65 (kind 10002) | `urls(pk)`, `readUrls(pk)`, `writeUrls(pk)`, `addReadUrl`/`addWriteUrl`, `removeReadUrl`/`removeWriteUrl`, `setReadUrls`/`setWriteUrls` → `Command` | +| `BlockedRelayLists` | kind-10006 | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` | +| `MessagingRelayLists` | kind-10050 (NIP-17 DM relays) | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` | +| `SearchRelayLists` | kind-10007 | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` | +| `BlossomServerLists` | kind-10063 media servers | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` | +| `FeedLists` | kind-10014 saved-feed lists | list accessors + `update(fn)` → `Command` | +| `RoomLists` | kind-10009 room lists | `addRoom`/`removeRoom`/`addRelay`/`removeRelay`/`setRelays` → `Command` | +| `Feeds` | kind-31890 saved feeds (keyed by address) | `forAuthor(pk)`, `loadForAuthor(pk)`, `create(fields)`, `update(addr, fn)` → `Command`; `makeFeedController(...)` | +| `Pinboards` | kind-30067 pinboards (many per author, keyed by address) | `forAuthor(pk)`, `loadForAuthor(pk)`, `create(fields)`, `update(addr, fn)` → `Command` | +| `Pins` | kind-39067 pins (keyed by address; each pin has its own `d` tag) | `forBoard(addr)`, `forProfile(pk)`, `loadForBoard(addr)`, `loadForProfile(pk)`, `create`, `update`, `addToBoard`, `removeFromBoard` → `Command` | +| `Relays` | NIP-11 relay info (HTTP) | `display(url)`, `hasNip(url, n)`, `hasNegentropy(url)`; `relaySearch` | +| `RelayManagement` | NIP-86 mgmt API | `forUrl(url)` → a `ManagementApi` client that signs auth as the app's user (`forUrl(url).signEvent(event)`, role/member ops, …) | +| `RelayStats` | per-relay connection counters | `get(url)`, `getQuality(url)` (0–1, drives router ranking) | +| `RelayRoles` / `RelayMemberLists` / `RoomPinLists` | relay-signed state, keyed per relay | relay-scoped collections (see `RelaySignedDerivedPlugin`) | +| `Handles` | NIP-05 (HTTP, batched) | `forPubkey(pk)`, `display(nip05)`, `loadForPubkey(pk)` | +| `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validateZapReceipts(...)`, `validZapReceipts(...)` | +| `Topics` | hashtags w/ counts | `all`, `byName` (`Projection`s); `topicSearch` | +| `Reactions` / `Deletes` | kind-7 reactions and kind-5 deletes over the repository | reactive lookups | +| `Rooms` | NIP-29 rooms, keyed `${url}'${h}` | `forRoom(url, h)`, `forUrl(url)`, `members(url, h)`, `membershipStatus(...)`, `pendingJoins(url, h?)`, `createRoom`/`editRoom`/`deleteRoom`/`joinRoom`/`leaveRoom`/`addMember`/`removeMember(url, room, …)` → `Command` | +| `Plaintext` | decrypted-content cache, keyed by ciphertext | `ensure(ciphertext, decrypt)`, `get(ciphertext)` | ```typescript -import {User, createApp, nip07, toSession} from "@welshman/app" - -const session = toSession(nip07, {pubkey}) -const user = await User.fromSession(session) // undefined if the handler can't build a signer - +import {createApp, Profiles, RelayLists} from "@welshman/app" const app = createApp({user}) + +// Reactive (Svelte): subscribe or use $ in a component +const profile$ = app.use(Profiles).one(pubkey) // Readable>, lazy-loads +const name$ = app.use(Profiles).display(pubkey).$ // Readable + +// Synchronous snapshot (no load) +const profileNow = app.use(Profiles).get(pubkey) + +// Explicit load +await app.use(Profiles).load(pubkey) + +// Relay selections (outbox model) +const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[] + +// Mutations return a Command — build it, then decide how to publish it +const command = await app.use(RelayLists).addWriteUrl("wss://relay.example") +command.publish() // normal outbox/relays flow via Thunks +// or: command.publishAsRelay("wss://relay.example") // sign + send straight to one relay (NIP-86 style) + +// Since these methods are async, `publish`/`publishAsRelay` free functions avoid a double-await: +import {publish} from "@welshman/app" +await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish) ``` -`User` wraps a signer and pubkey: - -- `User.fromSigner(signer)` / `User.fromSession(session)` -- `User.require(app)` — the signed-in user or **throws**; use on paths that require login -- `user.sign(event)`, `user.wrapSigner(fn)` - -Persist the `Session`, not the `User` — rebuild the user on startup and construct the app with it. - -## Publishing - -Two layers, and you usually want the first. - -### Commands - -A `Command` owns a rendered event plus the relays routing resolved for it: +## Publishing (optimistic thunks) ```typescript -import {Domain, publish} from "@welshman/app" -import {Note} from "@welshman/domain" +import {Thunks, Router} from "@welshman/app" +import {makeEvent, NOTE, userOutbox} from "@welshman/util" -const writer = app.use(Domain).writer(Note).setContent("hello") -const command = await app.use(Domain).command(writer) - -command.publish() // to the resolved relays -command.publishToRelays(urls) // to specific relays -command.publishAsRelay(url) // signed by the relay itself (NIP-86) -``` - -Plugin mutators already return a `Command`, so `.then(publish)` is the common shape: - -```typescript -await app.use(FollowLists).follow(["p", pubkey]).then(publish) -await app.use(RelayLists).addWriteUrl(url).then(publish) -``` - -Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`, -`publishAsRelay(url)`, `signAsRelay(url)`. - -### Thunks - -`app.use(Thunks).publish({event, relays, delay})` publishes optimistically: the event lands in the -local repository immediately, so the UI updates before the network settles. The returned `Thunk` -is a store you can render: - -```typescript -const thunk = app.use(Thunks).publish({event, relays}) - -thunk.getUrlsWithStatus(PublishStatus.Success) -thunk.getFailedUrls() -thunk.isComplete() -await thunk.waitForError() // "" when everything succeeded -await thunk.waitForCompletion() -``` - -`app.use(Thunks).history` is a writable of every thunk this app has published — useful for a -"sending" indicator or deciding which relays the user has actually written to. - -## Requests - -```typescript -const network = app.use(Network) - -network.load({relays, filters}) // batched, deduped, shared loader -network.request({relays, filters, onEvent}) -network.publish({event, relays}) -network.loadUsingOutbox(pubkey, filter) // newest matching event from the author's write relays -network.loadAllUsingOutbox(pubkey, filter) // every matching event -``` - -Prefer a plugin's `one(key)` / `load(key)` when one exists — they handle outbox routing and -caching for you. The bare `load`/`request`/`publish` from `@welshman/net` need an explicit -`context`; `Network` supplies `app.netContext`. - -## Relay selection - -Routing is the `RelaySelection` DSL from `@welshman/util`, resolved by `app.use(Router)`: - -```typescript -import {outbox, inbox, seen, userOutbox, indexers, relay, relays} from "@welshman/util" - -const scenario = await app.use(Router).resolve([userOutbox(), outbox(pubkey)]) -const urls = scenario.getUrls() - -// single best relay for a route -const hint = await app.use(Router).resolver.relay([outbox(event.pubkey)]) -``` - -Selections are weighted (`outbox(pubkey, 2)`), and resolution is **async** — it may need to load -the target's relay list first. - -## App policies - -An `AppPolicy` is `(app) => Unsubscriber`, applied once at construction and torn down by -`cleanup()`. Policies own everything that subscribes or wires components together, keeping the -data classes free of side effects. - -Built-ins: `appPolicyIngest`, `appPolicyRelayStats`, `appPolicyWraps`, `appPolicyCacheDecrypt`, -`appPolicyLogSignerMethods`, plus auth: `appPolicyAuthNever`, `appPolicyAuthAlways`, -`appPolicyAuthUnlessBlocked`, and `makeAppPolicyAuth(shouldAuth)` for a custom predicate. - -```typescript -const app = createApp({ - user, - policies: [...defaultAppPolicies, appPolicyAuthUnlessBlocked, myPolicy], +// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the +// Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the +// hood for every data-plugin mutation, whose `relays` come from the writer's own routes): +const thunk = app.use(Thunks).publish({ + event: makeEvent(NOTE, {content: "hi"}), + relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise + delay: 3000, // abortable soft-undo window (ms) }) -const myPolicy: AppPolicy = app => { - const unsubscribe = on(app.repository, "update", handleUpdate) +// To specific relays: +app.use(Thunks).publish({event, relays: ["wss://relay.example"]}) - return unsubscribe -} +// A thunk is a Svelte store with per-relay status: +thunk.subscribe(t => console.log(t.results)) +thunk.abort() // effective only before `delay` elapses +await thunk.waitForCompletion() +thunk.getError() // string | undefined +app.use(Thunks).history // writable — optimistic log +app.use(Thunks).retry(thunk) + +// Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps: +app.use(Thunks).publish({event, relays, recipient: theirPubkey}) +const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]}) + +// Proof of work (NIP-13): +app.use(Thunks).publish({event, relays, pow: 20}) ``` -**Ordering gotcha:** policies run in the `App` constructor. If a policy module imports something -that transitively imports your app module, construct the app lazily (on first access) so every -policy has registered by the time it's built. +`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. + +## Commands (deferred publishing) + +Data-plugin mutation methods (`create`, `update`, `follow`, `addRelay`, `setRelays`, `Rooms.*`, …) don't publish — they build the `EventTemplate` and the relays it would go to, and hand back a **`Command`** for you to decide what to do with: + +```typescript +import type {Command} from "@welshman/app" + +const command: Command = await app.use(FollowLists).follow(["p", otherPubkey]) + +command.app // the IApp it was built for +command.event // EventTemplate — unsigned, inspectable before publishing +command.relays // string[] — where publish() will send it + +command.publish() // normal path: app.use(Thunks).publish({event, relays: command.relays}) +command.publishToRelays(urls) // publish to a specific relay set instead of command.relays +command.publishAsRelay(url) // NIP-86: the relay signs the event with its own key + // (signevent), then publish the relay-signed event back to `url` +command.signAsRelay(url) // just the NIP-86 signevent step (returns {result, error}) +``` + +This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `MergedThunk` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly. + +`publish`/`publishToRelays`/`publishAsRelay`/`signAsRelay` are also exported as free functions (e.g. `(command) => command.publish()`, `(url) => (command) => command.publishAsRelay(url)`) so you can chain straight off the mutation method's promise instead of double-awaiting: + +```typescript +import {publish, publishAsRelay} from "@welshman/app" + +await app.use(FollowLists).follow(["p", otherPubkey]).then(publish) +await app.use(Rooms).leave(relayUrl, roomMeta).then(publish) +await app.use(Rooms).join(relayUrl, roomMeta).then(publishAsRelay(relayUrl)) +``` + +## Requests & sync + +```typescript +import {Network, Sync} from "@welshman/app" +const net = app.use(Network) + +const events = await net.load({filters: [{kinds: [1], authors: [pk]}], relays}) +await net.request({filters, relays, autoClose: true}) + +// Outbox-model author load (resolves the author's write relays automatically). +// loadUsingOutbox returns the newest matching event; loadAllUsingOutbox returns them all. +const profileEvent = await net.loadUsingOutbox(pk, {kinds: [0]}) +const allFeeds = await net.loadAllUsingOutbox(pk, {kinds: [31890]}) + +// A loader with different batching, still bound to this app's net context: +const slowLoad = net.makeLoader({delay: 500, timeout: 5000, threshold: 0.5}) + +// Negentropy-aware reconciliation (falls back to request/publish when unsupported): +await app.use(Sync).pull({relays, filters: [{authors: [pk]}]}) +await app.use(Sync).push({relays, filters: [{authors: [pk]}]}) +``` + +## Querying the repository (`Events`) + +`Network` fetches; `Events` reads what's already local. Every method binds this app's repository +and tracker and returns a `Projection` — `.get()` for a snapshot, `.$` to subscribe — so there's no +get/derive pair to keep in sync. + +```typescript +import {Events} from "@welshman/app" +const events = app.use(Events) + +events.byId(filters).$ // Map +events.all(filters).$ // repository order +events.asc(filters).$ // oldest first +events.desc(filters).$ // newest first +events.one(idOrAddress, hints) // one event, loaded on first read if missing +events.isDeleted(event).$ + +// Scoped to a relay, via the tracker +events.byIdForUrl(url, filters).$ +events.forUrl(url, filters).$ +events.byIdByUrl(filters).$ // Map> +events.relaySignedForUrl(url, filters).$ // only what the relay itself signed +``` + +`relaySignedForUrl` is the loose counterpart to `RelaySignedDerivedPlugin` — relay-generated kinds +mean nothing from another author, so anything not signed by the relay's NIP-11 `self` is dropped. + +## Routing & tags + +`app.use(Router)` turns the declarative **`RelaySelection`** DSL (from `@welshman/util`) into scored relay urls. It exposes a `Resolver` (`router.resolver`) plus a `resolve(selections)` shortcut. That same `resolver` is injected into every `@welshman/domain` kind by `app.use(Domain)`, so writers/readers route through it too. + +```typescript +import {Router} from "@welshman/app" +import {userOutbox, outbox, seen, relay, addMinimalFallbacks} from "@welshman/util" + +const router = app.use(Router) // per-app; NOT Router.get() + +// resolver.relays(...) -> Promise; resolver.relay(...) -> Promise +const writeRelays = await router.resolver.relays([userOutbox()]) +const hint = await router.resolver.relay([seen({id: event.id})]) + +// resolve(...) -> Promise; then tune fallbacks/limit and read urls +const relays = (await router.resolve([userOutbox()])).policy(addMinimalFallbacks).limit(8).getUrls() + +// DSL selectors: userInbox/userOutbox/userMessaging, inbox(pk)/outbox(pk)/messaging(pk), +// inboxes(pks), eventInbox(ref)/eventOutbox(ref), seen(ref), relay(url)/relays(urls), +// indexers(), searchRelays() — each returns a RelaySelection (relays/inboxes return arrays). +``` + +Event tagging (reply/quote/reaction threading, p-tags, zap splits) now lives on the domain **writers** — `writer.tagPubkey(pk)`, `writer.addQuote(event)`, `writer.addZapSplit(pk)`, and kind-specific setters like `NoteWriter.setParent(parentEvent)` — not on a separate `Tags` plugin. See the `welshman-domain` skill. + +`Router` is the one `ResolveRoute` implementation in the stack. It resolves each route against the app: + +- **inbox / outbox** — `app.use(RelayLists).load(pubkey)` then `readUrls()` / `writeUrls()` (NIP-65, kind 10002). +- **messaging** — `app.use(MessagingRelayLists).load(pubkey)` (kind 10050). +- **eventInbox / eventOutbox** — a known `ref.pubkey` routes directly; otherwise `ref.id` is looked up in the repository to find the author. `ref.relays` are always included. +- **seen** — `app.tracker.getRelays(ref.id)`, or for a replaceable `ref` the tracker entry of the event at its address, plus `ref.relays`. +- **index / search** — `app.config.getIndexerRelays?.()` / `getSearchRelays?.()`. + +When `app.user` is undefined, `user*` routes resolve to no relays rather than throwing. + +`Router` also satisfies `@welshman/feeds`' `FeedRouter` interface, which is how `app.use(Feeds).makeFeedController(...)` routes a feed's filters. + +### Relay quality + +The resolver ranks relays by `app.use(RelayStats).getQuality(url)`, 0–1: + +| Score | Condition | +|---|---| +| `0` | not a relay url, blocked by the user's kind-10006 list, or recently error-prone (any error in the last minute, >3 in an hour, >10 in a day) | +| `1` | already in the pool | +| `0.9` | connected at some point before | +| `0.8` | a normal `wss://` url with no history | +| `0.7` | an IP, local, onion, or plain-`ws://` url with no history | + +A relay scoring `0` is dropped from the scenario's result entirely rather than deprioritized, so a scenario can come back empty even though its selections resolved to urls. + +The DSL constructors, `RelayScenario` scoring and the fallback policies are documented in the `welshman-util` skill. ## Web of trust +Built from the **public** `p` tags on follow (kind 3) and mute (kind 10000) lists as they land in the repository. Every read is a `Projection` (`.get()` / `.$`), and reads *about* a pubkey take a `WotScope`: + +- `WotScope.Global` — counts every list in the repository. +- `WotScope.Follows` — counts only lists published by the user's own follows, i.e. the pubkey as this user sees it. With no signed-in user it falls back to global. + ```typescript +import {Wot, WotScope} from "@welshman/app" + const wot = app.use(Wot) -wot.follows(pubkey).get() -wot.followers(pubkey).get() -wot.network(pubkey).$ // follows-of-follows -wot.followsWhoFollow(pubkey, target).$ -wot.wotScore(pubkey, target).$ +wot.follows(pk).get() // string[] — who pk follows +wot.mutes(pk).get() // string[] — who pk mutes +wot.followers(pk, WotScope.Follows).get() // string[] +wot.muters(pk, WotScope.Follows).get() // string[] +wot.score(pk, WotScope.Follows).get() // number — followers − muters, within scope +wot.network(pk).get() // follows-of-follows (minus direct follows) +wot.scores(WotScope.Follows).get() // Map — the whole picture at once ``` -## Feeds and sync +Use `scores(scope)` when ranking a list (search results, a WoT range); it walks the graph once instead of once per pubkey. + +## Feeds & search ```typescript -app.use(Feeds).makeFeedController({feed, onEvent, ...}) -app.use(Feeds).getPubkeysForScope(scope) -app.use(Feeds).forAuthor(pubkey).$ +import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds" +import {get} from "svelte/store" -app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing -app.use(Sync).push({relays, filters}) // publish what the relay is missing +const controller = app.use(Feeds).makeFeedController({ + feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(1)), + onEvent: event => {/* render */}, +}) +await controller.load(50) // scopes (Self/Follows/Network/Followers) resolved via Wot + +// Search lives on the collection that owns the data. There is no Searches plugin. +const search = get(app.use(Profiles).profileSearch) +const pubkeys = search.searchValues("alice") // also fires a NIP-50 network search; ranked by WoT +// also: app.use(Topics).topicSearch, app.use(Relays).relaySearch +// createSearch(options, {...}) builds a custom index over anything else ``` -## Using welshman stores outside Svelte +## Plugin architecture (for extending) -Projections and plugin stores implement the Svelte store contract — `subscribe(cb) → unsubscribe`, -firing synchronously with the current value — so they adapt to any reactive framework with a small -hook. Only the `svelte/store` *types* are needed, not the runtime. +Base classes in `plugins/base.ts`: + +- **`DerivedPlugin`** — collection derived from repository events (the repo is the single source of truth). Pass `{filters, eventToItem, getKey, loadOptions?}`; implement `fetch`. This is the dominant pattern. Gives you `index`/`all` (`Projection`s), `get(key)`, `one(key)`, `load`/`forceLoad`, and `project(key, read)`. +- **`RelayScopedDerivedPlugin`** — the same, keyed per relay via the tracker (`getKey(item, url)`), so the same addressable coordinate on two relays stays two entries. `RelaySignedDerivedPlugin` (in `plugins/relays.ts`) narrows it further to events signed by the relay's own NIP-11 `self` key, which is what `RelayRoles`, `RelayMemberLists` and `RoomPinLists` use. +- **`LoadableMapPlugin`** — owns its own `Map`, lazily fetches over HTTP (e.g. `Relays`, `Handles`, `Zappers`). Implement `fetch`. +- **`MapPlugin`** — owns its own `Map`, no network (e.g. `RelayStats`, `Plaintext`). + +Decode events with the app-configured `@welshman/domain` reader (`app.use(Domain).reader(Kind)`) as `eventToItem`, and mutate through `app.use(Domain).writer(Kind, reader?)` + `app.use(Domain).command(writer)`: ```typescript -// React -const useStore = (store: Readable): T => { - const [value, setValue] = useState(() => get(store)) +import {DerivedPlugin, Network, Domain, User, type IApp} from "@welshman/app" +import {SOME_KIND} from "@welshman/util" +import {SomeKind, SomeKindReader, SomeKindWriter} from "@welshman/domain" - useEffect(() => store.subscribe(setValue), [store]) +export class Somethings extends DerivedPlugin { + constructor(app: IApp) { + super(app, { + filters: [{kinds: [SOME_KIND]}], + eventToItem: app.use(Domain).reader(SomeKind), // async: validates kind + parses + getKey: item => item.author(), + }) + } - return value + fetch = (pk: string, hints: string[] = []) => + this.app.use(Network).loadUsingOutbox(pk, {kinds: [SOME_KIND]}, hints) + + // Build a writer (optionally seeded from the current reader for edits), mutate it, then + // wrap it in a Command via Domain.command — the caller decides when/how to publish. + update = async (fn: (writer: SomeKindWriter) => void) => { + const user = User.require(this.app) + const writer = this.app.use(Domain).writer(SomeKind, await this.forceLoad(user.pubkey)) + + fn(writer) + + return this.app.use(Domain).command(writer) + } } + +const things = app.use(Somethings) // lazily constructed + memoized ``` -For a `Projection`, subscribe to `.$` and read `.get()` for a synchronous snapshot. +Caching/backoff for `load` come from `makeLoadItem` (`@welshman/store`); default staleness window is 1 hour; `forceLoad` bypasses it. + +## Policies & logging + +Side effects live in `AppPolicy`s (`(app) => Unsubscriber`), run at construction, cleaned up by `cleanup()`. + +- `defaultAppPolicies` = `[appPolicyIngest, appPolicyRelayStats, appPolicyWraps, appPolicyCacheDecrypt, appPolicyLogSignerMethods, appPolicyAuthUnlessBlocked]`. +- Auth builders: `makeAppPolicyAuth(shouldAuth)`, `appPolicyAuthAlways`, `appPolicyAuthNever`, `appPolicyAuthUnlessBlocked`. +- `appPolicyCacheDecrypt` and `appPolicyLogSignerMethods` both layer onto the user's signer via `User.wrapSigner` — the first caches decryptions into `app.use(Plaintext)`, the second records signer calls into `app.use(Logger)` (read them from `app.use(Logger).messages`). + +```typescript +// Opt out of a default, or add your own: +import {App, defaultAppPolicies, appPolicyAuthNever, appPolicyIngest} from "@welshman/app" + +const app = new App({user, policies: [appPolicyIngest, appPolicyAuthNever]}) +``` + +## Gotchas & tips + +- **`use()` is memoized per app.** `app.use(Profiles)` always returns the same instance for a given app. Cheap to call repeatedly. +- **`Projection` vs `Readable`.** Convenience accessors (`display`, `urls`, `score`, …) return a `Projection` — use `.$` for the store, `.get()` for a snapshot. `one(key)` returns a plain `Readable` (and triggers a load on subscribe). +- **`get(key)` does not load; `one(key)`/`load(key)` do.** Use `get` for a pure cache read. +- **Most loads use the outbox model**, which needs the author's relay list. `loadUsingOutbox` (and therefore most `fetch` methods) first loads NIP-65 relays for the author. +- **`createApp` vs `new App`.** `createApp` installs default policies; `new App` installs none. In tests prefer `new App` (no background subscriptions) unless you need ingestion. +- **Pass the `user` to `createApp`/`new App`, don't assign `app.user` afterwards.** Policies run once, at construction. `appPolicyCacheDecrypt` and `appPolicyLogSignerMethods` bail out immediately when there is no user, so a user attached later gets no decrypt caching and no signer log. To switch identities, build a new app and `cleanup()` the old one. +- **Call `cleanup()`** when discarding an app to close sockets and free the repository/tracker/wrap state. + +## Old API → new API + +| Old (global) | New (instance-based) | +|---|---| +| `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` | +| `deriveProfile(pk)` | `app.use(Profiles).one(pk)` | +| `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` | +| `publishThunk({...})` | `app.use(Thunks).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) | +| `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag).then(publish)` / `app.use(MuteLists).mutePublicly(tag).then(publish)`, which return a [`Command`](#commands-deferred-publishing) | +| `load({...})` / `request({...})` | `app.use(Network).load({...})` / `request({...})` | +| `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) | +| `app.use(Tags).tagEventForReply(e)` | domain writer tagging (`NoteWriter.setParent(e)`, `writer.tagPubkey/addQuote/addZapSplit`) | +| `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` | +| `app.use(Searches).profileSearch` | `app.use(Profiles).profileSearch` (likewise `Topics.topicSearch`, `Relays.relaySearch`) | +| `wot.graph` / `wot.wotScore(a, b)` | `app.use(Wot).scores(WotScope.Follows)` / `.score(pk, scope)` | +| `RelayLists.addRelay(url, mode)` | `RelayLists.addReadUrl(url)` / `addWriteUrl(url)` | ## Related skills -- `welshman-domain` — the readers/writers every plugin decodes events with -- `welshman-net` — sockets, adapters, request/publish lifecycle, auth -- `welshman-store` — the repository and the derive helpers plugins are built on -- `welshman-signer` — signer implementations behind `User` -- `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL +- `welshman-store` — the `Repository` and Svelte-store primitives this layer builds on. +- `welshman-domain` — the `Kind`/reader/writer model behind `app.use(Domain)` (event decoding + publishing). +- `welshman-util` — the `RelaySelection` DSL, `Resolver` and `RelayScenario` that `app.use(Router)` dereferences. +- `welshman-net` — request/publish/sockets behind `app.use(Network)`. +- `welshman-signer` — signers and login methods used by `User`/sessions. +- `welshman-feeds` — feed construction used by `app.use(Feeds)`. diff --git a/.agents/skills/welshman-content/SKILL.md b/.agents/skills/welshman-content/SKILL.md index 359e4d7e..89667fdb 100644 --- a/.agents/skills/welshman-content/SKILL.md +++ b/.agents/skills/welshman-content/SKILL.md @@ -36,12 +36,14 @@ yarn add @welshman/content | `Text` | `string` | Plain text | | `Newline` | `string` | One or more `\n` characters | | `Topic` | `string` | Hashtag text without the `#`; numeric-only tags are skipped | +| `Command` | `{ command: string, pubkey?: string }` | A NIP-CD invocation (`/kick`, `/kick@npub1…`), only ever at the start of the content; `pubkey` is the executor the qualifier names | | `Link` | `{ url: URL, meta: Record }` | URLs with any scheme (http, https, ftp, ws, wss, etc.) and bare domains without a protocol; `meta` is populated from `imeta` tags or URL hash params | | `LinkGrid` | `{ links: ParsedLinkValue[] }` | Produced by `reduceLinks`; a collection of adjacent block links | | `Profile` | `ProfilePointer` (`{ pubkey, relays? }`) | nostr:npub / nostr:nprofile / @nostr:npub / @nostr:nprofile references (the `nostr:` prefix is required) | | `Event` | `EventPointer` (`{ id, relays?, author?, kind? }`) | note / nevent references | | `Address` | `AddressPointer` (`{ identifier, pubkey, kind, relays? }`) | naddr references | | `Emoji` | `{ name: string, url?: string }` | `:shortcode:` — `url` resolved from `emoji` tags | +| `Room` | `ParsedRoomValue` (`{ url, room }`) | A NIP-29 room reference written as `relay.example.com'roomid` (an apostrophe or `’` between host and room id). The url is normalized to include a protocol and trailing slash; possessives like `example.com's` are skipped | | `Code` | `string` | Backtick inline code or triple-backtick blocks | | `Cashu` | `string` | cashu: token strings | | `Invoice` | `string` | Bare lightning invoice string (without `lightning:` prefix); the `lightning:` prefix is in `raw` | @@ -55,9 +57,10 @@ Every `Parsed` element also has a `raw: string` field holding the original match All guards narrow the union type: ``` -isAddress isCashu isCode isEllipsis isEmail -isEmoji isEvent isImage isInvoice isLink -isLinkGrid isNewline isProfile isText isTopic +isAddress isCashu isCode isCommand isEllipsis +isEmail isEmoji isEvent isImage isInvoice +isLink isLinkGrid isNewline isProfile isRoom +isText isTopic ``` `isImage(parsed)` — special guard: true only for `ParsedLink` elements whose URL ends in `.jpg/.jpeg/.png/.gif/.webp`. @@ -83,7 +86,11 @@ isLinkGrid isNewline isProfile isText isTopic | `renderEntity(entity)` | `entity.slice(0, 16) + "…"` | Display text for entity links | | `createElement(tag)` | `document.createElement(tag)` | DOM element factory; override for SSR/non-browser | -Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`). +Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderRoom`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCommand`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`), along with the default option bags `textRenderOptions` and `htmlRenderOptions`. + +### Extending the parser + +`parsers` is the ordered array of individual parsers (`parseCommand`, `parseNewline`, `parseLegacyMention`, `parseTopic`, `parseCodeBlock`, `parseCodeInline`, `parseAddress`, `parseProfile`, `parseEmoji`, `parseEvent`, `parseCashu`, `parseInvoice`, `parseEmail`, `parseRoom`, `parseLink`), and `parseNext(raw, context)` runs them in order at the current position. Each takes `(text, context: ParseContext)` and returns a `Parsed` or nothing. Order matters — `parseRoom` runs before `parseLink` so a `host'room` reference isn't swallowed as a bare-domain link. ## Common Patterns @@ -206,4 +213,7 @@ const emojiElements = parsed.filter(isEmoji) - **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid). - **Legacy mentions** (`#[0]`, `#[1]`) are parsed automatically from the `tags` array and emitted as `ParsedProfile` or `ParsedEvent` elements. - **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element. +- **A `Command` is syntax, not a promise that anyone answers to it**: the parser has no idea which NIP-CD definitions exist, so `/nonsense` parses as a `Command` too. Match `value.command` (and `value.pubkey`, when the invocation names an executor) against the definitions you have, and render the element's `raw` as text when nothing matches. +- **Only the start of the content is an invocation**: a slash later in the text is punctuation or part of a path, so `look in /etc/passwd` produces no `Command`. +- **`ParsedRoom` is emitted by `renderOne` as plain text** (`r.addText(p.raw)`) rather than a link, because the renderer has no way to know a room's display name. Handle `isRoom` yourself when building a UI, the same as `isLinkGrid`. - **Email matching** strips a leading `mailto:` — the resulting `ParsedEmail.value` is always the bare address string. diff --git a/.agents/skills/welshman-editor/SKILL.md b/.agents/skills/welshman-editor/SKILL.md index c1d571d6..10ce4462 100644 --- a/.agents/skills/welshman-editor/SKILL.md +++ b/.agents/skills/welshman-editor/SKILL.md @@ -41,6 +41,7 @@ import "@welshman/editor/index.css" | `BreakOrSubmit` | Keyboard handler: `Mod-Enter` always submits; `Enter` submits only when `aggressive: true` (chat-style); `Shift-Enter` inserts a hard break. | | `CodeInline` | Inline `code` node with backtick input/paste rules. | | `WordCount` | Extension that tracks `editor.storage.wordCount.words` and `editor.storage.wordCount.chars` on every document update. | +| `CommandExtension` | Inline atom node (`name: "command"`) holding a slash-command invocation — `{command, pubkey?}` attributes. `renderText` emits the canonical invocation via `renderCommandInvocation` from `@welshman/util`, so an executor that knows nothing about this client can read it back out of the note. | ### Node Views @@ -57,8 +58,9 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele | Export | Description | |--------|-------------| -| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. | -| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. | +| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. | +| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. | +| `CommandSuggestion` | Pre-configured `TippySuggestion` for `/`-triggered slash commands. Requires `editor`, `search`, and `getAttributes(value) => CommandAttributes \| undefined`. Sets `showOnEmpty: true` (a bare `/` lists what's available) and `allow: ({range}) => range.from === 1` (an invocation is only valid at the very start of the content). | | `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. | **`TippySuggestion` options:** @@ -71,7 +73,11 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele | `search` | yes | `(term: string) => string[]` — returns item values matching the query | | `select` | yes | `(value: string, props) => void` — called when the user picks an item; call `props.command({...attrs})` to insert the node | | `updateSignal` | no | A Svelte `Readable` store; when it emits, the suggestion list re-renders (use for async/reactive search results) | +| `allowCreate` | no | Let the user commit the raw term as an item (default `false`) | +| `showOnEmpty` | no | Show the whole list before anything is typed (default `false`). Right for a small closed set the user browses; wrong for profiles, where a bare trigger matches everything | +| `allow` | no | `({state, range}) => boolean` — narrow where the suggestion can fire, on top of the schema check | | `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item | +| `createSuggestionsWrapper` | no | `(target, props) => ISuggestionsWrapper` — swap the dropdown for a framework component | `MentionSuggestion` is a pre-wired `TippySuggestion` for nprofile nodes. It handles `select` internally (encodes the pubkey as an nprofile with relay hints from `getRelays`) so you only need to supply `editor`, `search`, and `getRelays`. @@ -81,7 +87,6 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele |--------|--------| | `Editor` | `@tiptap/core` — the editor instance class | | `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) | -| `NodeViewRendererProps` | `@tiptap/core` — alternate props type used in `Node.create({ addNodeView })` | | `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload | | `FileAttributes` | `nostr-editor` — `{ file: File, … }` passed to the `upload` callback | | `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` | @@ -138,7 +143,7 @@ import {get, writable} from "svelte/store" import {Node, Extension, mergeAttributes} from "@tiptap/core" import {Plugin, PluginKey} from "@tiptap/pm/state" import type {NodeViewRendererProps} from "@tiptap/core" -import {Profiles, Router, createSearch} from "@welshman/app" +import {Profiles, Router} from "@welshman/app" import {outbox} from "@welshman/util" import { Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps, @@ -187,11 +192,8 @@ export const makeEditor = ({ charCount?: ReturnType> submit: () => void }) => { - const profileSearch = createSearch(get(profiles), { - onSearch: searchProfiles, - getValue: (p: any) => p.event.pubkey, - fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3}, - }) + // The Profiles plugin maintains a ready-made fuzzy search over known profiles + const profileSearch = get(app.use(Profiles).profileSearch) const editor = new Editor({ content, @@ -232,7 +234,7 @@ export const makeEditor = ({ addNodeView: () => ({node}: NodeViewRendererProps) => { const dom = document.createElement("span") dom.classList.add("mention") - const unsub = deriveProfileDisplay(node.attrs.pubkey) + const unsub = app.use(Profiles).display(node.attrs.pubkey).$ .subscribe($d => { dom.textContent = "@" + $d }) return { dom, destroy: unsub, @@ -246,7 +248,8 @@ export const makeEditor = ({ MentionSuggestion({ editor: (this as any).editor, search: term => profileSearch.searchValues(term), - getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(), + getRelays: async pubkey => + (await app.use(Router).resolve([outbox(pubkey)])).getUrls(), createSuggestion: pubkey => { const el = document.createElement("span") el.textContent = pubkey.slice(0, 12) + "…" @@ -324,7 +327,7 @@ const onSubmit = (editor: Editor) => { ## Integration Notes - **`@welshman/app`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names. -- **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`. +- **`@welshman/app`** — `app.use(Router).resolve([outbox(pubkey)])` provides the relay hints encoded into nprofile bech32 strings. - **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying. - **`nostr-editor`** — `WelshmanExtension` extends `NostrExtension` from this package. Storage at `editor.storage.nostr` (including `getEditorTags()`) is provided by `nostr-editor`, not welshman itself. - **`@tiptap/core`** — `Editor`, `NodeViewProps`, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from `@tiptap/core` as needed. diff --git a/.agents/skills/welshman-feeds/SKILL.md b/.agents/skills/welshman-feeds/SKILL.md index 600e9924..35c02879 100644 --- a/.agents/skills/welshman-feeds/SKILL.md +++ b/.agents/skills/welshman-feeds/SKILL.md @@ -115,11 +115,12 @@ class FeedCompiler { } type FeedCompilerOptions = { + router: FeedRouter // REQUIRED — resolves relay selections getPubkeysForScope: (scope: Scope) => string[] getPubkeysForWOTRange: (min: number, max: number) => string[] signer?: ISigner signal?: AbortSignal - context?: AdapterContext + context?: AdapterContext // net context: {pool, repository, getAdapter?} } ``` @@ -147,6 +148,32 @@ type FeedControllerOptions = FeedCompilerOptions & { } ``` +### Routing (`FeedRouter`) + +`@welshman/feeds` has no way to turn a pubkey into relay urls, so it declares the capability as an interface and the caller supplies it. + +```typescript +import type {RelaySelection, RelayScenario} from '@welshman/util' + +export interface FeedRouter { + resolve(selections: RelaySelection[]): Promise +} + +// Decide which relays serve which filters under the outbox model +getFilterSelections(filters: Filter[], router: FeedRouter): Promise +// RelaysAndFilters = {relays: string[]; filters: Filter[]} +``` + +`getFilterSelections` applies one rule per outbox source: a `search` filter goes to `searchRelays(10)`; a gift-wrap filter with no `authors` goes to `userMessaging()`; a filter with `authors` is chunked and sent to each author's `outbox()`; and everything additionally gets a low-weight `userInbox(0.2)` pass. Each group resolves with `addMinimalFallbacks`. + +`@welshman/app`'s `Router` plugin implements `FeedRouter`, and `app.use(Feeds).makeFeedController(...)` supplies it (along with `getPubkeysForScope`, `getPubkeysForWOTRange`, the signer, and the app's net context) so you only pass `feed` and your callbacks. The `RelaySelection` DSL itself is documented in the `welshman-util` skill. + +### Display & validation helpers + +`display*` functions render a feed definition as human-readable text — `displayFeed(feed)` dispatches on type, with `displayAuthorFeed`, `displayKindFeed`, `displayScopeFeed`, `displayTagFeed`, … underneath, plus `displayFeeds(feeds)` for a list. + +`validate*` functions throw on a malformed feed tuple — `validateFeed(feed)` dispatches, with `validateAuthorFeed`, `validateDVMFeed`, `validateFeedArgs`, `validateTagFeedMapping`, … underneath. Run `validateFeed` on anything decoded from a kind-31890 event before compiling it. + ## Common Patterns ### 1. Simple author + kind feed @@ -156,6 +183,7 @@ import { FeedController, makeIntersectionFeed, makeAuthorFeed, makeKindFeed } fr import { Scope } from '@welshman/feeds' const controller = new FeedController({ + router, // a FeedRouter — e.g. app.use(Router) feed: makeIntersectionFeed( makeAuthorFeed("pubkey1", "pubkey2"), makeKindFeed(1), @@ -178,6 +206,7 @@ import { } from '@welshman/feeds' const controller = new FeedController({ + router, feed: makeIntersectionFeed( makeScopeFeed(Scope.Follows), makeWOTFeed({ min: 0.1 }), @@ -206,6 +235,7 @@ import { // DVMItem.mappings controls how DVM result tags become sub-feeds const controller = new FeedController({ + router, feed: makeIntersectionFeed( makeDVMFeed({ kind: 5300, @@ -227,6 +257,7 @@ await controller.load(30) import { FeedController, makeListFeed, makeKindFeed, makeUnionFeed, FeedType } from '@welshman/feeds' const controller = new FeedController({ + router, feed: makeUnionFeed( makeListFeed({ addresses: ["10003:pubkey:identifier"], @@ -255,6 +286,7 @@ const filters = [ const feed = feedFromFilters(filters) const compiler = new FeedCompiler({ + router, getPubkeysForScope: () => [], getPubkeysForWOTRange: () => [], }) @@ -285,11 +317,13 @@ console.log('Authors in feed:', [...authors]) - **`@welshman/util`** — `Filter`, `TrustedEvent`, and nostr primitives used throughout. `getIdFilters()` is used internally by the compiler for address feeds. - **`@welshman/signer`** — `ISigner` interface, passed optionally through `FeedCompilerOptions` for DVM requests that require signing. - **`@welshman/net`** — The `FeedController` delegates to `requestPage` for relay communication. The `FeedCompiler` delegates to `requestDVM` for DVM-based feeds. Neither accepts `request` or `requestDVM` as constructor options. `AdapterContext` from net is passed through `FeedCompilerOptions`. -- **`@welshman/app`** — Higher-level app packages typically wire up `getPubkeysForScope` and `getPubkeysForWOTRange` using their own follow/WOT stores, then construct `FeedController` instances from user-facing feed definitions. +- **`@welshman/app`** — `app.use(Feeds).makeFeedController({feed, onEvent, …})` supplies `router` (the `Router` plugin), `getPubkeysForScope`/`getPubkeysForWOTRange` (from `Wot`), the user's signer, and the app's `{pool, repository}` context. `Feeds` is also the kind-31890 saved-feed collection. - **`Tracker`** — Optional deduplication helper (from `@welshman/net` or app layer). Pass a shared `Tracker` instance to avoid re-emitting events seen in other controllers. ## Gotchas & Tips +- **`router` is required.** `FeedCompilerOptions.router` has no default; a `FeedController` or `FeedCompiler` constructed without one will fail when it tries to resolve relays. In an app, go through `app.use(Feeds).makeFeedController(...)`. + - **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories. - **`useWindowing: true`** is for relays that may return events out of chronological order. Do not use it for DVM/algorithmic feeds where order is part of the result. - **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset. diff --git a/.agents/skills/welshman-lib/SKILL.md b/.agents/skills/welshman-lib/SKILL.md index cc7f5800..98df2ffb 100644 --- a/.agents/skills/welshman-lib/SKILL.md +++ b/.agents/skills/welshman-lib/SKILL.md @@ -23,7 +23,7 @@ pnpm add @welshman/lib |--------|-------------| | `Deferred` | Type: a `Promise` with `.resolve(T)` and `.reject(E)` methods attached | | `defer()` | Creates a `Deferred` — a promise with exposed `.resolve()` and `.reject()` | -| `makePromise(executor)` | Creates a strongly-typed promise with typed error | +| `makePromise(executor)` | Creates a strongly-typed promise with typed error (`CustomPromise`) | `E` defaults to `T` when omitted. `defer()` for a signal-style deferred. `thunk.complete` in `@welshman/app` is a `Deferred`. @@ -54,8 +54,8 @@ bus.emit('login', { pubkey: '...' }) | Export | Description | |--------|-------------| | `LRUCache` | LRU cache; evicts least-recently-used entries when full | +| `simpleCache(getValue)` | Minimal memoization wrapper over an `LRUCache` with default settings | | `cached(options)` | Memoizes a function with an LRU backing cache; exposes `.cache` and `.pop()` | -| `simpleCache(getValue)` | Minimal memoization wrapper with default settings | ```typescript import { LRUCache, cached } from '@welshman/lib' @@ -125,6 +125,7 @@ displayDomain('relay.damus.io/path') // => 'relay.damus.io' | `batch(t, fn)` | First call fires `fn([item])` immediately; subsequent calls within `t` ms are collected and `fn` is called with all accumulated items | | `batcher(t, execute)` | Collects calls for `t` ms, then calls `execute` with all accumulated requests; each individual call returns a `Promise` resolved with its result from the batch. Unlike `batch`, the first call is also deferred — nothing fires immediately. | | `race(threshold, promises)` | Resolves when `threshold` fraction of promises complete | +| `makeQueue()` | Returns `(f: () => Promise) => Promise` — chains every call onto one serial promise, logging and swallowing rejections so a failure doesn't stall the chain | ### Timestamp / Time Constants @@ -160,6 +161,7 @@ displayDomain('relay.damus.io/path') // => 'relay.damus.io' | `within([low, high], n)` | `n >= low && n <= high` (inclusive) | | `clamp([min, max], n)` | Constrains `n` to the range | | `round(precision, x)` | Rounds to `precision` decimal places | +| `toInt(x)` | `parseInt` that returns `undefined` instead of `NaN` — accepts `number \| string \| undefined` | ### Array / Sequence Utilities @@ -250,6 +252,8 @@ type MaybeStr = Maybe // string | undefined | `ifLet(x, f)` | Calls `f(x)` only if `x` is defined | | `doLet(x, f)` | Calls `f(x)` and returns the result — scoped binding without a variable | | `isDefined(x)` / `isUndefined(x)` / `assertDefined(x)` | `undefined` checks (not null) | +| `maybe(x?)` | Identity, typed `T \| undefined` — widens a value to `Maybe` | +| `allPass(...preds)` / `somePass(...preds)` | Combine predicates into one `(x) => boolean` | ### Curried Collection Helpers @@ -388,7 +392,6 @@ const label = formatTimestampRelative(event.created_at) // "3 hours ago" ```typescript import { on } from '@welshman/lib' -// Each App owns its repository. const unsub = on(app.repository, 'update', updates => { console.log('added', updates.flatMap(u => u.added).length, 'events') }) diff --git a/.agents/skills/welshman-net/SKILL.md b/.agents/skills/welshman-net/SKILL.md index abf1108e..dadf880b 100644 --- a/.agents/skills/welshman-net/SKILL.md +++ b/.agents/skills/welshman-net/SKILL.md @@ -1,11 +1,13 @@ --- name: welshman-net -description: "Use this skill when working with @welshman/net: relay connections, request/publish flows, auth, relay pool management, adapters, policies, or low-level nostr network I/O." +description: "Use this skill when working with @welshman/net: relay connections, request/publish flows, auth, relay pool management, adapters, socket policies, the Repository/Tracker/WrapManager stores, or low-level nostr network I/O." --- # welshman/net — Relay Network Layer -`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which owns an `App` instance and its plugins) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities. +`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which owns instances of these primitives and wires them together) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities. + +**There are no module-level singletons.** `Pool`, `Repository`, `Tracker` and `WrapManager` are plain classes you instantiate; the pool and repository a call should use are passed per call as a `context`. `Pool.get()`, `Repository.get()` and a mutable global `netContext` do not exist. ## Installation @@ -18,18 +20,31 @@ yarn add @welshman/net ## Key Exports +### Context + +| Export | Description | +|--------|-------------| +| `NetContext` | `{pool?: Pool, repository?: Repository, getAdapter?: AdapterFactory}` — the instances a call should use | +| `AdapterContext` | `Partial` — what every `context` parameter accepts | +| `AdapterFactory` | `(url: string, context: NetContext) => AbstractAdapter \| undefined` | + +Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader`, `diff`/`pull`/`push`) takes an optional `context`. There is no default: without `context.pool` a `wss://` url throws `"Unable to connect to relays without context.pool"`, and without `context.repository` `LOCAL_RELAY_URL` throws `"LOCAL_RELAY_URL cannot be used without context.repository"`. In an app, `app.netContext` is that object and `app.use(Network)` passes it for you. + ### Pool & Sockets | Export | Description | |--------|-------------| -| `Pool` | Singleton connection pool; creates and manages `Socket` instances per relay URL | -| `Pool.get()` | Returns the singleton `Pool` instance | -| `pool.get(url)` | Gets or lazily creates a `Socket` for the given relay URL | -| `pool.remove(url)` | Removes and cleans up a socket | -| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns unsubscriber | +| `Pool` | Connection pool; creates and manages `Socket` instances per relay url. Construct with `new Pool()` | +| `pool.socketPolicies` | The policies applied to sockets this pool creates — copied from `defaultSocketPolicies` at construction | +| `pool.get(url)` | Gets or lazily creates a `Socket` for a (normalized) relay url | +| `pool.has(url)` | Whether a socket already exists for the url | +| `pool.remove(url)` | Cleans up the socket and forgets the url | +| `pool.clear()` | Removes every socket | +| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns an unsubscriber | | `Socket` | WebSocket wrapper with status tracking, send queue, and auth state | | `SocketStatus` | Enum: `Open`, `Opening`, `Closing`, `Closed`, `Error` | | `SocketEvent` | Enum: `Status`, `Send`, `Sending`, `Receive`, `Receiving`, `Error` | +| `socket.open()` / `attemptToOpen()` / `close()` / `cleanup()` / `send(message)` | Connection and send control | | `socket.auth` | `AuthState` instance for NIP-42 on this connection | ### Request @@ -39,20 +54,24 @@ yarn add @welshman/net | `requestOne(options)` | Subscribe to a single relay; returns `Promise` | | `request(options)` | Subscribe to multiple relays in parallel; returns `Promise` | | `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold | -| `load(options)` | Pre-built loader: 200 ms batch delay, 3 s timeout, 0.5 threshold. Simpler than `request()` when you just want events — auto-closes after EOSE, timeout, or disconnect; resolves when half the relays' subscriptions have closed; returns a `Promise`. When used with `@welshman/app`, received events auto-flow into the repository and tracker. | +| `load(options)` | Pre-built loader with a 30 ms batch delay, 3 s timeout and 0.5 threshold. It auto-closes after EOSE, timeout, or disconnect, and resolves when half the relays' subscriptions have closed. It carries no context, so it only works where no pool or repository is needed. | -`request` / `requestOne` options (key fields): -- `relay` / `relays` — relay URL(s) +`request` / `requestOne` options (`BaseRequestOptions`): +- `relay` / `relays` — relay url(s) - `filters` — array of nostr `Filter` objects -- `autoClose?: boolean` — close subscription after EOSE or on socket disconnect +- `autoClose?: boolean` — close the subscription after EOSE or on socket disconnect - `signal?: AbortSignal` — cancellation - `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`) +- `context?: AdapterContext` +- `resubscribeAttempts?: number` — how many times to retry a subscription the relay CLOSEs (default `0`; each retry backs off `2 ** attempt` seconds) +- `isEventValid?: (event, url) => boolean` — signature check override; defaults to `verifyEvent` - Callbacks: `onEvent(event, url)`, `onEose(url)`, `onClose()`, `onDisconnect(url)`, `onFiltered`, `onDuplicate`, `onDeleted`, `onInvalid`, `onClosed(reason, url)` -`request`-only options: -- `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`) +`request`-only: `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`). -Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the returned promise only resolves if the relay sends CLOSED for all active subscription IDs. Default policies also re-send the REQ when sockets reconnect. +`makeLoader` options: `{delay, timeout?, threshold?, context?, isEventValid?}`. The returned `Loader` takes `{relays, filters, signal?, onEvent?, onDisconnect?, onEose?, onClose?}`. + +Without `autoClose` or a `signal`, `requestOne` streams indefinitely. The returned promise only resolves if the relay sends CLOSED for all active subscription ids. ### Publish @@ -61,7 +80,7 @@ Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the ret | `publish(options)` | Publishes to multiple relays; resolves to `PublishResultsByRelay` | | `publishOne(options)` | Publishes to a single relay; resolves to `PublishResult` | | `PublishStatus` | Enum: `Sending`, `Pending`, `Success`, `Failure`, `Timeout`, `Aborted` | -| `PublishResult` | `{ relay: string, status: PublishStatus, detail: string }` | +| `PublishResult` | `{status: PublishStatus, detail: string, relay: string}` | | `PublishResultsByRelay` | `Record` | `publish` options: `event`, `relays`, `timeout?` (default 10 s), `signal?`, `context?`, plus callbacks `onSuccess`, `onFailure`, `onPending`, `onTimeout`, `onAborted`, `onComplete`. @@ -73,78 +92,74 @@ Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the ret | `AuthState` | Manages auth state for one socket; available as `socket.auth` | | `AuthStatus` | Enum: `None`, `Requested`, `PendingSignature`, `DeniedSignature`, `PendingResponse`, `Forbidden`, `Ok` | | `AuthStateEvent.Status` | Emitted when auth status changes | -| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges | -| `defaultSocketPolicies` | Mutable array of policies applied to every new socket | +| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges. Options: `{sign, shouldAuth?}` | ### Policies +A `SocketPolicy` is `(socket: Socket) => Unsubscriber`, run once per socket at creation. + | Export | Description | |--------|-------------| -| `socketPolicyPing` | Sends a PING frame every 30 s when the socket is open and idle, to keep the connection alive | -| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays after success | +| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays them once it completes | | `socketPolicyConnectOnSend` | Auto-opens closed sockets when a message is queued | -| `socketPolicyCloseInactive` | Closes idle sockets after 30 s (when no pending work remains); if the socket closes with pending work it delays and reopens, replaying queued messages | -| `defaultSocketPolicies` | Array of the four above; passed to every socket created by `Pool` | +| `socketPolicyLifecycle` | Owns the socket's lifetime: closes it after 30 s idle with nothing pending; while work *is* pending, probes with a throwaway REQ when nothing has been received for a while and closes if the probe goes unanswered; on an unexpected close, reopens after a flap delay and replays pending messages, rewriting each REQ's filters with `catchUpFilter` so nothing is missed | +| `defaultSocketPolicies` | `[socketPolicyAuthBuffer, socketPolicyConnectOnSend, socketPolicyLifecycle]` | -A `SocketPolicy` is `(socket: Socket) => Unsubscriber`. +`defaultSocketPolicies` is a template. `new Pool()` copies it into `pool.socketPolicies`, so mutating the array after a pool exists does nothing for that pool; assign to `pool.socketPolicies` instead. ### Repository | Export | Description | |--------|-------------| -| `Repository` | In-memory indexed event store with delete/expiry support | -| `Repository.get()` | Returns the singleton instance | -| `repository.publish(event)` | Stores an event; returns `false` if duplicate/stale | -| `repository.query(filters, opts?)` | Returns matching `TrustedEvent[]` sorted by `created_at` desc | +| `Repository` | In-memory indexed event store with delete/expiry support. Construct with `new Repository()` | +| `repository.publish(event, {shouldNotify?})` | Stores an event; returns `false` if duplicate/stale | +| `repository.query(filters, {shouldSort?})` | Returns matching `TrustedEvent[]`, sorted by `created_at` desc unless disabled | | `repository.getEvent(idOrAddress)` | Look up by id or NIP-01 address (`kind:pubkey:d`) | -| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event | -| `repository.dump()` | Returns all stored events as `TrustedEvent[]` | -| `repository.load(events)` | Bulk-replaces all stored events; emits a single `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification. | -| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional URL for the local repository | -| `RepositoryUpdate` | `{ added: TrustedEvent[], removed: Set }` — payload of `"update"` events | +| `repository.hasEvent(event)` | Whether the event (or a newer replacement) is already stored | +| `repository.removeEvent(idOrAddress)` | Drops an event and unwinds its index entries | +| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event (`isDeletedById` / `isDeletedByAddress` check one path each) | +| `repository.isExpired(event)` | `true` past the event's NIP-40 `expiration` | +| `repository.dump()` | All stored events as `TrustedEvent[]` | +| `repository.load(events)` | Bulk-**replaces** all stored events; emits one `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification | +| `repository.clear()` | Empties every index | +| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional url for the local repository (also exported by `@welshman/util`) | +| `RepositoryUpdate` | `{added: TrustedEvent[], removed: Set}` — payload of `"update"` events | | `mergeRepositoryUpdates(updates)` | Merges an array of `RepositoryUpdate` objects into one | -Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set }`) on every change. +Emits `"update"` with a `RepositoryUpdate` on every change. -> **Prefer `LOCAL_RELAY_URL` over direct repository access.** Rather than calling `repository.query()` or `repository.publish()` directly, pass `LOCAL_RELAY_URL` as a relay URL to the standard `load()`, `request()`, and `publish()` functions. This keeps local reads/writes going through the same policy, deduplication, and tracking pipeline as remote relay operations. Direct repository access is appropriate only for bulk startup (`repository.load()`) and low-level introspection (`repository.getEvent()`, `repository.isDeleted()`). +> **Prefer `LOCAL_RELAY_URL` over direct repository access.** Rather than calling `repository.query()` or `repository.publish()` directly, pass `LOCAL_RELAY_URL` as a relay url to `load()`, `request()` and `publish()` (with the repository in `context`). Local reads and writes then go through the same policy, deduplication and tracking pipeline as remote ones. Reserve the direct API for bulk startup (`repository.load()`) and low-level introspection (`getEvent`, `isDeleted`, `dump`). ### Tracker | Export | Description | |--------|-------------| -| `Tracker` | Bidirectional map of `eventId ↔ Set` | -| `tracker.track(eventId, relay)` | Records relay; returns `true` if the event was already seen | -| `tracker.getRelays(eventId)` | Set of relay URLs that have sent this event | +| `Tracker` | Bidirectional map of `eventId ↔ Set` (`relaysById` / `idsByRelay`) | +| `tracker.track(eventId, relay)` | Records the relay; returns `true` if the event was already seen | +| `tracker.addRelay(id, relay)` / `removeRelay(id, relay)` | Explicit edge management | +| `tracker.hasRelay(id, relay)` | Membership check | +| `tracker.getRelays(eventId)` | Set of relay urls that have sent this event | | `tracker.getIds(relay)` | Set of event ids seen from a relay | | `tracker.copy(id1, id2)` | Copies relay associations from one id to another (used for gift wraps) | -| `tracker.load(relaysById)` | Bulk-replaces all relay mappings from a `Map>`; emits `"load"` | -| `tracker.clear()` | Removes all relay mappings; emits `"clear"` | +| `tracker.load(relaysById)` | Bulk-replaces all mappings from a `Map>`; emits `"load"` | +| `tracker.clear()` | Removes all mappings; emits `"clear"` | ### Adapters | Export | Description | |--------|-------------| -| `getAdapter(url, context?)` | Factory: returns `SocketAdapter`, `LocalAdapter`, or custom adapter | +| `getAdapter(url, context?)` | Factory: `context.getAdapter` first, then `LocalAdapter` for `LOCAL_RELAY_URL`, then `SocketAdapter` for relay urls | | `SocketAdapter` | WebSocket relay adapter | -| `LocalAdapter` | In-memory relay adapter | +| `LocalAdapter` | In-memory adapter over a `Repository` | | `MockAdapter` | Test adapter with manual send control | | `AbstractAdapter` | Base class for custom adapters | | `AdapterEvent.Receive` | Emitted when a relay message arrives | -### Context - -| Export | Description | -|--------|-------------| -| `NetContext` | `{ pool?, repository?, getAdapter? }` | - -Pass `context` to each `request`/`publish`/`load` call. An `App` from `@welshman/app` owns one per -instance (`app.netContext`), and `app.use(Network)` supplies it for you. - ### Negentropy / Diff (NIP-77) | Export | Description | |--------|-------------| -| `diff(options)` | Compares local events against relays; returns `{ relay, have, need }[]` | +| `diff(options)` | Compares local events against relays; returns `{relay, have, need}[]` | | `pull(options)` | Fetches events relays have that you don't | | `push(options)` | Publishes events you have that relays don't | | `Difference` | Low-level per-relay negentropy session | @@ -153,27 +168,43 @@ instance (`app.netContext`), and `app.use(Network)` supplies it for you. | Export | Description | |--------|-------------| -| `RelayMessageType` | Enum of relay→client message types | -| `ClientMessageType` | Enum of client→relay message types | -| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, etc. | Type guards for relay messages | -| `isClientReq()`, `isClientEvent()`, etc. | Type guards for client messages | +| `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types | +| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, … | Type guards for relay messages | +| `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, … | Type guards for client messages | +| `matchReason(prefix, reason)` / `RelayReasonPrefix` | Match a relay's machine-readable `OK`/`CLOSED` reason prefix (`auth-required:`, `restricted:`, …) | ### WrapManager | Export | Description | |--------|-------------| -| `WrapManager` | Tracks NIP-59 gift wrap → rumor relationships; stores decrypted rumors in the repository and copies relay tracking from the wrap to the rumor | +| `WrapManager` | Tracks NIP-59 gift wrap ↔ rumor relationships. `new WrapManager({tracker, repository})` | +| `wrapManager.add({wrap, rumor, recipient})` | Stores the rumor in the repository and copies the wrap's relay tracking onto it | +| `wrapManager.getRumor(wrapId)` / `getWraps(rumorId)` | Look up either direction | +| `wrapManager.remove(id)` / `removeByRumorId(id)` / `clear()` | Teardown | +| `wrapManager.dump()` / `load(wrapItems)` | Persist and restore (`WrapItem[]`) | --- ## Common Patterns +### Set up a context + +```typescript +import {Pool, Repository} from '@welshman/net' +import type {NetContext} from '@welshman/net' + +const pool = new Pool() +const repository = new Repository() +const context: NetContext = {pool, repository} +``` + +Pass `context` to every net call below. + ### Connect to a relay and stream events ```typescript -import {Pool, SocketEvent, SocketStatus} from '@welshman/net' +import {SocketEvent, SocketStatus} from '@welshman/net' -const pool = Pool.get() const socket = pool.get('wss://relay.example.com') socket.on(SocketEvent.Status, (status: SocketStatus) => { @@ -187,10 +218,12 @@ socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}]) ### Load events (one-shot, batched) ```typescript -import {load} from '@welshman/net' +import {makeLoader} from '@welshman/net' + +// Bind a loader to the context once; concurrent calls within `delay` collapse +// into a single REQ per relay. +const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5, context}) -// load() batches multiple concurrent calls within 200 ms into a single REQ per relay. -// It auto-closes after EOSE, timeout, or disconnect, and resolves at 50 % relay threshold. const events = await load({ relays: ['wss://relay.example.com', 'wss://relay2.example.com'], filters: [{kinds: [0], authors: ['']}], @@ -203,14 +236,15 @@ const events = await load({ import {request} from '@welshman/net' import {now} from '@welshman/lib' -// Without autoClose this will stream forever. -// The returned promise never settles unless all relays close the subscription. +// Without autoClose this streams forever; the returned promise never settles +// unless all relays close the subscription. const ctrl = new AbortController() request({ relays: ['wss://relay.example.com'], filters: [{kinds: [1], since: now()}], signal: ctrl.signal, + context, onEvent: (event, url) => console.log(event.id, 'from', url), }) @@ -227,6 +261,7 @@ const results = await publish({ event: signedEvent, relays: ['wss://relay.example.com', 'wss://relay2.example.com'], timeout: 5000, + context, onSuccess: r => console.log('accepted by', r.relay), onFailure: r => console.warn('rejected by', r.relay, r.detail), }) @@ -238,36 +273,40 @@ for (const [relay, result] of Object.entries(results)) { } ``` -### Enable NIP-42 auth globally +### Enable NIP-42 auth + +Policies are per-pool. Set them before the pool creates any sockets, because a socket already in the pool keeps the policies it was built with. ```typescript -import {defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net' +import {Pool, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net' import type {StampedEvent} from '@welshman/util' -// Call once at app startup, before any sockets are opened. -defaultSocketPolicies.push( +const pool = new Pool() + +pool.socketPolicies = [ + ...defaultSocketPolicies, makeSocketPolicyAuth({ sign: (event: StampedEvent) => mySigner.sign(event), - shouldAuth: (socket) => true, // auth on every relay + shouldAuth: socket => true, // auth on every relay }), -) +] ``` ### Custom socket policies -A `SocketPolicy` is `(socket: Socket) => Unsubscriber`. It receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function. Push custom policies onto `defaultSocketPolicies` before any sockets are opened. +A policy receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function. ```typescript import {writable} from 'svelte/store' import {on} from '@welshman/lib' -import {defaultSocketPolicies, SocketEvent, isRelayEvent} from '@welshman/net' +import {SocketEvent, isRelayEvent} from '@welshman/net' import type {Socket, RelayMessage} from '@welshman/net' // Track how many events each relay has delivered this session export const eventCountByRelay = writable>({}) -const eventCountPolicy = (socket: Socket) => { - const unsub = on(socket, SocketEvent.Receive, (message: RelayMessage) => { +const eventCountPolicy = (socket: Socket) => + on(socket, SocketEvent.Receive, (message: RelayMessage) => { if (isRelayEvent(message)) { eventCountByRelay.update(counts => ({ ...counts, @@ -276,13 +315,10 @@ const eventCountPolicy = (socket: Socket) => { } }) - return unsub // called when the socket is destroyed -} - -defaultSocketPolicies.push(eventCountPolicy) +pool.socketPolicies = [...pool.socketPolicies, eventCountPolicy] ``` -The same structure applies to more advanced patterns — patch `socket.open` to block connections, listen to `SocketEvent.Sending`/`SocketEvent.Receiving` to intercept messages before they are processed, or manipulate `socket._recvQueue` directly to suppress or replay messages. +The same structure covers more advanced patterns. Patch `socket.open` to block connections, or listen to `SocketEvent.Sending`/`SocketEvent.Receiving` to intercept messages before they are processed. ### Custom adapter (e.g. non-WebSocket backend) @@ -300,7 +336,8 @@ class MyAdapter extends AbstractAdapter { get sockets() { return [] } send(message: ClientMessage) { - // forward message to your backend; call this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives + // forward to your backend; call + // this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives } } @@ -309,17 +346,18 @@ request({ filters: [{kinds: [1]}], autoClose: true, context: { - getAdapter: (url) => url.startsWith('myscheme://') ? new MyAdapter(url) : undefined, + ...context, + getAdapter: url => (url.startsWith('myscheme://') ? new MyAdapter(url) : undefined), }, }) ``` +A `getAdapter` that returns `undefined` falls through to the built-in resolution, so you can special-case one scheme and leave the rest alone. + ### Use LOCAL_RELAY_URL to read/write the local repository -Pass `LOCAL_RELAY_URL` as a relay to the standard net functions so local operations go through the same pipeline as remote ones (policies, deduplication, tracker): - ```typescript -import {load, publish, request, LOCAL_RELAY_URL} from '@welshman/net' +import {publish, request, LOCAL_RELAY_URL} from '@welshman/net' import {now} from '@welshman/lib' // Read from the local repository the same way you'd read from a remote relay @@ -332,27 +370,24 @@ const events = await load({ await publish({ event: signedEvent, relays: [LOCAL_RELAY_URL, 'wss://relay.example.com'], + context, }) // Subscribe to new local events in real time request({ relays: [LOCAL_RELAY_URL], filters: [{kinds: [1], since: now()}], - onEvent: (event) => console.log('new local event', event.id), + context, + onEvent: event => console.log('new local event', event.id), }) ``` -Direct `repository` API calls (`repository.load()`, `repository.getEvent()`, `repository.isDeleted()`, `repository.dump()`) are still appropriate for bulk startup and low-level introspection — but for routine reads and writes prefer `LOCAL_RELAY_URL`. - ### Startup: bulk-load persisted events (skip re-verification) ```typescript -import {Repository} from '@welshman/net' import {verifiedSymbol} from '@welshman/util' import type {TrustedEvent} from '@welshman/util' -const repo = Repository.get() - // Mark events as already-verified so welshman skips signature checks const storedEvents: TrustedEvent[] = await loadFromStorage() for (const event of storedEvents) { @@ -360,23 +395,21 @@ for (const event of storedEvents) { } // Replaces all in-memory events in one pass; emits a single "update" -repo.load(storedEvents) +repository.load(storedEvents) ``` ### Startup: bulk-load Tracker state ```typescript -// `app.tracker` — wired to that app's pool and repository - -// Build the map from your stored relay<->event mappings +// Build the map from your stored relay <-> event mappings const relaysById = new Map>() for (const {id, relays} of storedTrackerItems) { - if (repo.getEvent(id)) { // skip orphaned entries + if (repository.getEvent(id)) { // skip orphaned entries relaysById.set(id, new Set(relays)) } } -// Takes Map> — same shape as tracker.relaysById +// Takes Map> — the same shape as tracker.relaysById tracker.load(relaysById) ``` @@ -384,7 +417,6 @@ tracker.load(relaysById) ```typescript import {on, batch} from '@welshman/lib' -// `app.repository`, or `new Repository()` when using @welshman/net standalone import type {RepositoryUpdate} from '@welshman/net' import type {TrustedEvent} from '@welshman/util' @@ -415,28 +447,30 @@ on( ## Integration Notes -- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified). -- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, etc.) used internally; `Emitter` (from `@welshman/lib`) is the base class for `Tracker`, `Repository`, and `WrapManager`. `Socket`, `AuthState`, `AbstractAdapter`, and `Difference` extend node's built-in `EventEmitter` directly. -- **`@welshman/app`** — wraps `@welshman/net` in an `App` instance whose plugins own routing, collections, and publishing. Most app-level code should go through `app.use(Network)`; drop down to `@welshman/net` only for raw relay I/O or when building without an `App`. -- **`NetContext`** — passed explicitly per call. Each `App` owns its own pool and repository, which keeps one identity's data out of another's. +- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, `normalizeRelayUrl`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent`. +- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, …). `Emitter` is the base class for `Tracker`, `Repository` and `WrapManager`; `Socket`, `AuthState`, `AbstractAdapter` and `Difference` extend node's `EventEmitter` directly. +- **`@welshman/app`** — an `App` owns one `Pool`, `Repository`, `Tracker` and `WrapManager`, exposes them as `app.netContext`, and wraps the request/publish entry points as `app.use(Network)`. Most app-level code should go through that; drop to `@welshman/net` for raw relay I/O or non-Svelte clients. --- ## Gotchas & Tips -- **Use `LOCAL_RELAY_URL`, not direct repository calls, for routine reads/writes.** Passing `LOCAL_RELAY_URL` to `load()`, `publish()`, or `request()` routes through the normal net pipeline (policies, deduplication, tracker). Calling `repository.query()` / `repository.publish()` directly bypasses all of that. Reserve the direct API for bulk startup (`repository.load()`), introspection (`getEvent`, `isDeleted`, `dump`), and listening to `"update"` events. +- **Build one `{pool, repository}` per identity and thread it through.** Two contexts never share sockets or events. +- **`request()` without `autoClose` or `signal` never resolves.** Pass one when you want a one-shot fetch; use a loader for the common case. +- **Relay url normalization happens inside `pool.get(url)`** via `normalizeRelayUrl`. Pass raw urls everywhere. +- **`pool.get(url)` creates a fresh socket after `pool.remove(url)`.** `remove` forgets the url and cleans up the socket, policies included; call it only when you want the pool to forget the relay, not merely to disconnect. +- **`socketPolicyLifecycle` reopens a closed socket only when work is pending.** Opening a socket because something was queued is `socketPolicyConnectOnSend`'s job. +- **Subscriptions the relay CLOSEs are not retried by default.** Set `resubscribeAttempts` when a caller should survive a transient refusal (e.g. auth in flight). +- **`Tracker` is shared across relays in `request()`**, so `onDuplicate` fires for events received from more than one relay. That is cross-relay deduplication, not an error. +- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version is already stored, the older one is silently dropped. +- **`makeSocketPolicyAuth` requires a `sign` function** returning `Promise`. If the user cancels, throw or reject: `doAuth` catches it and transitions to `AuthStatus.DeniedSignature`, preventing a retry loop. +- **Each filter in `filters` generates a separate REQ** inside `requestOne`. For large filter arrays merge them with `unionFilters` from `@welshman/util` first. +- **`repository.load()` replaces, it does not append.** It clears the indexes then re-inserts, emitting a single batched `"update"`. Use `repository.publish(event)` for incremental updates. +- **`RepositoryUpdate.removed` is a `Set`.** Iterate with `for...of` or `Array.from`. `batch()` from `@welshman/lib` hands your callback a `RepositoryUpdate[]` — merge them yourself or use `mergeRepositoryUpdates`. +- **`tracker.load()` takes `Map>`.** Load it after `repository.load()` so you can drop orphaned event ids. -- **`request()` without `autoClose` or `signal` never resolves.** Always pass `autoClose: true` or an `AbortSignal` when you just want a one-shot fetch. Use `load()` for the common case. -- **`load()` sets `autoClose: true` internally** and uses a 0.5 relay threshold; it resolves when half the relays' subscriptions have closed (typically after EOSE, timeout, or disconnect) — useful when some relays are slow or offline. -- **Relay URL normalization** happens inside `Pool.get(url)` via `normalizeRelayUrl`. Pass raw URLs everywhere; the pool handles canonicalization. -- **`defaultSocketPolicies` is mutable.** Push policies before any sockets are created. Sockets created before a policy is pushed will not have it applied. -- **`socketPolicyCloseInactive` only replays pending work on unexpected close.** It reopens and replays queued messages when a socket closes while work is pending — it does not proactively open sockets when new work is queued (that is `socketPolicyConnectOnSend`'s job). After `pool.remove(url)` the socket is cleaned up including its policy listeners, so `socketPolicyCloseInactive` can no longer reopen it. -- **`Pool.get(url)` lazily creates a new socket on every call after `pool.remove(url)`.** Calling `pool.remove(url)` forgets the URL and cleans up the socket — any subsequent `pool.get(url)` will construct a fresh socket. Call `pool.remove()` only when you want the pool to forget the URL entirely, not merely to disconnect temporarily. -- **`Tracker` is shared across relays in `request()`.** This means `onDuplicate` fires for events received from more than one relay — expected behavior for cross-relay deduplication. -- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version of a replaceable event is already stored, the older one is silently dropped. -- **`WrapManager` stores the decrypted rumor in the `Repository`** and copies relay tracking from the gift-wrap event id to the rumor id. Keep a reference to the `WrapManager` instance alongside your `Repository` and `Tracker` singletons. -- **`makeSocketPolicyAuth` requires a `sign` function** that returns a `Promise`. If the user cancels signing, have the `sign` function throw or reject; `doAuth` will catch the failure via `tryCatch` and automatically transition to `AuthStatus.DeniedSignature`, preventing infinite retry loops. -- **Each filter in `filters` array generates a separate REQ** inside `requestOne`. For large filter arrays consider merging them with `unionFilters` from `@welshman/util` before calling `request`. -- **`repository.load()` replaces all events, not appends.** It clears internal indexes first, then re-inserts every event. Emit a single batched `"update"` diff — do not call it repeatedly for incremental updates; use `repository.publish(event)` for that. -- **`RepositoryUpdate.removed` is `Set`, not an array.** Iterate with `for...of` or `Array.from(removed)`. The `batch()` helper from `@welshman/lib` delivers updates as `RepositoryUpdate[]` to your flush callback — merge them yourself or use `mergeRepositoryUpdates`. -- **`tracker.load()` takes `Map>`** (the same type as `tracker.relaysById`). Load it after `repository.load()` so you can filter out orphaned event ids. +## Related skills + +- `welshman-app` — the instance-based layer that owns these primitives (`app.netContext`, `app.use(Network)`, `app.use(Sync)`). +- `welshman-store` — Svelte derivations over a `Repository` and `Tracker`. +- `welshman-util` — the event, filter and relay-url types on the wire, and the routing DSL for choosing which urls to pass to these functions. diff --git a/.agents/skills/welshman-signer/SKILL.md b/.agents/skills/welshman-signer/SKILL.md index 498f8cdb..4c36ea14 100644 --- a/.agents/skills/welshman-signer/SKILL.md +++ b/.agents/skills/welshman-signer/SKILL.md @@ -25,25 +25,34 @@ yarn add @welshman/signer The common contract all signers implement. ```typescript -import type { ISigner, SignOptions, SignWithOptions } from '@welshman/signer' +import type {ISigner, SignOptions, EncryptionImplementation} from '@welshman/signer' interface ISigner { - sign: (event: StampedEvent, options?: SignOptions) => Promise + sign: SignWithOptions + nip04: EncryptionImplementation + nip44: EncryptionImplementation getPubkey: () => Promise - nip04: { - encrypt: (pubkey: string, message: string) => Promise - decrypt: (pubkey: string, message: string) => Promise - } - nip44: { - encrypt: (pubkey: string, message: string) => Promise - decrypt: (pubkey: string, message: string) => Promise - } cleanup?: () => Promise } -type SignOptions = { signal?: AbortSignal } +type SignOptions = {signal?: AbortSignal} +type Sign = (event: StampedEvent) => Promise +type SignWithOptions = (event: StampedEvent, options?: SignOptions) => Promise +type Encrypt = (pubkey: string, message: string) => Promise +type Decrypt = (pubkey: string, message: string) => Promise +type EncryptionImplementation = {encrypt: Encrypt; decrypt: Decrypt} ``` +### Helpers & wrappers + +| Export | Description | +|---|---| +| `decrypt(signer, pubkey, message)` | Picks nip04 or nip44 by sniffing the ciphertext for `?iv=` | +| `nip04` / `nip44` | The raw primitives (`encrypt`/`decrypt` taking an explicit secret; `nip04.detect(m)`; `nip44.getSharedSecret`, LRU-cached) | +| `WrappedSigner` | An `ISigner` that routes every method through a `SignerMethodWrapper`, and an `Emitter`. `@welshman/app` layers decrypt-caching and signer logging onto the user's signer this way, via `User.wrapSigner(wrap)` | +| `SignerMethodWrapper` | `(method: string, thunk: () => Promise, args: unknown[]) => Promise` — `method` is `"sign"`, `"getPubkey"`, `"nip44.decrypt"`, …; `args` lets a wrapper key a cache off the call's arguments | +| `signWithOptions(promise, options)` | Races a signing promise against a 30 s timeout and the caller's `AbortSignal` | + ### Nip01Signer (local keypair) | Export | Description | @@ -73,13 +82,22 @@ type SignOptions = { signal?: AbortSignal } ### Nip55Signer (native mobile) +This package never imports `nostr-signer-capacitor-plugin` — it types the plugin structurally as `Nip55` and the app hands it over. Install it (`npm install nostr-signer-capacitor-plugin`) and register it once at startup: + +```ts +import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin" +import {setNip55Plugin} from "@welshman/signer" + +setNip55Plugin(NostrSignerPlugin) +``` + | Export | Description | |---|---| -| `getNip55()` | Returns `Promise` — installed signing apps via Capacitor | +| `setNip55Plugin(plugin)` | Registers the Capacitor plugin. Until called, `Nip55Signer` operations throw `"Nip55 is not enabled"` | +| `getNip55Plugin()` | Returns the registered plugin, or `undefined` | +| `getNip55()` | Returns `Promise` — installed signing apps, or `[]` when no plugin is registered | | `new Nip55Signer(packageName, pubkey?)` | Communicates with the specified native app; pass saved pubkey to resume a session | -Requires the peer dependency: `npm install nostr-signer-capacitor-plugin` - ### Nip59 (Gift Wrap) | Export | Description | @@ -211,13 +229,13 @@ const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext) - **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above. - **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable. -- **`@welshman/app`** wraps a signer in a `User` (`User.fromSigner(signer)` or `User.fromSession(session)`), passed to the app at construction: `createApp({user})`. Reach it again via `app.user?.signer`, or `User.require(app).signer` where a login is required. +- **`@welshman/app`** has no `signer` store. An identity is a `User` (`{pubkey, signer}`) hanging off the app: `User.fromSigner(signer)` / `User.fromSession(session)`, then `createApp({user})`. Reach it as `app.user?.signer`, and require it with `User.require(app)`. - `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one. ## Gotchas & Tips - **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first. -- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app after confirming `getNip55()` returns apps. +- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app, after calling `setNip55Plugin(NostrSignerPlugin)` and confirming `getNip55()` returns apps. Without the plugin, `getNip55()` returns `[]` rather than throwing, so it doubles as the feature check. - **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away. - **`makeSecret()`** (from `@welshman/util`) generates a cryptographically secure random hex private key. Use it for the `clientSecret` in NIP-46 — never reuse the user's actual private key as the client secret. - **`nip59.wrap()` returns the gift-wrap `SignedEvent` directly** — the return value itself is the kind-1059 event to publish. There is no `.wrap` sub-property on the return value. diff --git a/.agents/skills/welshman-store/SKILL.md b/.agents/skills/welshman-store/SKILL.md index 9f9e63ed..46fcc2a3 100644 --- a/.agents/skills/welshman-store/SKILL.md +++ b/.agents/skills/welshman-store/SKILL.md @@ -30,6 +30,29 @@ yarn add @welshman/store | `deriveEventsDesc(eventsByIdStore)` | Takes a `Readable>` and returns events sorted descending by `created_at` | | `makeDeriveEvent(options)` | Factory returning `(idOrAddress: string) => Readable` for single-event lookups | | `deriveIsDeleted(repository, event)` | `Readable` — tracks deletion status of an event | +| `deriveArray(itemsByIdStore)` | Turns any `Readable>` into `Readable` | +| `getEventsById(options)` | The one-shot, non-reactive form of `deriveEventsById` | + +### Relay-scoped event stores + +These key events by the relays they were seen on, via a `Tracker`. Use them when the same event (or the same addressable coordinate) has to stay distinct per relay — NIP-29 rooms, relay membership, per-relay replaceables. + +| Export | Description | +|---|---| +| `deriveEventsByIdByUrl(options)` | `Readable>>` — every relay at once | +| `deriveEventsByIdForUrl(options)` | `Readable>` — one relay | +| `getEventsByIdByUrl` / `getEventsByIdForUrl` | Non-reactive equivalents | +| `deriveItemsByKeyByUrl(options)` | The relay-scoped `deriveItemsByKey`: an event is keyed once per relay via `getKey(item, url)` | + +Both relay-scoped option types extend `EventsByIdOptions` with `tracker: Tracker` (and `url: string` for the `ForUrl` variants). `ItemsByKeyByUrlOptions` adds two things over `ItemsByKeyOptions`: + +```typescript +{ + getKey: (item: T, url: string) => string | undefined // undefined excludes the item on that relay + revalidateOn?: Readable // re-evaluate every key when this changes — for keys that + // depend on state settling later (e.g. a relay's NIP-11 self) +} +``` `deriveEventsById` / `deriveEvents` options (`EventsByIdOptions`): ```typescript @@ -55,13 +78,6 @@ const deriveEvent = makeDeriveEvent({ repository }) const eventStore = deriveEvent(someIdOrAddress) // Readable ``` -`deriveEventsAsc` / `deriveEventsDesc` take a map store, not an array store: -```typescript -// correct: pass the Readable> directly -const notesAsc = deriveEventsAsc(noteEventsById) -const notesDesc = deriveEventsDesc(noteEventsById) -``` - ### Indexed collections | Export | Description | @@ -73,6 +89,17 @@ const notesDesc = deriveEventsDesc(noteEventsById) | `makeLoadItem(loadItem, getItem, options?)` | Cached async loader with staleness checks and exponential backoff | | `makeForceLoadItem(loadItem, getItem)` | Async loader that always fetches fresh data | +`makeLoadItem` options (`MakeLoadItemOptions`): +```typescript +{ + timeout?: number // staleness window in SECONDS (default 3600) + maxSize?: number // LRU size for the fetched/attempt caches (default 10_000) + getFetched?: (key: string) => number // override where "last fetched" is stored + setFetched?: (key: string, ts: number) => void + getSource?: (...args: any[]) => string // how extra args key the backoff (default JSON.stringify) +} +``` + `deriveItemsByKey` options: ```typescript { @@ -88,7 +115,8 @@ const notesDesc = deriveEventsDesc(noteEventsById) | Export | Description | |---|---| -| `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise | +| `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise (type `Synced`) | +| `sync(config)` | The lower-level primitive: binds an existing store to a `StorageProvider` | | `localStorageProvider` | Built-in `StorageProvider` backed by `localStorage` | `StorageProvider` interface: @@ -110,7 +138,16 @@ interface StorageProvider { | Export | Description | |---|---| | `getter(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) | -| `withGetter(store)` | Adds a `.get()` method to a `Readable` or `Writable` store | +| `withGetter(store)` | Adds a `.get()` method to a `Readable` or `Writable` store (`WritableWithGetter` / `ReadableWithGetter`) | + +### Derivation helpers + +| Export | Description | +|---|---| +| `memoized(store)` | Suppresses notifications when the derived value is deep-equal to the last one | +| `deriveDeduplicated(stores, read)` | Derives `read()` from `stores`, skipping updates whose result is deep-equal to the previous. The backbone of `Projection`s in `@welshman/app` | +| `deriveDeduplicatedByValue(stores, read)` | Same, comparing by value identity rather than deep equality | +| `merged(stores)` | `derived(stores, identity)` — combine several stores into one array store | ## Common Patterns @@ -138,21 +175,22 @@ notes.subscribe($notes => { ### 2. Profiles indexed by pubkey ```typescript +import { parseJson } from "@welshman/lib" import { Repository } from "@welshman/net" import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store" -import { PROFILE } from "@welshman/util" -import { Profile, type ProfileReader } from "@welshman/domain" +import { PROFILE, type TrustedEvent } from "@welshman/util" const repository = new Repository() -// Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem. -const readProfile = Profile.configure({}).reader +type Profile = { event: TrustedEvent; name?: string } -const profilesByPubkey = deriveItemsByKey({ +const profilesByPubkey = deriveItemsByKey({ repository, filters: [{ kinds: [PROFILE] }], - eventToItem: event => readProfile(event).parse(), - getKey: profile => profile.author(), + // eventToItem decodes each event; here we parse the profile's JSON content. + // In a full @welshman/app setup use `app.use(Domain).reader(Profile)` instead. + eventToItem: event => ({ event, ...parseJson(event.content) }), + getKey: profile => profile.event.pubkey, }) // All profiles as array @@ -163,7 +201,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey) const aliceProfile = deriveProfile("alice-pubkey-hex") aliceProfile.subscribe($profile => { - console.log($profile?.display()) + console.log($profile?.name) }) ``` @@ -228,11 +266,10 @@ const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey) This `getBookmark` function is the right shape to pass as `getItem` to `makeLoadItem` (see Pattern 6). -### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem +### 6. Full reactive item chain This is the canonical pattern for domain objects derived from repository events with -on-demand network loading. `@welshman/app` packages it as `DerivedPlugin` — prefer subclassing -that over hand-rolling the chain unless you need something it doesn't cover. +on-demand network loading. ```typescript import { @@ -243,8 +280,7 @@ import { makeDeriveItem, } from "@welshman/store" import { Network, Router } from "@welshman/app" -import { outbox } from "@welshman/util" -import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util" +import { outbox, tagSpec, tagValue, tagValues } from "@welshman/util" import type { TrustedEvent } from "@welshman/util" const BOOKMARK_KIND = 30003 @@ -259,13 +295,13 @@ type Bookmark = { const parseBookmark = (event: TrustedEvent): Bookmark => ({ pubkey: event.pubkey, title: tagValue(tagSpec("title"), event.tags) ?? "Untitled", - urls: tagValues(relayTags("r"), event.tags), + urls: tagValues(tagSpec("r"), event.tags), event, }) // Step 1: Reactive Map — live-updates from repository const bookmarksByPubkey = deriveItemsByKey({ - repository: app.repository, + repository, filters: [{ kinds: [BOOKMARK_KIND] }], getKey: b => b.pubkey, eventToItem: parseBookmark, @@ -304,18 +340,18 @@ aliceBookmark.subscribe($b => console.log($b?.title)) ## Integration Notes - **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically. -- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, and the tag specs (`tagValue`, `hexTags`, …) used when decoding events. -- **`@welshman/domain`** — the typed readers you normally pass as `eventToItem`, instead of writing a parser by hand. +- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, and the tag/event helpers you use to decode events inside `eventToItem`. Higher-level typed decoders (profiles, lists, …) now live in `@welshman/domain` as async Reader classes (e.g. `Profile.configure(context).reader(event)`). - **`@welshman/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you. - Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit. ## Gotchas & Tips -- **`eventToItem` can return `null`/`undefined`** — returning a falsy value from `eventToItem` in `deriveItemsByKey` causes that event to be skipped. Use this to filter out malformed events (e.g. `event.tags.length > 1 ? readList(event) : null`). +- **`eventToItem` can return `null`/`undefined`** — returning a falsy value from `eventToItem` in `deriveItemsByKey` causes that event to be skipped. Use this to filter out malformed events (e.g. `event.tags.length > 1 ? {event, pubkeys: tagValues(hexTags("p"), event.tags)} : null`). - **`synced` is async on first read** — the store emits `defaultValue` synchronously, then overwrites it once storage resolves. Always `await store.ready` before reading in server-side or initialization code where you need the persisted value. - **`throttled(0, store)` is a no-op** — it returns the original store unchanged, so it is safe to call with a user-configurable delay that may be zero. - **`makeDeriveItem` is a factory** — call it once to create the lookup function, then call the returned function with a key to get a per-key `Readable`. Do not call `deriveItemsByKey` inside a Svelte `$:` block repeatedly; derive once at module level and pass the store down. -- **`makeLoadItem` timeout is in seconds** — the `timeout` option is compared against `now()` from `@welshman/lib`, which returns Unix time in seconds. The default is `3600` (one hour). Use `{ timeout: 30 }` for a 30-second staleness window, not `30_000`. -- **`makeLoadItem` uses exponential backoff** — repeated calls for the same key that already has a fresh result (item exists AND was fetched within the timeout window) are returned from cache without re-fetching. If the timeout has elapsed, it will re-fetch even if a previous value exists. Use `makeForceLoadItem` when you explicitly need fresh data. +- **`makeLoadItem` timeout is in seconds**, because it is compared against `now()` from `@welshman/lib`. Write `{ timeout: 30 }` for a 30-second staleness window, not `30_000`. +- **`makeLoadItem` propagates errors.** A rejection from `loadItem` rejects the returned promise; only the in-flight bookkeeping is cleaned up in a `finally`. Callers that treat a failed load as "no data yet" must catch it themselves, which is why app plugins wrap `load` in `.catch(noop)` on read paths. +- **`makeLoadItem` backs off exponentially per source**, so repeat attempts against a key that is not resolving are throttled rather than retried on every call. Use `makeForceLoadItem` when you need a fetch regardless of the cache. - **`deriveEventsAsc`/`deriveEventsDesc` take a map store** — both functions accept a `Readable>` (the output of `deriveEventsById`), not an array store. To sort an array store use `deriveItemsSorted`. - **`getter` vs `withGetter`** — use `getter(store)` when you only need the accessor function; use `withGetter(store)` when you want to keep the full store API (`.subscribe`, `.set`, `.update`) plus `.get()` on the same object. diff --git a/.agents/skills/welshman-util/SKILL.md b/.agents/skills/welshman-util/SKILL.md index ea2e723a..3ac02347 100644 --- a/.agents/skills/welshman-util/SKILL.md +++ b/.agents/skills/welshman-util/SKILL.md @@ -1,11 +1,11 @@ --- name: welshman-util -description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, NIPs (42/86/98), profiles, relays, zaps, wallets, or any core nostr data structures." +description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, keys, NIPs (13/42/86/98), relay urls, lightning, wallets, slash commands, or any core nostr data structure. Also covers relay selection — the RelaySelection routing DSL, Resolver, RelayScenario and fallback policies all live here (there is no @welshman/router package). (Profiles, lists, handlers, and rooms now live in @welshman/domain as Reader/Writer classes.)" --- # welshman/util — Core Nostr Utilities -`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, profiles, lists, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here. +`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here. ## Installation @@ -44,15 +44,41 @@ yarn add @welshman/util | `getIdOrAddress(event)` | Returns address string for replaceable events, id otherwise | | `getIdAndAddress(event)` | Returns array with both id and address (if applicable) | | `deduplicateEvents(events)` | Deduplicate by id or address | +| `compareEventsAsc(a, b)` / `compareEventsDesc(a, b)` | Canonical event order: `created_at`, then `id` to break a same-second tie | +| `sortEventsAsc(events)` / `sortEventsDesc(events)` | Sort an iterable of events by that order | | `isEphemeral(event)` | True for ephemeral kinds (20000–29999) | | `isReplaceable(event)` | True for plain or parameterized replaceable | | `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts | | `isParameterizedReplaceable(event)` | True for kinds 30000–39999 | +| `sortEventsAsc(events)` / `sortEventsDesc(events)` | Sort by `created_at` | +| `asEventTemplate`, `asStampedEvent`, `asOwnedEvent`, `asHashedEvent`, `asSignedEvent` | Narrow an event down to the fields of a given level | + +NIP-10 and NIP-22 threading lives on the `Note` and `Comment` classes in `@welshman/domain`, not here. The free helpers `getAncestors`, `getParentIdOrAddr`, `isChildOf`, `getReplyTags` and `getCommentTags` do not exist in this package. ### Type Guards `isEventTemplate`, `isStampedEvent`, `isOwnedEvent`, `isHashedEvent`, `isSignedEvent` +### Keys & event construction + +| Export | Description | +|--------|-------------| +| `makeSecret()` | Cryptographically secure random hex private key | +| `getPubkey(secret)` | Derive the hex pubkey from a hex secret | +| `stamp(event, created_at?)` | `EventTemplate` → `StampedEvent` | +| `own(event, pubkey)` | `StampedEvent` → `OwnedEvent` | +| `hash(event)` / `getHash(event)` | `OwnedEvent` → `HashedEvent` / the id alone | +| `sign(event, secret)` / `getSig(event, secret)` | `HashedEvent` → `SignedEvent` / the sig alone | +| `prep(event, pubkey, created_at?)` | Stamp + own + hash in one step — an unsigned rumor | + +### Proof of work (NIP-13) + +| Export | Description | +|--------|-------------| +| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits; returns a `ProofOfWork` | +| `getPow(event)` | Leading zero bits on an event's id | +| `estimateWork(difficulty)` / `benchmarkDifficulty` | Rough cost estimate for a difficulty target | + ### Event Kinds (constants) All constants are exported by name from `@welshman/util`. @@ -133,8 +159,22 @@ ROOM_ADD_MEMBER = 9000 ROOM_REMOVE_MEMBER = 9001 ROOM_ADD_PERM = 9003 ROOM_REMOVE_PERM = 9004 ROOM_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006 ROOM_CREATE_PERMISSION = 19004 +ROOM_UPDATE_PINS = 9010 ROOM_PINS = 39005 RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001 RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936 +RELAY_ROLE = 33534 +``` + +**Pinboards** + +``` +PINBOARD = 30067 PIN = 39067 +``` + +**Slash commands** + +``` +COMMAND = 31992 ``` **Replaceable lists (kinds 10000–10099)** @@ -190,7 +230,7 @@ ALERT_ANDROID = 32833 ALERT_IOS = 32834 **Zaps / wallet / Lightning** ``` -ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RESPONSE = 9735 +ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RECEIPT = 9735 WALLET_INFO = 13194 WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195 LIGHTNING_PUB_RPC = 21000 OTS = 1040 @@ -270,16 +310,20 @@ isDVMKind(kind) // 5000–7000 | Export | Description | |--------|-------------| -| `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization | -| `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) | -| `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) | -| `kindTags(keys)` | Spec for kind numbers (`k`) | -| `topicTags(keys)` | Spec for topics, normalized (`t`) | -| `relayTags(keys)` | Spec for relay urls (`r`, `relay`) | -| `tagValue(spec, tags)` | Value (index 1) of the first matching tag | -| `tagValues(spec, tags)` | Values of all matching tags | -| `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves | -| `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass | +| `tagSpec(keys, matchValue?, normalizeValue?)` | Build a `TagSpec` — `keys` is a string or string[]; optional value filter/normalizer | +| `hexTags(keys)` | Spec matching 32-byte hex values (`isHex32`) — e/p tags | +| `addressTags(keys)` | Spec matching replaceable addresses (`Address.isAddress`) — a tags | +| `relayTags(keys)` | Spec matching relay urls (`isRelayUrl`) — r/relay tags | +| `topicTags(keys)` | Spec that strips a leading `#` from values — t tags | +| `kindTags(keys)` | Spec whose values parse to `number` — k tags | +| `matchTags(spec, tags)` | All tags matching the spec — spec first, then the tags array | +| `matchTag(spec, tags)` | First tag matching the spec, or `undefined` | +| `tagValues(spec, tags)` | Values (index 1, normalized) of all matching tags; undefined dropped | +| `tagValue(spec, tags)` | Value of the first matching tag, or `undefined` | +| `tagMatcher(spec)` / `tagValueExtractor(spec)` | The raw `(tag)=>boolean` / `(tag)=>T` for a spec | +| `tagValueMatcher(spec, value)` | Predicate for one value, compared after normalization — matches however the tag spelled it | + +The old `getTagValue`/`getPubkeyTagValues`/`getEventTags`/… accessors were removed — use the spec selectors above (e.g. `tagValues(hexTags("p"), tags)`, `tagValue(tagSpec("title"), tags)`). ### Filters @@ -307,27 +351,166 @@ isDVMKind(kind) // 5000–7000 | `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string | | `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr | | `Address.fromEvent(event, relays?)` | Create from addressable event | +| `address.toString()` | Serialize to `kind:pubkey:identifier` | +| `address.toNaddr()` | Serialize to NIP-19 naddr | | `getAddress(event)` | Convenience: get address string from event | ### Relay | Export | Description | |--------|-------------| +| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — the conventional url for the in-memory repository | | `isRelayUrl(url)` | Validate relay URL | | `isShareableRelayUrl(url)` | True if valid relay URL and not a local address | | `isOnionUrl(url)` | Tor address check | | `isLocalUrl(url)` | Local address check | | `isIPAddress(url)` | IP address check | -| `normalizeRelayUrl(url)` | Normalize to standard wss:// format | +| `normalizeRelayUrl(url)` | Normalize to standard wss:// format (passes `LOCAL_RELAY_URL` through unchanged) | | `displayRelayUrl(url)` | Strip protocol and trailing slash | -### Zaps (NIP-57) +`RelayMode`, `RelayProfile` and `displayRelayProfile` are gone. Read/write intent is expressed by the NIP-65 `RelayList` reader/writer in `@welshman/domain` (`readUrls()`/`writeUrls()`, `addReadUrl`/`addWriteUrl`), and NIP-11 relay info by the `Relay` type in `@welshman/domain` plus `app.use(Relays)`. + +### Relay Selection (routing DSL) + +`RelaySelection.ts` holds the relay-routing DSL. A *relay selection* names a source, such as +"the author's outbox" or "the relays this event was seen on", rather than a list of urls. Turning +one into urls needs relay lists, a tracker and a repository, so producing and resolving selections +are separate steps: this package defines and scores them, and `@welshman/app`'s `Router` plugin +supplies the one `ResolveRoute` implementation that dereferences them (see the `welshman-app` +skill). `@welshman/domain` readers/writers/queries emit selections, and `@welshman/feeds` asks for +the capability as its `FeedRouter` interface. + +**Route + selection types** + +| Type | Description | +|------|-------------| +| `EventRef` | `{ id?, pubkey?, kind?, identifier?, relays? }` — all optional and additive. A known `pubkey` routes directly without finding the event; `id` (or `kind`+`pubkey`+`identifier`) lets the resolver look it up; `relays` are hints for that lookup and a last-resort fallback | +| `RelayRoute` | Discriminated union: `userInbox` / `userOutbox` / `userMessaging`, `pubkeyInbox` / `pubkeyOutbox` / `pubkeyMessaging` (`{pubkey}`), `eventInbox` / `eventOutbox` / `seen` (`{ref}`), `relay` (`{url}`), `index`, `search` | +| `RelaySelection` | `{ route: RelayRoute; weight: number }` | + +**DSL constructors** (each defaults `weight = 1`) + +| Export | Returns | Description | +|--------|---------|-------------| +| `inbox(pubkey, weight?)` | `RelaySelection` | that pubkey's read relays | +| `outbox(pubkey, weight?)` | `RelaySelection` | that pubkey's write relays | +| `messaging(pubkey, weight?)` | `RelaySelection` | that pubkey's NIP-17 messaging relays | +| `userInbox(weight?)` / `userOutbox(weight?)` / `userMessaging(weight?)` | `RelaySelection` | the current user's relays | +| `eventInbox(ref, weight?)` / `eventOutbox(ref, weight?)` | `RelaySelection` | the referenced event's author's relays | +| `seen(ref, weight?)` | `RelaySelection` | relays the event was found on (tracker + ref hints) | +| `relay(url, weight?)` | `RelaySelection` | a literal relay url (formerly `relayHint`) | +| `relays(urls, weight?)` | `RelaySelection[]` | one selection per url (formerly `relayHints`) | +| `inboxes(pubkeys, weight?)` | `RelaySelection[]` | `uniq(pubkeys).map(inbox)` — inbox per referenced pubkey | +| `indexers(weight?)` | `RelaySelection` | profile/relay-list index relays | +| `searchRelays(weight?)` | `RelaySelection` | full-text search relays | + +Note `relays` and `inboxes` return **arrays** — spread them with `...` into a route list; every +other constructor returns a single `RelaySelection`. + +**Resolved selections + fallback policies** | Export | Description | |--------|-------------| -| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid | -| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice | -| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) | +| `Selection` | `{ weight: number; relays: string[] }` — a concrete, resolved weighted relay set | +| `makeSelection(relays, weight?)` | Build a `Selection`, filtering `isRelayUrl` and normalizing each url | +| `FallbackPolicy` | `(count: number, limit: number) => number` — how many defaults to add | +| `addNoFallbacks` | Never add fallback relays (**the default**) | +| `addMinimalFallbacks` | Add one fallback only if nothing else was found | +| `addMaximalFallbacks` | Top up to the limit with fallbacks | + +**`RelayScenario`** — scores and picks concrete relays from weighted `Selection`s: + +```typescript +new RelayScenario(selections: Selection[], options?: RelayScenarioOptions) +// options: { policy?, limit?, allowLocal?, allowOnion?, allowInsecure?, +// getRelayQuality?, getDefaultRelays? } + +scenario.limit(n) // chainable (returns a cloned scenario) +scenario.policy(fn) // chainable +scenario.allowLocal(bool) / allowOnion(bool) / allowInsecure(bool) // chainable +scenario.getUrls() // string[] +scenario.getUrl() // first of getUrls() +``` + +`getUrls()` drops onion, local and plain-`ws://` urls unless explicitly allowed, sums each url's +weight across selections, scores as `quality * (1 + log(weight))` times a random factor, takes the +best `limit` (default 3), then adds shuffled `getDefaultRelays()` urls per the fallback policy. The +log keeps a relay that many selections name from dominating, and the random factor lets lower-ranked +relays get picked occasionally. + +**`Resolver`** — bundles a single route-resolution function with the scenario options to apply to +everything it produces: + +```typescript +type ResolveRoute = (route: RelayRoute) => MaybeAsync + +new Resolver(routeResolver: ResolveRoute, options?: RelayScenarioOptions) + +await resolver.scenario(selections) // Promise — resolves each route, builds a scenario +await resolver.relays(selections) // Promise — scenario(...).getUrls() +await resolver.relay(selections) // Promise — scenario(...).getUrl() +``` + +In an app, `@welshman/app`'s `Router` owns the `Resolver` (`app.use(Router).resolver`), built with +`getRelayQuality`/`getDefaultRelays` from app config, and injects it into every domain kind's +context. Domain readers/writers then call `def.context.resolver.scenario(...)` / `.relay(...)`. + +```typescript +import {outbox, inboxes, relay, Resolver} from '@welshman/util' + +// Declarative selections: author's write relays (weight 1) + each mentioned +// pubkey's read relays (weight 0.5) + an explicit relay hint. +const selections = [ + outbox(authorPubkey), + ...inboxes(mentionedPubkeys, 0.5), + relay('wss://relay.example.com'), +] + +// A Resolver dereferences routes -> urls given some route resolver. +const resolver = new Resolver(resolveRoute, {limit: 5, getRelayQuality}) +const urls = await resolver.relays(selections) // string[] +``` + +**Routing gotchas** + +- **Resolution is async**, because resolving an outbox may have to load a NIP-65 list first. +- **A scenario that resolves to nothing yields an empty array.** Add `.policy(addMinimalFallbacks)` where an empty result would break the caller. +- **Weights express preference, not selection.** Naming the same url in ten selections does not make it ten times more likely. To force a relay, use `forceRoutes` (domain writers) or `setRoutes` (domain queries). +- **Two calls with identical selections can return different urls.** In tests, assert on membership rather than exact url lists, or inject a deterministic `getRelayQuality`. +- **A relay whose `getRelayQuality` is 0 is dropped entirely**, because the scenario filters on the score and `-0` is falsy. A scenario can come back empty even though its selections resolved to urls. + +### Slash commands + +`Command.ts` models NIP-89-style slash commands: `CommandArg`/`CommandArgType`/`COMMAND_ARG_TYPES` +(`pubkey`, `event`, `address`, `relay`, `number`, `bool`, `enum`, `word`, `text`) with +`validateCommandArgs`; `CommandScope`/`CommandScopeTarget` with `parseCommandScope`, +`renderCommandScope`, `matchesCommandScopes`, `commandScopeRelays`, `commandScopesToFilter`; and +the invocation grammar — `parseCommandInvocation`, `renderCommandInvocation`, `bindCommandArgs`, +`parseCommandArgs`, `getActiveCommandArgIndex`. Kind `COMMAND` is 31992. `@welshman/editor`'s +`CommandExtension`/`CommandSuggestion` render and autocomplete these in the composer. + +### Lightning (NIP-57 support) + +| Export | Description | +|--------|-------------| +| `getLnUrl(address)` | Convert a lud16 address, HTTPS URL, or existing `lnurl1…` to an LNURL; `undefined` if invalid | +| `getInvoiceAmount(bolt11)` | Extract the millisatoshi amount from a BOLT11 invoice | +| `hrpToMillisat(hrpString)` | Convert a human-readable BTC amount to millisats (`bigint`) | +| `toMsats(sats)` / `fromMsats(msats)` | Unit conversion | + +The `Zapper` and `Zap` types moved to `@welshman/domain` (`other/Zapper.ts`), alongside the `ZapRequest`/`ZapReceipt`/`ZapGoal` kinds. Receipt validation is `app.use(Zappers).validateZapReceipt(...)`. + +### NIP-05 handles + +| Export | Description | +|--------|-------------| +| `Handle` | `{ nip05, pubkey?, nip46?, relays? }` | +| `queryProfile(nip05)` | Resolve a NIP-05 identifier via `/.well-known/nostr.json`; `undefined` on failure | +| `displayNip05(nip05)` / `displayHandle(handle)` | Drop a leading `_@` for display | + +### Pubkey + +`Pubkey` wraps a hex pubkey plus relay hints. `Pubkey.from(entity, relays?)` accepts hex, `npub…` or `nprofile…`; instances expose `toString()`, `toNpub()`, `toNprofile()`. ### Wallet @@ -357,17 +540,11 @@ makeHttpAuthHeader(event: SignedEvent): string // Returns "Nostr " ```typescript sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise // ManagementResponse = { result?: any; error?: string } -// ManagementMethod enum covers: BanPubkey, AllowPubkey, BanEvent, AllowEvent, etc. ``` -### Handlers (NIP-89) +Requests are built by `make*` factories rather than an enum: `makeBanPubkey`, `makeAllowPubkey`, `makeBanEvent`, `makeAllowEvent`, `makeCreateRole`/`makeEditRole`/`makeDeleteRole`, `makeAssignRole`/`makeUnassignRole`, `makeAssignMethod`/`makeUnassignMethod`, `makeCreateClaim`/`makeDeleteClaim`/`makeListClaims`, `makeChangeRelayName`/`Description`/`Icon`, `makeAllowKind`/`makeDisallowKind`, `makeBlockIp`/`makeUnblockIp`, `makeSignEvent`, `makeSupportedMethods`, and the matching `makeList*` readers. -```typescript -readHandlers(event: TrustedEvent): Handler[] -getHandlerKey(handler: Handler): string // "kind:address" format -getHandlerAddress(event: TrustedEvent): string | undefined -displayHandler(handler?: Handler, fallback?: string): string -``` +`ManagementApi` is a client class that pairs a relay url with a `ManagementSign` function so you don't have to build the NIP-98 auth event per call. `app.use(RelayManagement).forUrl(url)` returns one bound to the app's user. ### Links @@ -439,7 +616,7 @@ for (const event of storedEvents) { event[verifiedSymbol] = true } -repository.load(storedEvents) +app.repository.load(storedEvents) ``` Only do this for events you persisted yourself after they were validated. Never set @@ -448,14 +625,25 @@ Only do this for events you persisted yourself after they were validated. Never ### Working with tags ```typescript -import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util' +import { + tagSpec, + hexTags, + topicTags, + relayTags, + tagValue, + tagValues, +} from '@welshman/util' -// Specs come first, then the tags array. The spec says which keys to match and how to -// validate the value, so malformed tags are skipped rather than silently returned. -const title = tagValue(tagSpec('title'), event.tags) // string | undefined -const urls = tagValues(relayTags('r'), event.tags) // string[], valid relay urls only -const ids = tagValues(hexTags(['e', 'a']), event.tags) // string[], 32-byte hex only -const topics = tagValues(topicTags('t'), event.tags) // string[], normalized +// A selector takes a spec FIRST, then the tags array +const title = tagValue(tagSpec('title'), event.tags) // string | undefined +const urls = tagValues(tagSpec('r'), event.tags) // string[] + +// Multiple keys at once +const ids = tagValues(tagSpec(['e', 'a']), event.tags) // string[] + +const mentions = tagValues(hexTags('p'), event.tags) // string[] +const topics = tagValues(topicTags('t'), event.tags) // string[] ("#x" -> "x") +const relays = tagValues(relayTags(['r', 'relay']), event.tags) ``` ### Matching and building filters @@ -564,9 +752,9 @@ await fetch('https://api.example.com/upload', { - **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions. - **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication. -- **`@welshman/app`** — high-level application layer; wraps net/store/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`. -- **`@welshman/domain`** — builds its typed readers on the tag specs (`tagValue`, `hexTags`, `addressTags`) defined here. -- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here, and supplies the encryption used when writing encrypted list kinds. +- **`@welshman/app`** — high-level application layer; composes net/store/domain and uses the lightning helpers from this package (profile/list/handler/room helpers now live in `@welshman/domain`). +- **`@welshman/app`'s `Router` plugin** — dereferences the `RelaySelection` DSL defined here, and injects its `Resolver` into every `@welshman/domain` kind. +- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; signers also provide the `nip44` encrypt/decrypt functions used by `@welshman/domain` list writers to encrypt private (NIP-44) tags. --- @@ -576,16 +764,23 @@ await fetch('https://api.example.com/upload', { - **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not. - - -- **`validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps, use `app.use(Zappers).validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads. +- **`app.use(Zappers).validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps use `validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads. - **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else. +- **`normalizeTopic` is not exported.** `Topics.ts` isn't re-exported from the index; use `topicTags("t")` to get normalized topic values off an event's tags. + - **`normalizeRelayUrl` vs `displayRelayUrl`**: Use `normalizeRelayUrl` before storing or comparing relay URLs. Use `displayRelayUrl` only for human-readable display (strips protocol/trailing slash). - **`Address.isAddress`** checks the `kind:pubkey:identifier` format only, not naddr. To validate an naddr string, use `Address.fromNaddr` inside a try/catch. -- **`getTagValue` / `getTagValues` argument order**: the type(s) come **first**, the tags array comes **second** — `getTagValue('title', event.tags)`. This is the opposite of the specialized helpers like `getEventTags(tags)` which take only the tags array. Mixing up the order produces no TypeScript error but silently returns `undefined` or `[]`. +- **Tag selector argument order**: the spec comes **first**, the tags array **second** — `tagValue(tagSpec('title'), event.tags)`, `tagValues(hexTags('p'), event.tags)`. Mixing up the order produces no TypeScript error but silently returns `undefined` or `[]`. - **`verifiedSymbol` is a Symbol key**: you must import `verifiedSymbol` from `@welshman/util` and use it as a computed property key — `event[verifiedSymbol] = true`. You cannot use a string key. The symbol is re-exported from `nostr-tools/pure`, so it is the same identity as the one used internally by `verifyEvent`. + +--- + +## Related skills + +- **`welshman-app`** (welshman-app skill) — the `Router` plugin that dereferences the routing DSL above, plus `RelayStats.getQuality` for the scoring input. +- **`@welshman/domain`** (welshman-domain skill) — Profiles, lists, handlers, rooms, and event routing moved out of `@welshman/util` and now live here. The old free functions (`readProfile`/`makeProfile`, `readList`/`makeList`, `PublishedProfile`/`PublishedList`, `Encryptable`, the handler/room helpers, …) were replaced by configurable `KindFactory` bundles: `Kind.configure(context).reader(event)` returns an async Reader that decodes the event, and `.writer(reader?)` builds/edits one. Private (NIP-44) list tags are handled inside the list Reader/Writer, so `Encryptable`/`DecryptedEvent` no longer exist. diff --git a/.agents/skills/welshman/SKILL.md b/.agents/skills/welshman/SKILL.md index 58394238..ad2017f4 100644 --- a/.agents/skills/welshman/SKILL.md +++ b/.agents/skills/welshman/SKILL.md @@ -11,14 +11,14 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http | Package | Description | |---|---| -| `@welshman/util` | Core nostr types, event helpers, filters, tag specs, NIPs, and the `RelaySelection` routing DSL | +| `@welshman/util` | Core nostr types, event helpers, filters, NIP implementations, and the relay-selection routing DSL | | `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue | -| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling | -| `@welshman/domain` | A typed Reader/Writer pair per event kind, so you never hand-parse tags | -| `@welshman/store` | Svelte stores and a Repository for indexing/querying nostr events client-side | +| `@welshman/net` | Relay connections, request/publish lifecycle, auth, and the `Repository`/`Tracker`/`WrapManager` stores | +| `@welshman/store` | Svelte store primitives over a `Repository` — live event and domain-object collections, cached loaders, persistence | | `@welshman/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) | +| `@welshman/domain` | Typed Reader/Writer classes per event kind (profiles, notes, lists, rooms, relay management) that parse events, build templates, and emit relay routing | | `@welshman/feeds` | Dynamic feed construction, filtering, and composition | -| `@welshman/app` | The `App` instance and its plugin registry, composing net, store, domain, signer, and feeds into a full application framework | +| `@welshman/app` | Instance-based application framework: an `App` composes net, store, signer, feeds, and domain, exposing data modules via `app.use(...)` | | `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) | | `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support | @@ -27,43 +27,28 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http Packages are layered so lower-level ones have no welshman dependencies: - **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util` -- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`, `@welshman/domain` -- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app` +- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer` +- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/domain` +- **Application** (composes everything above): `@welshman/app` - **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor` -For deep-dives on any package, load the `welshman-` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`). +For deep-dives on any package, load the `welshman-` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`, `welshman-signer`). -## The App instance +Relay selection spans two packages. The `RelaySelection` DSL, `Resolver` and `RelayScenario` are in `@welshman/util` (`welshman-util` skill); the `Router` plugin that dereferences them is in `@welshman/app` (`welshman-app` skill). There is no `@welshman/router` package. -Everything in the framework hangs off one `App`. An app owns the primitives a single identity -needs — repository, socket pool, tracker, wrap manager — so data never bleeds across sessions. +## Getting started -```typescript -import {createApp, Network, Profiles, User} from "@welshman/app" +Install only what you need: -// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping) -const app = createApp({ - user: await User.fromSigner(signer), // omit for a signed-out app - config: { - getDefaultRelays: () => ["wss://relay.example.com"], - getIndexerRelays: () => ["wss://indexer.example.com"], - }, -}) +```bash +# Full application framework (includes app, net, store, signer, feeds, domain) +npm i @welshman/app -app.use(Profiles).load(pubkey) // plugins are per-app singletons, constructed on demand -app.use(Network).load({relays, filters}) +# Or assemble manually for more control +npm i @welshman/util @welshman/net @welshman/signer ``` -Three rules follow from this design: - -1. **`app.use(Plugin)` is memoized and cheap** — call it inline rather than caching the result. -2. **An app is scoped to one identity.** Logging in means building a *new* app and calling - `cleanup()` on the old one, not attaching a user to the existing one. -3. **Side effects live in policies**, not in the data classes. An `AppPolicy` is - `(app) => Unsubscriber`, applied once at construction and torn down by `cleanup()`. - -Svelte apps typically wrap `app` in a store so plugin reads re-subscribe when login swaps the -instance. That binding layer is app-specific and deliberately not part of welshman. +If you're building a conventional nostr web client, use `@welshman/app` for batteries-included functionality. For more advanced usage, use the lower-level modules without `app` for more control. ## Key nostr concepts @@ -79,67 +64,72 @@ instance. That binding layer is app-specific and deliberately not part of welshm | Goal | Package(s) to use | |---|---| -| Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control | -| Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` | -| Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` | -| Sign and publish events | `@welshman/signer` + `Command` from `@welshman/app` | -| Build a feed UI | `@welshman/feeds` + `app.use(Feeds)` | +| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) | +| Compose typed events (notes, profiles, lists) | `@welshman/domain` | +| Select which relays to read from / publish to | `@welshman/util` (routing DSL) + `@welshman/app` (Router plugin) | +| Sign and publish events | `@welshman/domain` + `@welshman/app`, or `@welshman/signer` + `@welshman/net` | +| Build a feed UI | `@welshman/feeds` + `@welshman/app` | | Parse note text and media | `@welshman/content` | | Embed a composer / editor | `@welshman/editor` | -| Cache nostr events client-side | `@welshman/store` + `app.repository` | -| Core event/filter/tag utilities | `@welshman/util` | +| Cache nostr events client-side | `@welshman/net` (`Repository`) + `@welshman/store` (reactive views over it) | +| Core event/filter utilities | `@welshman/util` | | Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` | -## App example +### App Example ```typescript -import {createApp, Domain, Profiles, User} from "@welshman/app" -import {Note} from "@welshman/domain" -import {Nip07Signer} from "@welshman/signer" +import { Nip07Signer } from "@welshman/signer" +import { Note } from "@welshman/domain" +import { createApp, User, Domain, Profiles } from "@welshman/app" + +// 1. Create an app instance. Each App owns its own repository, socket pool, +// tracker, and (optional) signing user, so data never leaks across identities. +// Pass the user at construction rather than assigning app.user afterwards. +const user = await User.fromSigner(new Nip07Signer()) -// 1. Build an app for the signed-in user -const signer = new Nip07Signer() const app = createApp({ - user: await User.fromSigner(signer), + user, config: { - getDefaultRelays: () => ["wss://relay.example.com"], + getDefaultRelays: () => ["wss://relay.example.com", "wss://relay2.example.com"], getIndexerRelays: () => ["wss://indexer.example.com"], }, }) -// 2. Read the user's profile (loads from their write relays if not cached) -const profile = await app.use(Profiles).load(app.user!.pubkey) +// 2. Hydrate the repository from storage and flush changes back to it. +// See the welshman-net skill for repository.load() and the "update" listener. -console.log("Hello,", profile?.display()) +// 3. Load the user's profile through the Profiles data module +// (triggers a network fetch via the outbox model if not cached) +const profile = await app.use(Profiles).forceLoad(user.pubkey) +if (profile) console.log("Hello,", profile.display()) -// 3. Publish a note — build a writer, wrap it in a command, publish it +// ...or subscribe reactively: +app.use(Profiles).one(user.pubkey).subscribe($profile => { + if ($profile) console.log("Profile:", $profile.display()) +}) + +// 4. Compose and publish a note. Domain builds the event and resolves relays; +// the returned Command sends it through the publish pipeline. const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!") const command = await app.use(Domain).command(writer) -await command.publish().waitForError() +await command.publish() ``` -Publishing goes through a `Command`, which owns the rendered event and its resolved relays: -`command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)`. Plugin mutators -(`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`) already return a `Command`, -so `.then(publish)` is usually all you need. - -## Lower-level example - -The net layer takes an explicit context, so it can be used without an `App` at all. +### Lower-level Example ```typescript -import {AbstractAdapter, isClientEvent, publish, request} from "@welshman/net" -import type {ClientMessage, NetContext} from "@welshman/net" -import {call, sleep} from "@welshman/lib" -import {Nip01Signer} from "@welshman/signer" -import {makeEvent, NOTE} from "@welshman/util" +import { AbstractAdapter, ClientMessage, isClientEvent, publish, request } from '@welshman/net' +import type { NetContext } from '@welshman/net' +import { call, sleep } from '@welshman/lib' +import { Nip01Signer } from '@welshman/signer' +import { makeEvent, NOTE } from '@welshman/util' const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */) const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */) const RELAY_URL = "bogus.relay" -// An adapter for our relay url which just prints the content +// Create an adapter for our relay url which just prints the content export class PrintAdapter extends AbstractAdapter { get sockets() { return [] } get urls() { return [] } @@ -151,29 +141,38 @@ export class PrintAdapter extends AbstractAdapter { } } -// Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one. +// A net context that routes our relay url to the custom adapter. Context is +// passed per call now — there is no module-level singleton. const context: NetContext = { - getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined), + getAdapter: (url: string) => { + if (url === RELAY_URL) { + return new PrintAdapter() + } + }, } +// Loop, sending off pings every so often call(async () => { while (true) { await sleep(1000) - const ping = await pingSigner.sign(makeEvent(NOTE, {content: "ping"})) + const ping = await pingSigner.sign( + makeEvent(NOTE, {content: 'ping'}) + ) await publish({event: ping, relays: [RELAY_URL], context}) } }) +// Meanwhile, listen for pings and quote-note with a pong call(async () => { request({ relays: [RELAY_URL], - filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}], context, + filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}], onEvent: async (ping, url) => { const pong = await pongSigner.sign( - makeEvent(NOTE, {content: "pong", tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}), + makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}) ) await publish({event: pong, relays: [RELAY_URL], context}) diff --git a/skills-lock.json b/skills-lock.json index e278034a..4e31af88 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,67 +5,61 @@ "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman/SKILL.md", - "computedHash": "586c6b142324e0a9043e7af16c662cee5f114d649367baba088282cc0de12734" + "computedHash": "e5b2fe539d3e53081ca2c95765fa6206debe8ab12d6915b9494f5f773869a894" }, "welshman-app": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-app/SKILL.md", - "computedHash": "764a3bed16678b18e3935fee6069ceed965004ce4e624ae1a7edadfca6708ca1" + "computedHash": "5246d8285dbac8e63fbd73d01fd79ffdcd70a60017f34b54ee7bc899b7db7c17" }, "welshman-content": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-content/SKILL.md", - "computedHash": "8ad4b3646e781d124c5332565cc4e0333664fcbcb5dccafc70b1d21266bcafd8" + "computedHash": "ed729978596d99e5e83ad284f064f6d37af1588887ce26921818278bde1e7ec5" }, "welshman-editor": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-editor/SKILL.md", - "computedHash": "dbc39e6506231d1071b75453a78d99bb90017c17a57fd087c659a1daae335536" + "computedHash": "46047b6821d4f96c5bea11f34043261844970f6095927fb5c299b9b2b2e0d12f" }, "welshman-feeds": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-feeds/SKILL.md", - "computedHash": "148bd556a0a08b9dc07b959c7ecd8b3259058dd2b3a3b2a2f2171c1cc669b25c" + "computedHash": "edf3cf2dc14d70aeffdf7bce30ed160b35c9cffd772750b312dc06688309ca56" }, "welshman-lib": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-lib/SKILL.md", - "computedHash": "16cf693a002d2e781c085a38c3b43f93c25ab5a0f43f9404c3fa2ead39139b50" + "computedHash": "807f8caaa7272cb6216290e0c8a13f67cb037f19657530a61f903df1715e48fd" }, "welshman-net": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-net/SKILL.md", - "computedHash": "234d48ff9ebea01919011db7f471bf997574424c313090bcfaf440762f6e2284" - }, - "welshman-router": { - "source": "coracle-social/welshman", - "sourceType": "github", - "skillPath": "skills/welshman-router/SKILL.md", - "computedHash": "f37e0c08fa32b577786f33fa21462fabf325eee27d32bad87269466e94d7dd72" + "computedHash": "38e33629e441bc82294c66b8bfc429d4c46e8f4a8e0bed388c4358531a47cf19" }, "welshman-signer": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-signer/SKILL.md", - "computedHash": "a62c6d5211b904e4edb992ad2003765ab9956694bf1c30f3b4179c3d1d159a40" + "computedHash": "bd9a80597f1f96c98b01ad8bd6fa0869fcc8554a6bd5ee13f9a7f01ef39151a8" }, "welshman-store": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-store/SKILL.md", - "computedHash": "2079746f8b5ac0b8ef3ba49afc31a120f9a3a17671b6e2512e8481c5d9979a5c" + "computedHash": "94e3b912bbb521b8abeae58436170cf3afc382b575f7484805359d1f1563a6da" }, "welshman-util": { "source": "coracle-social/welshman", "sourceType": "github", "skillPath": "skills/welshman-util/SKILL.md", - "computedHash": "2d4c06937e9417c72347f1a54ae5c3688fbaa001a4f1c2a094f79f6637a99f33" + "computedHash": "3b3a5fcf5fa143118e34828dab4b09f3f7b1065cfb78891c1ebe4bdc244ec56a" } } }