@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 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
- const clientFor = (token, bundle) =>
54
- // `host`, not `baseUrl`: a wrong key is ignored rather than rejected, and the client then
55
- // builds a nonsense URL — an error about a malformed URL rather than about a mistyped option.
56
- new Client({
57
- host: options.agentUrl,
58
- auth: { bearer: token },
59
- headers: bundle ? { "x-kybernesis-bundle": bundle } : {},
60
- });
61
- async function ask(channel, text, token, bundle) {
62
- // Built per turn, for the person whose turn it is.
63
- const client = clientFor(token, bundle);
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, speaker.token, speaker.bundle);
236
+ const reply = await serialize(channel, () => ask(channel, text, event.pubkey));
174
237
  if (!reply) {
175
- log("the agent produced no text");
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.4.4",
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",