@kybernesis/buzz 0.8.0 → 0.9.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/README.md CHANGED
@@ -60,6 +60,7 @@ sessions are keyed by channel.
60
60
  | `BUZZ_RELAY` | The workspace relay (`wss://…`) — or several, comma-separated |
61
61
  | `BUZZ_AGENT_URL` | Where the agent listens (default `http://127.0.0.1:8000`) |
62
62
  | `BUZZ_KEYFILE` | The agent's key (default `~/.kybernesis/buzz-agent.json`) |
63
+ | `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
63
64
  | `KYBERNESIS_ISSUER` | The control plane |
64
65
  | `KYBERNESIS_AGENT_CREDENTIAL` | This agent's credential |
65
66
 
package/dist/bridge.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { channelIdentity } from "@kybernesis/enterprise";
2
- import { fetchMedia, isImage, parseMedia } from "./media.js";
2
+ import { fetchMedia, parseMedia } from "./media.js";
3
3
  import { speakerCredentials } from "./credentials.js";
4
4
  import { SessionStore } from "./sessions.js";
5
+ import { answerTurn, composeMessage, rejectedTurnReply } from "./turn.js";
6
+ import { followPendingConversation, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, } from "./hitl.js";
7
+ import { existsSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
5
9
  import { Client } from "eve/client";
6
10
  import { BuzzRelay } from "./relay.js";
7
11
  import { loadKey, npubEncode } from "./keys.js";
@@ -56,7 +60,12 @@ export function buzzBridge(options) {
56
60
  * had no context, while the old session sat intact holding a reply nobody was
57
61
  * left to read.
58
62
  */
59
- const sessions = new SessionStore(process.env.BUZZ_SESSIONS_FILE ?? ".buzz-sessions.json");
63
+ const legacySessionsFile = ".buzz-sessions.json";
64
+ const sessionsFile = process.env.BUZZ_SESSIONS_FILE ??
65
+ (existsSync(legacySessionsFile)
66
+ ? legacySessionsFile
67
+ : join(dirname(options.keyFile), "buzz-sessions.json"));
68
+ const sessions = new SessionStore(sessionsFile, { onError: log });
60
69
  if (sessions.size > 0)
61
70
  log(`resumed ${sessions.size} conversation(s) from the last run`);
62
71
  /** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
@@ -97,10 +106,11 @@ export function buzzBridge(options) {
97
106
  * turns are ordered whether or not the people in it take turns.
98
107
  */
99
108
  const inFlight = new Map();
100
- function serialize(channel, work) {
101
- const queued = (inFlight.get(channel) ?? Promise.resolve()).then(work, work);
109
+ function serialize(community, channel, work) {
110
+ const key = SessionStore.key(community, channel);
111
+ const queued = (inFlight.get(key) ?? Promise.resolve()).then(work, work);
102
112
  // Kept only while it matters: the chain holds the LAST promise, not a history.
103
- inFlight.set(channel, queued.catch(() => { }));
113
+ inFlight.set(key, queued.catch(() => { }));
104
114
  return queued;
105
115
  }
106
116
  /**
@@ -113,107 +123,6 @@ export function buzzBridge(options) {
113
123
  * nothing. The bridge knew the answer the whole time: it is the connection
114
124
  * the message arrived on.
115
125
  */
116
- /**
117
- * One inbound message, as the parts a turn can carry.
118
- *
119
- * @remarks
120
- * Returns a plain string when there is nothing but text, so the common case
121
- * stays exactly as it was — a change in the shape of every ordinary turn is
122
- * not worth paying for the rare one.
123
- *
124
- * A failed fetch does NOT throw. The person is owed an answer either way, and
125
- * an agent told "there was an attachment I could not retrieve" can say so;
126
- * an agent told nothing answers the caption and looks like it ignored the
127
- * picture, which is the bug being fixed.
128
- */
129
- async function composeMessage(text, attachments) {
130
- if (attachments.length === 0)
131
- return text;
132
- const parts = [];
133
- if (text)
134
- parts.push({ type: "text", text });
135
- for (const ref of attachments) {
136
- try {
137
- const media = await fetchMedia(key, ref);
138
- if (isImage(media.mediaType)) {
139
- parts.push({ type: "image", image: media.bytes, mediaType: media.mediaType });
140
- }
141
- else {
142
- parts.push({
143
- type: "text",
144
- text: `[The sender attached a ${media.mediaType} file, which cannot be read as an image. Tell them what you received and ask for it in a readable form if you need it.]`,
145
- });
146
- }
147
- }
148
- catch (error) {
149
- log(`could not fetch an attachment: ${error.message}`);
150
- parts.push({
151
- type: "text",
152
- text: `[The sender attached a file, and it could not be retrieved (${error.message}). Say so rather than answering as if there were no attachment.]`,
153
- });
154
- }
155
- }
156
- return parts;
157
- }
158
- async function ask(channel, message, pubkey, community) {
159
- // Built per turn, for the person whose turn it is — and kept current for as
160
- // long as that turn runs.
161
- const client = clientFor(pubkey);
162
- const existing = sessions.get(community, channel);
163
- if (existing) {
164
- try {
165
- const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
166
- /**
167
- * Move to the true end of the conversation before speaking.
168
- *
169
- * `send()` opens its response stream at the position the handle already
170
- * holds and stops at the FIRST turn boundary it meets. If anything is
171
- * still unread there — the tail of a turn that outlived its reader, a
172
- * turn that was running when the bridge restarted — that boundary
173
- * belongs to the older turn. The read ends on it, this turn's answer is
174
- * never collected, and the stored position stays one turn behind.
175
- *
176
- * Which makes it permanent: every later turn reads the previous turn's
177
- * tail. The channel answers nothing, then answers a question from ten
178
- * minutes ago, and a fresh conversation elsewhere works perfectly —
179
- * because a fresh conversation has nothing left over to trip on.
180
- *
181
- * Draining first costs one bounded read of only what is unread, and it
182
- * repairs a position that has already drifted rather than requiring
183
- * anyone to notice. Non-following, so it ends at the tail instead of
184
- * waiting for the future.
185
- */
186
- let unread = 0;
187
- for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
188
- unread += 1;
189
- }
190
- if (unread > 0) {
191
- log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
192
- }
193
- const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
194
- const result = await response.result();
195
- // Where this turn left the conversation, so the next one starts after it.
196
- sessions.set(community, channel, { id: existing.id, streamIndex: session.state.streamIndex });
197
- return result.message ?? "";
198
- }
199
- catch (error) {
200
- // A session the agent no longer holds cannot be resumed. Starting a new one loses the
201
- // thread but keeps the conversation alive, which is the better of the two failures.
202
- log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
203
- sessions.delete(community, channel);
204
- }
205
- }
206
- const created = await client.sessions.create({
207
- message,
208
- clientContext: { buzzCommunity: community, buzzChannel: channel },
209
- });
210
- const reply = (await created.response.result()).message ?? "";
211
- sessions.set(community, channel, {
212
- id: created.session.state.sessionId,
213
- streamIndex: created.session.state.streamIndex,
214
- });
215
- return reply;
216
- }
217
126
  /**
218
127
  * Send someone the link that makes them known, privately.
219
128
  *
@@ -264,6 +173,97 @@ export function buzzBridge(options) {
264
173
  },
265
174
  }));
266
175
  }
176
+ /** One abortable durable-stream follower owns each parked conversation. */
177
+ const followers = new Map();
178
+ const followerRestartTimers = new Map();
179
+ let stopped = false;
180
+ function clearFollowerRestart(conversation) {
181
+ const timer = followerRestartTimers.get(conversation);
182
+ if (timer)
183
+ clearTimeout(timer);
184
+ followerRestartTimers.delete(conversation);
185
+ }
186
+ function stopFollower(community, channel) {
187
+ const conversation = SessionStore.key(community, channel);
188
+ clearFollowerRestart(conversation);
189
+ followers.get(conversation)?.abort();
190
+ followers.delete(conversation);
191
+ }
192
+ function startFollower(community, channel) {
193
+ if (stopped)
194
+ return;
195
+ const conversation = SessionStore.key(community, channel);
196
+ clearFollowerRestart(conversation);
197
+ if (followers.has(conversation))
198
+ return;
199
+ const stored = sessions.get(community, channel);
200
+ const relay = relays.get(community);
201
+ if (!stored || !needsPendingFollower(stored) || !stored.speakerPublicKey || !relay)
202
+ return;
203
+ const controller = new AbortController();
204
+ followers.set(conversation, controller);
205
+ const session = clientFor(stored.speakerPublicKey).sessions.attach(stored.id, {
206
+ streamIndex: stored.streamIndex,
207
+ });
208
+ void followPendingConversation(session, stored, {
209
+ // A stopped/replaced follower may already have queued a callback. Check
210
+ // its generation inside the same conversation queue used by inbound
211
+ // messages so stale state cannot overwrite a newer responder or cursor.
212
+ onState: (state) => serialize(community, channel, async () => {
213
+ if (followers.get(conversation) !== controller)
214
+ return;
215
+ const current = sessions.get(community, channel);
216
+ if (state.pendingInputRequests?.length) {
217
+ sessions.set(community, channel, { ...state, promptEventIds: current?.promptEventIds });
218
+ return;
219
+ }
220
+ if (current?.promptEventIds?.length)
221
+ relay.unwatchReplies(current.promptEventIds);
222
+ sessions.set(community, channel, state);
223
+ }),
224
+ onInputRequested: (requests) => serialize(community, channel, async () => {
225
+ if (followers.get(conversation) !== controller)
226
+ return;
227
+ const promptId = relay.reply(channel, formatInputRequests(requests));
228
+ if (!promptId)
229
+ throw new Error("Buzz relay is not connected");
230
+ relay.watchReplies([promptId]);
231
+ const current = sessions.get(community, channel);
232
+ if (current) {
233
+ sessions.set(community, channel, {
234
+ ...current,
235
+ promptEventIds: [...(current.promptEventIds ?? []), promptId],
236
+ });
237
+ }
238
+ log(`posted another input request in ${channel.slice(0, 8)}`);
239
+ }),
240
+ onMessage: (message) => serialize(community, channel, async () => {
241
+ if (followers.get(conversation) !== controller)
242
+ return;
243
+ if (!relay.reply(channel, message))
244
+ throw new Error("Buzz relay is not connected");
245
+ log(`posted resumed reply in ${channel.slice(0, 8)} (${message.length} chars)`);
246
+ }),
247
+ onLog: log,
248
+ }, controller.signal).catch((error) => {
249
+ if (!controller.signal.aborted) {
250
+ // Keep the persisted request, cursor and public identity. A later reply
251
+ // or process restart can refresh credentials and reattach.
252
+ log(`pending follower for ${channel.slice(0, 8)} disconnected (${error.message})`);
253
+ }
254
+ }).finally(() => {
255
+ if (followers.get(conversation) === controller)
256
+ followers.delete(conversation);
257
+ const recoverable = sessions.get(community, channel);
258
+ if (!controller.signal.aborted && recoverable && needsPendingFollower(recoverable)) {
259
+ const timer = setTimeout(() => {
260
+ followerRestartTimers.delete(conversation);
261
+ startFollower(community, channel);
262
+ }, 1_000);
263
+ followerRestartTimers.set(conversation, timer);
264
+ }
265
+ });
266
+ }
267
267
  async function handle(event, from) {
268
268
  const relay = relays.get(from);
269
269
  if (!relay)
@@ -272,24 +272,15 @@ export function buzzBridge(options) {
272
272
  // Addressed by TAG, not by text. A name in prose is a string anyone can type; a p tag is what
273
273
  // the client emits when someone actually picks this member out of a mention list.
274
274
  const addressed = event.tags.some((t) => t[0] === "p" && t[1] === key.publicKey);
275
- if (!channel || !addressed)
275
+ // A reply to a prompt this agent posted is an answer even when it names nobody: people answer
276
+ // a question by replying to it, not by mentioning the asker. Only while that prompt is open.
277
+ const repliedTo = event.tags.find((t) => t[0] === "e")?.[1];
278
+ const open = channel ? sessions.get(from, channel) : undefined;
279
+ const answersPrompt = Boolean(repliedTo && open?.pendingInputRequests?.length && open.promptEventIds?.includes(repliedTo));
280
+ if (!channel || !(addressed || answersPrompt))
276
281
  return;
277
282
  const text = String(event.content ?? "").trim();
278
283
  const attachments = parseMedia(event);
279
- /**
280
- * Drop only a message with NOTHING in it — and say so when we do.
281
- *
282
- * This guard used to test the text alone, and sat above the log line, so an
283
- * image sent without a caption was discarded before anything was written
284
- * down. From the outside the agent had simply ignored someone; from the
285
- * inside there was no record that a message had ever arrived. The reply
286
- * path was hardened against exactly this failure — an empty answer now
287
- * says so out loud — and the receiving path was left as it was.
288
- */
289
- if (!text && attachments.length === 0) {
290
- log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
291
- return;
292
- }
293
284
  let speaker;
294
285
  try {
295
286
  speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
@@ -309,38 +300,89 @@ export function buzzBridge(options) {
309
300
  }
310
301
  log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}` +
311
302
  (attachments.length ? ` [+${attachments.length} attachment(s)]` : ""));
312
- /**
313
- * Attachments become parts of the turn, or become words about themselves.
314
- *
315
- * An image the model can see goes in as bytes. Anything else — a zip, a
316
- * PDF the model cannot read, a fetch that failed — goes in as a sentence
317
- * saying so, because the alternative is an agent that answers the caption
318
- * while the person waits for a reply about the file they sent. Being told
319
- * "I got a file I cannot read" is a worse answer than the truth only if
320
- * you never wanted the truth.
321
- */
322
- const message = await composeMessage(text, attachments);
323
303
  void relay.react(event.id, SEEN);
324
304
  const stopTyping = relay.typingIn(channel);
325
305
  try {
326
- const reply = await serialize(channel, () => ask(channel, message, event.pubkey, from));
327
- if (!reply) {
328
- /**
329
- * Silence is the worst answer available.
330
- *
331
- * An empty result used to end here, with a line in a log nobody was
332
- * reading. In a room that is indistinguishable from the agent ignoring
333
- * you — so people ask again, which is how one unanswered question
334
- * became five, none of which looked like a fault to anyone watching.
335
- */
336
- log(`no text for ${channel.slice(0, 8)} — telling them rather than going quiet`);
337
- relay.reply(channel, "I didn't get an answer back for that one — ask me again and I'll retry.", event);
338
- return;
339
- }
340
- relay.reply(channel, reply, event);
341
- log(`replied (${reply.length} chars)`);
306
+ await serialize(from, channel, async () => {
307
+ const pending = sessions.get(from, channel);
308
+ if (pending?.pendingInputRequests?.length) {
309
+ // "@Ava approve" is an approve. The mention is how the message reached us, not the answer.
310
+ const answer = addressed ? text.replace(/^@\S+\s*/, "") : text;
311
+ const responses = resolveInputReply(answer, pending.pendingInputRequests);
312
+ if (!responses) {
313
+ relay.reply(channel, invalidInputReply(pending.pendingInputRequests), event);
314
+ return;
315
+ }
316
+ // Reattach both POST and follower with the current responder's freshly
317
+ // resolved credentials. The follower remains the sole output reader.
318
+ stopFollower(from, channel);
319
+ sessions.set(from, channel, {
320
+ ...pending,
321
+ speakerPublicKey: event.pubkey,
322
+ });
323
+ startFollower(from, channel);
324
+ const session = clientFor(event.pubkey).sessions.attach(pending.id, {
325
+ streamIndex: pending.streamIndex,
326
+ });
327
+ await respondToPendingConversation(session, responses, from, channel);
328
+ log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
329
+ return;
330
+ }
331
+ if (pending?.resumeInFlight) {
332
+ startFollower(from, channel);
333
+ relay.reply(channel, "I'm still finishing the resumed request. I'll post the answer here when it is ready.", event);
334
+ return;
335
+ }
336
+ /** Drop only a message with NOTHING in it — pending HITL replies are handled above. */
337
+ if (!text && attachments.length === 0) {
338
+ log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
339
+ return;
340
+ }
341
+ /** Images become file parts; unreadable files become explicit model context. */
342
+ const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
343
+ const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log);
344
+ if (result.status === "waiting" && result.inputRequests.length > 0) {
345
+ sessions.set(from, channel, {
346
+ id: result.sessionId,
347
+ streamIndex: result.streamIndex,
348
+ pendingInputRequests: result.inputRequests,
349
+ speakerPublicKey: event.pubkey,
350
+ });
351
+ const promptId = relay.reply(channel, formatInputRequests(result.inputRequests), event);
352
+ if (promptId) {
353
+ relay.watchReplies([promptId]);
354
+ const current = sessions.get(from, channel);
355
+ if (current)
356
+ sessions.set(from, channel, { ...current, promptEventIds: [promptId] });
357
+ }
358
+ log(`posted ${result.inputRequests.length} input request(s) in ${channel.slice(0, 8)}`);
359
+ startFollower(from, channel);
360
+ return;
361
+ }
362
+ if (!result.message) {
363
+ log(`no text for ${channel.slice(0, 8)} — telling them rather than going quiet`);
364
+ relay.reply(channel, "I didn't get an answer back for that one — ask me again and I'll retry.", event);
365
+ return;
366
+ }
367
+ relay.reply(channel, result.message, event);
368
+ log(`replied (${result.message.length} chars)`);
369
+ });
342
370
  }
343
371
  catch (error) {
372
+ /** A rejected HITL response stays pending; the room gets a concrete retry path. */
373
+ const stillPending = sessions.get(from, channel)?.pendingInputRequests;
374
+ if (stillPending?.length) {
375
+ log(`could not submit pending input for ${channel.slice(0, 8)}: ${error.message}`);
376
+ relay.reply(channel, invalidInputReply(stillPending), event);
377
+ startFollower(from, channel);
378
+ return;
379
+ }
380
+ const rejected = rejectedTurnReply(error);
381
+ if (rejected) {
382
+ log(`turn rejected for ${channel.slice(0, 8)}: ${error.message}`);
383
+ relay.reply(channel, rejected, event);
384
+ return;
385
+ }
344
386
  log(`failed to answer: ${error.message}`);
345
387
  }
346
388
  finally {
@@ -354,14 +396,30 @@ export function buzzBridge(options) {
354
396
  /** The communities this agent is a member of. */
355
397
  relays: urls,
356
398
  start() {
399
+ stopped = false;
357
400
  for (const relay of relays.values())
358
401
  relay.connect();
402
+ for (const { community, channel, session } of sessions.entries()) {
403
+ if (session.pendingInputRequests?.length && session.promptEventIds?.length) {
404
+ relays.get(community)?.watchReplies(session.promptEventIds);
405
+ }
406
+ if (needsPendingFollower(session) && session.speakerPublicKey) {
407
+ startFollower(community, channel);
408
+ }
409
+ }
359
410
  log(urls.length > 1
360
411
  ? `listening on ${urls.length} communities — turns run as whoever sent them`
361
412
  : "listening — turns run as whoever sent them");
362
413
  },
363
414
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
364
415
  stop() {
416
+ stopped = true;
417
+ for (const controller of followers.values())
418
+ controller.abort();
419
+ followers.clear();
420
+ for (const timer of followerRestartTimers.values())
421
+ clearTimeout(timer);
422
+ followerRestartTimers.clear();
365
423
  for (const relay of relays.values())
366
424
  relay.setPresence("offline");
367
425
  setTimeout(() => {
package/dist/cli.js CHANGED
@@ -294,6 +294,7 @@ else {
294
294
  comma-separated
295
295
  BUZZ_AGENT_URL where the agent listens (default http://127.0.0.1:8000)
296
296
  BUZZ_KEYFILE this agent's key (default ~/.kybernesis/buzz-agent.json)
297
+ BUZZ_SESSIONS_FILE channel-to-session continuity store
297
298
  KYBERNESIS_ISSUER the control plane
298
299
  KYBERNESIS_AGENT_CREDENTIAL this agent's credential
299
300
  `);
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "kind": "eve-extension",
3
- "formatVersion": 1,
4
- "builtWithEve": "0.38.3",
3
+ "formatVersion": 2,
4
+ "builtWithEve": "0.49.0",
5
5
  "requires": {
6
6
  "extension": 1,
7
- "tool": 13,
7
+ "tool": 24,
8
8
  "instructions": 2
9
9
  }
10
10
  }
package/dist/hitl.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
2
+ import type { StoredSession } from "./sessions.js";
3
+ /** Render pending Eve input in a text-only Buzz channel. */
4
+ export declare function formatInputRequests(requests: readonly InputRequest[]): string;
5
+ /** Resolve a Buzz text reply using Eve's certified matching semantics. */
6
+ export declare function resolveInputReply(text: string, requests: readonly InputRequest[]): readonly InputResponse[] | null;
7
+ /** Actionable response for text that cannot satisfy every pending request. */
8
+ export declare function invalidInputReply(requests: readonly InputRequest[]): string;
9
+ /** Submit a matched reply without consuming its response stream. */
10
+ export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[], community: string, channel: string): Promise<void>;
11
+ /** A session needs its follower while asking or while resumed output is unpublished. */
12
+ export declare function needsPendingFollower(session: Pick<StoredSession, "pendingInputRequests" | "resumeInFlight">): boolean;
13
+ export interface PendingFollowerCallbacks {
14
+ /** Persist the latest cursor and supervision state before publishing outward. */
15
+ onState(state: Omit<StoredSession, "updated">): void | Promise<void>;
16
+ onInputRequested(requests: readonly InputRequest[]): void | Promise<void>;
17
+ onMessage(message: string): void | Promise<void>;
18
+ onLog?: (message: string) => void;
19
+ }
20
+ type MutableStoredSession = Omit<StoredSession, "updated">;
21
+ /**
22
+ * Follow one parked conversation until it resumes, asks another HITL question,
23
+ * or reaches a terminal/non-HITL boundary. This follower is the sole publisher
24
+ * of resumed output; callers of `respond()` must not consume or publish its
25
+ * response stream as well.
26
+ *
27
+ * The durable `resumeInFlight` marker deliberately survives `input.resolved`.
28
+ * It is cleared only after the relay publication callback succeeds, so a crash
29
+ * or disconnect between Eve accepting input and Buzz publishing output can
30
+ * reattach from the persisted cursor instead of letting a normal stale drain
31
+ * swallow the resumed answer.
32
+ */
33
+ export declare function followPendingConversation(session: Pick<ClientSession, "state" | "stream">, initial: MutableStoredSession, callbacks: PendingFollowerCallbacks, signal: AbortSignal): Promise<void>;
34
+ export {};
package/dist/hitl.js ADDED
@@ -0,0 +1,148 @@
1
+ import { resolveTextToResponses, } from "eve/client";
2
+ /** Render pending Eve input in a text-only Buzz channel. */
3
+ export function formatInputRequests(requests) {
4
+ return requests.map((request, requestIndex) => {
5
+ const lines = [
6
+ requests.length > 1 ? `Question ${requestIndex + 1}: ${request.prompt}` : request.prompt,
7
+ ];
8
+ if (request.options?.length) {
9
+ lines.push("", ...request.options.map((option, optionIndex) => {
10
+ const description = option.description ? ` — ${option.description}` : "";
11
+ return `${optionIndex + 1}. ${option.label} [${option.id}]${description}`;
12
+ }));
13
+ }
14
+ if (request.allowFreeform === true || !request.options?.length) {
15
+ lines.push("", "Reply to this message with an option number, option ID, exact label, or your own answer.");
16
+ }
17
+ else {
18
+ lines.push("", "Reply to this message with an option number, option ID, or exact label.");
19
+ }
20
+ return lines.join("\n");
21
+ }).join("\n\n");
22
+ }
23
+ /** Resolve a Buzz text reply using Eve's certified matching semantics. */
24
+ export function resolveInputReply(text, requests) {
25
+ if (!text.trim())
26
+ return null;
27
+ const responses = resolveTextToResponses(text, requests);
28
+ return responses.length === requests.length ? responses : null;
29
+ }
30
+ /** Actionable response for text that cannot satisfy every pending request. */
31
+ export function invalidInputReply(requests) {
32
+ return `I still need a valid answer before I can continue.\n\n${formatInputRequests(requests)}`;
33
+ }
34
+ /** Submit a matched reply without consuming its response stream. */
35
+ export async function respondToPendingConversation(session, responses, community, channel) {
36
+ await session.respond(responses, {
37
+ clientContext: { buzzCommunity: community, buzzChannel: channel },
38
+ streamReconnectPolicy: { reconnect: false },
39
+ });
40
+ }
41
+ /** A session needs its follower while asking or while resumed output is unpublished. */
42
+ export function needsPendingFollower(session) {
43
+ return Boolean(session.pendingInputRequests?.length || session.resumeInFlight);
44
+ }
45
+ function withPending(state, requests) {
46
+ const { resumeInFlight: _, ...rest } = state;
47
+ return { ...rest, pendingInputRequests: requests };
48
+ }
49
+ function withResumeInFlight(state) {
50
+ const { pendingInputRequests: _, ...rest } = state;
51
+ return { ...rest, resumeInFlight: true };
52
+ }
53
+ function withoutSupervision(state) {
54
+ const { pendingInputRequests: _, resumeInFlight: __, ...rest } = state;
55
+ return rest;
56
+ }
57
+ /**
58
+ * Follow one parked conversation until it resumes, asks another HITL question,
59
+ * or reaches a terminal/non-HITL boundary. This follower is the sole publisher
60
+ * of resumed output; callers of `respond()` must not consume or publish its
61
+ * response stream as well.
62
+ *
63
+ * The durable `resumeInFlight` marker deliberately survives `input.resolved`.
64
+ * It is cleared only after the relay publication callback succeeds, so a crash
65
+ * or disconnect between Eve accepting input and Buzz publishing output can
66
+ * reattach from the persisted cursor instead of letting a normal stale drain
67
+ * swallow the resumed answer.
68
+ */
69
+ export async function followPendingConversation(session, initial, callbacks, signal) {
70
+ let state = { ...initial };
71
+ let streamIndex = initial.streamIndex;
72
+ let completedMessage = "";
73
+ const persist = async () => {
74
+ state = { ...state, streamIndex };
75
+ await callbacks.onState(state);
76
+ };
77
+ const publishThenClear = async () => {
78
+ // Do not persist past message.completed/turn.completed until publication
79
+ // succeeds. If this callback throws or the process dies, recovery replays
80
+ // the unpublished output from the last safe cursor.
81
+ if (completedMessage.trim())
82
+ await callbacks.onMessage(completedMessage);
83
+ state = withoutSupervision(state);
84
+ await persist();
85
+ };
86
+ for await (const event of session.stream({
87
+ follow: true,
88
+ startIndex: initial.streamIndex,
89
+ signal,
90
+ })) {
91
+ if (signal.aborted)
92
+ return;
93
+ const streamEvent = event;
94
+ // ClientSession updates its public cursor only when the async iterator
95
+ // closes; count yielded durable events so crash recovery can persist while
96
+ // the follower is still open.
97
+ streamIndex += 1;
98
+ switch (streamEvent.type) {
99
+ case "input.requested":
100
+ completedMessage = "";
101
+ state = withPending(state, streamEvent.data.requests);
102
+ // Keep the durable cursor before the prompt until Buzz accepts it for
103
+ // relay delivery; a reconnect can then replay and render the request.
104
+ await callbacks.onInputRequested(streamEvent.data.requests);
105
+ await persist();
106
+ break;
107
+ case "input.resolved": {
108
+ const resolved = new Set(streamEvent.data.resolutions.map((resolution) => resolution.requestId));
109
+ const remaining = state.pendingInputRequests?.filter((request) => !resolved.has(request.requestId)) ?? [];
110
+ state = remaining.length > 0 ? withPending(state, remaining) : withResumeInFlight(state);
111
+ await persist();
112
+ break;
113
+ }
114
+ case "message.completed":
115
+ // A resumed turn can end more than one step with prose (the answer, then a wrap-up line).
116
+ // The room gets the whole turn, not whichever message happened to come last.
117
+ if (streamEvent.data.finishReason !== "tool-calls" && streamEvent.data.message) {
118
+ completedMessage = completedMessage
119
+ ? `${completedMessage}\n\n${streamEvent.data.message}`
120
+ : streamEvent.data.message;
121
+ }
122
+ // The durable cursor remains before unpublished output. A restarted
123
+ // follower may replay this event safely because it is the sole publisher.
124
+ break;
125
+ case "turn.completed":
126
+ case "session.waiting":
127
+ if (!state.pendingInputRequests?.length) {
128
+ await publishThenClear();
129
+ return;
130
+ }
131
+ await persist();
132
+ break;
133
+ case "turn.cancelled":
134
+ case "turn.failed":
135
+ case "session.failed":
136
+ case "session.completed":
137
+ state = withoutSupervision(state);
138
+ await persist();
139
+ return;
140
+ default:
141
+ // Replaying progress events after a crash has no outward side effect.
142
+ // Persist semantic boundaries rather than writing the session file for
143
+ // every token delta.
144
+ break;
145
+ }
146
+ }
147
+ callbacks.onLog?.("pending Eve stream ended before a resumable boundary");
148
+ }
package/dist/relay.d.ts CHANGED
@@ -31,6 +31,8 @@ export declare const KIND_DM_OPEN = 41010;
31
31
  */
32
32
  export declare const TYPING_INTERVAL_MS = 3000;
33
33
  export declare const PRESENCE_INTERVAL_MS = 60000;
34
+ /** Enough overlap for same-second redelivery without retaining every event forever. */
35
+ export declare const MAX_SEEN_EVENT_IDS = 10000;
34
36
  export type NostrEvent = {
35
37
  id: string;
36
38
  pubkey: string;
@@ -54,6 +56,7 @@ export declare class BuzzRelay {
54
56
  private authenticated;
55
57
  private presenceTimer;
56
58
  private pollTimer;
59
+ private reconnectTimer;
57
60
  private closed;
58
61
  /** How long to wait before the next attempt, and what was last refused. */
59
62
  private backoff;
@@ -61,6 +64,8 @@ export declare class BuzzRelay {
61
64
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
62
65
  private readonly seen;
63
66
  private cursor;
67
+ /** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
68
+ private readonly replyWatch;
64
69
  private readonly url;
65
70
  private readonly rest;
66
71
  private readonly key;
@@ -69,6 +74,9 @@ export declare class BuzzRelay {
69
74
  private readonly log;
70
75
  constructor(options: RelayOptions);
71
76
  get pubkey(): string;
77
+ /** Also poll for replies to these events — a person answering a prompt rarely mentions the asker. */
78
+ watchReplies(ids: Iterable<string>): void;
79
+ unwatchReplies(ids: Iterable<string>): void;
72
80
  connect(): void;
73
81
  close(): void;
74
82
  private reconnect;
@@ -83,17 +91,18 @@ export declare class BuzzRelay {
83
91
  * failure — a process that connects, authenticates, logs that it is listening, and silently
84
92
  * ignores every message addressed to it.
85
93
  */
86
- private poll;
94
+ poll(): void;
95
+ /** Publish one event; returns its id when it left this process, null when the socket was down. */
87
96
  publish(event: {
88
97
  kind: number;
89
98
  tags: string[][];
90
99
  content: string;
91
- }): void;
100
+ }): string | null;
92
101
  setPresence(status: PresenceStatus): void;
93
102
  private keepPresent;
94
103
  /** Say "typing" in a channel until the returned function is called. */
95
104
  typingIn(channel: string): () => void;
96
- reply(channel: string, text: string, replyTo?: NostrEvent): void;
105
+ reply(channel: string, text: string, replyTo?: NostrEvent): string | null;
97
106
  /** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
98
107
  private httpAuth;
99
108
  private submit;
package/dist/relay.js CHANGED
@@ -32,6 +32,8 @@ export const KIND_DM_OPEN = 41010;
32
32
  */
33
33
  export const TYPING_INTERVAL_MS = 3_000;
34
34
  export const PRESENCE_INTERVAL_MS = 60_000;
35
+ /** Enough overlap for same-second redelivery without retaining every event forever. */
36
+ export const MAX_SEEN_EVENT_IDS = 10_000;
35
37
  const now = () => Math.floor(Date.now() / 1000);
36
38
  /**
37
39
  * The payload out of a command acknowledgement.
@@ -50,6 +52,7 @@ export class BuzzRelay {
50
52
  authenticated = false;
51
53
  presenceTimer = null;
52
54
  pollTimer = null;
55
+ reconnectTimer = null;
53
56
  closed = false;
54
57
  /** How long to wait before the next attempt, and what was last refused. */
55
58
  backoff = 5_000;
@@ -57,6 +60,8 @@ export class BuzzRelay {
57
60
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
58
61
  seen = new Set();
59
62
  cursor = now();
63
+ /** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
64
+ replyWatch = new Set();
60
65
  url;
61
66
  rest;
62
67
  key;
@@ -74,6 +79,15 @@ export class BuzzRelay {
74
79
  get pubkey() {
75
80
  return this.key.publicKey;
76
81
  }
82
+ /** Also poll for replies to these events — a person answering a prompt rarely mentions the asker. */
83
+ watchReplies(ids) {
84
+ for (const id of ids)
85
+ this.replyWatch.add(id);
86
+ }
87
+ unwatchReplies(ids) {
88
+ for (const id of ids)
89
+ this.replyWatch.delete(id);
90
+ }
77
91
  connect() {
78
92
  if (this.closed)
79
93
  return;
@@ -98,6 +112,11 @@ export class BuzzRelay {
98
112
  clearInterval(this.presenceTimer);
99
113
  if (this.pollTimer)
100
114
  clearTimeout(this.pollTimer);
115
+ if (this.reconnectTimer)
116
+ clearTimeout(this.reconnectTimer);
117
+ this.presenceTimer = null;
118
+ this.pollTimer = null;
119
+ this.reconnectTimer = null;
101
120
  try {
102
121
  this.socket?.close();
103
122
  }
@@ -108,6 +127,14 @@ export class BuzzRelay {
108
127
  reconnect() {
109
128
  if (this.closed)
110
129
  return;
130
+ if (this.presenceTimer)
131
+ clearInterval(this.presenceTimer);
132
+ if (this.pollTimer)
133
+ clearTimeout(this.pollTimer);
134
+ this.presenceTimer = null;
135
+ this.pollTimer = null;
136
+ if (this.reconnectTimer)
137
+ return;
111
138
  // Backs off rather than hammering. A membership refusal is the common case
112
139
  // here — an agent whose invite has not happened yet — and it is not
113
140
  // something a retry a second from now can fix, though a retry eventually
@@ -115,7 +142,10 @@ export class BuzzRelay {
115
142
  const wait = this.backoff;
116
143
  this.backoff = Math.min(this.backoff * 2, 60_000);
117
144
  this.log(`disconnected; reconnecting in ${Math.round(wait / 1000)}s`);
118
- setTimeout(() => this.connect(), wait);
145
+ this.reconnectTimer = setTimeout(() => {
146
+ this.reconnectTimer = null;
147
+ this.connect();
148
+ }, wait);
119
149
  }
120
150
  receive(data) {
121
151
  let frame;
@@ -177,14 +207,23 @@ export class BuzzRelay {
177
207
  if (this.seen.has(event.id))
178
208
  return;
179
209
  this.seen.add(event.id);
180
- if (event.created_at >= this.cursor)
181
- this.cursor = event.created_at + 1;
210
+ if (this.seen.size > MAX_SEEN_EVENT_IDS) {
211
+ const oldest = this.seen.values().next().value;
212
+ if (oldest)
213
+ this.seen.delete(oldest);
214
+ }
215
+ // Nostr timestamps have one-second precision and `since` is inclusive. Keep
216
+ // the latest second in the next poll; the seen set removes redelivery while
217
+ // allowing another event created during that same second to arrive.
218
+ if (event.created_at > this.cursor)
219
+ this.cursor = event.created_at;
182
220
  this.onMessage(event);
183
221
  }
184
222
  send(frame) {
185
223
  if (this.socket?.readyState !== WebSocket.OPEN)
186
- return;
224
+ return false;
187
225
  this.socket.send(JSON.stringify(frame));
226
+ return true;
188
227
  }
189
228
  /**
190
229
  * Ask for what is new, rather than waiting to be told.
@@ -198,11 +237,19 @@ export class BuzzRelay {
198
237
  poll() {
199
238
  if (this.closed)
200
239
  return;
201
- this.send(["REQ", `m${Date.now()}`, { kinds: [KIND_MESSAGE], "#p": [this.key.publicKey], since: this.cursor }]);
240
+ const filters = [
241
+ { kinds: [KIND_MESSAGE], "#p": [this.key.publicKey], since: this.cursor },
242
+ ];
243
+ if (this.replyWatch.size > 0) {
244
+ filters.push({ kinds: [KIND_MESSAGE], "#e": [...this.replyWatch], since: this.cursor });
245
+ }
246
+ this.send(["REQ", `m${Date.now()}`, ...filters]);
202
247
  this.pollTimer = setTimeout(() => this.poll(), this.pollMs);
203
248
  }
249
+ /** Publish one event; returns its id when it left this process, null when the socket was down. */
204
250
  publish(event) {
205
- this.send(["EVENT", finalizeEvent({ created_at: now(), ...event }, this.key.secretKey)]);
251
+ const signed = finalizeEvent({ created_at: now(), ...event }, this.key.secretKey);
252
+ return this.send(["EVENT", signed]) ? signed.id : null;
206
253
  }
207
254
  setPresence(status) {
208
255
  this.publish({ kind: KIND_PRESENCE, tags: [], content: status });
@@ -225,7 +272,7 @@ export class BuzzRelay {
225
272
  if (replyTo) {
226
273
  tags.push(["e", replyTo.id], ["p", replyTo.pubkey]);
227
274
  }
228
- this.publish({ kind: KIND_MESSAGE, tags, content: text });
275
+ return this.publish({ kind: KIND_MESSAGE, tags, content: text });
229
276
  }
230
277
  /** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
231
278
  httpAuth(url, body) {
@@ -1,10 +1,27 @@
1
+ import type { InputRequest } from "eve/client";
1
2
  /** One conversation: which eve session it is, and how far the bridge has read. */
2
3
  export interface StoredSession {
3
4
  id: string;
4
5
  streamIndex: number;
6
+ /** Requests that have parked this session, when HITL is awaiting a reply. */
7
+ pendingInputRequests?: readonly InputRequest[];
8
+ /** Eve accepted the HITL response and resumed, but Buzz has not published its output yet. */
9
+ resumeInFlight?: boolean;
10
+ /** Public identity used to refresh credentials when a follower reattaches. Never a bearer token. */
11
+ speakerPublicKey?: string;
12
+ /**
13
+ * Relay event ids of the prompts posted for the pending requests, so a reply to one of them
14
+ * counts as an answer whether or not it mentions this agent.
15
+ */
16
+ promptEventIds?: readonly string[];
5
17
  /** When it was last used, so a store that runs for years does not grow forever. */
6
18
  updated: number;
7
19
  }
20
+ export interface StoredSessionEntry {
21
+ community: string;
22
+ channel: string;
23
+ session: StoredSession;
24
+ }
8
25
  /**
9
26
  * Which eve session belongs to which channel, across restarts.
10
27
  *
@@ -21,17 +38,18 @@ export interface StoredSession {
21
38
  * other, and the join between them was the part that could not survive.
22
39
  *
23
40
  * Keyed by community AND channel: channel ids are issued per relay, so an agent
24
- * in two workspaces cannot assume they never collide.
41
+ * in two workspaces cannot assume they never collide. Optional HITL fields keep
42
+ * records written by older Buzz versions valid.
25
43
  */
26
44
  export declare class SessionStore {
27
45
  #private;
28
- constructor(file: string);
46
+ constructor(file: string, options?: {
47
+ onError?: (message: string) => void;
48
+ });
29
49
  static key(community: string, channel: string): string;
30
50
  get(community: string, channel: string): StoredSession | undefined;
31
- set(community: string, channel: string, session: {
32
- id: string;
33
- streamIndex: number;
34
- }): void;
51
+ set(community: string, channel: string, session: Omit<StoredSession, "updated">): void;
35
52
  delete(community: string, channel: string): void;
53
+ entries(): StoredSessionEntry[];
36
54
  get size(): number;
37
55
  }
package/dist/sessions.js CHANGED
@@ -18,13 +18,16 @@ const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
18
18
  * other, and the join between them was the part that could not survive.
19
19
  *
20
20
  * Keyed by community AND channel: channel ids are issued per relay, so an agent
21
- * in two workspaces cannot assume they never collide.
21
+ * in two workspaces cannot assume they never collide. Optional HITL fields keep
22
+ * records written by older Buzz versions valid.
22
23
  */
23
24
  export class SessionStore {
24
25
  #file;
25
26
  #entries = new Map();
26
- constructor(file) {
27
+ #onError;
28
+ constructor(file, options = {}) {
27
29
  this.#file = file;
30
+ this.#onError = options.onError ?? (() => { });
28
31
  this.#load();
29
32
  }
30
33
  static key(community, channel) {
@@ -41,6 +44,20 @@ export class SessionStore {
41
44
  this.#entries.delete(SessionStore.key(community, channel));
42
45
  this.#save();
43
46
  }
47
+ entries() {
48
+ const entries = [];
49
+ for (const [key, session] of this.#entries) {
50
+ const separator = key.lastIndexOf("|");
51
+ if (separator < 0)
52
+ continue;
53
+ entries.push({
54
+ community: key.slice(0, separator),
55
+ channel: key.slice(separator + 1),
56
+ session,
57
+ });
58
+ }
59
+ return entries;
60
+ }
44
61
  get size() {
45
62
  return this.#entries.size;
46
63
  }
@@ -51,14 +68,17 @@ export class SessionStore {
51
68
  const raw = JSON.parse(readFileSync(this.#file, "utf8"));
52
69
  const cutoff = Date.now() - KEEP_MS;
53
70
  for (const [key, value] of Object.entries(raw)) {
54
- if (typeof value?.id === "string" && (value.updated ?? 0) > cutoff) {
71
+ if (typeof value?.id === "string" &&
72
+ typeof value?.streamIndex === "number" &&
73
+ (value.updated ?? 0) > cutoff) {
55
74
  this.#entries.set(key, value);
56
75
  }
57
76
  }
58
77
  }
59
- catch {
78
+ catch (error) {
60
79
  // A corrupt store is not worth refusing to start over. Losing the mapping
61
80
  // costs conversation history; refusing to start costs the whole agent.
81
+ this.#onError(`could not load Buzz session store ${this.#file}: ${error.message}`);
62
82
  }
63
83
  }
64
84
  #save() {
@@ -72,9 +92,10 @@ export class SessionStore {
72
92
  writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.#entries)), { mode: 0o600 });
73
93
  renameSync(tmp, this.#file);
74
94
  }
75
- catch {
95
+ catch (error) {
76
96
  // Best effort: an agent that cannot write its store still answers, it
77
97
  // just forgets across restarts — which is where this started.
98
+ this.#onError(`could not write Buzz session store ${this.#file}: ${error.message}`);
78
99
  }
79
100
  }
80
101
  }
package/dist/turn.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type Client, type InputRequest, type MessageResult, type SendTurnInput } from "eve/client";
2
+ import { type FetchedMedia, type MediaRef } from "./media.js";
3
+ import { SessionStore } from "./sessions.js";
4
+ /** A message shape accepted by the certified eve client's send contract. */
5
+ export type TurnMessage = SendTurnInput["message"];
6
+ /** Everything the bridge needs to decide how one eve turn should be rendered. */
7
+ export interface TurnOutcome {
8
+ message: string;
9
+ status: MessageResult["status"];
10
+ inputRequests: readonly InputRequest[];
11
+ sessionId: string;
12
+ streamIndex: number;
13
+ }
14
+ /** Convert one inbound Buzz message into an eve-compatible turn. */
15
+ export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
16
+ /** Continue a channel's existing conversation, replacing it only when it is stale. */
17
+ export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void): Promise<TurnOutcome>;
18
+ /**
19
+ * What the room is told when eve refuses the turn itself.
20
+ *
21
+ * A 400 means the request was malformed — a part eve does not accept, a body
22
+ * it could not parse — not that the agent failed to think of an answer. The
23
+ * session survives it (`answerTurn` keeps the mapping), but the person who
24
+ * sent the message is still waiting, and a log line nobody is reading is not
25
+ * an answer. Returns `null` for anything that is not a request rejection, so
26
+ * the caller's ordinary failure path stays as it was.
27
+ */
28
+ export declare function rejectedTurnReply(error: unknown): string | null;
package/dist/turn.js ADDED
@@ -0,0 +1,129 @@
1
+ import { ClientError, createDataUrlFilePart, } from "eve/client";
2
+ import { isImage } from "./media.js";
3
+ /** Convert one inbound Buzz message into an eve-compatible turn. */
4
+ export async function composeMessage(text, attachments, fetchAttachment, log = () => { }) {
5
+ if (attachments.length === 0)
6
+ return text;
7
+ const parts = [];
8
+ if (text)
9
+ parts.push({ type: "text", text });
10
+ for (const ref of attachments) {
11
+ try {
12
+ const media = await fetchAttachment(ref);
13
+ if (isImage(media.mediaType)) {
14
+ parts.push(createDataUrlFilePart({
15
+ bytes: media.bytes,
16
+ mediaType: media.mediaType,
17
+ }));
18
+ }
19
+ else {
20
+ parts.push({
21
+ type: "text",
22
+ text: `[The sender attached a ${media.mediaType} file, which cannot be read as an image. Tell them what you received and ask for it in a readable form if you need it.]`,
23
+ });
24
+ }
25
+ }
26
+ catch (error) {
27
+ log(`could not fetch an attachment: ${error.message}`);
28
+ parts.push({
29
+ type: "text",
30
+ text: `[The sender attached a file, and it could not be retrieved (${error.message}). Say so rather than answering as if there were no attachment.]`,
31
+ });
32
+ }
33
+ }
34
+ return parts;
35
+ }
36
+ function outcome(result, streamIndex) {
37
+ return {
38
+ message: result.message ?? "",
39
+ status: result.status,
40
+ inputRequests: result.inputRequests,
41
+ sessionId: result.sessionId,
42
+ streamIndex,
43
+ };
44
+ }
45
+ /** Continue a channel's existing conversation, replacing it only when it is stale. */
46
+ export async function answerTurn(client, sessions, channel, message, community, log = () => { }) {
47
+ const existing = sessions.get(community, channel);
48
+ if (existing) {
49
+ try {
50
+ const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
51
+ /**
52
+ * Move to the true end of the conversation before speaking.
53
+ *
54
+ * `send()` opens its response stream at the position the handle already
55
+ * holds and stops at the FIRST turn boundary it meets. If anything is
56
+ * still unread there — the tail of a turn that outlived its reader, a
57
+ * turn that was running when the bridge restarted — that boundary
58
+ * belongs to the older turn. The read ends on it, this turn's answer is
59
+ * never collected, and the stored position stays one turn behind.
60
+ *
61
+ * Which makes it permanent: every later turn reads the previous turn's
62
+ * tail. The channel answers nothing, then answers a question from ten
63
+ * minutes ago, and a fresh conversation elsewhere works perfectly —
64
+ * because a fresh conversation has nothing left over to trip on.
65
+ *
66
+ * Draining first costs one bounded read of only what is unread, and it
67
+ * repairs a position that has already drifted rather than requiring
68
+ * anyone to notice. Non-following, so it ends at the tail instead of
69
+ * waiting for the future.
70
+ *
71
+ * Do not call this path while HITL is pending: the bridge's follower owns
72
+ * that unread stream until the parked turn reaches its next boundary.
73
+ */
74
+ let unread = 0;
75
+ for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
76
+ unread += 1;
77
+ }
78
+ if (unread > 0) {
79
+ log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
80
+ }
81
+ const response = await session.send(message, {
82
+ clientContext: { buzzCommunity: community, buzzChannel: channel },
83
+ });
84
+ const result = await response.result();
85
+ sessions.set(community, channel, {
86
+ id: existing.id,
87
+ streamIndex: session.state.streamIndex,
88
+ });
89
+ return outcome(result, session.state.streamIndex);
90
+ }
91
+ catch (error) {
92
+ // A malformed turn does not make its durable session stale. Rethrow so
93
+ // the bridge can tell the room (see `rejectedTurnReply`), and keep the
94
+ // mapping so the next valid message continues the same conversation.
95
+ if (error instanceof ClientError && error.status === 400)
96
+ throw error;
97
+ log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
98
+ sessions.delete(community, channel);
99
+ }
100
+ }
101
+ const created = await client.sessions.create({
102
+ message,
103
+ clientContext: { buzzCommunity: community, buzzChannel: channel },
104
+ });
105
+ const result = await created.response.result();
106
+ sessions.set(community, channel, {
107
+ id: created.session.state.sessionId,
108
+ streamIndex: created.session.state.streamIndex,
109
+ });
110
+ return outcome(result, created.session.state.streamIndex);
111
+ }
112
+ /**
113
+ * What the room is told when eve refuses the turn itself.
114
+ *
115
+ * A 400 means the request was malformed — a part eve does not accept, a body
116
+ * it could not parse — not that the agent failed to think of an answer. The
117
+ * session survives it (`answerTurn` keeps the mapping), but the person who
118
+ * sent the message is still waiting, and a log line nobody is reading is not
119
+ * an answer. Returns `null` for anything that is not a request rejection, so
120
+ * the caller's ordinary failure path stays as it was.
121
+ */
122
+ export function rejectedTurnReply(error) {
123
+ if (!(error instanceof ClientError) || error.status !== 400)
124
+ return null;
125
+ const detail = String(error.message ?? "").split("\n")[0].trim().slice(0, 160);
126
+ return detail
127
+ ? `I couldn't read that message (${detail}). Try sending it again, or as plain text.`
128
+ : "I couldn't read that message. Try sending it again, or as plain text.";
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -44,17 +44,17 @@
44
44
  "test": "node --test \"test/**/*.test.mjs\""
45
45
  },
46
46
  "dependencies": {
47
- "@kybernesis/enterprise": "^0.7.0",
47
+ "@kybernesis/enterprise": "^0.8.0",
48
48
  "nostr-tools": "^2.10.0",
49
49
  "zod": "^4"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "24.x",
53
- "eve": "0.38.3",
53
+ "eve": "0.49.0",
54
54
  "typescript": "7.0.2"
55
55
  },
56
56
  "peerDependencies": {
57
- "eve": ">=0.38.0 <0.39.0"
57
+ "eve": ">=0.49.0 <0.50.0"
58
58
  },
59
59
  "engines": {
60
60
  "node": ">=20"