@kybernesis/buzz 0.4.4 → 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 +53 -2
  2. package/package.json +1 -1
package/dist/bridge.js CHANGED
@@ -58,6 +58,21 @@ export function buzzBridge(options) {
58
58
  auth: { bearer: token },
59
59
  headers: bundle ? { "x-kybernesis-bundle": bundle } : {},
60
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
+ }
61
76
  async function ask(channel, text, token, bundle) {
62
77
  // Built per turn, for the person whose turn it is.
63
78
  const client = clientFor(token, bundle);
@@ -65,6 +80,33 @@ export function buzzBridge(options) {
65
80
  if (existing) {
66
81
  try {
67
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
+ }
68
110
  const response = await session.send(text);
69
111
  const result = await response.result();
70
112
  // Where this turn left the conversation, so the next one starts after it.
@@ -170,9 +212,18 @@ export function buzzBridge(options) {
170
212
  void relay.react(event.id, SEEN);
171
213
  const stopTyping = relay.typingIn(channel);
172
214
  try {
173
- const reply = await ask(channel, text, speaker.token, speaker.bundle);
215
+ const reply = await serialize(channel, () => ask(channel, text, speaker.token, speaker.bundle));
174
216
  if (!reply) {
175
- 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);
176
227
  return;
177
228
  }
178
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.4",
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",