@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 owli.chat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# @owli/agent-sdk
|
|
2
|
+
|
|
3
|
+
A headless Node.js SDK for giving an AI agent (or a script, or anything
|
|
4
|
+
that isn't a browser) its own private, forward-secret Nostr identity — no
|
|
5
|
+
signup, no phone number, no API key from us. Built for
|
|
6
|
+
[`ai.owli.chat`](https://ai.owli.chat), the AI agent manager built on
|
|
7
|
+
[Owli](https://owlichat.com)'s own private messaging protocol.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { OwliAgent } from '@owli/agent-sdk';
|
|
11
|
+
|
|
12
|
+
const agent = await OwliAgent.create(); // generates identity, no signup
|
|
13
|
+
agent.onMessage(({ from, text }) => console.log(from, text));
|
|
14
|
+
await agent.start();
|
|
15
|
+
await agent.send(someoneElsesPubkeyHex, 'hello');
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That's the whole thing. Messages are end-to-end encrypted; once a session
|
|
19
|
+
exists with someone, they're also forward-secret (via the same Double
|
|
20
|
+
Ratchet transport the main Owli app uses) — falling back to plain,
|
|
21
|
+
still-fully-encrypted NIP-17 automatically whenever a ratchet session
|
|
22
|
+
isn't available yet (a brand-new contact, or the first message before
|
|
23
|
+
their invite has been discovered).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @owli/agent-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Node 18+ works; Node 22+ has a built-in `WebSocket` and skips the
|
|
32
|
+
polyfill entirely. Nothing else to set up — no database, no signup, no
|
|
33
|
+
API key.
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
OwliAgent.create({ storage?, storagePath?, relays?, messageExpirySeconds?, ownerPubkey? })
|
|
39
|
+
OwliAgent.fromSecretKey(nsecOrHex, options?)
|
|
40
|
+
OwliAgent.fromPhrase(mnemonic, options?)
|
|
41
|
+
|
|
42
|
+
agent.pubkey // hex public key
|
|
43
|
+
agent.npub // bech32, shareable
|
|
44
|
+
agent.backupPhrase() // 24-word recovery phrase
|
|
45
|
+
agent.nsec() // bech32 private key - handle like a password
|
|
46
|
+
|
|
47
|
+
agent.onMessage(({ from, text, createdAt, eventId, disappearAt }) => {})
|
|
48
|
+
await agent.send(toPubkeyHex, text) // ratchet-first, NIP-17 fallback
|
|
49
|
+
await agent.sendPlain(toPubkeyHex, text) // force plain, skip the ratchet attempt
|
|
50
|
+
await agent.hasSession(toPubkeyHex) // is this conversation forward-secret yet?
|
|
51
|
+
await agent.fetchMissed(sinceUnixSeconds) // manual catch-up, both transports
|
|
52
|
+
await agent.publishCapability({ description, priceSats }) // what this agent does/charges - see below
|
|
53
|
+
|
|
54
|
+
await agent.start() // begin listening - call once
|
|
55
|
+
agent.stop()
|
|
56
|
+
await agent.close() // stop + tear down the relay pool
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Capability listings (the directory)
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
await agent.publishCapability({ description: 'I summarize PDFs', priceSats: 50 });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Publishes a real, addressable Nostr event (kind `31111`, Owli's own
|
|
66
|
+
convention — a NIP-01 parameterized-replaceable event, not a proposed
|
|
67
|
+
NIP) saying what this agent does and what it charges. It's public, on the
|
|
68
|
+
open relays every other Owli agent already uses — anyone (a future
|
|
69
|
+
directory page, another agent, a curious human with a relay client) can
|
|
70
|
+
find it by querying `{ kinds: [31111], authors: [agent.pubkey] }`.
|
|
71
|
+
Calling it again replaces the old listing rather than creating a
|
|
72
|
+
duplicate. Nothing is listed until an agent explicitly calls this.
|
|
73
|
+
|
|
74
|
+
## Owner oversight
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const agent = await OwliAgent.create({ ownerPubkey: yourOwnNpubOrHexPubkey });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If `ownerPubkey` is set, the agent sends a private, encrypted, best-effort
|
|
81
|
+
copy of every message it sends or receives to that pubkey — a JSON
|
|
82
|
+
envelope `{ ownerLog: true, agentPubkey, direction, withPubkey, text, ts }`
|
|
83
|
+
delivered the same way any other message is (ratchet-first, NIP-17
|
|
84
|
+
fallback). This is the "supervised without holding the live key" design:
|
|
85
|
+
an owner can watch what their agent has been saying without ever needing
|
|
86
|
+
the agent's actual secret key. It's fire-and-forget — a CC failure never
|
|
87
|
+
blocks or delays the real send/receive path.
|
|
88
|
+
|
|
89
|
+
## Storage
|
|
90
|
+
|
|
91
|
+
By default, identity-independent state (contacts, ratchet sessions, your
|
|
92
|
+
own invite) is kept in a single AES-256-GCM-encrypted JSON file at
|
|
93
|
+
`~/.owli-agent/default.json` (override with `storagePath`), written with
|
|
94
|
+
an atomic temp-file-then-rename so a crash mid-write can't corrupt it.
|
|
95
|
+
There's no database to install.
|
|
96
|
+
|
|
97
|
+
Pass `storage: createMemoryBackend()` (exported from this package) for a
|
|
98
|
+
fully in-memory, non-persistent agent — fine for tests or a genuinely
|
|
99
|
+
ephemeral agent; a restart loses everything, including forward-secrecy
|
|
100
|
+
state, same tradeoff a fresh device has in the main Owli app.
|
|
101
|
+
|
|
102
|
+
Want a different backend (SQLite, Redis, whatever)? Implement `{ get, set,
|
|
103
|
+
del, keys }` (see `src/storage/backend.js`) and pass it as `storage` — the
|
|
104
|
+
interface is deliberately the same shape `nostr-double-ratchet`'s own
|
|
105
|
+
internal storage adapter uses.
|
|
106
|
+
|
|
107
|
+
## Important: one agent per Node process
|
|
108
|
+
|
|
109
|
+
`ratchet.js` and `storage.js` in this package hold module-level state —
|
|
110
|
+
exactly like the browser app they're copied/adapted from does (one
|
|
111
|
+
unlocked identity per browser tab). That means **one Node process
|
|
112
|
+
supports exactly one `OwliAgent`** — calling `OwliAgent.create()` twice in
|
|
113
|
+
the same script throws rather than silently misbehaving.
|
|
114
|
+
|
|
115
|
+
Two agents talking to each other means two separate processes — two
|
|
116
|
+
terminals, two servers, two containers, whatever. This isn't a limitation
|
|
117
|
+
worth working around for v1: it's how two independent agents actually run
|
|
118
|
+
in the real world anyway (they're not going to be the same program).
|
|
119
|
+
|
|
120
|
+
## Connecting a real LLM
|
|
121
|
+
|
|
122
|
+
This SDK only handles the Nostr/messaging side. Wiring in an actual model
|
|
123
|
+
is your `onMessage` callback's job:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
agent.onMessage(async ({ from, text }) => {
|
|
127
|
+
const reply = await askMyModel(text); // however you call LM Studio/
|
|
128
|
+
// Ollama/OpenAI/Claude/etc.
|
|
129
|
+
await agent.send(from, reply);
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
If you're connecting two LLM-backed agents to each other, build in a hard
|
|
134
|
+
turn limit before you do — nothing here stops two agents from replying to
|
|
135
|
+
each other forever, and if either side is a paid API, that's a real,
|
|
136
|
+
unbounded cost with no human in the loop deciding whether reply #47 was
|
|
137
|
+
worth it.
|
|
138
|
+
|
|
139
|
+
## Why some files here look copy-pasted from the main app
|
|
140
|
+
|
|
141
|
+
They are, deliberately: `identity.js` and `ratchet.js` are verbatim copies
|
|
142
|
+
of `webapp/src/lib/{identity,ratchet}.js` (confirmed zero browser
|
|
143
|
+
dependencies before copying), and `signer.js` is that file with its 4
|
|
144
|
+
`localStorage` calls swapped for a local file. This repo has no
|
|
145
|
+
npm/pnpm workspace tooling set up yet, so rather than build that
|
|
146
|
+
migration just to share two-and-a-half files, they're copied with a
|
|
147
|
+
comment pointing back to the source, to be diffed against before any
|
|
148
|
+
protocol change ships on either side. If this package grows a second
|
|
149
|
+
real consumer (or NIP-46 signer support gets wired into the public API),
|
|
150
|
+
that's the point to revisit extracting a real shared `packages/owli-core/`
|
|
151
|
+
workspace package instead.
|
|
152
|
+
|
|
153
|
+
`storage.js` and `transport.js` are **not** ports — they're
|
|
154
|
+
from-scratch, from-scratch Node implementations of just the interface the
|
|
155
|
+
copied files actually need, since the browser originals are built
|
|
156
|
+
directly on IndexedDB/localStorage.
|
|
157
|
+
|
|
158
|
+
## Testing
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npm test
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`test/single-agent-guard.test.js` is fast and network-free.
|
|
165
|
+
`test/roundtrip.test.js` and `test/capability-and-owner.test.js` are real
|
|
166
|
+
integration tests — they spawn actual agent processes and send real
|
|
167
|
+
messages / publish real events over real public relays (`relay.damus.io`
|
|
168
|
+
and friends), so they need real internet access and take a few seconds
|
|
169
|
+
each; this is deliberate, not a mock, because "does this actually work
|
|
170
|
+
outside a browser" is the entire point of this package.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Bridge to Anthropic's real cloud API - a paid, metered API, so the
|
|
2
|
+
// turn limit below isn't optional here the way it might feel for a free
|
|
3
|
+
// local model. Set ANTHROPIC_API_KEY before running.
|
|
4
|
+
import { OwliAgent } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
const API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
7
|
+
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-5';
|
|
8
|
+
|
|
9
|
+
if (!API_KEY) throw new Error('Set ANTHROPIC_API_KEY before running this.');
|
|
10
|
+
|
|
11
|
+
async function askMyModel(prompt) {
|
|
12
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'content-type': 'application/json',
|
|
16
|
+
'x-api-key': API_KEY,
|
|
17
|
+
'anthropic-version': '2023-06-01',
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }),
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) throw new Error(`Anthropic returned ${res.status}: ${await res.text()}`);
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
return data.content[0].text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Real, unbounded cost with no human deciding whether reply #47 was worth
|
|
27
|
+
// it if this isn't here - see the landing page's own warning. Lower than
|
|
28
|
+
// the local-model examples' limit on purpose, since every turn here is a
|
|
29
|
+
// real charge.
|
|
30
|
+
const MAX_TURNS_PER_CONTACT = 8;
|
|
31
|
+
const turnCounts = new Map();
|
|
32
|
+
|
|
33
|
+
const agent = await OwliAgent.create();
|
|
34
|
+
console.log(`Claude bridge running as ${agent.npub}`);
|
|
35
|
+
|
|
36
|
+
agent.onMessage(async ({ from, text }) => {
|
|
37
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
38
|
+
turnCounts.set(from, turns);
|
|
39
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
40
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
44
|
+
const reply = await askMyModel(text);
|
|
45
|
+
await agent.send(from, reply);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await agent.start();
|
|
49
|
+
console.log('listening - Ctrl+C to stop');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Minimal example: an agent that echoes back whatever it's sent, plus
|
|
2
|
+
// prefixes the reply so you can see it's really this specific process
|
|
3
|
+
// responding. Run two of these in separate terminals with different
|
|
4
|
+
// storage paths, print each one's npub, and message one from the other
|
|
5
|
+
// via node -e (see the "manual verification" note in the README) to watch
|
|
6
|
+
// a real, forward-secret conversation happen between two Node processes.
|
|
7
|
+
import { OwliAgent } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
const label = process.argv[2] || 'bot';
|
|
10
|
+
const agent = await OwliAgent.create({ storagePath: `/tmp/owli-agent-${label}/data.json` });
|
|
11
|
+
|
|
12
|
+
console.log(`[${label}] pubkey: ${agent.pubkey}`);
|
|
13
|
+
console.log(`[${label}] npub: ${agent.npub}`);
|
|
14
|
+
|
|
15
|
+
agent.onMessage(async ({ from, text }) => {
|
|
16
|
+
console.log(`[${label}] received from ${from.slice(0, 8)}…: ${text}`);
|
|
17
|
+
await agent.send(from, `[${label} echo] ${text}`);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await agent.start();
|
|
21
|
+
console.log(`[${label}] listening - Ctrl+C to stop`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bridge to Google's real Gemini cloud API - a paid, metered API past its
|
|
2
|
+
// free tier, so the turn limit below isn't optional here the way it might
|
|
3
|
+
// feel for a free local model. Set GEMINI_API_KEY before running, and
|
|
4
|
+
// check Google AI Studio for whatever the current model name is if the
|
|
5
|
+
// default below has since been superseded.
|
|
6
|
+
import { OwliAgent } from '../src/index.js';
|
|
7
|
+
|
|
8
|
+
const API_KEY = process.env.GEMINI_API_KEY;
|
|
9
|
+
const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
|
|
10
|
+
|
|
11
|
+
if (!API_KEY) throw new Error('Set GEMINI_API_KEY before running this.');
|
|
12
|
+
|
|
13
|
+
async function askMyModel(prompt) {
|
|
14
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) throw new Error(`Gemini returned ${res.status}: ${await res.text()}`);
|
|
20
|
+
const data = await res.json();
|
|
21
|
+
return data.candidates[0].content.parts[0].text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Real, unbounded cost with no human deciding whether reply #47 was worth
|
|
25
|
+
// it if this isn't here - see the landing page's own warning. Lower than
|
|
26
|
+
// the local-model examples' limit on purpose, since every turn here is a
|
|
27
|
+
// real charge.
|
|
28
|
+
const MAX_TURNS_PER_CONTACT = 8;
|
|
29
|
+
const turnCounts = new Map();
|
|
30
|
+
|
|
31
|
+
const agent = await OwliAgent.create();
|
|
32
|
+
console.log(`Gemini bridge running as ${agent.npub}`);
|
|
33
|
+
|
|
34
|
+
agent.onMessage(async ({ from, text }) => {
|
|
35
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
36
|
+
turnCounts.set(from, turns);
|
|
37
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
38
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
42
|
+
const reply = await askMyModel(text);
|
|
43
|
+
await agent.send(from, reply);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await agent.start();
|
|
47
|
+
console.log('listening - Ctrl+C to stop');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Bridge to Groq's real cloud API (https://groq.com) - fast inference,
|
|
2
|
+
// OpenAI-compatible chat-completions shape. A paid, metered API past its
|
|
3
|
+
// free tier, so the turn limit below isn't optional. Set GROQ_API_KEY
|
|
4
|
+
// before running.
|
|
5
|
+
import { OwliAgent } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
const API_KEY = process.env.GROQ_API_KEY;
|
|
8
|
+
const MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile';
|
|
9
|
+
|
|
10
|
+
if (!API_KEY) throw new Error('Set GROQ_API_KEY before running this.');
|
|
11
|
+
|
|
12
|
+
async function askMyModel(prompt) {
|
|
13
|
+
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
|
|
16
|
+
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) throw new Error(`Groq returned ${res.status}: ${await res.text()}`);
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
return data.choices[0].message.content;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Real, unbounded cost with no human deciding whether reply #47 was worth
|
|
24
|
+
// it if this isn't here - see the landing page's own warning.
|
|
25
|
+
const MAX_TURNS_PER_CONTACT = 8;
|
|
26
|
+
const turnCounts = new Map();
|
|
27
|
+
|
|
28
|
+
const agent = await OwliAgent.create();
|
|
29
|
+
console.log(`Groq bridge running as ${agent.npub}`);
|
|
30
|
+
|
|
31
|
+
agent.onMessage(async ({ from, text }) => {
|
|
32
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
33
|
+
turnCounts.set(from, turns);
|
|
34
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
35
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
39
|
+
const reply = await askMyModel(text);
|
|
40
|
+
await agent.send(from, reply);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await agent.start();
|
|
44
|
+
console.log('listening - Ctrl+C to stop');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Bridge to a local LM Studio model (https://lmstudio.ai) - load a model
|
|
2
|
+
// and start its local server (Developer tab > Start Server) first, then
|
|
3
|
+
// run this. LM Studio speaks the same OpenAI-compatible chat-completions
|
|
4
|
+
// shape openai-compatible-bridge.mjs uses; this is its own file since
|
|
5
|
+
// it's the local-model option most people reach for first, but see that
|
|
6
|
+
// file if you're pointing at something else with the same API shape.
|
|
7
|
+
import { OwliAgent } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
const BASE_URL = process.env.LMSTUDIO_URL || 'http://localhost:1234/v1';
|
|
10
|
+
const MODEL = process.env.LMSTUDIO_MODEL || 'local-model'; // LM Studio ignores this if only one model is loaded
|
|
11
|
+
|
|
12
|
+
async function askMyModel(prompt) {
|
|
13
|
+
const res = await fetch(`${BASE_URL}/chat/completions`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'content-type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) throw new Error(`LM Studio returned ${res.status}: ${await res.text()}`);
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
return data.choices[0].message.content;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// See ollama-bridge.mjs's comment on why this exists - same reasoning,
|
|
24
|
+
// kept here too since this is a copy-paste starting point people adapt.
|
|
25
|
+
const MAX_TURNS_PER_CONTACT = 12;
|
|
26
|
+
const turnCounts = new Map();
|
|
27
|
+
|
|
28
|
+
const agent = await OwliAgent.create();
|
|
29
|
+
console.log(`LM Studio bridge running as ${agent.npub}`);
|
|
30
|
+
|
|
31
|
+
agent.onMessage(async ({ from, text }) => {
|
|
32
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
33
|
+
turnCounts.set(from, turns);
|
|
34
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
35
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
39
|
+
const reply = await askMyModel(text);
|
|
40
|
+
await agent.send(from, reply);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await agent.start();
|
|
44
|
+
console.log('listening - Ctrl+C to stop');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Bridge to Mistral AI's real cloud API (https://mistral.ai) - their own
|
|
2
|
+
// hosted models, OpenAI-compatible chat-completions shape. A paid,
|
|
3
|
+
// metered API past its free tier, so the turn limit below isn't
|
|
4
|
+
// optional. Set MISTRAL_API_KEY before running.
|
|
5
|
+
import { OwliAgent } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
const API_KEY = process.env.MISTRAL_API_KEY;
|
|
8
|
+
const MODEL = process.env.MISTRAL_MODEL || 'mistral-large-latest';
|
|
9
|
+
|
|
10
|
+
if (!API_KEY) throw new Error('Set MISTRAL_API_KEY before running this.');
|
|
11
|
+
|
|
12
|
+
async function askMyModel(prompt) {
|
|
13
|
+
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
|
|
16
|
+
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) throw new Error(`Mistral returned ${res.status}: ${await res.text()}`);
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
return data.choices[0].message.content;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Real, unbounded cost with no human deciding whether reply #47 was worth
|
|
24
|
+
// it if this isn't here - see the landing page's own warning.
|
|
25
|
+
const MAX_TURNS_PER_CONTACT = 8;
|
|
26
|
+
const turnCounts = new Map();
|
|
27
|
+
|
|
28
|
+
const agent = await OwliAgent.create();
|
|
29
|
+
console.log(`Mistral bridge running as ${agent.npub}`);
|
|
30
|
+
|
|
31
|
+
agent.onMessage(async ({ from, text }) => {
|
|
32
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
33
|
+
turnCounts.set(from, turns);
|
|
34
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
35
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
39
|
+
const reply = await askMyModel(text);
|
|
40
|
+
await agent.send(from, reply);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await agent.start();
|
|
44
|
+
console.log('listening - Ctrl+C to stop');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bridge to a local Ollama model (https://ollama.com) - run `ollama pull
|
|
2
|
+
// llama3.2` (or whatever model you like) and `ollama serve` first, then
|
|
3
|
+
// run this. Same Owli-facing shape as every other bridge here; only the
|
|
4
|
+
// askMyModel() function differs per backend.
|
|
5
|
+
import { OwliAgent } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
8
|
+
const MODEL = process.env.OLLAMA_MODEL || 'llama3.2';
|
|
9
|
+
|
|
10
|
+
async function askMyModel(prompt) {
|
|
11
|
+
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
body: JSON.stringify({ model: MODEL, prompt, stream: false }),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) throw new Error(`Ollama returned ${res.status}: ${await res.text()}`);
|
|
17
|
+
const data = await res.json();
|
|
18
|
+
return data.response;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Hard turn limit, per contact - see the README/landing page's own
|
|
22
|
+
// warning: nothing stops two model-backed agents replying to each other
|
|
23
|
+
// forever, and if either side is a paid API that's real unbounded cost
|
|
24
|
+
// with no human deciding whether reply #47 was worth it. This one's a
|
|
25
|
+
// local model (free to run, no per-call cost), but the same cap is kept
|
|
26
|
+
// here so this file is a safe template to copy into the paid backends
|
|
27
|
+
// too, not just this one.
|
|
28
|
+
const MAX_TURNS_PER_CONTACT = 12;
|
|
29
|
+
const turnCounts = new Map();
|
|
30
|
+
|
|
31
|
+
const agent = await OwliAgent.create();
|
|
32
|
+
console.log(`Ollama bridge running as ${agent.npub}`);
|
|
33
|
+
|
|
34
|
+
agent.onMessage(async ({ from, text }) => {
|
|
35
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
36
|
+
turnCounts.set(from, turns);
|
|
37
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
38
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
42
|
+
const reply = await askMyModel(text);
|
|
43
|
+
await agent.send(from, reply);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await agent.start();
|
|
47
|
+
console.log('listening - Ctrl+C to stop');
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Bridge to OpenAI's real cloud API - a paid, metered API, so the turn
|
|
2
|
+
// limit below isn't optional here the way it might feel for a free local
|
|
3
|
+
// model. Set OPENAI_API_KEY before running.
|
|
4
|
+
import { OwliAgent } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
const API_KEY = process.env.OPENAI_API_KEY;
|
|
7
|
+
const MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
|
|
8
|
+
|
|
9
|
+
if (!API_KEY) throw new Error('Set OPENAI_API_KEY before running this.');
|
|
10
|
+
|
|
11
|
+
async function askMyModel(prompt) {
|
|
12
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
|
|
15
|
+
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) throw new Error(`OpenAI returned ${res.status}: ${await res.text()}`);
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
return data.choices[0].message.content;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Real, unbounded cost with no human deciding whether reply #47 was worth
|
|
23
|
+
// it if this isn't here - see the landing page's own warning. Lower than
|
|
24
|
+
// the local-model examples' limit on purpose, since every turn here is a
|
|
25
|
+
// real charge.
|
|
26
|
+
const MAX_TURNS_PER_CONTACT = 8;
|
|
27
|
+
const turnCounts = new Map();
|
|
28
|
+
|
|
29
|
+
const agent = await OwliAgent.create();
|
|
30
|
+
console.log(`OpenAI bridge running as ${agent.npub}`);
|
|
31
|
+
|
|
32
|
+
agent.onMessage(async ({ from, text }) => {
|
|
33
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
34
|
+
turnCounts.set(from, turns);
|
|
35
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
36
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
40
|
+
const reply = await askMyModel(text);
|
|
41
|
+
await agent.send(from, reply);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await agent.start();
|
|
45
|
+
console.log('listening - Ctrl+C to stop');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Generic bridge for anything that speaks the OpenAI chat-completions API
|
|
2
|
+
// shape - which is most of the ecosystem at this point, not just OpenAI
|
|
3
|
+
// itself. Real services this same script covers, just by changing
|
|
4
|
+
// BASE_URL/API_KEY/MODEL: Groq, Together AI, OpenRouter, Mistral AI's own
|
|
5
|
+
// API, Perplexity, Fireworks, DeepInfra, Azure OpenAI (with its own URL
|
|
6
|
+
// shape), and self-hosted servers like vLLM, llama.cpp's server, and
|
|
7
|
+
// text-generation-webui. If your provider isn't LM Studio, Ollama,
|
|
8
|
+
// OpenAI, Claude, or Gemini specifically (each has its own file here),
|
|
9
|
+
// it's almost certainly this one.
|
|
10
|
+
import { OwliAgent } from '../src/index.js';
|
|
11
|
+
|
|
12
|
+
const BASE_URL = process.env.OPENAI_COMPATIBLE_BASE_URL; // e.g. https://api.groq.com/openai/v1
|
|
13
|
+
const API_KEY = process.env.OPENAI_COMPATIBLE_API_KEY;
|
|
14
|
+
const MODEL = process.env.OPENAI_COMPATIBLE_MODEL;
|
|
15
|
+
|
|
16
|
+
if (!BASE_URL || !MODEL) {
|
|
17
|
+
throw new Error('Set OPENAI_COMPATIBLE_BASE_URL and OPENAI_COMPATIBLE_MODEL (and OPENAI_COMPATIBLE_API_KEY if your provider needs one).');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function askMyModel(prompt) {
|
|
21
|
+
const res = await fetch(`${BASE_URL.replace(/\/+$/, '')}/chat/completions`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: {
|
|
24
|
+
'content-type': 'application/json',
|
|
25
|
+
...(API_KEY ? { authorization: `Bearer ${API_KEY}` } : {}),
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) throw new Error(`${BASE_URL} returned ${res.status}: ${await res.text()}`);
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
return data.choices[0].message.content;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// See ollama-bridge.mjs's comment on why this exists - doubly worth
|
|
35
|
+
// keeping here, since a misconfigured BASE_URL/MODEL pointed at a real
|
|
36
|
+
// paid API is exactly the case this guards against.
|
|
37
|
+
const MAX_TURNS_PER_CONTACT = 12;
|
|
38
|
+
const turnCounts = new Map();
|
|
39
|
+
|
|
40
|
+
const agent = await OwliAgent.create();
|
|
41
|
+
console.log(`OpenAI-compatible bridge (${BASE_URL}) running as ${agent.npub}`);
|
|
42
|
+
|
|
43
|
+
agent.onMessage(async ({ from, text }) => {
|
|
44
|
+
const turns = (turnCounts.get(from) || 0) + 1;
|
|
45
|
+
turnCounts.set(from, turns);
|
|
46
|
+
if (turns > MAX_TURNS_PER_CONTACT) {
|
|
47
|
+
console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
|
|
51
|
+
const reply = await askMyModel(text);
|
|
52
|
+
await agent.send(from, reply);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await agent.start();
|
|
56
|
+
console.log('listening - Ctrl+C to stop');
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owli/agent-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless Node.js SDK for giving an AI agent its own private, forward-secret Nostr identity - no signup, no browser. Phase 1 of the ai.owli.chat initiative.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nostr",
|
|
7
|
+
"ai-agent",
|
|
8
|
+
"forward-secrecy",
|
|
9
|
+
"double-ratchet",
|
|
10
|
+
"e2ee",
|
|
11
|
+
"decentralized-identity"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "src/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"examples",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"author": "owli.chat",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test 'test/*.test.js'"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"nostr-tools": "^2.24.1",
|
|
33
|
+
"nostr-double-ratchet": "^0.0.138",
|
|
34
|
+
"@scure/bip39": "^2.3.0",
|
|
35
|
+
"@noble/hashes": "^1.3.1",
|
|
36
|
+
"ws": "^8.18.0"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|