@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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/examples/claude-bridge.mjs +49 -0
- package/examples/echo-bot.mjs +21 -0
- package/examples/gemini-bridge.mjs +47 -0
- package/examples/groq-bridge.mjs +44 -0
- package/examples/lmstudio-bridge.mjs +44 -0
- package/examples/mistral-bridge.mjs +44 -0
- package/examples/ollama-bridge.mjs +47 -0
- package/examples/openai-bridge.mjs +45 -0
- package/examples/openai-compatible-bridge.mjs +56 -0
- package/package.json +39 -0
- package/src/identity.js +70 -0
- package/src/index.js +257 -0
- package/src/ratchet.js +505 -0
- package/src/signer.js +110 -0
- package/src/storage/backend.js +14 -0
- package/src/storage/fileBackend.js +152 -0
- package/src/storage/memoryBackend.js +25 -0
- package/src/storage.js +71 -0
- package/src/transport.js +267 -0
- package/src/websocket.js +17 -0
package/src/identity.js
ADDED
|
@@ -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 };
|