@kybernesis/buzz 0.13.0 → 0.14.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.
package/README.md CHANGED
@@ -95,6 +95,24 @@ share link as the person who asked (`POST /eve/v1/kyb/artifacts/:id/share`, `@ky
95
95
  app and opens it beside the conversation; everywhere else the link is in the text. The person
96
96
  can withdraw the link from the agent. `BUZZ_CARDS=off` stops card messages.
97
97
 
98
+ ## Questions and approvals
99
+
100
+ When the agent needs a yes or an answer, the bridge posts the question as a reply to the message
101
+ that asked: an approval as the email, event or message it would send (recipients, subject and
102
+ the whole body, not "Approve tool call"), any other question as its sentence, then the options by
103
+ number. Anyone can answer by replying with a number, an option or their own words. Since 0.14 the
104
+ question also travels hidden (`<!-- kyb:ask v1 {...} -->`), and the plugin draws it as Studio
105
+ does, with Send it, Edit and Don't send on an email, the amount and Approve in Link on a
106
+ purchase, and the fields of a form. A press posts the person's answer as a reply to the
107
+ question: a short line, with the answer hidden after it (`<!-- kyb:answer v1 {...} -->`). The
108
+ bridge accepts a hidden answer only as a direct reply to a prompt it posted, only for a request
109
+ still open, and only with that request's own options. Replies inside a thread count, whichever
110
+ `e` tag names the prompt. A vault request never takes a secret in the channel.
111
+
112
+ If the work an answer restarts fails (a model refusing the continuation, say), the bridge says so
113
+ in the channel with the stream's own error, rather than leaving the person who answered with
114
+ silence. A declined call is the person's choice and is not reported as a failure.
115
+
98
116
  ## Configuration
99
117
 
100
118
  | Variable | Meaning |
@@ -105,7 +123,7 @@ can withdraw the link from the agent. `BUZZ_CARDS=off` stops card messages.
105
123
  | `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
106
124
  | `BUZZ_AGENT_SILENCE_TIMEOUT_MS` | Maximum silence before the agent acknowledges a request: unread drain, send and create acknowledgement (default `300000`, five minutes). This is where a genuine stall shows: no run is ever created. |
107
125
  | `BUZZ_AGENT_WORK_TIMEOUT_MS` | Ceiling on silence once the response stream is open (default `3600000`, sixty minutes). The stream is quiet while the agent works, a long tool call or a delegated subagent produces nothing until it returns, so this is a ceiling on work, not a stall detector. |
108
- | `BUZZ_CARDS` | `off` stops posting cards after replies (default on) |
126
+ | `BUZZ_CARDS` | `off` stops posting cards after replies and hidden questions in prompts (default on) |
109
127
  | `BUZZ_PROFILE_SYNC_MS` | How often the profile is re-checked against the name and picture its owner set and its `.agent` name (default `900000`, fifteen minutes; `0` turns it off) |
110
128
  | `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
111
129
  | `KYBERNESIS_ISSUER` | The control plane |
package/dist/bridge.d.ts CHANGED
@@ -26,7 +26,9 @@ export type BuzzBridgeOptions = {
26
26
  /**
27
27
  * Post the cards a turn's tools handed back, each as its own message after
28
28
  * the reply: the card's text, with the card hidden in a comment for clients
29
- * that draw it. Default true; false posts the reply alone, as before 0.13.
29
+ * that draw it. Questions carry themselves the same way (0.14), so the
30
+ * plugin can draw an approval as its email and answer it with a press.
31
+ * Default true; false posts the reply and questions as words alone.
30
32
  */
31
33
  cards?: boolean;
32
34
  onLog?: (message: string) => void;
package/dist/bridge.js CHANGED
@@ -1,11 +1,11 @@
1
- import { cardMessage, cardText } from "@kybernesis/cards";
1
+ import { cardMessage, cardText, readHiddenAnswers } from "@kybernesis/cards";
2
2
  import { channelIdentity } from "@kybernesis/enterprise";
3
3
  import { fetchMedia, parseMedia } from "./media.js";
4
4
  import { speakerCredentials } from "./credentials.js";
5
5
  import { surfaceWriter } from "./surface.js";
6
6
  import { SessionStore } from "./sessions.js";
7
7
  import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
8
- import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, resumeStalledReply, } from "./hitl.js";
8
+ import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveCarriedAnswers, resolveInputReply, respondToPendingConversation, resumeFailedReply, resumeStalledReply, STALE_ANSWER_REPLY, } from "./hitl.js";
9
9
  import { existsSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { Client } from "eve/client";
@@ -297,7 +297,7 @@ export function buzzBridge(options) {
297
297
  onInputRequested: (requests) => serialize(community, channel, async () => {
298
298
  if (followers.get(conversation) !== controller)
299
299
  return;
300
- const promptId = relay.reply(channel, formatInputRequests(requests));
300
+ const promptId = relay.reply(channel, formatInputRequests(requests, { carry: options.cards !== false }));
301
301
  if (!promptId)
302
302
  throw new Error("Buzz relay is not connected");
303
303
  relay.watchReplies([promptId]);
@@ -319,6 +319,16 @@ export function buzzBridge(options) {
319
319
  log(`posted resumed reply in ${channel.slice(0, 8)} (${message.length} chars)`);
320
320
  await postCards(relay, channel, cards, stored.speakerPublicKey);
321
321
  }),
322
+ onFailed: (failures) => serialize(community, channel, async () => {
323
+ if (followers.get(conversation) !== controller)
324
+ return;
325
+ const current = sessions.get(community, channel);
326
+ if (current?.promptEventIds?.length)
327
+ relay.unwatchReplies(current.promptEventIds);
328
+ if (!relay.reply(channel, resumeFailedReply(failures)))
329
+ throw new Error("Buzz relay is not connected");
330
+ log(`resumed turn failed in ${channel.slice(0, 8)} (${describeSilentTurn(failures)}); told the room`);
331
+ }),
322
332
  onLog: log,
323
333
  }, controller.signal).catch((error) => {
324
334
  if (!controller.signal.aborted) {
@@ -378,9 +388,13 @@ export function buzzBridge(options) {
378
388
  const addressed = event.tags.some((t) => t[0] === "p" && t[1] === key.publicKey);
379
389
  // A reply to a prompt this agent posted is an answer even when it names nobody: people answer
380
390
  // a question by replying to it, not by mentioning the asker. Only while that prompt is open.
381
- const repliedTo = event.tags.find((t) => t[0] === "e")?.[1];
391
+ // Any `e` tag counts: a reply inside a thread names the thread's root first and the prompt
392
+ // after it (NIP-10 `root` and `reply` markers), so the first tag is often not the prompt.
393
+ const referenced = event.tags.filter((t) => t[0] === "e" && typeof t[1] === "string").map((t) => t[1]);
394
+ // The message this one answers directly: the `reply` marker, else the last `e` tag.
395
+ const parent = event.tags.find((t) => t[0] === "e" && t[3] === "reply")?.[1] ?? referenced.at(-1);
382
396
  const open = channel ? sessions.get(from, channel) : undefined;
383
- const answersPrompt = Boolean(repliedTo && open?.pendingInputRequests?.length && open.promptEventIds?.includes(repliedTo));
397
+ const answersPrompt = Boolean(open?.pendingInputRequests?.length && referenced.some((id) => open.promptEventIds?.includes(id)));
384
398
  // In a one-to-one DM every message is to the agent, mention or not, as with Buzz's own
385
399
  // agents. Not one written by another agent (it carries its owner's `auth` attestation):
386
400
  // two agents in a DM would otherwise answer each other forever.
@@ -414,9 +428,27 @@ export function buzzBridge(options) {
414
428
  await serialize(from, channel, async () => {
415
429
  const pending = sessions.get(from, channel);
416
430
  if (pending?.pendingInputRequests?.length) {
417
- // "@Ava approve" is an approve. The mention is how the message reached us, not the answer.
418
- const answer = addressed ? text.replace(/^@\S+\s*/, "") : text;
419
- const responses = resolveInputReply(answer, pending.pendingInputRequests);
431
+ let responses;
432
+ const carried = readHiddenAnswers(text);
433
+ if (carried.length) {
434
+ // A press in the plugin. It counts only as a direct reply to a prompt this bridge
435
+ // posted, so a message quoting someone else's question cannot answer this one.
436
+ if (!parent || !pending.promptEventIds?.includes(parent)) {
437
+ log(`ignored a pressed answer in ${channel.slice(0, 8)} that does not reply to an open prompt`);
438
+ return;
439
+ }
440
+ const resolved = resolveCarriedAnswers(carried, pending.pendingInputRequests);
441
+ if (!resolved.responses) {
442
+ relay.reply(channel, resolved.stale ? STALE_ANSWER_REPLY : invalidInputReply(pending.pendingInputRequests), event);
443
+ return;
444
+ }
445
+ responses = resolved.responses;
446
+ }
447
+ else {
448
+ // "@Ava approve" is an approve. The mention is how the message reached us, not the answer.
449
+ const answer = addressed ? text.replace(/^@\S+\s*/, "") : text;
450
+ responses = resolveInputReply(answer, pending.pendingInputRequests);
451
+ }
420
452
  if (!responses) {
421
453
  relay.reply(channel, invalidInputReply(pending.pendingInputRequests), event);
422
454
  return;
@@ -432,7 +464,7 @@ export function buzzBridge(options) {
432
464
  const session = clientFor(event.pubkey).sessions.attach(pending.id, {
433
465
  streamIndex: pending.streamIndex,
434
466
  });
435
- await respondToPendingConversation(session, responses, from, channel);
467
+ await respondToPendingConversation(session, responses);
436
468
  log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
437
469
  /**
438
470
  * The answer is in, so this conversation is no longer waiting on a
@@ -447,10 +479,13 @@ export function buzzBridge(options) {
447
479
  * a human, so while that flag was set the room could only be told
448
480
  * nothing.
449
481
  */
482
+ // A press answers one question; any others stay open until they are answered too.
450
483
  const answered = sessions.get(from, channel);
451
484
  if (answered?.pendingInputRequests?.length) {
485
+ const done = new Set(responses.map((response) => response.requestId));
486
+ const remaining = answered.pendingInputRequests.filter((request) => !done.has(request.requestId));
452
487
  const { pendingInputRequests: _resolved, ...rest } = answered;
453
- sessions.set(from, channel, { ...rest, resumeInFlight: true });
488
+ sessions.set(from, channel, remaining.length ? { ...rest, pendingInputRequests: remaining } : { ...rest, resumeInFlight: true });
454
489
  }
455
490
  return;
456
491
  }
@@ -474,7 +509,7 @@ export function buzzBridge(options) {
474
509
  pendingInputRequests: result.inputRequests,
475
510
  speakerPublicKey: event.pubkey,
476
511
  });
477
- const promptId = relay.reply(channel, formatInputRequests(result.inputRequests), event);
512
+ const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry: options.cards !== false }), event);
478
513
  if (promptId) {
479
514
  relay.watchReplies([promptId]);
480
515
  const current = sessions.get(from, channel);
package/dist/hitl.d.ts CHANGED
@@ -1,19 +1,52 @@
1
1
  import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
2
- import { type Card } from "@kybernesis/cards";
2
+ import { type AskAnswer, type Card } from "@kybernesis/cards";
3
3
  import type { StoredSession } from "./sessions.js";
4
+ import { type TurnFailure } from "./turn.js";
4
5
  /**
5
- * Render pending Eve input in a text-only Buzz channel.
6
+ * Render pending Eve input in a Buzz channel.
6
7
  *
7
- * A Kybernesis tool may put a `[kyb:…] {json}` marker on a question's first
8
- * line for apps that draw it as a card. Buzz shows only the sentence after it.
8
+ * Everyone reads the words: a question's sentence (a `[kyb:…]` marker line is
9
+ * dropped), an approval as the email or event it would send, then the options
10
+ * by number. With `carry`, each question also travels hidden after the words
11
+ * (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
12
+ * Studio does and answer it with a press. Only a posted prompt carries them:
13
+ * a press is accepted only as a reply to a prompt the bridge is watching.
9
14
  */
10
- export declare function formatInputRequests(requests: readonly InputRequest[]): string;
15
+ export declare function formatInputRequests(requests: readonly InputRequest[], options?: {
16
+ carry?: boolean;
17
+ }): string;
18
+ /**
19
+ * Answers a client pressed, checked against what is actually open.
20
+ *
21
+ * Each answer must name an open request. An option must be one of that
22
+ * request's own; words alone answer only a question that takes words. eve's
23
+ * strict schema checks the result before it is sent. `stale` is true when no
24
+ * answer named an open request, which is what a second press on a question
25
+ * someone already answered looks like.
26
+ */
27
+ export declare function resolveCarriedAnswers(answers: readonly AskAnswer[], requests: readonly InputRequest[]): {
28
+ responses: readonly InputResponse[];
29
+ stale: false;
30
+ } | {
31
+ responses: null;
32
+ stale: boolean;
33
+ };
34
+ /** What a press on a question nobody is waiting on any more gets. */
35
+ export declare const STALE_ANSWER_REPLY = "That question was already answered, so nothing changed.";
11
36
  /** Resolve a Buzz text reply using Eve's certified matching semantics. */
12
37
  export declare function resolveInputReply(text: string, requests: readonly InputRequest[]): readonly InputResponse[] | null;
13
38
  /** Actionable response for text that cannot satisfy every pending request. */
14
39
  export declare function invalidInputReply(requests: readonly InputRequest[]): string;
15
- /** Submit a matched reply without consuming its response stream. */
16
- export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[], community: string, channel: string): Promise<void>;
40
+ /**
41
+ * Submit a matched reply without consuming its response stream.
42
+ *
43
+ * No `clientContext`: the turn already carries the channel from when it
44
+ * started, and eve 0.81 treats context on a respond as new turn input. It then
45
+ * runs one more model step after the agent's reply, with the agent's message
46
+ * last, which models that refuse prefill reject. The turn fails and the whole
47
+ * conversation ends, on an approve, a decline or an answer alike.
48
+ */
49
+ export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[]): Promise<void>;
17
50
  /**
18
51
  * How long a follower waits between reattaching when it saw no progress.
19
52
  *
@@ -34,6 +67,16 @@ export declare function followerRetryDelayMs(cycles: number, capMs?: number): nu
34
67
  * Say the answer arrived, say the work did not, and say the channel is usable.
35
68
  */
36
69
  export declare function resumeStalledReply(stallMs: number): string;
70
+ /**
71
+ * What the room is told when the turn an answer resumed failed.
72
+ *
73
+ * @remarks
74
+ * Found live: a declined approval resumed the turn, the model refused the
75
+ * continuation, and the run ended. The follower saw the stream end and said
76
+ * nothing, so the person who pressed Decline got silence. They answered; the
77
+ * failure is the one thing they must hear about, in the stream's own words.
78
+ */
79
+ export declare function resumeFailedReply(failures: readonly TurnFailure[]): string;
37
80
  /** A session needs its follower while asking or while resumed output is unpublished. */
38
81
  export declare function needsPendingFollower(session: Pick<StoredSession, "pendingInputRequests" | "resumeInFlight">): boolean;
39
82
  export interface PendingFollowerCallbacks {
@@ -42,6 +85,8 @@ export interface PendingFollowerCallbacks {
42
85
  onInputRequested(requests: readonly InputRequest[]): void | Promise<void>;
43
86
  /** The resumed turn's words, and the cards its tools handed back. */
44
87
  onMessage(message: string, cards: readonly Card[]): void | Promise<void>;
88
+ /** The resumed turn failed: what the stream said, for the room. */
89
+ onFailed?(failures: readonly TurnFailure[]): void | Promise<void>;
45
90
  onLog?: (message: string) => void;
46
91
  }
47
92
  type MutableStoredSession = Omit<StoredSession, "updated">;
package/dist/hitl.js CHANGED
@@ -1,14 +1,21 @@
1
- import { resolveTextToResponses, } from "eve/client";
2
- import { cardsOf, newStepState, promptText, readStep } from "@kybernesis/cards";
1
+ import { parseInputResponses, resolveTextToResponses, } from "eve/client";
2
+ import { askFrom, askText, cardsOf, hiddenAsk, newStepState, readStep } from "@kybernesis/cards";
3
+ import { failureFromEvent, silentTurnReply } from "./turn.js";
3
4
  /**
4
- * Render pending Eve input in a text-only Buzz channel.
5
+ * Render pending Eve input in a Buzz channel.
5
6
  *
6
- * A Kybernesis tool may put a `[kyb:…] {json}` marker on a question's first
7
- * line for apps that draw it as a card. Buzz shows only the sentence after it.
7
+ * Everyone reads the words: a question's sentence (a `[kyb:…]` marker line is
8
+ * dropped), an approval as the email or event it would send, then the options
9
+ * by number. With `carry`, each question also travels hidden after the words
10
+ * (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
11
+ * Studio does and answer it with a press. Only a posted prompt carries them:
12
+ * a press is accepted only as a reply to a prompt the bridge is watching.
8
13
  */
9
- export function formatInputRequests(requests) {
10
- return requests.map((request, requestIndex) => {
11
- const prompt = promptText(request.prompt);
14
+ export function formatInputRequests(requests, options = {}) {
15
+ const asks = requests.map((request) => askFrom(request));
16
+ const text = asks.map((ask, requestIndex) => {
17
+ const request = requests[requestIndex];
18
+ const prompt = askText(ask);
12
19
  const lines = [
13
20
  requests.length > 1 ? `Question ${requestIndex + 1}: ${prompt}` : prompt,
14
21
  ];
@@ -26,7 +33,58 @@ export function formatInputRequests(requests) {
26
33
  }
27
34
  return lines.join("\n");
28
35
  }).join("\n\n");
36
+ if (!options.carry)
37
+ return text;
38
+ const hidden = asks.map((ask) => hiddenAsk(ask)).filter((h) => h !== null);
39
+ return hidden.length ? `${text}\n\n${hidden.join("\n")}` : text;
29
40
  }
41
+ /**
42
+ * Answers a client pressed, checked against what is actually open.
43
+ *
44
+ * Each answer must name an open request. An option must be one of that
45
+ * request's own; words alone answer only a question that takes words. eve's
46
+ * strict schema checks the result before it is sent. `stale` is true when no
47
+ * answer named an open request, which is what a second press on a question
48
+ * someone already answered looks like.
49
+ */
50
+ export function resolveCarriedAnswers(answers, requests) {
51
+ const open = new Map(requests.map((request) => [request.requestId, request]));
52
+ const picked = new Map();
53
+ let named = false;
54
+ for (const answer of answers) {
55
+ const request = open.get(answer.requestId);
56
+ if (!request)
57
+ continue;
58
+ named = true;
59
+ if (picked.has(request.requestId))
60
+ continue;
61
+ const ids = request.options?.map((option) => option.id) ?? [];
62
+ if (answer.optionId !== undefined) {
63
+ if (ids.length > 0 && !ids.includes(answer.optionId))
64
+ return { responses: null, stale: false };
65
+ picked.set(request.requestId, {
66
+ requestId: request.requestId,
67
+ optionId: answer.optionId,
68
+ ...(answer.text?.trim() ? { text: answer.text.trim() } : {}),
69
+ });
70
+ continue;
71
+ }
72
+ const words = answer.text?.trim();
73
+ if (!words || (ids.length > 0 && request.allowFreeform !== true))
74
+ return { responses: null, stale: false };
75
+ picked.set(request.requestId, { requestId: request.requestId, text: words });
76
+ }
77
+ if (picked.size === 0)
78
+ return { responses: null, stale: !named };
79
+ try {
80
+ return { responses: parseInputResponses([...picked.values()]), stale: false };
81
+ }
82
+ catch {
83
+ return { responses: null, stale: false };
84
+ }
85
+ }
86
+ /** What a press on a question nobody is waiting on any more gets. */
87
+ export const STALE_ANSWER_REPLY = "That question was already answered, so nothing changed.";
30
88
  /** Resolve a Buzz text reply using Eve's certified matching semantics. */
31
89
  export function resolveInputReply(text, requests) {
32
90
  if (!text.trim())
@@ -38,10 +96,17 @@ export function resolveInputReply(text, requests) {
38
96
  export function invalidInputReply(requests) {
39
97
  return `I still need a valid answer before I can continue.\n\n${formatInputRequests(requests)}`;
40
98
  }
41
- /** Submit a matched reply without consuming its response stream. */
42
- export async function respondToPendingConversation(session, responses, community, channel) {
99
+ /**
100
+ * Submit a matched reply without consuming its response stream.
101
+ *
102
+ * No `clientContext`: the turn already carries the channel from when it
103
+ * started, and eve 0.81 treats context on a respond as new turn input. It then
104
+ * runs one more model step after the agent's reply, with the agent's message
105
+ * last, which models that refuse prefill reject. The turn fails and the whole
106
+ * conversation ends, on an approve, a decline or an answer alike.
107
+ */
108
+ export async function respondToPendingConversation(session, responses) {
43
109
  await session.respond(responses, {
44
- clientContext: { buzzCommunity: community, buzzChannel: channel },
45
110
  streamReconnectPolicy: { reconnect: false },
46
111
  });
47
112
  }
@@ -73,6 +138,19 @@ export function resumeStalledReply(stallMs) {
73
138
  `${minutes === 1 ? "" : "s"} without producing anything, so I have stopped waiting on it. ` +
74
139
  `Nothing was posted and no work landed from it. This channel is clear — ask me again and I will start fresh.`);
75
140
  }
141
+ /**
142
+ * What the room is told when the turn an answer resumed failed.
143
+ *
144
+ * @remarks
145
+ * Found live: a declined approval resumed the turn, the model refused the
146
+ * continuation, and the run ended. The follower saw the stream end and said
147
+ * nothing, so the person who pressed Decline got silence. They answered; the
148
+ * failure is the one thing they must hear about, in the stream's own words.
149
+ */
150
+ export function resumeFailedReply(failures) {
151
+ const said = silentTurnReply(failures);
152
+ return `Your answer reached me, but the work it restarted stopped. ${said}`;
153
+ }
76
154
  /** A session needs its follower while asking or while resumed output is unpublished. */
77
155
  export function needsPendingFollower(session) {
78
156
  return Boolean(session.pendingInputRequests?.length || session.resumeInFlight);
@@ -106,6 +184,16 @@ export async function followPendingConversation(session, initial, callbacks, sig
106
184
  let streamIndex = initial.streamIndex;
107
185
  let completedMessage = "";
108
186
  let steps = newStepState();
187
+ /** Failures since the last answer. A declined call is the person's choice, not one. */
188
+ let failures = [];
189
+ /** Tell the room, then stop supervising: a failed run will not come back. */
190
+ const failThenClear = async () => {
191
+ // Only an answered question resumed this work; a run failing while it waits on a person stays quiet as before.
192
+ if (state.resumeInFlight)
193
+ await callbacks.onFailed?.(failures);
194
+ state = withoutSupervision(state);
195
+ await persist();
196
+ };
109
197
  const persist = async () => {
110
198
  state = { ...state, streamIndex };
111
199
  await callbacks.onState(state);
@@ -133,10 +221,15 @@ export async function followPendingConversation(session, initial, callbacks, sig
133
221
  // the follower is still open.
134
222
  streamIndex += 1;
135
223
  readStep(steps, streamEvent.type, streamEvent.data);
224
+ const declined = streamEvent.type === "action.result" && streamEvent.data.status === "rejected";
225
+ const failure = declined ? null : failureFromEvent(streamEvent);
226
+ if (failure)
227
+ failures.push(failure);
136
228
  switch (streamEvent.type) {
137
229
  case "input.requested":
138
230
  completedMessage = "";
139
231
  steps = newStepState();
232
+ failures = [];
140
233
  state = withPending(state, streamEvent.data.requests);
141
234
  // Keep the durable cursor before the prompt until Buzz accepts it for
142
235
  // relay delivery; a reconnect can then replay and render the request.
@@ -169,9 +262,11 @@ export async function followPendingConversation(session, initial, callbacks, sig
169
262
  }
170
263
  await persist();
171
264
  break;
172
- case "turn.cancelled":
173
265
  case "turn.failed":
174
266
  case "session.failed":
267
+ await failThenClear();
268
+ return;
269
+ case "turn.cancelled":
175
270
  case "session.completed":
176
271
  state = withoutSupervision(state);
177
272
  await persist();
@@ -183,5 +278,16 @@ export async function followPendingConversation(session, initial, callbacks, sig
183
278
  break;
184
279
  }
185
280
  }
281
+ /**
282
+ * The stream closed without a boundary. Usually a dropped connection, and
283
+ * the caller reattaches. But after an answer, a model failure followed by
284
+ * the end of the stream is the run ending: reattaching finds nothing new,
285
+ * and the room would be told only after the work ceiling, an hour later.
286
+ */
287
+ if (state.resumeInFlight && failures.some((f) => f.kind === "model")) {
288
+ callbacks.onLog?.("resumed turn failed and its stream ended; telling the room");
289
+ await failThenClear();
290
+ return;
291
+ }
186
292
  callbacks.onLog?.("pending Eve stream ended before a resumable boundary");
187
293
  }
package/dist/relay.d.ts CHANGED
@@ -37,6 +37,9 @@ export declare const TYPING_INTERVAL_MS = 3000;
37
37
  export declare const PRESENCE_INTERVAL_MS = 60000;
38
38
  /** Enough overlap for same-second redelivery without retaining every event forever. */
39
39
  export declare const MAX_SEEN_EVENT_IDS = 10000;
40
+ /** At most this many messages wait for a reconnect, and none older than this goes out late. */
41
+ export declare const MAX_OUTBOX = 100;
42
+ export declare const OUTBOX_MAX_AGE_MS: number;
40
43
  /** How often to re-read which direct conversations this agent is in. */
41
44
  export declare const DIRECT_REFRESH_MS = 30000;
42
45
  export type NostrEvent = {
@@ -69,6 +72,14 @@ export declare class BuzzRelay {
69
72
  private refusal;
70
73
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
71
74
  private readonly seen;
75
+ /**
76
+ * Messages and reactions signed while the connection was down or not yet signed in.
77
+ *
78
+ * The relay drops in and out every few minutes. A question posted in one of those gaps
79
+ * used to vanish: the person never saw it, and their next message was taken as a wrong
80
+ * answer to it. They go out, in order, as soon as the connection is signed in again.
81
+ */
82
+ private readonly outbox;
72
83
  private cursor;
73
84
  /** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
74
85
  private readonly replyWatch;
@@ -134,12 +145,16 @@ export declare class BuzzRelay {
134
145
  private discoveryDone;
135
146
  /** Take the DMs just found; read back any that are new, from when they could last have been missed. */
136
147
  private adopt;
137
- /** Publish one event; returns its id when it left this process, null when the socket was down. */
148
+ /**
149
+ * Publish one event; returns its id when it left this process or waits in the outbox, null
150
+ * when it was dropped. Presence and typing are dropped while offline: late, they are wrong.
151
+ */
138
152
  publish(event: {
139
153
  kind: number;
140
154
  tags: string[][];
141
155
  content: string;
142
156
  }): string | null;
157
+ private flushOutbox;
143
158
  setPresence(status: PresenceStatus): void;
144
159
  private keepPresent;
145
160
  /** Say "typing" in a channel until the returned function is called. */
package/dist/relay.js CHANGED
@@ -38,6 +38,9 @@ export const TYPING_INTERVAL_MS = 3_000;
38
38
  export const PRESENCE_INTERVAL_MS = 60_000;
39
39
  /** Enough overlap for same-second redelivery without retaining every event forever. */
40
40
  export const MAX_SEEN_EVENT_IDS = 10_000;
41
+ /** At most this many messages wait for a reconnect, and none older than this goes out late. */
42
+ export const MAX_OUTBOX = 100;
43
+ export const OUTBOX_MAX_AGE_MS = 15 * 60_000;
41
44
  /** How often to re-read which direct conversations this agent is in. */
42
45
  export const DIRECT_REFRESH_MS = 30_000;
43
46
  /** Ids per `#d`/`#h` filter, and filters per request: the relay caps a request at ten. */
@@ -68,6 +71,14 @@ export class BuzzRelay {
68
71
  refusal = null;
69
72
  /** Answered already, so a redelivery in two overlapping polls is not answered twice. */
70
73
  seen = new Set();
74
+ /**
75
+ * Messages and reactions signed while the connection was down or not yet signed in.
76
+ *
77
+ * The relay drops in and out every few minutes. A question posted in one of those gaps
78
+ * used to vanish: the person never saw it, and their next message was taken as a wrong
79
+ * answer to it. They go out, in order, as soon as the connection is signed in again.
80
+ */
81
+ outbox = [];
71
82
  cursor = now();
72
83
  /** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
73
84
  replyWatch = new Set();
@@ -229,6 +240,7 @@ export class BuzzRelay {
229
240
  this.backoff = 5_000;
230
241
  this.refusal = null;
231
242
  this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
243
+ this.flushOutbox();
232
244
  this.keepPresent();
233
245
  this.discoverDirect();
234
246
  this.poll();
@@ -404,10 +416,38 @@ export class BuzzRelay {
404
416
  this.send(["REQ", `m${Date.now()}c`, ...filters]);
405
417
  }
406
418
  }
407
- /** Publish one event; returns its id when it left this process, null when the socket was down. */
419
+ /**
420
+ * Publish one event; returns its id when it left this process or waits in the outbox, null
421
+ * when it was dropped. Presence and typing are dropped while offline: late, they are wrong.
422
+ */
408
423
  publish(event) {
409
424
  const signed = finalizeEvent({ created_at: now(), ...event }, this.key.secretKey);
410
- return this.send(["EVENT", signed]) ? signed.id : null;
425
+ if (this.authenticated && this.send(["EVENT", signed]))
426
+ return signed.id;
427
+ // Held only between connections of a running relay; one never started or closed sends nothing.
428
+ if (this.closed || !this.socket || (event.kind !== KIND_MESSAGE && event.kind !== KIND_REACTION))
429
+ return null;
430
+ if (this.outbox.length >= MAX_OUTBOX)
431
+ this.outbox.shift();
432
+ this.outbox.push({ event: signed, at: Date.now() });
433
+ return signed.id;
434
+ }
435
+ flushOutbox() {
436
+ const cutoff = Date.now() - OUTBOX_MAX_AGE_MS;
437
+ const stale = this.outbox.filter(({ at }) => at < cutoff).length;
438
+ if (stale)
439
+ this.log(`dropped ${stale} message(s) held too long while disconnected`);
440
+ const due = this.outbox.splice(0).filter(({ at }) => at >= cutoff);
441
+ let sent = 0;
442
+ for (const held of due) {
443
+ if (!this.send(["EVENT", held.event]))
444
+ break;
445
+ sent += 1;
446
+ }
447
+ // The connection dropped again mid-flush: the rest wait for the next sign-in, in order.
448
+ this.outbox.unshift(...due.slice(sent));
449
+ if (sent)
450
+ this.log(`sent ${sent} message(s) held while disconnected`);
411
451
  }
412
452
  setPresence(status) {
413
453
  this.publish({ kind: KIND_PRESENCE, tags: [], content: status });
package/dist/turn.d.ts CHANGED
@@ -98,9 +98,10 @@ export declare const DEFAULT_UNREAD_DRAIN_TIMEOUT_MS = 20000;
98
98
  export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void, agentSilenceTimeoutMs?: number, agentWorkTimeoutMs?: number, unreadDrainTimeoutMs?: number): Promise<TurnOutcome>;
99
99
  /** What the room is told when eve refuses the turn itself. */
100
100
  /**
101
- * The two answers from eve that mean the stored session cannot carry another
102
- * turn: an unknown session id (404), and a session that ended (409 with
103
- * `session_not_active`, "The session is no longer active."). A session ends
101
+ * The answers from eve that mean the stored session cannot carry another
102
+ * turn: an unknown session id (404), a session that ended (409 with
103
+ * `session_not_active`, "The session is no longer active."), and one whose
104
+ * history expired (410 `session_expired`). A session ends
104
105
  * when one of its turns fails hard, for instance a model call that 404s at
105
106
  * the gateway; the channel must then start a fresh one.
106
107
  *
package/dist/turn.js CHANGED
@@ -430,9 +430,10 @@ export async function answerTurn(client, sessions, channel, message, community,
430
430
  }
431
431
  /** What the room is told when eve refuses the turn itself. */
432
432
  /**
433
- * The two answers from eve that mean the stored session cannot carry another
434
- * turn: an unknown session id (404), and a session that ended (409 with
435
- * `session_not_active`, "The session is no longer active."). A session ends
433
+ * The answers from eve that mean the stored session cannot carry another
434
+ * turn: an unknown session id (404), a session that ended (409 with
435
+ * `session_not_active`, "The session is no longer active."), and one whose
436
+ * history expired (410 `session_expired`). A session ends
436
437
  * when one of its turns fails hard, for instance a model call that 404s at
437
438
  * the gateway; the channel must then start a fresh one.
438
439
  *
@@ -446,7 +447,10 @@ export async function answerTurn(client, sessions, channel, message, community,
446
447
  export function isSessionGone(error) {
447
448
  if (!(error instanceof ClientError))
448
449
  return false;
449
- if (error.status === 404)
450
+ // 410 session_expired, "Session history is no longer available.": a run that
451
+ // ended (a failed turn ends the session) can no longer be read. Seen on
452
+ // 2026-10-24 when the next message in a channel failed instead of starting over.
453
+ if (error.status === 404 || error.status === 410)
450
454
  return true;
451
455
  return (error.status === 409 &&
452
456
  (error.code === "session_not_active" || /no longer active/i.test(String(error.message ?? ""))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -44,7 +44,7 @@
44
44
  "test": "node --test \"test/**/*.test.mjs\""
45
45
  },
46
46
  "dependencies": {
47
- "@kybernesis/cards": "^0.4.0",
47
+ "@kybernesis/cards": "^0.5.0",
48
48
  "@kybernesis/enterprise": "^0.10.0",
49
49
  "nostr-tools": "^2.10.0",
50
50
  "zod": "^4"