@owli/agent-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ // Copied verbatim from webapp/src/lib/identity.js (2026-08-28) - zero
2
+ // browser dependencies, confirmed by direct audit before copying. Diff
3
+ // against that file before any change here ships, and vice versa - see
4
+ // this SDK's README for why these two files are duplicated rather than
5
+ // shared (no workspace tooling exists in this repo yet).
6
+
7
+ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
8
+ import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
9
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
10
+ import * as nip19 from 'nostr-tools/nip19';
11
+ import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
12
+
13
+ // Pure key derivation only - persistence at rest is the SDK caller's
14
+ // concern (an env var, a secrets manager, this package's own storage
15
+ // adapter), never plaintext-by-default. Callers hold the secret key in
16
+ // memory for the session; nothing here writes to disk on its own.
17
+
18
+ export function generateIdentity() {
19
+ const secretKey = generateSecretKey();
20
+ return { secretKey, publicKey: getPublicKey(secretKey) };
21
+ }
22
+
23
+ export function publicKeyFor(secretKey) {
24
+ return getPublicKey(secretKey);
25
+ }
26
+
27
+ export function backupPhrase(secretKey) {
28
+ return entropyToMnemonic(secretKey, wordlist);
29
+ }
30
+
31
+ // Raw hex encoding of the same secret key backupPhrase() above encodes as
32
+ // words - identical bytes, just the format someone might want for an nsec-
33
+ // style backup instead of a phrase.
34
+ export function secretKeyHex(secretKey) {
35
+ return bytesToHex(secretKey);
36
+ }
37
+
38
+ // Standard NIP-19 bech32 encodings - the same bytes hex/the phrase encode,
39
+ // written the way the rest of the Nostr ecosystem expects (checksummed,
40
+ // recognizable, pasteable into any other Nostr client). Pure display
41
+ // encoding, not a different key or a separate derivation.
42
+ export function npubFor(publicKeyHex) {
43
+ return nip19.npubEncode(publicKeyHex);
44
+ }
45
+
46
+ export function nsecFor(secretKey) {
47
+ return nip19.nsecEncode(secretKey);
48
+ }
49
+
50
+ export function fromPhrase(phrase) {
51
+ const secretKey = mnemonicToEntropy(phrase.trim().toLowerCase(), wordlist);
52
+ return { secretKey, publicKey: getPublicKey(secretKey) };
53
+ }
54
+
55
+ // Lets an agent bring a key generated elsewhere into Owli - it's the same
56
+ // secp256k1 keyspace as any other Nostr client, so an existing nsec works
57
+ // here unmodified.
58
+ export function fromNsecOrHex(input) {
59
+ const trimmed = input.trim();
60
+ if (trimmed.startsWith('nsec1')) {
61
+ const { type, data } = nip19.decode(trimmed);
62
+ if (type !== 'nsec') throw new Error('Not an nsec key');
63
+ return { secretKey: data, publicKey: getPublicKey(data) };
64
+ }
65
+ if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
66
+ const secretKey = hexToBytes(trimmed);
67
+ return { secretKey, publicKey: getPublicKey(secretKey) };
68
+ }
69
+ throw new Error('Not a recognized nsec or hex key');
70
+ }
package/src/index.js ADDED
@@ -0,0 +1,257 @@
1
+ // Public API. See README.md for the full picture; this file is deliberately
2
+ // thin - it wires identity.js + storage.js + transport.js + ratchet.js
3
+ // together into the OwliAgent class, without owning any protocol logic
4
+ // itself.
5
+ //
6
+ // Important, honest limitation: ratchet.js and storage.js (both in this
7
+ // package) hold module-level state, exactly like the browser app they were
8
+ // copied/adapted from does (one identity per browser tab). That means
9
+ // *one Node process supports exactly one OwliAgent identity*, same as the
10
+ // browser app supports one unlocked identity per tab. Running two agents
11
+ // that talk to each other means two separate processes (two terminals,
12
+ // two servers, whatever) - not `new OwliAgent()` twice in one script. See
13
+ // test/roundtrip.test.js, which spawns two child processes for exactly
14
+ // this reason.
15
+ import { finalizeEvent } from 'nostr-tools/pure';
16
+ import * as identity from './identity.js';
17
+ import * as storage from './storage.js';
18
+ import { createMemoryBackend } from './storage/memoryBackend.js';
19
+ import { createFileBackend } from './storage/fileBackend.js';
20
+ import { createTransport } from './transport.js';
21
+ import { ensureWebSocket } from './websocket.js';
22
+
23
+ // Owli's own convention, not a proposed NIP - a parameterized replaceable
24
+ // event (30000-39999 range, per NIP-01) listing what an agent does and
25
+ // what it charges. Re-publishing with the same `d` tag replaces the old
26
+ // listing rather than duplicating it, same addressable-event mechanic
27
+ // most Nostr profile/listing kinds already use.
28
+ const CAPABILITY_LISTING_KIND = 31111;
29
+
30
+ let anyAgentCreated = false;
31
+
32
+ async function buildAgent({ secretKey, publicKey }, options = {}) {
33
+ if (anyAgentCreated) {
34
+ throw new Error(
35
+ 'This process already has an OwliAgent - ratchet.js/storage.js use module-level state (one identity per process, by design, same as the browser app). Run a second agent as a separate process instead.'
36
+ );
37
+ }
38
+ anyAgentCreated = true;
39
+
40
+ await ensureWebSocket();
41
+
42
+ // The default storage file has to be unique per identity - two agents
43
+ // run on the same machine without an explicit storagePath (which is
44
+ // exactly what the dashboard's generated scripts do; it's never asked
45
+ // of the user) would otherwise both fall back to the same fixed
46
+ // filename and silently share one contacts list, one invite, and
47
+ // - the actually dangerous part - the same ratchet-session key for
48
+ // "my session with the owner", since that key is scoped by who the
49
+ // session is *with*, not by whose session it is. Two different agents
50
+ // both talking to the same owner would each overwrite the other's
51
+ // session state with it, corrupting both without either ever seeing
52
+ // an error. Keying the default filename by this agent's own pubkey is
53
+ // what the original design called for - see fileBackend.js's own
54
+ // module comment - this just makes createFileBackend actually do it.
55
+ const backend = options.storage || (await createFileBackend({ path: options.storagePath, pubkey: publicKey }));
56
+ storage.configure(backend);
57
+
58
+ const transport = createTransport({
59
+ relays: options.relays,
60
+ messageExpirySeconds: options.messageExpirySeconds ?? null,
61
+ });
62
+
63
+ const ownerPubkey = options.ownerPubkey || null;
64
+
65
+ // Best-effort, fire-and-forget CC of this agent's own activity to its
66
+ // owner's npub - the "supervised, not impersonatable" design from the
67
+ // brainstorm: the owner never holds this agent's live operating key,
68
+ // just a private, encrypted, read-only feed of what it's been doing.
69
+ // Failures here never block the real send/receive path - oversight
70
+ // logging is not allowed to be a reason a message fails to go out or
71
+ // be processed.
72
+ function ccToOwner(direction, withPubkey, text) {
73
+ if (!ownerPubkey) return;
74
+ const record = JSON.stringify({ ownerLog: true, agentPubkey: publicKey, direction, withPubkey, text, ts: Date.now() });
75
+ // Was a bare .catch(() => {}) - "never block the real send/receive
76
+ // path" doesn't mean "never let anyone know it failed". A silent
77
+ // failure here is genuinely hard to diagnose after the fact (a
78
+ // missing log entry with zero trace of why) - a visible warning
79
+ // costs nothing and turns a mystery into an actual clue next time.
80
+ transport.sendMessage(secretKey, publicKey, ownerPubkey, record).catch((err) => {
81
+ console.warn(`[owli-agent] failed to CC activity to owner: ${err?.message || err}`);
82
+ });
83
+ }
84
+
85
+ let messageCallback = null;
86
+ let started = false;
87
+ let subs = null;
88
+ let inviteRefreshTimer = null;
89
+ let presenceTimer = null;
90
+
91
+ // How often a long-running agent re-publishes its own invite and
92
+ // re-subscribes to the accept-response filter (see ratchet.js's
93
+ // refreshInviteListen for why this is needed at all: a REQ subscription
94
+ // that's been open a long time can miss a brand-new incoming session,
95
+ // and re-subscribing is the only way to recover it - a relay replays
96
+ // stored events to a *fresh* subscribe). A human using the dashboard has
97
+ // a manual Resync button for this; a deployed script has no one to click
98
+ // it, so start() does this on a timer instead.
99
+ const INVITE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
100
+
101
+ // Same cadence the dashboard's own presence heartbeat already uses
102
+ // (session.js, 10s) against its 35s online-window (state.js) - only
103
+ // sent when there's actually an owner to tell, same "opt-in via
104
+ // ownerPubkey" gate ccToOwner uses, since a script with no owner has
105
+ // no one to show a green dot to anyway. Was 20s/75s - shortened after
106
+ // direct feedback that it took too long (up to ~95s) to notice a
107
+ // killed agent had actually gone offline; 10s/35s cuts that to
108
+ // ~35-45s while still tolerating 2-3 missed pings before flipping,
109
+ // the same tolerance ratio the old numbers had (window ~3.5x interval).
110
+ const PRESENCE_INTERVAL_MS = 10 * 1000;
111
+
112
+ return {
113
+ secretKey,
114
+ pubkey: publicKey,
115
+ npub: identity.npubFor(publicKey),
116
+ backupPhrase: () => identity.backupPhrase(secretKey),
117
+ nsec: () => identity.nsecFor(secretKey),
118
+
119
+ onMessage(callback) {
120
+ messageCallback = callback;
121
+ },
122
+
123
+ // Auto-adds the recipient as a contact so a session survives a
124
+ // restart (ratchet.js's start() reloads sessions for known contacts
125
+ // on startup - see its own comment on why) - a caller never has to
126
+ // think about "adding a contact" as its own step.
127
+ async send(toPubkey, text, opts = {}) {
128
+ await storage.addContact(toPubkey);
129
+ const event = await transport.sendMessage(secretKey, publicKey, toPubkey, text, opts.disappearAfterSec);
130
+ ccToOwner('out', toPubkey, text);
131
+ return event;
132
+ },
133
+ async sendPlain(toPubkey, text, opts = {}) {
134
+ await storage.addContact(toPubkey);
135
+ const event = await transport.sendPlainMessage(secretKey, publicKey, toPubkey, text, opts.disappearAfterSec);
136
+ ccToOwner('out', toPubkey, text);
137
+ return event;
138
+ },
139
+ // Tells recipientPubkey this agent's name/emoji, the same wire format
140
+ // (kind 30079) the dashboard's own human contacts already exchange -
141
+ // this is what a fresh contact needs to show a real name instead of a
142
+ // generic "Contact" placeholder. Not sent automatically on start() or
143
+ // on every send() - deliberately a plain method a caller's own script
144
+ // decides when to use (see agentScriptTemplates.js's generated
145
+ // scripts, which call it the first time they hear from someone new).
146
+ async sendProfile(recipientPubkey, profile) {
147
+ return transport.sendProfile(secretKey, recipientPubkey, profile);
148
+ },
149
+ async hasSession(toPubkey) {
150
+ return transport.hasSession(toPubkey);
151
+ },
152
+ async fetchMissed(sinceSeconds) {
153
+ return transport.fetchMissed(secretKey, publicKey, sinceSeconds);
154
+ },
155
+
156
+ // Publishes (or replaces, via the same `d` tag) what this agent does
157
+ // and charges - the piece the directory reads. No listing exists
158
+ // until an agent explicitly calls this; nothing gets listed silently.
159
+ async publishCapability({ description, priceSats } = {}) {
160
+ const event = finalizeEvent(
161
+ {
162
+ kind: CAPABILITY_LISTING_KIND,
163
+ content: JSON.stringify({ description, priceSats }),
164
+ tags: [['d', 'listing']],
165
+ created_at: Math.floor(Date.now() / 1000),
166
+ },
167
+ secretKey
168
+ );
169
+ await Promise.any(transport.getPool().publish(transport.getActiveRelays(), event));
170
+ return event;
171
+ },
172
+
173
+ // Starts both transports - plain NIP-17 (for a brand-new contact who
174
+ // hasn't got a ratchet session yet) and the Double Ratchet (for
175
+ // everyone else) - both have to run together, same as the browser
176
+ // app's own enterUnlocked() does, or messages on one path go unseen.
177
+ async start() {
178
+ if (started) return;
179
+ started = true;
180
+ const onMessage = (msg) => {
181
+ messageCallback?.(msg);
182
+ ccToOwner('in', msg.from, msg.text);
183
+ };
184
+ const plainSub = transport.subscribeInbox(secretKey, publicKey, onMessage);
185
+ await transport.startRatchetInbox(secretKey, publicKey, onMessage);
186
+ subs = { plainSub };
187
+
188
+ // Cheap insurance for a script that runs for hours/days: periodically
189
+ // refresh the invite-accept listener so a session someone started
190
+ // while the old subscription was stale still gets picked up, without
191
+ // needing a restart. Errors are swallowed the same way ccToOwner's
192
+ // are - a relay hiccup on one refresh tick shouldn't crash a running
193
+ // agent, and the next tick tries again anyway.
194
+ inviteRefreshTimer = setInterval(() => {
195
+ transport.refreshInviteListen(secretKey, publicKey).catch(() => {});
196
+ }, INVITE_REFRESH_INTERVAL_MS);
197
+ if (typeof inviteRefreshTimer.unref === 'function') inviteRefreshTimer.unref();
198
+
199
+ // Lets the owner's dashboard show this agent as online (Avatar.js's
200
+ // green/red dot) - the dashboard has always been able to render
201
+ // this, it just never had anything to receive since no agent ever
202
+ // sent one before now.
203
+ if (ownerPubkey) {
204
+ const ping = () => transport.sendPresencePing(secretKey, publicKey, ownerPubkey).catch(() => {});
205
+ ping();
206
+ presenceTimer = setInterval(ping, PRESENCE_INTERVAL_MS);
207
+ if (typeof presenceTimer.unref === 'function') presenceTimer.unref();
208
+ }
209
+ },
210
+
211
+ stop() {
212
+ if (!started) return;
213
+ started = false;
214
+ subs?.plainSub?.close();
215
+ transport.stopRatchetInbox();
216
+ subs = null;
217
+ if (inviteRefreshTimer) {
218
+ clearInterval(inviteRefreshTimer);
219
+ inviteRefreshTimer = null;
220
+ }
221
+ if (presenceTimer) {
222
+ clearInterval(presenceTimer);
223
+ presenceTimer = null;
224
+ }
225
+ },
226
+
227
+ async close() {
228
+ this.stop();
229
+ await transport.close();
230
+ },
231
+ };
232
+ }
233
+
234
+ export const OwliAgent = {
235
+ // Generates a fresh identity - no signup, nothing to register.
236
+ async create(options = {}) {
237
+ const { secretKey, publicKey } = identity.generateIdentity();
238
+ return buildAgent({ secretKey, publicKey }, options);
239
+ },
240
+
241
+ // Brings in a key generated elsewhere (an nsec from another Nostr
242
+ // client, or raw hex).
243
+ async fromSecretKey(input, options = {}) {
244
+ const parsed = typeof input === 'string' ? identity.fromNsecOrHex(input) : { secretKey: input, publicKey: identity.publicKeyFor(input) };
245
+ return buildAgent(parsed, options);
246
+ },
247
+
248
+ // Restores from a 24-word recovery phrase - the same phrase format the
249
+ // browser app uses (this is a real, standard BIP-39 phrase, portable
250
+ // either direction).
251
+ async fromPhrase(mnemonic, options = {}) {
252
+ const parsed = identity.fromPhrase(mnemonic);
253
+ return buildAgent(parsed, options);
254
+ },
255
+ };
256
+
257
+ export { createMemoryBackend, createFileBackend };