@kybernesis/buzz 0.9.0 → 0.9.2

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
@@ -3,6 +3,7 @@ import { fetchMedia, parseMedia } from "./media.js";
3
3
  import { speakerCredentials } from "./credentials.js";
4
4
  import { SessionStore } from "./sessions.js";
5
5
  import { answerTurn, composeMessage, rejectedTurnReply } from "./turn.js";
6
+ import { followPendingConversation, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, } from "./hitl.js";
6
7
  import { existsSync } from "node:fs";
7
8
  import { dirname, join } from "node:path";
8
9
  import { Client } from "eve/client";
@@ -105,10 +106,11 @@ export function buzzBridge(options) {
105
106
  * turns are ordered whether or not the people in it take turns.
106
107
  */
107
108
  const inFlight = new Map();
108
- function serialize(channel, work) {
109
- 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);
110
112
  // Kept only while it matters: the chain holds the LAST promise, not a history.
111
- inFlight.set(channel, queued.catch(() => { }));
113
+ inFlight.set(key, queued.catch(() => { }));
112
114
  return queued;
113
115
  }
114
116
  /**
@@ -171,6 +173,97 @@ export function buzzBridge(options) {
171
173
  },
172
174
  }));
173
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
+ }
174
267
  async function handle(event, from) {
175
268
  const relay = relays.get(from);
176
269
  if (!relay)
@@ -179,24 +272,15 @@ export function buzzBridge(options) {
179
272
  // Addressed by TAG, not by text. A name in prose is a string anyone can type; a p tag is what
180
273
  // the client emits when someone actually picks this member out of a mention list.
181
274
  const addressed = event.tags.some((t) => t[0] === "p" && t[1] === key.publicKey);
182
- 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))
183
281
  return;
184
282
  const text = String(event.content ?? "").trim();
185
283
  const attachments = parseMedia(event);
186
- /**
187
- * Drop only a message with NOTHING in it — and say so when we do.
188
- *
189
- * This guard used to test the text alone, and sat above the log line, so an
190
- * image sent without a caption was discarded before anything was written
191
- * down. From the outside the agent had simply ignored someone; from the
192
- * inside there was no record that a message had ever arrived. The reply
193
- * path was hardened against exactly this failure — an empty answer now
194
- * says so out loud — and the receiving path was left as it was.
195
- */
196
- if (!text && attachments.length === 0) {
197
- log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
198
- return;
199
- }
200
284
  let speaker;
201
285
  try {
202
286
  speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
@@ -216,44 +300,83 @@ export function buzzBridge(options) {
216
300
  }
217
301
  log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}` +
218
302
  (attachments.length ? ` [+${attachments.length} attachment(s)]` : ""));
219
- /**
220
- * Attachments become parts of the turn, or become words about themselves.
221
- *
222
- * An image the model can see goes in as bytes. Anything else — a zip, a
223
- * PDF the model cannot read, a fetch that failed — goes in as a sentence
224
- * saying so, because the alternative is an agent that answers the caption
225
- * while the person waits for a reply about the file they sent. Being told
226
- * "I got a file I cannot read" is a worse answer than the truth only if
227
- * you never wanted the truth.
228
- */
229
- const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
230
303
  void relay.react(event.id, SEEN);
231
304
  const stopTyping = relay.typingIn(channel);
232
305
  try {
233
- const reply = await serialize(channel, () => answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log));
234
- if (!reply) {
235
- /**
236
- * Silence is the worst answer available.
237
- *
238
- * An empty result used to end here, with a line in a log nobody was
239
- * reading. In a room that is indistinguishable from the agent ignoring
240
- * you — so people ask again, which is how one unanswered question
241
- * became five, none of which looked like a fault to anyone watching.
242
- */
243
- log(`no text for ${channel.slice(0, 8)} — telling them rather than going quiet`);
244
- relay.reply(channel, "I didn't get an answer back for that one — ask me again and I'll retry.", event);
245
- return;
246
- }
247
- relay.reply(channel, reply, event);
248
- 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
+ });
249
370
  }
250
371
  catch (error) {
251
- /**
252
- * eve refused the turn as malformed. The conversation is intact — the
253
- * session mapping was kept — but silence here is indistinguishable from
254
- * being ignored, the same failure the empty-reply branch above exists
255
- * for. Say what happened; the log gets the full error.
256
- */
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
+ }
257
380
  const rejected = rejectedTurnReply(error);
258
381
  if (rejected) {
259
382
  log(`turn rejected for ${channel.slice(0, 8)}: ${error.message}`);
@@ -273,14 +396,30 @@ export function buzzBridge(options) {
273
396
  /** The communities this agent is a member of. */
274
397
  relays: urls,
275
398
  start() {
399
+ stopped = false;
276
400
  for (const relay of relays.values())
277
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
+ }
278
410
  log(urls.length > 1
279
411
  ? `listening on ${urls.length} communities — turns run as whoever sent them`
280
412
  : "listening — turns run as whoever sent them");
281
413
  },
282
414
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
283
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();
284
423
  for (const relay of relays.values())
285
424
  relay.setPresence("offline");
286
425
  setTimeout(() => {
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
@@ -64,6 +64,8 @@ export declare class BuzzRelay {
64
64
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
65
65
  private readonly seen;
66
66
  private cursor;
67
+ /** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
68
+ private readonly replyWatch;
67
69
  private readonly url;
68
70
  private readonly rest;
69
71
  private readonly key;
@@ -72,6 +74,9 @@ export declare class BuzzRelay {
72
74
  private readonly log;
73
75
  constructor(options: RelayOptions);
74
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;
75
80
  connect(): void;
76
81
  close(): void;
77
82
  private reconnect;
@@ -86,17 +91,18 @@ export declare class BuzzRelay {
86
91
  * failure — a process that connects, authenticates, logs that it is listening, and silently
87
92
  * ignores every message addressed to it.
88
93
  */
89
- private poll;
94
+ poll(): void;
95
+ /** Publish one event; returns its id when it left this process, null when the socket was down. */
90
96
  publish(event: {
91
97
  kind: number;
92
98
  tags: string[][];
93
99
  content: string;
94
- }): void;
100
+ }): string | null;
95
101
  setPresence(status: PresenceStatus): void;
96
102
  private keepPresent;
97
103
  /** Say "typing" in a channel until the returned function is called. */
98
104
  typingIn(channel: string): () => void;
99
- reply(channel: string, text: string, replyTo?: NostrEvent): void;
105
+ reply(channel: string, text: string, replyTo?: NostrEvent): string | null;
100
106
  /** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
101
107
  private httpAuth;
102
108
  private submit;
package/dist/relay.js CHANGED
@@ -60,6 +60,8 @@ export class BuzzRelay {
60
60
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
61
61
  seen = new Set();
62
62
  cursor = now();
63
+ /** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
64
+ replyWatch = new Set();
63
65
  url;
64
66
  rest;
65
67
  key;
@@ -77,6 +79,15 @@ export class BuzzRelay {
77
79
  get pubkey() {
78
80
  return this.key.publicKey;
79
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
+ }
80
91
  connect() {
81
92
  if (this.closed)
82
93
  return;
@@ -210,8 +221,9 @@ export class BuzzRelay {
210
221
  }
211
222
  send(frame) {
212
223
  if (this.socket?.readyState !== WebSocket.OPEN)
213
- return;
224
+ return false;
214
225
  this.socket.send(JSON.stringify(frame));
226
+ return true;
215
227
  }
216
228
  /**
217
229
  * Ask for what is new, rather than waiting to be told.
@@ -225,11 +237,19 @@ export class BuzzRelay {
225
237
  poll() {
226
238
  if (this.closed)
227
239
  return;
228
- 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]);
229
247
  this.pollTimer = setTimeout(() => this.poll(), this.pollMs);
230
248
  }
249
+ /** Publish one event; returns its id when it left this process, null when the socket was down. */
231
250
  publish(event) {
232
- 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;
233
253
  }
234
254
  setPresence(status) {
235
255
  this.publish({ kind: KIND_PRESENCE, tags: [], content: status });
@@ -252,7 +272,7 @@ export class BuzzRelay {
252
272
  if (replyTo) {
253
273
  tags.push(["e", replyTo.id], ["p", replyTo.pubkey]);
254
274
  }
255
- this.publish({ kind: KIND_MESSAGE, tags, content: text });
275
+ return this.publish({ kind: KIND_MESSAGE, tags, content: text });
256
276
  }
257
277
  /** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
258
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,7 +38,8 @@ 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;
@@ -30,10 +48,8 @@ export declare class SessionStore {
30
48
  });
31
49
  static key(community: string, channel: string): string;
32
50
  get(community: string, channel: string): StoredSession | undefined;
33
- set(community: string, channel: string, session: {
34
- id: string;
35
- streamIndex: number;
36
- }): void;
51
+ set(community: string, channel: string, session: Omit<StoredSession, "updated">): void;
37
52
  delete(community: string, channel: string): void;
53
+ entries(): StoredSessionEntry[];
38
54
  get size(): number;
39
55
  }
package/dist/sessions.js CHANGED
@@ -18,7 +18,8 @@ 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;
@@ -43,6 +44,20 @@ export class SessionStore {
43
44
  this.#entries.delete(SessionStore.key(community, channel));
44
45
  this.#save();
45
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
+ }
46
61
  get size() {
47
62
  return this.#entries.size;
48
63
  }
@@ -53,7 +68,9 @@ export class SessionStore {
53
68
  const raw = JSON.parse(readFileSync(this.#file, "utf8"));
54
69
  const cutoff = Date.now() - KEEP_MS;
55
70
  for (const [key, value] of Object.entries(raw)) {
56
- 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) {
57
74
  this.#entries.set(key, value);
58
75
  }
59
76
  }
package/dist/turn.d.ts CHANGED
@@ -1,12 +1,20 @@
1
- import { type Client, type SendTurnInput } from "eve/client";
1
+ import { type Client, type InputRequest, type MessageResult, type SendTurnInput } from "eve/client";
2
2
  import { type FetchedMedia, type MediaRef } from "./media.js";
3
3
  import { SessionStore } from "./sessions.js";
4
4
  /** A message shape accepted by the certified eve client's send contract. */
5
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
+ }
6
14
  /** Convert one inbound Buzz message into an eve-compatible turn. */
7
15
  export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
8
16
  /** Continue a channel's existing conversation, replacing it only when it is stale. */
9
- export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void): Promise<string>;
17
+ export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void): Promise<TurnOutcome>;
10
18
  /**
11
19
  * What the room is told when eve refuses the turn itself.
12
20
  *
package/dist/turn.js CHANGED
@@ -33,6 +33,15 @@ export async function composeMessage(text, attachments, fetchAttachment, log = (
33
33
  }
34
34
  return parts;
35
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
+ }
36
45
  /** Continue a channel's existing conversation, replacing it only when it is stale. */
37
46
  export async function answerTurn(client, sessions, channel, message, community, log = () => { }) {
38
47
  const existing = sessions.get(community, channel);
@@ -58,6 +67,9 @@ export async function answerTurn(client, sessions, channel, message, community,
58
67
  * repairs a position that has already drifted rather than requiring
59
68
  * anyone to notice. Non-following, so it ends at the tail instead of
60
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.
61
73
  */
62
74
  let unread = 0;
63
75
  for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
@@ -74,7 +86,7 @@ export async function answerTurn(client, sessions, channel, message, community,
74
86
  id: existing.id,
75
87
  streamIndex: session.state.streamIndex,
76
88
  });
77
- return result.message ?? "";
89
+ return outcome(result, session.state.streamIndex);
78
90
  }
79
91
  catch (error) {
80
92
  // A malformed turn does not make its durable session stale. Rethrow so
@@ -86,16 +98,23 @@ export async function answerTurn(client, sessions, channel, message, community,
86
98
  sessions.delete(community, channel);
87
99
  }
88
100
  }
101
+ // Every fresh conversation is logged, whatever the reason. The silent case
102
+ // used to be the common one: after a bridge restart the map was empty, the
103
+ // message fell straight through to create(), and eight exchanges of context
104
+ // vanished without a line anyone could read (KYB-502).
105
+ log(existing
106
+ ? `starting a new conversation in ${channel.slice(0, 8)} (the previous session could not continue)`
107
+ : `starting a new conversation in ${channel.slice(0, 8)} (no session on record for this channel)`);
89
108
  const created = await client.sessions.create({
90
109
  message,
91
110
  clientContext: { buzzCommunity: community, buzzChannel: channel },
92
111
  });
93
- const reply = (await created.response.result()).message ?? "";
112
+ const result = await created.response.result();
94
113
  sessions.set(community, channel, {
95
114
  id: created.session.state.sessionId,
96
115
  streamIndex: created.session.state.streamIndex,
97
116
  });
98
- return reply;
117
+ return outcome(result, created.session.state.streamIndex);
99
118
  }
100
119
  /**
101
120
  * What the room is told when eve refuses the turn itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",