@kybernesis/buzz 0.4.3 → 0.5.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.
Files changed (2) hide show
  1. package/dist/bridge.js +71 -6
  2. package/package.json +1 -1
package/dist/bridge.js CHANGED
@@ -39,6 +39,13 @@ export function buzzBridge(options) {
39
39
  * same conversation through a client built for the person sending it, which
40
40
  * is what the runtime reads to decide whose memory and whose connections the
41
41
  * turn may touch.
42
+ *
43
+ * The stream position is kept beside the id for a reason that cost an
44
+ * afternoon: attaching defaults to index 0, so a turn reads the conversation
45
+ * from its beginning and reports the FIRST completed message it finds — the
46
+ * previous person's answer. The turn itself runs correctly, as the right
47
+ * person, against the right connections; only the text posted back is
48
+ * somebody else's, which is the most convincing way to look broken.
42
49
  */
43
50
  const sessions = new Map();
44
51
  /** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
@@ -51,15 +58,60 @@ export function buzzBridge(options) {
51
58
  auth: { bearer: token },
52
59
  headers: bundle ? { "x-kybernesis-bundle": bundle } : {},
53
60
  });
61
+ /**
62
+ * One turn at a time per channel.
63
+ *
64
+ * Two people asking at once used to interleave: both turns read and wrote the
65
+ * same stored stream position, so one of them read the other's boundary and
66
+ * came back with the wrong answer or none. A channel is a conversation; its
67
+ * turns are ordered whether or not the people in it take turns.
68
+ */
69
+ const inFlight = new Map();
70
+ function serialize(channel, work) {
71
+ const queued = (inFlight.get(channel) ?? Promise.resolve()).then(work, work);
72
+ // Kept only while it matters: the chain holds the LAST promise, not a history.
73
+ inFlight.set(channel, queued.catch(() => { }));
74
+ return queued;
75
+ }
54
76
  async function ask(channel, text, token, bundle) {
55
77
  // Built per turn, for the person whose turn it is.
56
78
  const client = clientFor(token, bundle);
57
79
  const existing = sessions.get(channel);
58
80
  if (existing) {
59
81
  try {
60
- const session = client.sessions.attach(existing);
82
+ const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
83
+ /**
84
+ * Move to the true end of the conversation before speaking.
85
+ *
86
+ * `send()` opens its response stream at the position the handle already
87
+ * holds and stops at the FIRST turn boundary it meets. If anything is
88
+ * still unread there — the tail of a turn that outlived its reader, a
89
+ * turn that was running when the bridge restarted — that boundary
90
+ * belongs to the older turn. The read ends on it, this turn's answer is
91
+ * never collected, and the stored position stays one turn behind.
92
+ *
93
+ * Which makes it permanent: every later turn reads the previous turn's
94
+ * tail. The channel answers nothing, then answers a question from ten
95
+ * minutes ago, and a fresh conversation elsewhere works perfectly —
96
+ * because a fresh conversation has nothing left over to trip on.
97
+ *
98
+ * Draining first costs one bounded read of only what is unread, and it
99
+ * repairs a position that has already drifted rather than requiring
100
+ * anyone to notice. Non-following, so it ends at the tail instead of
101
+ * waiting for the future.
102
+ */
103
+ let unread = 0;
104
+ for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
105
+ unread += 1;
106
+ }
107
+ if (unread > 0) {
108
+ log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
109
+ }
61
110
  const response = await session.send(text);
62
- return (await response.result()).message ?? "";
111
+ const result = await response.result();
112
+ // Where this turn left the conversation, so the next one starts after it.
113
+ sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
114
+ return result.message ?? "";
63
115
  }
64
116
  catch (error) {
65
117
  // A session the agent no longer holds cannot be resumed. Starting a new one loses the
@@ -69,8 +121,12 @@ export function buzzBridge(options) {
69
121
  }
70
122
  }
71
123
  const created = await client.sessions.create({ message: text });
72
- sessions.set(channel, created.session.state.sessionId);
73
- return (await created.response.result()).message ?? "";
124
+ const message = (await created.response.result()).message ?? "";
125
+ sessions.set(channel, {
126
+ id: created.session.state.sessionId,
127
+ streamIndex: created.session.state.streamIndex,
128
+ });
129
+ return message;
74
130
  }
75
131
  /**
76
132
  * Send someone the link that makes them known, privately.
@@ -156,9 +212,18 @@ export function buzzBridge(options) {
156
212
  void relay.react(event.id, SEEN);
157
213
  const stopTyping = relay.typingIn(channel);
158
214
  try {
159
- const reply = await ask(channel, text, speaker.token, speaker.bundle);
215
+ const reply = await serialize(channel, () => ask(channel, text, speaker.token, speaker.bundle));
160
216
  if (!reply) {
161
- log("the agent produced no text");
217
+ /**
218
+ * Silence is the worst answer available.
219
+ *
220
+ * An empty result used to end here, with a line in a log nobody was
221
+ * reading. In a room that is indistinguishable from the agent ignoring
222
+ * you — so people ask again, which is how one unanswered question
223
+ * became five, none of which looked like a fault to anyone watching.
224
+ */
225
+ log(`no text for ${channel.slice(0, 8)} — telling them rather than going quiet`);
226
+ relay.reply(channel, "I didn't get an answer back for that one — ask me again and I'll retry.", event);
162
227
  return;
163
228
  }
164
229
  relay.reply(channel, reply, event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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",