@kybernesis/buzz 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 +202 -0
- package/NOTICE +2 -0
- package/README.md +73 -0
- package/dist/bridge.d.ts +23 -0
- package/dist/bridge.js +158 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +122 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/keys.d.ts +30 -0
- package/dist/keys.js +47 -0
- package/dist/relay.d.ts +108 -0
- package/dist/relay.js +260 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/NOTICE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @kybernesis/buzz
|
|
2
|
+
|
|
3
|
+
Put an eve agent in a [Buzz](https://buzz.xyz) workspace as a **member**, not as a shared
|
|
4
|
+
service account: every message is answered as the person who sent it.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx kybernesis-buzz init # create the agent's identity, print what to invite
|
|
8
|
+
npx kybernesis-buzz run # run the bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Why a member
|
|
12
|
+
|
|
13
|
+
A workspace signs every message with the sender's key, so the bridge knows exactly who spoke.
|
|
14
|
+
That is enough to run each turn as *that person* — their memory, their connections, their
|
|
15
|
+
grants — instead of collapsing a whole room onto one identity and giving everyone in it the
|
|
16
|
+
access of whoever set the agent up.
|
|
17
|
+
|
|
18
|
+
## How someone becomes known
|
|
19
|
+
|
|
20
|
+
Nobody is configured by hand. The first time a person talks to the agent, they are sent a
|
|
21
|
+
one-time sign-in link **privately**, they sign in to the control plane as themselves, and from
|
|
22
|
+
then on their turns run as them.
|
|
23
|
+
|
|
24
|
+
The privacy is the security argument, not politeness: holding that link is what proves control
|
|
25
|
+
of the account it names, so it is delivered to a direct conversation and never posted in a room.
|
|
26
|
+
|
|
27
|
+
Administrators can also link identities directly in the control plane, which is the path for
|
|
28
|
+
bulk provisioning.
|
|
29
|
+
|
|
30
|
+
## What the host holds
|
|
31
|
+
|
|
32
|
+
Only the agent's own key and the agent's own credential — neither of which can act as any
|
|
33
|
+
person. Every turn's authority is minted at the moment it is needed and expires shortly after,
|
|
34
|
+
so revoking a link, suspending a user or pulling a grant takes effect within one token lifetime
|
|
35
|
+
without redeploying anything.
|
|
36
|
+
|
|
37
|
+
## Presence, typing, and seen
|
|
38
|
+
|
|
39
|
+
A member that only ever speaks looks offline between sentences, so the bridge also publishes
|
|
40
|
+
what a person's client publishes: presence on a heartbeat, a typing indicator while a turn is
|
|
41
|
+
running, and 👀 on a message the moment it is picked up.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
| Variable | Meaning |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `BUZZ_RELAY` | The workspace relay (`wss://…`) |
|
|
48
|
+
| `BUZZ_AGENT_URL` | Where the agent listens (default `http://127.0.0.1:8000`) |
|
|
49
|
+
| `BUZZ_KEYFILE` | The agent's key (default `~/.kybernesis/buzz-agent.json`) |
|
|
50
|
+
| `KYBERNESIS_ISSUER` | The control plane |
|
|
51
|
+
| `KYBERNESIS_AGENT_CREDENTIAL` | This agent's credential |
|
|
52
|
+
|
|
53
|
+
`kybernesis-buzz service` prints a systemd unit for it. `kybernesis-buzz id <npub|hex>` shows a
|
|
54
|
+
public key in both forms, which is what the control plane's manual linking wants.
|
|
55
|
+
|
|
56
|
+
## Library
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { buzzBridge } from "@kybernesis/buzz";
|
|
60
|
+
|
|
61
|
+
const bridge = buzzBridge({
|
|
62
|
+
relay: process.env.BUZZ_RELAY!,
|
|
63
|
+
agentUrl: "http://127.0.0.1:8000",
|
|
64
|
+
keyFile: "/etc/agent/buzz.json",
|
|
65
|
+
issuer: process.env.KYBERNESIS_ISSUER!,
|
|
66
|
+
credential: process.env.KYBERNESIS_AGENT_CREDENTIAL!,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
console.log(`invite ${bridge.npub}`);
|
|
70
|
+
bridge.start();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Apache-2.0.
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type BuzzBridgeOptions = {
|
|
2
|
+
/** The workspace relay, e.g. `wss://example.communities.buzz.xyz`. */
|
|
3
|
+
relay: string;
|
|
4
|
+
/** Where the agent is listening, e.g. `http://127.0.0.1:8000`. */
|
|
5
|
+
agentUrl: string;
|
|
6
|
+
/** The agent's own key file. Its public half is what the workspace invited. */
|
|
7
|
+
keyFile: string;
|
|
8
|
+
/** The control plane, e.g. `https://control.example.com`. */
|
|
9
|
+
issuer: string;
|
|
10
|
+
/** This agent's credential from the control plane. The only durable secret here. */
|
|
11
|
+
credential: string;
|
|
12
|
+
pollMs?: number;
|
|
13
|
+
onLog?: (message: string) => void;
|
|
14
|
+
};
|
|
15
|
+
export declare function buzzBridge(options: BuzzBridgeOptions): {
|
|
16
|
+
/** The key the workspace has to invite for any of this to happen. */
|
|
17
|
+
npub: string;
|
|
18
|
+
pubkey: string;
|
|
19
|
+
start(): void;
|
|
20
|
+
/** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
|
|
21
|
+
stop(): void;
|
|
22
|
+
};
|
|
23
|
+
export type BuzzBridge = ReturnType<typeof buzzBridge>;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
|
+
import { Client } from "eve/client";
|
|
3
|
+
import { BuzzRelay } from "./relay.js";
|
|
4
|
+
import { loadKey, npubEncode } from "./keys.js";
|
|
5
|
+
/**
|
|
6
|
+
* An agent as a member of a workspace, answering as whoever spoke to it.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The design decision this file exists to express: a message is answered as the PERSON who sent
|
|
10
|
+
* it, not as the agent's owner and not as one shared service account. The sender is a verified
|
|
11
|
+
* public key — the workspace signed it — so it can be resolved to a real identity, and the turn
|
|
12
|
+
* can run with that person's memory, connections and grants.
|
|
13
|
+
*
|
|
14
|
+
* The bridge itself is deliberately powerless. It holds the agent's key and the agent's own
|
|
15
|
+
* credential, and neither of those can act as any person; every turn's authority is minted at
|
|
16
|
+
* the moment it is needed and expires shortly after. A stolen copy of this process buys an
|
|
17
|
+
* attacker the ability to be the agent, which the workspace already knows about, and nothing else.
|
|
18
|
+
*/
|
|
19
|
+
/** How the workspace names people, in the control plane's records. */
|
|
20
|
+
const PROVIDER = "buzz";
|
|
21
|
+
/** Acknowledge receipt before doing any work: silence and thinking look identical otherwise. */
|
|
22
|
+
const SEEN = "👀";
|
|
23
|
+
export function buzzBridge(options) {
|
|
24
|
+
const key = loadKey(options.keyFile);
|
|
25
|
+
const identity = channelIdentity({ issuer: options.issuer, credential: options.credential });
|
|
26
|
+
const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
|
|
27
|
+
/** One conversation per channel, which is how a channel already behaves. */
|
|
28
|
+
const sessions = new Map();
|
|
29
|
+
/** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
|
|
30
|
+
const invited = new Map();
|
|
31
|
+
const clientFor = (token, bundle) =>
|
|
32
|
+
// `host`, not `baseUrl`: a wrong key is ignored rather than rejected, and the client then
|
|
33
|
+
// builds a nonsense URL — an error about a malformed URL rather than about a mistyped option.
|
|
34
|
+
new Client({
|
|
35
|
+
host: options.agentUrl,
|
|
36
|
+
auth: { bearer: token },
|
|
37
|
+
headers: bundle ? { "x-kybernesis-bundle": bundle } : {},
|
|
38
|
+
});
|
|
39
|
+
async function ask(channel, text, token, bundle) {
|
|
40
|
+
const existing = sessions.get(channel);
|
|
41
|
+
if (existing) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await existing.send(text);
|
|
44
|
+
return (await response.result()).message ?? "";
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// A session the agent no longer holds cannot be resumed. Starting a new one loses the
|
|
48
|
+
// thread but keeps the conversation alive, which is the better of the two failures.
|
|
49
|
+
log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
|
|
50
|
+
sessions.delete(channel);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const client = clientFor(token, bundle);
|
|
54
|
+
const created = await client.sessions.create({ message: text });
|
|
55
|
+
sessions.set(channel, created.session);
|
|
56
|
+
return (await created.response.result()).message ?? "";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Send someone the link that makes them known, privately.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Privately is not a nicety. Holding the link is what proves control of the account it names,
|
|
63
|
+
* so posting it in a room would let anyone in that room claim to be that person. It goes to a
|
|
64
|
+
* direct conversation or it does not go at all.
|
|
65
|
+
*/
|
|
66
|
+
async function invite(relay, sender, link) {
|
|
67
|
+
const last = invited.get(sender) ?? 0;
|
|
68
|
+
if (Date.now() - last < 10 * 60_000)
|
|
69
|
+
return;
|
|
70
|
+
invited.set(sender, Date.now());
|
|
71
|
+
const dm = await relay.openDirectMessage(sender);
|
|
72
|
+
if (!dm) {
|
|
73
|
+
log(`could not reach ${npubEncode(sender).slice(0, 16)}… privately to send a sign-in link`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
relay.reply(dm, `Hi — before I can help, I need to know who you are.\n\nSign in here and I will work as you, with your own memory and access:\n${link}\n\nThe link is for this account only, and it expires shortly.`);
|
|
77
|
+
log(`sent a sign-in link to ${npubEncode(sender).slice(0, 16)}…`);
|
|
78
|
+
}
|
|
79
|
+
/** What to say to someone who is known but not allowed. Said privately, for the same reason. */
|
|
80
|
+
async function refuse(relay, sender, reason) {
|
|
81
|
+
const dm = await relay.openDirectMessage(sender);
|
|
82
|
+
const text = reason === "agent_not_granted"
|
|
83
|
+
? "You are signed in, but you do not have access to this agent yet. An administrator can grant it."
|
|
84
|
+
: "Your account is not active, so I cannot act as you.";
|
|
85
|
+
if (dm)
|
|
86
|
+
relay.reply(dm, text);
|
|
87
|
+
log(`refused ${npubEncode(sender).slice(0, 16)}…: ${reason}`);
|
|
88
|
+
}
|
|
89
|
+
const relay = new BuzzRelay({
|
|
90
|
+
url: options.relay,
|
|
91
|
+
key,
|
|
92
|
+
pollMs: options.pollMs,
|
|
93
|
+
onLog: log,
|
|
94
|
+
onMessage: (event) => {
|
|
95
|
+
void handle(event);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
async function handle(event) {
|
|
99
|
+
const channel = event.tags.find((t) => t[0] === "h")?.[1];
|
|
100
|
+
// Addressed by TAG, not by text. A name in prose is a string anyone can type; a p tag is what
|
|
101
|
+
// the client emits when someone actually picks this member out of a mention list.
|
|
102
|
+
const addressed = event.tags.some((t) => t[0] === "p" && t[1] === key.publicKey);
|
|
103
|
+
if (!channel || !addressed)
|
|
104
|
+
return;
|
|
105
|
+
const text = String(event.content ?? "").trim();
|
|
106
|
+
if (!text)
|
|
107
|
+
return;
|
|
108
|
+
let speaker;
|
|
109
|
+
try {
|
|
110
|
+
speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// The control plane being unreachable is not a refusal. Saying nothing and retrying on the
|
|
114
|
+
// next message is better than telling someone they are not allowed when they are.
|
|
115
|
+
log(`could not check who sent this (${error.message}); leaving it unanswered`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!speaker.linked) {
|
|
119
|
+
if (speaker.reason === "not_linked")
|
|
120
|
+
await invite(relay, event.pubkey, speaker.link);
|
|
121
|
+
else
|
|
122
|
+
await refuse(relay, event.pubkey, speaker.reason);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}`);
|
|
126
|
+
void relay.react(event.id, SEEN);
|
|
127
|
+
const stopTyping = relay.typingIn(channel);
|
|
128
|
+
try {
|
|
129
|
+
const reply = await ask(channel, text, speaker.token, speaker.bundle);
|
|
130
|
+
if (!reply) {
|
|
131
|
+
log("the agent produced no text");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
relay.reply(channel, reply, event);
|
|
135
|
+
log(`replied (${reply.length} chars)`);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
log(`failed to answer: ${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
stopTyping();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
/** The key the workspace has to invite for any of this to happen. */
|
|
146
|
+
npub: key.npub,
|
|
147
|
+
pubkey: key.publicKey,
|
|
148
|
+
start() {
|
|
149
|
+
relay.connect();
|
|
150
|
+
log("listening — turns run as whoever sent them");
|
|
151
|
+
},
|
|
152
|
+
/** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
|
|
153
|
+
stop() {
|
|
154
|
+
relay.setPresence("offline");
|
|
155
|
+
setTimeout(() => relay.close(), 250);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { buzzBridge } from "./bridge.js";
|
|
6
|
+
import { asHexPubkey, loadOrCreateKey, npubEncode } from "./keys.js";
|
|
7
|
+
/**
|
|
8
|
+
* Setup and operation for an agent that lives in a workspace.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* The commands are shaped around the one step that cannot be automated: a person in the
|
|
12
|
+
* workspace has to invite the agent's public key, because membership is theirs to grant. So
|
|
13
|
+
* `init` exists to produce exactly the thing they need to be given, and to say what to do with
|
|
14
|
+
* it — rather than leaving an operator to work out which of several long hex strings is the one.
|
|
15
|
+
*/
|
|
16
|
+
const KEY_FILE = process.env.BUZZ_KEYFILE ?? join(homedir(), ".kybernesis", "buzz-agent.json");
|
|
17
|
+
function env(name, fallback) {
|
|
18
|
+
const value = process.env[name] ?? fallback;
|
|
19
|
+
if (!value) {
|
|
20
|
+
console.error(`${name} is not set.`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function init() {
|
|
26
|
+
const { key, created } = loadOrCreateKey(KEY_FILE);
|
|
27
|
+
console.log(created ? `\n A new identity was created at ${KEY_FILE}\n` : `\n Using the identity at ${KEY_FILE}\n`);
|
|
28
|
+
console.log(` Invite this to the workspace:\n\n ${key.npub}\n`);
|
|
29
|
+
console.log(` hex: ${key.publicKey}\n`);
|
|
30
|
+
console.log(" Then, once it has been invited:\n");
|
|
31
|
+
console.log(" KYBERNESIS_AGENT_CREDENTIAL=… kybernesis-buzz run\n");
|
|
32
|
+
console.log(" Anyone who talks to it is sent a sign-in link the first time, and after that");
|
|
33
|
+
console.log(" their turns run as them.\n");
|
|
34
|
+
}
|
|
35
|
+
function run() {
|
|
36
|
+
const bridge = buzzBridge({
|
|
37
|
+
relay: env("BUZZ_RELAY"),
|
|
38
|
+
agentUrl: process.env.BUZZ_AGENT_URL ?? "http://127.0.0.1:8000",
|
|
39
|
+
keyFile: KEY_FILE,
|
|
40
|
+
issuer: env("KYBERNESIS_ISSUER", "https://agent.kybernesis.ai"),
|
|
41
|
+
credential: env("KYBERNESIS_AGENT_CREDENTIAL"),
|
|
42
|
+
...(process.env.BUZZ_POLL_MS ? { pollMs: Number(process.env.BUZZ_POLL_MS) } : {}),
|
|
43
|
+
});
|
|
44
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
45
|
+
process.on(signal, () => {
|
|
46
|
+
bridge.stop();
|
|
47
|
+
setTimeout(() => process.exit(0), 400);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
bridge.start();
|
|
51
|
+
}
|
|
52
|
+
/** A systemd unit, written out rather than described — the description is what gets mistyped. */
|
|
53
|
+
function service() {
|
|
54
|
+
const user = process.env.SUDO_USER ?? process.env.USER ?? "root";
|
|
55
|
+
const workingDirectory = process.cwd();
|
|
56
|
+
const unit = `[Unit]
|
|
57
|
+
Description=agent workspace bridge
|
|
58
|
+
After=network-online.target
|
|
59
|
+
Wants=network-online.target
|
|
60
|
+
|
|
61
|
+
[Service]
|
|
62
|
+
Type=simple
|
|
63
|
+
User=${user}
|
|
64
|
+
WorkingDirectory=${workingDirectory}
|
|
65
|
+
ExecStart=/bin/bash -lc 'set -a && . ./.env.local && set +a && exec npx kybernesis-buzz run'
|
|
66
|
+
Restart=always
|
|
67
|
+
RestartSec=10
|
|
68
|
+
# The bridge publishes "offline" on the way out; this gives it time to, rather than
|
|
69
|
+
# being killed mid-publish and leaving a stopped agent showing as online.
|
|
70
|
+
KillSignal=SIGTERM
|
|
71
|
+
TimeoutStopSec=10
|
|
72
|
+
StartLimitBurst=5
|
|
73
|
+
StartLimitIntervalSec=300
|
|
74
|
+
StandardOutput=append:${workingDirectory}/buzz-bridge.log
|
|
75
|
+
StandardError=append:${workingDirectory}/buzz-bridge.log
|
|
76
|
+
|
|
77
|
+
[Install]
|
|
78
|
+
WantedBy=multi-user.target
|
|
79
|
+
`;
|
|
80
|
+
const path = process.argv[3];
|
|
81
|
+
if (path) {
|
|
82
|
+
writeFileSync(path, unit);
|
|
83
|
+
console.log(`\n Written to ${path}\n`);
|
|
84
|
+
console.log(" sudo systemctl daemon-reload && sudo systemctl enable --now <name>\n");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
process.stdout.write(unit);
|
|
88
|
+
}
|
|
89
|
+
const command = process.argv[2];
|
|
90
|
+
if (command === "init")
|
|
91
|
+
init();
|
|
92
|
+
else if (command === "run")
|
|
93
|
+
run();
|
|
94
|
+
else if (command === "service")
|
|
95
|
+
service();
|
|
96
|
+
else if (command === "id") {
|
|
97
|
+
const hex = asHexPubkey(process.argv[3]);
|
|
98
|
+
if (!hex) {
|
|
99
|
+
console.error("usage: kybernesis-buzz id <npub… or 64-char hex>");
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
console.log(`hex: ${hex}`);
|
|
103
|
+
console.log(`npub: ${npubEncode(hex)}`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
console.log(`
|
|
107
|
+
kybernesis-buzz — put an agent in a workspace
|
|
108
|
+
|
|
109
|
+
init create or show this agent's identity, and what to invite
|
|
110
|
+
run run the bridge
|
|
111
|
+
service [path] print (or write) a systemd unit for it
|
|
112
|
+
id <npub|hex> show a public key in both forms
|
|
113
|
+
|
|
114
|
+
Environment:
|
|
115
|
+
|
|
116
|
+
BUZZ_RELAY the workspace relay (wss://…)
|
|
117
|
+
BUZZ_AGENT_URL where the agent listens (default http://127.0.0.1:8000)
|
|
118
|
+
BUZZ_KEYFILE this agent's key (default ~/.kybernesis/buzz-agent.json)
|
|
119
|
+
KYBERNESIS_ISSUER the control plane
|
|
120
|
+
KYBERNESIS_AGENT_CREDENTIAL this agent's credential
|
|
121
|
+
`);
|
|
122
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { buzzBridge, type BuzzBridge, type BuzzBridgeOptions } from "./bridge.js";
|
|
2
|
+
export { BuzzRelay, type NostrEvent, type PresenceStatus, type RelayOptions, KIND_MESSAGE, KIND_REACTION, KIND_PRESENCE, KIND_TYPING, KIND_DM_OPEN, } from "./relay.js";
|
|
3
|
+
export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode, type AgentKey } from "./keys.js";
|
package/dist/index.js
ADDED
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { npubEncode } from "nostr-tools/nip19";
|
|
2
|
+
/**
|
|
3
|
+
* The agent's own key, which is its whole identity in a workspace.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Kept in a file rather than an environment variable because it is generated once and then
|
|
7
|
+
* referred to forever: the workspace invites a public key, and losing the private half means
|
|
8
|
+
* being invited again as a stranger with none of the history.
|
|
9
|
+
*/
|
|
10
|
+
export type AgentKey = {
|
|
11
|
+
secretKey: Uint8Array;
|
|
12
|
+
publicKey: string;
|
|
13
|
+
npub: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function loadKey(file: string): AgentKey;
|
|
16
|
+
/** Load the key, or make one on first run. Never overwrites an existing key. */
|
|
17
|
+
export declare function loadOrCreateKey(file: string): {
|
|
18
|
+
key: AgentKey;
|
|
19
|
+
created: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* A public key in either form people actually have one in.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* A workspace shows a member as `npub1…`; the protocol underneath uses hex, and so does the
|
|
26
|
+
* control plane's record of who that member is. Asking a person to convert between them is
|
|
27
|
+
* asking them to get it wrong, so both are accepted and normalised to one canonical form here.
|
|
28
|
+
*/
|
|
29
|
+
export declare function asHexPubkey(input: string | undefined | null): string | null;
|
|
30
|
+
export { npubEncode };
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { generateSecretKey, getPublicKey } from "nostr-tools/pure";
|
|
4
|
+
import { decode as decodeBech32, npubEncode } from "nostr-tools/nip19";
|
|
5
|
+
export function loadKey(file) {
|
|
6
|
+
const stored = JSON.parse(readFileSync(file, "utf8"));
|
|
7
|
+
const secretKey = Uint8Array.from(stored.secretKey);
|
|
8
|
+
const publicKey = getPublicKey(secretKey);
|
|
9
|
+
return { secretKey, publicKey, npub: npubEncode(publicKey) };
|
|
10
|
+
}
|
|
11
|
+
/** Load the key, or make one on first run. Never overwrites an existing key. */
|
|
12
|
+
export function loadOrCreateKey(file) {
|
|
13
|
+
try {
|
|
14
|
+
return { key: loadKey(file), created: false };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code !== "ENOENT")
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
const secretKey = generateSecretKey();
|
|
21
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
22
|
+
// 0600: this file is the agent. Anyone who can read it can be the agent.
|
|
23
|
+
writeFileSync(file, JSON.stringify({ secretKey: Array.from(secretKey) }), { mode: 0o600 });
|
|
24
|
+
const publicKey = getPublicKey(secretKey);
|
|
25
|
+
return { key: { secretKey, publicKey, npub: npubEncode(publicKey) }, created: true };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A public key in either form people actually have one in.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* A workspace shows a member as `npub1…`; the protocol underneath uses hex, and so does the
|
|
32
|
+
* control plane's record of who that member is. Asking a person to convert between them is
|
|
33
|
+
* asking them to get it wrong, so both are accepted and normalised to one canonical form here.
|
|
34
|
+
*/
|
|
35
|
+
export function asHexPubkey(input) {
|
|
36
|
+
const value = String(input ?? "").trim();
|
|
37
|
+
if (/^[0-9a-f]{64}$/i.test(value))
|
|
38
|
+
return value.toLowerCase();
|
|
39
|
+
try {
|
|
40
|
+
const { type, data } = decodeBech32(value);
|
|
41
|
+
return type === "npub" ? String(data).toLowerCase() : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export { npubEncode };
|
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AgentKey } from "./keys.js";
|
|
2
|
+
/**
|
|
3
|
+
* The workspace protocol, as far as a member needs it.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Two transports, and which one carries what is not a style choice. Messages and the presence
|
|
7
|
+
* signals go over the socket, because they are either addressed to a room or ephemeral. Anything
|
|
8
|
+
* the server has to answer — a reaction it stores, a conversation it has to create and name —
|
|
9
|
+
* goes over HTTP with a signed request, because the answer is the point.
|
|
10
|
+
*
|
|
11
|
+
* Getting that backwards is expensive: a reaction published on the socket is accepted and then
|
|
12
|
+
* simply does not appear, which reads as a broken feature rather than a wrong door.
|
|
13
|
+
*/
|
|
14
|
+
/** Message in a channel. */
|
|
15
|
+
export declare const KIND_MESSAGE = 9;
|
|
16
|
+
/** NIP-25 reaction. */
|
|
17
|
+
export declare const KIND_REACTION = 7;
|
|
18
|
+
/** NIP-42 relay authentication. */
|
|
19
|
+
export declare const KIND_AUTH = 22242;
|
|
20
|
+
/** NIP-98 HTTP authentication. */
|
|
21
|
+
export declare const KIND_HTTP_AUTH = 27235;
|
|
22
|
+
/** Presence: "online" | "away" | "offline". */
|
|
23
|
+
export declare const KIND_PRESENCE = 20001;
|
|
24
|
+
/** Typing, scoped to a channel. */
|
|
25
|
+
export declare const KIND_TYPING = 20002;
|
|
26
|
+
/** Open (or find) a direct conversation with a set of people. */
|
|
27
|
+
export declare const KIND_DM_OPEN = 41010;
|
|
28
|
+
/**
|
|
29
|
+
* A receiving client expires a typing indicator shortly after the event's own timestamp, so
|
|
30
|
+
* anything slower than that has to keep saying so.
|
|
31
|
+
*/
|
|
32
|
+
export declare const TYPING_INTERVAL_MS = 3000;
|
|
33
|
+
export declare const PRESENCE_INTERVAL_MS = 60000;
|
|
34
|
+
export type NostrEvent = {
|
|
35
|
+
id: string;
|
|
36
|
+
pubkey: string;
|
|
37
|
+
created_at: number;
|
|
38
|
+
kind: number;
|
|
39
|
+
tags: string[][];
|
|
40
|
+
content: string;
|
|
41
|
+
sig: string;
|
|
42
|
+
};
|
|
43
|
+
export type PresenceStatus = "online" | "away" | "offline";
|
|
44
|
+
export type RelayOptions = {
|
|
45
|
+
url: string;
|
|
46
|
+
key: AgentKey;
|
|
47
|
+
/** How often to ask for new messages. The relay answers with what it has and stops. */
|
|
48
|
+
pollMs?: number;
|
|
49
|
+
onMessage: (event: NostrEvent) => void;
|
|
50
|
+
onLog?: (message: string) => void;
|
|
51
|
+
};
|
|
52
|
+
export declare class BuzzRelay {
|
|
53
|
+
private socket;
|
|
54
|
+
private authenticated;
|
|
55
|
+
private presenceTimer;
|
|
56
|
+
private pollTimer;
|
|
57
|
+
private closed;
|
|
58
|
+
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
59
|
+
private readonly seen;
|
|
60
|
+
private cursor;
|
|
61
|
+
private readonly url;
|
|
62
|
+
private readonly rest;
|
|
63
|
+
private readonly key;
|
|
64
|
+
private readonly pollMs;
|
|
65
|
+
private readonly onMessage;
|
|
66
|
+
private readonly log;
|
|
67
|
+
constructor(options: RelayOptions);
|
|
68
|
+
get pubkey(): string;
|
|
69
|
+
connect(): void;
|
|
70
|
+
close(): void;
|
|
71
|
+
private reconnect;
|
|
72
|
+
private receive;
|
|
73
|
+
private send;
|
|
74
|
+
/**
|
|
75
|
+
* Ask for what is new, rather than waiting to be told.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* This relay answers a subscription with the events it has and then stops; it does not hold the
|
|
79
|
+
* subscription open and push what arrives next. Assuming otherwise produces the worst kind of
|
|
80
|
+
* failure — a process that connects, authenticates, logs that it is listening, and silently
|
|
81
|
+
* ignores every message addressed to it.
|
|
82
|
+
*/
|
|
83
|
+
private poll;
|
|
84
|
+
publish(event: {
|
|
85
|
+
kind: number;
|
|
86
|
+
tags: string[][];
|
|
87
|
+
content: string;
|
|
88
|
+
}): void;
|
|
89
|
+
setPresence(status: PresenceStatus): void;
|
|
90
|
+
private keepPresent;
|
|
91
|
+
/** Say "typing" in a channel until the returned function is called. */
|
|
92
|
+
typingIn(channel: string): () => void;
|
|
93
|
+
reply(channel: string, text: string, replyTo?: NostrEvent): void;
|
|
94
|
+
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
95
|
+
private httpAuth;
|
|
96
|
+
private submit;
|
|
97
|
+
/** React to a message. Stored by the server, so it goes over HTTP rather than the socket. */
|
|
98
|
+
react(eventId: string, emoji: string): Promise<void>;
|
|
99
|
+
/**
|
|
100
|
+
* Open a private conversation with someone, and answer with its channel id.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* Needed for anything that must reach ONE person rather than a room. Opening a conversation
|
|
104
|
+
* that already exists returns the existing one, so this is safe to call whenever it is needed
|
|
105
|
+
* instead of tracking which conversations exist.
|
|
106
|
+
*/
|
|
107
|
+
openDirectMessage(pubkey: string): Promise<string | null>;
|
|
108
|
+
}
|
package/dist/relay.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { finalizeEvent } from "nostr-tools/pure";
|
|
3
|
+
/**
|
|
4
|
+
* The workspace protocol, as far as a member needs it.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Two transports, and which one carries what is not a style choice. Messages and the presence
|
|
8
|
+
* signals go over the socket, because they are either addressed to a room or ephemeral. Anything
|
|
9
|
+
* the server has to answer — a reaction it stores, a conversation it has to create and name —
|
|
10
|
+
* goes over HTTP with a signed request, because the answer is the point.
|
|
11
|
+
*
|
|
12
|
+
* Getting that backwards is expensive: a reaction published on the socket is accepted and then
|
|
13
|
+
* simply does not appear, which reads as a broken feature rather than a wrong door.
|
|
14
|
+
*/
|
|
15
|
+
/** Message in a channel. */
|
|
16
|
+
export const KIND_MESSAGE = 9;
|
|
17
|
+
/** NIP-25 reaction. */
|
|
18
|
+
export const KIND_REACTION = 7;
|
|
19
|
+
/** NIP-42 relay authentication. */
|
|
20
|
+
export const KIND_AUTH = 22242;
|
|
21
|
+
/** NIP-98 HTTP authentication. */
|
|
22
|
+
export const KIND_HTTP_AUTH = 27235;
|
|
23
|
+
/** Presence: "online" | "away" | "offline". */
|
|
24
|
+
export const KIND_PRESENCE = 20001;
|
|
25
|
+
/** Typing, scoped to a channel. */
|
|
26
|
+
export const KIND_TYPING = 20002;
|
|
27
|
+
/** Open (or find) a direct conversation with a set of people. */
|
|
28
|
+
export const KIND_DM_OPEN = 41010;
|
|
29
|
+
/**
|
|
30
|
+
* A receiving client expires a typing indicator shortly after the event's own timestamp, so
|
|
31
|
+
* anything slower than that has to keep saying so.
|
|
32
|
+
*/
|
|
33
|
+
export const TYPING_INTERVAL_MS = 3_000;
|
|
34
|
+
export const PRESENCE_INTERVAL_MS = 60_000;
|
|
35
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
36
|
+
export class BuzzRelay {
|
|
37
|
+
socket = null;
|
|
38
|
+
authenticated = false;
|
|
39
|
+
presenceTimer = null;
|
|
40
|
+
pollTimer = null;
|
|
41
|
+
closed = false;
|
|
42
|
+
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
43
|
+
seen = new Set();
|
|
44
|
+
cursor = now();
|
|
45
|
+
url;
|
|
46
|
+
rest;
|
|
47
|
+
key;
|
|
48
|
+
pollMs;
|
|
49
|
+
onMessage;
|
|
50
|
+
log;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.url = options.url;
|
|
53
|
+
this.rest = options.url.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/$/, "");
|
|
54
|
+
this.key = options.key;
|
|
55
|
+
this.pollMs = options.pollMs ?? 4_000;
|
|
56
|
+
this.onMessage = options.onMessage;
|
|
57
|
+
this.log = options.onLog ?? (() => { });
|
|
58
|
+
}
|
|
59
|
+
get pubkey() {
|
|
60
|
+
return this.key.publicKey;
|
|
61
|
+
}
|
|
62
|
+
connect() {
|
|
63
|
+
if (this.closed)
|
|
64
|
+
return;
|
|
65
|
+
const socket = new WebSocket(this.url);
|
|
66
|
+
this.socket = socket;
|
|
67
|
+
this.authenticated = false;
|
|
68
|
+
socket.addEventListener("open", () => this.log(`connected to ${this.url}`));
|
|
69
|
+
socket.addEventListener("message", (raw) => this.receive(String(raw.data)));
|
|
70
|
+
socket.addEventListener("close", () => this.reconnect());
|
|
71
|
+
socket.addEventListener("error", () => {
|
|
72
|
+
try {
|
|
73
|
+
socket.close();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* the close handler reconnects */
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
close() {
|
|
81
|
+
this.closed = true;
|
|
82
|
+
if (this.presenceTimer)
|
|
83
|
+
clearInterval(this.presenceTimer);
|
|
84
|
+
if (this.pollTimer)
|
|
85
|
+
clearTimeout(this.pollTimer);
|
|
86
|
+
try {
|
|
87
|
+
this.socket?.close();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* closing a closed socket is not an error worth raising */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
reconnect() {
|
|
94
|
+
if (this.closed)
|
|
95
|
+
return;
|
|
96
|
+
this.log("disconnected; reconnecting in 5s");
|
|
97
|
+
setTimeout(() => this.connect(), 5_000);
|
|
98
|
+
}
|
|
99
|
+
receive(data) {
|
|
100
|
+
let frame;
|
|
101
|
+
try {
|
|
102
|
+
frame = JSON.parse(data);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const type = frame[0];
|
|
108
|
+
if (type === "AUTH") {
|
|
109
|
+
this.send([
|
|
110
|
+
"AUTH",
|
|
111
|
+
finalizeEvent({
|
|
112
|
+
kind: KIND_AUTH,
|
|
113
|
+
created_at: now(),
|
|
114
|
+
tags: [
|
|
115
|
+
["relay", this.url],
|
|
116
|
+
["challenge", String(frame[1])],
|
|
117
|
+
],
|
|
118
|
+
content: "",
|
|
119
|
+
}, this.key.secretKey),
|
|
120
|
+
]);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (type === "OK" && !this.authenticated) {
|
|
124
|
+
if (frame[2] !== true) {
|
|
125
|
+
this.log(`the relay refused this identity: ${String(frame[3] ?? "")}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.authenticated = true;
|
|
129
|
+
this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
|
|
130
|
+
this.keepPresent();
|
|
131
|
+
this.poll();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (type === "EOSE") {
|
|
135
|
+
// One poll's worth delivered. Closing it matters: the relay caps how many subscriptions one
|
|
136
|
+
// connection may hold, and a bridge that never closes them stops receiving within minutes.
|
|
137
|
+
this.send(["CLOSE", String(frame[1])]);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (type !== "EVENT")
|
|
141
|
+
return;
|
|
142
|
+
const event = frame[2];
|
|
143
|
+
if (!event || event.pubkey === this.key.publicKey)
|
|
144
|
+
return;
|
|
145
|
+
if (this.seen.has(event.id))
|
|
146
|
+
return;
|
|
147
|
+
this.seen.add(event.id);
|
|
148
|
+
if (event.created_at >= this.cursor)
|
|
149
|
+
this.cursor = event.created_at + 1;
|
|
150
|
+
this.onMessage(event);
|
|
151
|
+
}
|
|
152
|
+
send(frame) {
|
|
153
|
+
if (this.socket?.readyState !== WebSocket.OPEN)
|
|
154
|
+
return;
|
|
155
|
+
this.socket.send(JSON.stringify(frame));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Ask for what is new, rather than waiting to be told.
|
|
159
|
+
*
|
|
160
|
+
* @remarks
|
|
161
|
+
* This relay answers a subscription with the events it has and then stops; it does not hold the
|
|
162
|
+
* subscription open and push what arrives next. Assuming otherwise produces the worst kind of
|
|
163
|
+
* failure — a process that connects, authenticates, logs that it is listening, and silently
|
|
164
|
+
* ignores every message addressed to it.
|
|
165
|
+
*/
|
|
166
|
+
poll() {
|
|
167
|
+
if (this.closed)
|
|
168
|
+
return;
|
|
169
|
+
this.send(["REQ", `m${Date.now()}`, { kinds: [KIND_MESSAGE], "#p": [this.key.publicKey], since: this.cursor }]);
|
|
170
|
+
this.pollTimer = setTimeout(() => this.poll(), this.pollMs);
|
|
171
|
+
}
|
|
172
|
+
publish(event) {
|
|
173
|
+
this.send(["EVENT", finalizeEvent({ created_at: now(), ...event }, this.key.secretKey)]);
|
|
174
|
+
}
|
|
175
|
+
setPresence(status) {
|
|
176
|
+
this.publish({ kind: KIND_PRESENCE, tags: [], content: status });
|
|
177
|
+
}
|
|
178
|
+
keepPresent() {
|
|
179
|
+
this.setPresence("online");
|
|
180
|
+
if (this.presenceTimer)
|
|
181
|
+
clearInterval(this.presenceTimer);
|
|
182
|
+
this.presenceTimer = setInterval(() => this.setPresence("online"), PRESENCE_INTERVAL_MS);
|
|
183
|
+
}
|
|
184
|
+
/** Say "typing" in a channel until the returned function is called. */
|
|
185
|
+
typingIn(channel) {
|
|
186
|
+
const tick = () => this.publish({ kind: KIND_TYPING, tags: [["h", channel]], content: "" });
|
|
187
|
+
tick();
|
|
188
|
+
const timer = setInterval(tick, TYPING_INTERVAL_MS);
|
|
189
|
+
return () => clearInterval(timer);
|
|
190
|
+
}
|
|
191
|
+
reply(channel, text, replyTo) {
|
|
192
|
+
const tags = [["h", channel]];
|
|
193
|
+
if (replyTo) {
|
|
194
|
+
tags.push(["e", replyTo.id], ["p", replyTo.pubkey]);
|
|
195
|
+
}
|
|
196
|
+
this.publish({ kind: KIND_MESSAGE, tags, content: text });
|
|
197
|
+
}
|
|
198
|
+
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
199
|
+
httpAuth(url, body) {
|
|
200
|
+
const auth = finalizeEvent({
|
|
201
|
+
kind: KIND_HTTP_AUTH,
|
|
202
|
+
created_at: now(),
|
|
203
|
+
tags: [
|
|
204
|
+
["u", url],
|
|
205
|
+
["method", "POST"],
|
|
206
|
+
["payload", createHash("sha256").update(body).digest("hex")],
|
|
207
|
+
],
|
|
208
|
+
content: "",
|
|
209
|
+
}, this.key.secretKey);
|
|
210
|
+
return `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`;
|
|
211
|
+
}
|
|
212
|
+
async submit(event) {
|
|
213
|
+
const url = `${this.rest}/events`;
|
|
214
|
+
const body = JSON.stringify(event);
|
|
215
|
+
const response = await fetch(url, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { authorization: this.httpAuth(url, body), "content-type": "application/json" },
|
|
218
|
+
body,
|
|
219
|
+
});
|
|
220
|
+
const text = await response.text();
|
|
221
|
+
let message = text;
|
|
222
|
+
try {
|
|
223
|
+
message = String(JSON.parse(text).message ?? text);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* a non-JSON body is its own message */
|
|
227
|
+
}
|
|
228
|
+
return { ok: response.ok, status: response.status, message };
|
|
229
|
+
}
|
|
230
|
+
/** React to a message. Stored by the server, so it goes over HTTP rather than the socket. */
|
|
231
|
+
async react(eventId, emoji) {
|
|
232
|
+
const event = finalizeEvent({ kind: KIND_REACTION, created_at: now(), tags: [["e", eventId]], content: emoji }, this.key.secretKey);
|
|
233
|
+
const result = await this.submit(event);
|
|
234
|
+
if (!result.ok)
|
|
235
|
+
this.log(`reaction ${emoji} refused: ${result.status} ${result.message.slice(0, 120)}`);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Open a private conversation with someone, and answer with its channel id.
|
|
239
|
+
*
|
|
240
|
+
* @remarks
|
|
241
|
+
* Needed for anything that must reach ONE person rather than a room. Opening a conversation
|
|
242
|
+
* that already exists returns the existing one, so this is safe to call whenever it is needed
|
|
243
|
+
* instead of tracking which conversations exist.
|
|
244
|
+
*/
|
|
245
|
+
async openDirectMessage(pubkey) {
|
|
246
|
+
const event = finalizeEvent({ kind: KIND_DM_OPEN, created_at: now(), tags: [["p", pubkey]], content: "" }, this.key.secretKey);
|
|
247
|
+
const result = await this.submit(event);
|
|
248
|
+
if (!result.ok) {
|
|
249
|
+
this.log(`could not open a direct message: ${result.status} ${result.message.slice(0, 120)}`);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const ack = JSON.parse(result.message);
|
|
254
|
+
return typeof ack.channel_id === "string" ? ack.channel_id : null;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kybernesis/buzz",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Put an eve agent in a Buzz workspace as a member: per-speaker verified identity resolved through the control plane, self-service identity linking, presence, typing and seen signals.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://kybernesis.ai",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"eve",
|
|
10
|
+
"kybernesis",
|
|
11
|
+
"buzz",
|
|
12
|
+
"nostr",
|
|
13
|
+
"agent",
|
|
14
|
+
"channel",
|
|
15
|
+
"multiplayer"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"NOTICE"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"kybernesis-buzz": "dist/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"prepack": "tsc -p tsconfig.build.json",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"prepublishOnly": "node ../../scripts/prepublish.mjs",
|
|
36
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@kybernesis/enterprise": "^0.7.0",
|
|
40
|
+
"nostr-tools": "^2.10.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "24.x",
|
|
44
|
+
"eve": "0.38.3",
|
|
45
|
+
"typescript": "7.0.2"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"eve": ">=0.38.0 <0.39.0"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20"
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "https://github.com/KybernesisAI/platform.git",
|
|
56
|
+
"directory": "packages/buzz"
|
|
57
|
+
}
|
|
58
|
+
}
|