@kybernesis/buzz 0.4.4 → 0.5.1
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/dist/bridge.js +85 -13
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +36 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
|
+
import { speakerCredentials } from "./credentials.js";
|
|
2
3
|
import { Client } from "eve/client";
|
|
3
4
|
import { BuzzRelay } from "./relay.js";
|
|
4
5
|
import { loadKey, npubEncode } from "./keys.js";
|
|
@@ -50,21 +51,83 @@ export function buzzBridge(options) {
|
|
|
50
51
|
const sessions = new Map();
|
|
51
52
|
/** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
|
|
52
53
|
const invited = new Map();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A client whose credentials are resolved per request, not captured once.
|
|
56
|
+
*
|
|
57
|
+
* The identity token a turn starts with lives about five minutes. That was
|
|
58
|
+
* invisible while turns ended early — and became a failure the moment they
|
|
59
|
+
* were allowed to run to completion: a turn doing real work for six minutes
|
|
60
|
+
* reconnected its stream with the token it began with, the agent answered
|
|
61
|
+
* "Authorization is required for this route", and the reply was lost after
|
|
62
|
+
* all the work had been done. In a channel that reads as the agent typing
|
|
63
|
+
* for five minutes and then saying nothing.
|
|
64
|
+
*
|
|
65
|
+
* Resolving per request costs nothing in the normal case — the resolver
|
|
66
|
+
* caches and re-mints only near expiry — and it keeps the property the short
|
|
67
|
+
* lifetime exists for: a person whose access is revoked mid-turn stops being
|
|
68
|
+
* able to act on the next request rather than at the end of the turn.
|
|
69
|
+
*/
|
|
70
|
+
const credentialsFor = speakerCredentials((externalId) => identity.resolve(PROVIDER, externalId, npubEncode(externalId)));
|
|
71
|
+
const clientFor = (pubkey) => {
|
|
72
|
+
const credentials = credentialsFor(pubkey);
|
|
73
|
+
// `host`, not `baseUrl`: a wrong key is ignored rather than rejected, and the client then
|
|
74
|
+
// builds a nonsense URL — an error about a malformed URL rather than about a mistyped option.
|
|
75
|
+
return new Client({
|
|
76
|
+
host: options.agentUrl,
|
|
77
|
+
auth: { bearer: credentials.bearer },
|
|
78
|
+
headers: credentials.headers,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* One turn at a time per channel.
|
|
83
|
+
*
|
|
84
|
+
* Two people asking at once used to interleave: both turns read and wrote the
|
|
85
|
+
* same stored stream position, so one of them read the other's boundary and
|
|
86
|
+
* came back with the wrong answer or none. A channel is a conversation; its
|
|
87
|
+
* turns are ordered whether or not the people in it take turns.
|
|
88
|
+
*/
|
|
89
|
+
const inFlight = new Map();
|
|
90
|
+
function serialize(channel, work) {
|
|
91
|
+
const queued = (inFlight.get(channel) ?? Promise.resolve()).then(work, work);
|
|
92
|
+
// Kept only while it matters: the chain holds the LAST promise, not a history.
|
|
93
|
+
inFlight.set(channel, queued.catch(() => { }));
|
|
94
|
+
return queued;
|
|
95
|
+
}
|
|
96
|
+
async function ask(channel, text, pubkey) {
|
|
97
|
+
// Built per turn, for the person whose turn it is — and kept current for as
|
|
98
|
+
// long as that turn runs.
|
|
99
|
+
const client = clientFor(pubkey);
|
|
64
100
|
const existing = sessions.get(channel);
|
|
65
101
|
if (existing) {
|
|
66
102
|
try {
|
|
67
103
|
const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
|
|
104
|
+
/**
|
|
105
|
+
* Move to the true end of the conversation before speaking.
|
|
106
|
+
*
|
|
107
|
+
* `send()` opens its response stream at the position the handle already
|
|
108
|
+
* holds and stops at the FIRST turn boundary it meets. If anything is
|
|
109
|
+
* still unread there — the tail of a turn that outlived its reader, a
|
|
110
|
+
* turn that was running when the bridge restarted — that boundary
|
|
111
|
+
* belongs to the older turn. The read ends on it, this turn's answer is
|
|
112
|
+
* never collected, and the stored position stays one turn behind.
|
|
113
|
+
*
|
|
114
|
+
* Which makes it permanent: every later turn reads the previous turn's
|
|
115
|
+
* tail. The channel answers nothing, then answers a question from ten
|
|
116
|
+
* minutes ago, and a fresh conversation elsewhere works perfectly —
|
|
117
|
+
* because a fresh conversation has nothing left over to trip on.
|
|
118
|
+
*
|
|
119
|
+
* Draining first costs one bounded read of only what is unread, and it
|
|
120
|
+
* repairs a position that has already drifted rather than requiring
|
|
121
|
+
* anyone to notice. Non-following, so it ends at the tail instead of
|
|
122
|
+
* waiting for the future.
|
|
123
|
+
*/
|
|
124
|
+
let unread = 0;
|
|
125
|
+
for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
|
|
126
|
+
unread += 1;
|
|
127
|
+
}
|
|
128
|
+
if (unread > 0) {
|
|
129
|
+
log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
|
|
130
|
+
}
|
|
68
131
|
const response = await session.send(text);
|
|
69
132
|
const result = await response.result();
|
|
70
133
|
// Where this turn left the conversation, so the next one starts after it.
|
|
@@ -170,9 +233,18 @@ export function buzzBridge(options) {
|
|
|
170
233
|
void relay.react(event.id, SEEN);
|
|
171
234
|
const stopTyping = relay.typingIn(channel);
|
|
172
235
|
try {
|
|
173
|
-
const reply = await ask(channel, text,
|
|
236
|
+
const reply = await serialize(channel, () => ask(channel, text, event.pubkey));
|
|
174
237
|
if (!reply) {
|
|
175
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Silence is the worst answer available.
|
|
240
|
+
*
|
|
241
|
+
* An empty result used to end here, with a line in a log nobody was
|
|
242
|
+
* reading. In a room that is indistinguishable from the agent ignoring
|
|
243
|
+
* you — so people ask again, which is how one unanswered question
|
|
244
|
+
* became five, none of which looked like a fault to anyone watching.
|
|
245
|
+
*/
|
|
246
|
+
log(`no text for ${channel.slice(0, 8)} — telling them rather than going quiet`);
|
|
247
|
+
relay.reply(channel, "I didn't get an answer back for that one — ask me again and I'll retry.", event);
|
|
176
248
|
return;
|
|
177
249
|
}
|
|
178
250
|
relay.reply(channel, reply, event);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SpeakerResolution } from "@kybernesis/enterprise";
|
|
2
|
+
/** What the eve client needs to authenticate one person's turn. */
|
|
3
|
+
export interface SpeakerCredentials {
|
|
4
|
+
/** Bearer resolver — called by the client before every HTTP request. */
|
|
5
|
+
bearer: () => Promise<string>;
|
|
6
|
+
/** Header resolver, for the same reason and on the same schedule. */
|
|
7
|
+
headers: () => Promise<Readonly<Record<string, string>>>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Credentials for a turn, resolved per request rather than captured once.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* An identity token lives about five minutes. That was invisible while turns
|
|
14
|
+
* ended early, and became a failure the moment they ran to completion: a turn
|
|
15
|
+
* doing six minutes of real work reconnected its stream with the token it began
|
|
16
|
+
* with, the agent answered "Authorization is required for this route", and the
|
|
17
|
+
* reply was lost after every tool call had already succeeded. In a channel that
|
|
18
|
+
* reads as the agent typing for five minutes and then saying nothing at all.
|
|
19
|
+
*
|
|
20
|
+
* Resolving per request costs nothing in the normal case, because the resolver
|
|
21
|
+
* caches and re-mints only near expiry. It also preserves the property the
|
|
22
|
+
* short lifetime exists for: access withdrawn mid-turn stops the next request
|
|
23
|
+
* rather than being noticed after the work is done.
|
|
24
|
+
*/
|
|
25
|
+
export declare function speakerCredentials(resolve: (externalId: string) => Promise<SpeakerResolution>): (externalId: string) => SpeakerCredentials;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials for a turn, resolved per request rather than captured once.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* An identity token lives about five minutes. That was invisible while turns
|
|
6
|
+
* ended early, and became a failure the moment they ran to completion: a turn
|
|
7
|
+
* doing six minutes of real work reconnected its stream with the token it began
|
|
8
|
+
* with, the agent answered "Authorization is required for this route", and the
|
|
9
|
+
* reply was lost after every tool call had already succeeded. In a channel that
|
|
10
|
+
* reads as the agent typing for five minutes and then saying nothing at all.
|
|
11
|
+
*
|
|
12
|
+
* Resolving per request costs nothing in the normal case, because the resolver
|
|
13
|
+
* caches and re-mints only near expiry. It also preserves the property the
|
|
14
|
+
* short lifetime exists for: access withdrawn mid-turn stops the next request
|
|
15
|
+
* rather than being noticed after the work is done.
|
|
16
|
+
*/
|
|
17
|
+
export function speakerCredentials(resolve) {
|
|
18
|
+
return (externalId) => {
|
|
19
|
+
const current = async () => {
|
|
20
|
+
const speaker = await resolve(externalId);
|
|
21
|
+
if (!speaker.linked) {
|
|
22
|
+
// Failing here is the point: the alternative is finishing a turn with a
|
|
23
|
+
// credential the control plane has already withdrawn.
|
|
24
|
+
throw new Error(`no longer authorized (${speaker.reason})`);
|
|
25
|
+
}
|
|
26
|
+
return { token: speaker.token, bundle: speaker.bundle };
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
bearer: async () => (await current()).token,
|
|
30
|
+
headers: async () => {
|
|
31
|
+
const { bundle } = await current();
|
|
32
|
+
return bundle ? { "x-kybernesis-bundle": bundle } : {};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
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
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|